(Swift) Swift의 String타입 기초 – 02. 문자열 조작

목차

  1. 문자열 생성하기
  2. * 문자열 조작하기
  3. 활용

문자열의 기본 조작

문자열 데이터를 다룰 때 가장 많이 쓰며, 또 중요한 스킬은 바로 문자열을 조작하는 것이다. 문자열 내의 특정 글자나 부분 문자열을 찾거나, 문자열에 어떤 글자를 추가, 삽입, 삭제, 변경하고, 문자열을 잘라서 나눈다던지 하는 등의 처리는 “간단한” 프로그램을 작성할 때 아주 많이 쓰이는 가장 기본적인 테크닉들이다.

또한 중요한 부분 중 하나는 문자열을 변경하는 작업은 크게 두 가지 타입으로 나뉘는데 하나는 원본 문자열 그 자체를 변경하는 것이고 다른 하나는 조작이 적용된 사본을 만드는 것이다. 이 장의 각 절에서는 이를 각각의 내용을 다뤄보도록 하겠다. (Swift) Swift의 String타입 기초 – 02. 문자열 조작 더보기

Swift의 String타입 기초 정복 – 01. 문자열 생성 방법들

문자열은 대부분의 프로그래밍 언어에서 중요한 비중을 차지하는 데이터타입이다. 많은 경우에 프로그램의 입출력은 주로 문자열 형식으로 전달되며, 사람이 읽을 수 있는 데이터를 그대로 받아서 처리하려는 경우에 문자열을 자르고 변환하고 조사하고 합치는 등의 작업은 거의 모든 프로그래머들의 필수적인 소양이며, 그만큼 프로그래밍 분야에서 문자열은 중요한 타입이다.

Swift의 문자열은 인코딩 독립적인 문자의 집합으로 타 언어에서 개별문자의 배열처럼 다뤄지는 것과는 내부적인 동작이 다르다. 따라서 직접적인 정수 인자에 의한 subscription이 불가능한 등의 제약 사항이 많아 보인다. 하지만 유니코드 문자열에 대한 완전한 지원과 유서깊은(?) NSString과의 연계등으로 여러가지 편의 기능들을 공짜로 얻게 되는 부분도 있다. 이 글에서는 Swift의 문자열에 대해서 여러 다양한 방법으로 문자열을 생성하는 것부터 시작해서 기본적인 변환/조작과 실제로 사용할 수 있는 몇 가지 예제들에 대해서 살펴보도록 하겠다. Swift의 String타입 기초 정복 – 01. 문자열 생성 방법들 더보기

UnicodeScalar

UnicodeScalar

유니코드 문자 1개에 해당하는 코드 포인트값을 담고 있는 데이터이다. UnicodeScalar는 4바이트 유니코드 값 1개에 대응되기 때문에 UInt32 타입으로 변환하거나, UInt32값으로부터 생성해낼 수 있다.

let i:Uint32 = 0xac01
let c = UnicodeScalar(i)
print(c)
let v:UInt32 = c.value

Swift의 Character 타입은 글자 1개를 나타내는 자료형이지만, 유니코드 문자 중에서는 여러 개의 코드 값이 하나로 합쳐진 글자들이 있기 때문에 1개의 Character 타입 값은 1개 혹은 그 이상의 UnicodeScalar 값으로 표현된다.

예를 들어 한글 "학"은 1개의 글자로 그 자체로 유니코드 코드포인트 값 1개에 대응될 수도 있지만, 한글자모 "ㅎ" + "ㅏ" + "ㄱ"의 조합일 수도 있다. 이 경우에는 코드 포인트값은 3개이지만, 글자로는 1개로 취급된다.

예를 들어 한글 “학”의 경우를 보자.

let a = "학"
let c = a.unicodeScalars[a.unicodeScalars.startIndex].value
print(String(format:"0x%x", c))
// 0xd559 (45617)

print("학 ==> 학")
print("글자 수: \(a.characters.count)") // 1
print("코드 수: \(a.unicodeScalars.count)") //
print("\n")

"학"의 유니코드 코드포인트 값은 45617로 하나의 코드로 된 문자이다. 그리고 당연히 1개의 글자로 되어 있다. 이를 유니코드의 초중종성 분리를 통해 각각의 자모로 분리해보자.

// 각각의 자모 인덱스로 분리
let x = (c - 0xac00) / 28 / 21
let y = ((c - 0xac00) / 28) % 21
let z = (c - 0xac00) % 28

// 각각의 자모 인덱스로부터 자모 코드값 생성
let i = UnicodeScalar(0x1100 + x) // 0x1100 -> 초성 'ㄱ'
let j = UnicodeScalar(0x1161 + y) // 0x1161 -> 중성 'ㅏ'
let k = UnicodeScalar(0x11a6 + 1 + z) // 0x11A6 -> 종성 {받침없음}

print("분해된 자모: \(i), \(j), \(k)")
// 분해된 자모: ㅎ, ㅏ, ㄱ

이 때 분해된 각 자모의 값은 0x1112, 0x1161, 0x11A8이 된다. 이 자모코드값을 이용해서 문자열 "학"은 다음과 같이 쓸 수 있다.

let compositedString = "\u{1112}\u{1161}\u{11A8}"
print("자모코드를 합성한 문자열: \(compositedString)")
// 학

이 세 개의 자모 코드는 하나의 문자를 표시하도록 조합되기 때문에, 표현상 한 글자 짜리 문자열이 된다.

print("ㅎㅏㄱ ==> 학")
print("글자 수: \(compositedString.characters.count)") // 1
print("코드 수: \(compositedString.unicodeScalars.count)") // 3

하지만 글자 수는 1개이지만 학(0xD559)와 달리 3개의 코드포인트로 저장되어 있고 표시하는 글자는 같다. 그리고 이 두 개는 문자열로 표현되는 경우 내부에 저장된 데이터는 각각 다르지만

print(a == compositedString)
// true

동일한 값으로 간주된다.

다루는 방법

문자열 타입에서 .unicodeScalars 프로퍼티를 이용해서 해당 문자열의 유니코드 코드 포인트뷰를 얻을 수 있다. 이는 UnicodeScalarView 타입으로, Slicable, SequenceType, ExtensibleCollectionType, RangeReplaceableColloectionType 등의 프로토콜을 따르고 있어서 배열과 유사하게 서브스크립팅을 하거나 append, insert, remove등의 조작을 통해 manipulate할 수 있다.

또한 앞서 언급한 바와 같이 내부적으로 하나의 코드 포인트는 UInt32 타입의 정수값으로 취급되므로, 유니코드 스칼라 타입을 이용하여 유니코드 코드값을 문자로 변환하는 것도 가능하다.

String in Swift

Swift의 문자열

수정 (2015. 12. 07) : Swift2.0에서부터 문자열은 더 이상 Collection 타입이 아니므로 [Character] 타입으로 변경할 수 없으며, advance() 함수 역시 전역 함수에서 제거되고 Index 타입의 메소드로 변경되었다. NSString은 유니코드 문자열을 UTF16으로 인코딩한 바이트배열로 문자를 다루는데 비해, Swift의 문자열은 유니코드 문자열의 복잡 다단한 특성들을 정확히 반영하기 위해 애써서 디자인한 흔적들이 눈에 띈다. 보다 자세한 내용에 대해서는 따로 포스팅하겠다.

Swift의 문자열은 유니코드 문자열이고, 유니코드의어떤 특징들(여러개의 스칼라 코드가 하나의 문자로 결합하는 등)로 인해서 내부적으로는 단순 배열이 아니다. 따라서 Swift의 문자열은 인덱스에 의한 랜덤액세스를 지원하지 않는다. 즉, NSStringcharacterAtIndex:와 동일한 메소드를 지원하지 않는다. 컨셉상, Swift의 문자열은 배열보다는 양방향 리스트에 더 까운 구조이다.

문자열 내 개별문자(Character)와 범위의 인덱스들은 String.Index라는 불투명 타입에 의해 구현되며, 이 타입은 BidirectionalIndex라는 프로토콜 타입이다. 따라서 특정 위치의 문자를 얻기 위해서는 먼저 해당 문자열에 startIndex 값을 물어본 다음, 표준 함수인 —advance()를 사용하여 해당 위치 인덱스로 순차적으로 이동하고– 이 시작 인덱스의 advancedBy() 메소드를 이용해서 해당 위치로부터 원하는 만큼의 오프셋을 준 인덱스를 구한 후, 이 인덱스를 이용한 subscription으로 특정 위치의 문자를 구할 수 있다. (참 어렵게도 해놨다)

let digits = "0123456789"
let position = 3
let index = digits.startIndex.advancedBy(position)
let character = digits[index]

이를 이용하면 문자열의 뒤에서부터 접근하는 경우에

let index2 = digits.endIndex.advancedBy( -1 * position)
let character2 = digits[index2]

이런 식으로 advance() 함수의 두 번째 인자에 음수값을 주어 뒤에서부터 세어 나갈 수 있다.

메모리를 좀 더 많이 쓰는 대신 타이핑의 수고로움을 더는 방법으로는 문자열을 배열로 캐스팅하면 Character의 배열을 얻게 된다는 점을 착안하여,

let character3 = Array(digits)[4]

라고 쓰는 방법도 있긴하다.

Swift 2.0에서 문자열은 더 이상 집합 타입이 아니며, 따라서 [Characters] 타입으로 변환되지 않는다. 이는 문자열의 개수를 세는 countElement() 등의 함수의 비효율성을 극복하기 위해서, 단일 문자열이 여러 가지 타입으로 인코딩된 데이터 맵을 가지는 형태로 변환되었다.

좀 더 세련된 방법으로 문자열을 정수 인덱스를 지원하도록 서브스크립트를 확장하는 방법이 있다.

extension String {
    subscript(integerIndex: Int) -> Character {
        if integerIndex >= 0 {
            let index = startIndex.advancedBy(integerRange.startIndex)
            return self[index]
        }

    }

    subscript(integerRange: Range<Int>) -> String {
        let start = startIndex.advancedBy(integerRange.startIndex)
        let end = startIndexBy(integerRange.endIndex)
        let range = start..<end
        return self[range]
    }
}

위 서브스크립트에서 정수 인덱스가 음수인 경우, 파이썬과 비슷하게 뒤쪽에서부터 문자를 출력하도록 하는 것도 가능할 것이다.

[C] 문자열 상수와 문자열 변수의 차이

문자열 상수와 문자열 변수

문자열을 초기화하는 다음 두 가지 방법은 거의 비슷해 보이지만 근본적으로 완전히 다른 동작을 한다.

char *s1 = "abcdefg";
char s2[8] = "abcdefg";

첫번째 s1은 프로그램이 로딩될 때 정적영역에 "abcdefg"를 저장한 다음 이 시작 주소를 s1에 대입한다. 문자열 포인터는 s1에는 나중에 다른 주소의 값을 대입할 수 있다. 즉, s1자체는 변경이 가능한 포인터 변수이지만, 지금 s1이 가리키고 있는 문자열은 문자열 상수이므로 이 문자열을 변경할 수 없다. 반대로 s2는 힙 영역에 메모리를 할당한 후 "abcdefg"를 이곳에 저장했다. 따라서 s2에 담겨 있는 문자열은 변경이 가능하다. (각각의 바이트는 모두 개별 변수처럼 취급되므로)

간단한 예시를 보자. 다음 함수는 문자열을 모두 대문자로 만들어 주는 함수이다. (윈도우용 C컴파일러에 있는 함수임)

#include <ctype.h>
void strupr(char *origin) {
    char *temp = origin;
    while(*temp != '\0') {
        *temp = toupper(*temp);
    }
}

이 함수는 문자열 포인터(=문자열의 시작번지)를 인수로 받아, 각 문자를 대문자로 바꿔준다. toupper 함수는 표준 C 라이브러리에 포함되어 있는 함수이다. 만약 이 함수에 s1을 넘기면 프로그램이 다운된다. 문자열 상수를 변경하려는 시도를 했기 때문이다. 반대로 s2를 넘긴 경우에는 정상적으로 동작하는 것을 볼 수 있다. 다음은 전체 코드이다.

#include <stdio.h>
#include <ctype.h>

void strupr(char *origin);

int main(void) {
    char *s1 = "abcdefg";
    char s2[8] = "abcdefg";
    //printf("%s\n", strupr(s1)); // ==> 프로그램이 다운된다. 
    printf("%s\n", strupr(s2)); // ==> 정상적으로 동작한다. 
    return 0;
}

void strupr(char *origin) {
    char *temp = origin;
    while(*temp != '\0') {
        *temp = toupper(*temp);
    }
}

만약 문자열 상수로 만들어진 문자열을 사용하고자 한다면 다음과 같이 고친다.
char *s1 = "abcdefg";
char s2[8];
strcpy(s2,s1);

언뜻 생각하기에는 s2 = s1; 이라고 하면 안되나? 하는 생각이 들 수 있는데, 안된다. 비록 문자열 배열 변수의 변수명이 배열의 시작번지를 가리킨다고 해서 이것이 포인터라는 것은 아니다. 그것은 일종의 convension으로 배열의 변수명을 배열의 시작번지와 같이 다루는 것일 뿐이다. 즉, s2는 포인터 상수이고, 방금 예를 든 저 구문은 s2의 자리에 s1의 값 (정적영역내의 "abcdefg"가 자리잡은 메모리 번지)을 대입하고자 하는 시도이고, 이는 서로 타입이 맞지 않는 대입 구문이기 때문에 에러를 유발한다.

정리

  • 문자열 포인터로 문자열을 초기화하면 문자열 상수가 된다.
  • 배열로 선언한 문자열에 문자열을 집어넣을 때는 직접 대입이 아니라 strcpy를 쓰거나 한자 한자 루프를 돌면서 넣어야 한다.