태그 보관물: swift

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

목차

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

문자열의 기본 조작

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

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

결합

두 개의 문자열을 하나도 합치는 것이다. 먼저 append(_:) 메소드는 Character, String 타입을 받을 수 있는데 전달한 내용을 현재의 문자열 뒤에 추가로 들여붙이는 기능을 한다.

appending(_:)은 문자열을 추가로 붙인 사본을 만드는 역할을 한다.1 사실 이렇게 2개 이상의 문자열을 결합하는 것은 + 연산자를 쓰면 된다.

appending(_:)의 친구로 appendingFormat(_:_:) 이 있는데, 이는 뒤에 붙이는 문자열을 포맷으로 부터 만들 수 있게 한다. (얼마나 필요가 있을지는 모르겠다.)2 이 외에도 append의 변형에는 append(contentsOf:)가 있어서 [Character]의 내용을 문자열 뒤에 붙이는 변형이 있다. 이는 insert(contentsOf:at:)의 형태로로 비슷하게 존재한다.

여러개의 문자열을 한꺼번에 결합하기

Array 타입의 메소드 중에 .joined(separator:) 가 있는데3 이는 원소타입이 String인 경우에 연결자를 중간에 삽입하여 문자열의 배열을 하나의 문자열로 결합하는 메소드이다. 많은 문자열을 append, + 등으로 결합하는 것보다 이를 사용하는 것을 권장한다.

let lines = [ ... ]
let paragraph = lines.joined(separator: "\n")

let words = [ ... ]
let line = words.joined(sepeartor: " ")

삽입

“추가”가 문자열의 맨 끝에 덧붙이는 거라면, 삽입은 중간에 어떤 지점에 하나 혹은 그 이상의 글자들을 끼워넣는 동작이다. 따라서 “어딘가에”를 가리킬 값이 필요한데, 앞에서도 말했다시피 문자열의 인덱스는 정수값이 아니기 때문에 여러모로 불편한 것이 함정. 사용하는 메소드는 insert(_:at:), insert(contentsOf:at:) 이며, 후자는 다른 문자열 혹은 문자의 집합(Array<Character>)을 삽입한다.

var s = "hello world type here"
let i = s.index(s.startIndex, offsetBy: 6)

// 혹은 특정 글자를 찾아서 그 인덱스를 쓴다.
// let i = s.characters.index(of:"w")

s.insert("W", at: i) /// hello Wworld type here

// 다음과 같이 문자의 집합을 쓸 수도 있다. 
s.insert(contentsOf: "Wow".characters, at: i)
print(s)

삭제

문자열 내의 특정 위치의 글자(Character)나 특정 부분범위의 내용을 삭제하는 작업이다. 4개의 메소드가 이와 관련되는데 모든 메소드는 mutating이며 사본을 생성해주는 버전은 없다.

  • remove(at:) 특정 글자 하나를 삭제
  • removeAll(keepCapacity:) 전체를 삭제하고 빈 문자열로 만든다. keepCapacity는 디폴트로 false가 넘어가는데, 이를 true로 넘겨주면 이미 할당된 메모리 스토리지를 그대로 유지하기 때문에 글자를 다시 append하여 채워넣을 때 효율을 높일 수 있다.
  • removeSubrange(_: Range<String.Index>) 특정 범위의 글자들을 삭제한다.
  • removeSubrange(_: ClasedRange<String.Index>) 위 메소드의 같은 버전. upper bound를 포함하는 ClosedRange 를 썼을 때 호출된다.

특정 문자/문자열 포함 여부

특정 글자가 포함되어 있는지를 알고 싶다면 문자열의 CharacterView 를 이용할 수 있다. 여기에 index(of:)가 정의되어 있다. 4 단지, 그 여부값만 알고 싶다면 contains(_:)를 써도 된다.

하지만 이 기능은 매우 제한적이다. 왜냐면 특정한 글자가 여러 개 들어있는 긴 문자열에서 항상 첫 번째 등장하는 위치만 알수 있기 때문이다.

더군다나 문자열에서는 특정한 문자하나 뿐만 아니라, 특정한 문자열이 포함되는지, 특정한 부분 문자열이 어디에 있는지를 알고 싶은 경우가 더 많다.

만약 문자열로 문자열 내부를 검색하고 싶다면 다음과 같이 확장 메소드를 추가해준다. (Foundation에 의존하지 않는다.)

extension String {
    func search(of target: String) -> Range<Index>? {
        // 찾는 결과는 `leftIndex`와 `rightIndex`사이에 들어가게 된다.
        var leftIndex = startIndex
        while true {
            // 우선 `leftIndex`의 글자가 찾고자하는 target의 첫글자와 일치하는 곳까지 커서를 전진한다.
            guard self[leftIndex] == target[target.startIndex] else {
                leftIndex = index(after:leftIndex)
                if leftIndex >= endIndex { return nil }
                    continue
            }
            // `leftIndex`의 글자가 일치하는 곳이후부터 `rightIndex`를 늘려가면서 일치여부를 찾는다.
            var rightIndex = index(after:leftIndex)
            var targetIndex = target.index(after:target.startIndex)
            while self[rightIndex] == target[targetIndex] {
                // target의 전체 구간이 일치함이 확인되는 경우
                guard distance(from:leftIndex, to:rightIndex) < target.characters.count - 1
                else {
                    return leftIndex..<index(after:rightIndex)
                }
                rightIndex = index(after:rightIndex)
                targetIndex = target.index(after:targetIndex)
                // 만약 일치한 구간을 찾지못하고 범위를 벗어나는 경우
                if rightIndex >= endIndex {
                    return nil
                }

            }
            leftIndex = index(after:leftIndex)
        }
    }
}

let s = "hello world this is sample string"
if let r = s.search(of:"sample") {
    print(s[r])
}

https://swiftlang.ng.bluemix.net/#/repl/5819a6c5f9f5f14d876a3052

이 코드는 원본 문자열 내에 좌/우 인덱스를 이용해서 좌 인덱스가 찾고자 하는 타깃의 첫글자와 같은지를 검사한 후, 순차적으로 타깃의 끝까지 검사하는 방식을 이용한다.

하지만 보다 확실한 방법은 정규식을 활용하는 것이다. 정규식으로 부분문자열을 찾는 예제

단순히 부분문자열이 내부에 존재하는지 여부만 알고 싶다면, NSStringcontains(_:)를 쓸 수 있다. 또한 위에서 만든 search(of:)보다 좀 더 많은 기능을 제공하는 메소드가 NSString에 이미 구현돼 있는데, 바로 range(of:options:range:locale:)이다. 이 메소드에서 range 값을 순차적으로 만들어서 탐색하면 문자열 내의 모든 부분문자열의 출현 구간을 찾을 수 있을 것다.

정규 표현식

문자열 내의 특정 문자열을 찾고 치환하는 등의 작업은 사실 정규식(NSRegularExpression)을 이용하는 것이 가장 간편하고 확실한 방법이라 할 수 있다. 다만 정규 표현식을 사용할 때의 매칭은 주의를 기울여야 하는 부분이 있다.

  1. 매칭 결과는 범위가 아니라 NSTextCheckingResult 이다. 정규식 패턴은 1개 이상의 그룹을 포함할 수 잇기 때문에 매치 1개에는 여러 개의 범위가 있을 수 있다. 다만, 전체 매칭 영역은 0번 범위에 있다.
  2. 검색된 결과가 없어도 매칭 결과가 나올 수 있다. 예를 들어 rangeOfFirstMatch(in:options:range:)의 리턴값은 매칭 결과가 없을 때 nil이 아니라 {NSNotFound, 0}이다. 이 부분을 잘 체크해야 한다.
  3. 매칭 범위값이 Range<String.Index>가 아니라 NSRange이다. 따라서 이를 String에 적용하기 위해서는 별도의 변환이 필요하다.

정규 표현식을 이용하는데는 NSRange와 관련하여 보다 쉽게 처리를 위해서 다음과 같이 확장을 붙여주는 것이 좋다.

extension String {
    var fullRange: NSRange { return NSRange(location:0, length:characters.count)}
    func range(from r:NSRange) -> Range<Index> {
        let s = index(startIndex, offsetBy: r.location)
        let e = index(s, offsetBy: r.length)
        return s..<e
    }
}

문자열에 정규식을 사용하는 방법과 관련해서는 Foundation쪽의 수정이 더 필요해 보인다. 정규식 사용과 관련한 부분은 나중에 별도 포스팅에서 더 다뤄보도록 하겠다.

치환

String의 기본 치환 메소드는 replaceSubrange(_:with:) 로 치환내용의 타입이 문자열이거나, [Character] 타입을 받는 오버로딩들이 있다. 기본 타입의 치환 메소드는 모두 mutating 메소드이며, 변경한 사본을 얻으려면 NSString의 메소드들인 replacingCharacters(in:with:), replacingOccurences(of:with:options:range:) 를 이용한다.

정규 표현식

정규 표현식으로 문자열을 치환하는 메소드로는 replaceMatches(in:options:range:withTemplage:)stringByReplacingMatches(in:options:range:withTemplate)이 있다. 전자의 경우에는 in: 으로 넘겨지는 문자열이 변경되는데, NSMutableString을 받으므로 결국은 변경된 사본을 만드는 것과 별 차이가 없다. 참고로 StringNSMutableString으로는 브릿징되지 않는다. NSMutableString(string:)을 사용해서 사본을 만들어야 한다.

인덱스

문자열 인덱스 타입은 문자열 내에서 의미를 갖는 타입이므로, 인덱스 조작 관련 연산이 문자열의 인스턴스 메소드로 구현되어 있다.

  • index(_:offsetBy:) – 주어진 인덱스로부터 떨어진 위치의 인덱스
  • index(_:offsetBy:limitedBy:) – 주어진 인덱스로부터 떨어진 위치의 인덱스를 찾는데, 해당 값이 특정 인덱스를 벗어나지 않게 한다.
  • index(after:) – 주어진 인덱스의 바로 다음 인덱스
  • index(before:) – 주어진 인덱스의 바로 직전 인덱스

문자열의 인덱스 혹은 인덱스의 범위(Range<String.Index>)는 문자열을 서브스크립팅하여 n 번째 글자 혹은 특정 구간의 부분열을 액세스하려고 할 때 사용된다. Swift에서 문자열을 사용할 때 가장 성가신 부분이 바로 이 부분이다. 직관적으로는 n번째 글자를 얻으려는 것이기 때문에 양의 정수값을 사용해도 무리가 없지 않겠냐고 생각된다. 아마도 이렇게 한 이유는 String타입은 내부적으로 UnicodeScalar 시퀀스로 콘텐츠를 저장하는데, 유니코드 중 일부 코드들은 그 자체로 하나의 글자가 되기 보다는 합자를 만든다. 따라서 하나의 글자가 2개 이상의 유니코드 스칼라값으로 구성될 소지가 있다. 예를 들어 흔히 café를 쓸 때 엑센트가 붙은 e의 코드값은 00E9이다. 이는 다음과 같이 출력해볼 수 있다.

print("\u{00E9}") // é

이 엑센트 기호의 기본 코드값은 U+00B4인데, 이 때는 하나의 글자로 표현되는 ´문자를 뜻한다. 이외에도 U+301이 정의되어 있는데, 이 이름은 Combining Acute Accent이다. 즉 다른 글자와 합쳐져서 합자를 이룰 때 쓴다는 말이다.

print("e\u{0301}") // é

위 두 예제에서 출력되는 글자는 é으로 동일하지만 두 문자열은 내부적으로 다른 코드값으로 구성되어 있다. 첫번째 예의 문자열은 이미 합자 상태로 정의된 문자의 코드이며, 두 번째 글자는 e´이 합쳐져서 하나의 글자가 된다는 점을 보이는 것이다.

이와 비슷한 문제는 사실 한글에도 있을 수 있다. 자를 살펴보자. 의 유니코드 테이블상의 코드값은 U+C6444이다. 다들 알겠지만 이 글자는 ㅇ ㅘ ㄴ의 세 글자가 합쳐진 글자이고, 이 세 글자의 코드값은 각각 U+3147, U+3158, U+3134이다.5 물론 이 세 코드값을 나란히 사용하여 만든 문자열은 이 아니라 ㅇㅘㄴ이 된다.

print("\u{3147}\u{3158}\u{3134}") // ㅇㅘㄴ

유니코드의 한글 자모는 Hangle Jamo라는 이름의 블럭에 정의되어 있으며 여기에는 문자의 특성을 반영하여 초성, 중성, 종성을 구분한다. 따라서 초성에 오는 과 종성에 오는 은 별개의 코드값을 가지며, 이들은 특정한 알고리듬에 의해 조합되어 미리 조합된 한글 낱자 세트인 Hangle Syllables 블럭의 각 낱자에 맵핑될 수 있다. 한글 자모 블럭에서 의 각 소리글자들을 모으면 110B, 116A, 11AB이다.

print("\u{110b}\u{116a}\u{11ab}") // 완

Swift 문자열은 이러한 합자를 구성하는 알고리듬을 내장하고 있고, 따라서 합자가 가능한 코드값이 연속적으로 이어지는 경우, 이를 개별 낱자로 표현하는 것이 아니라 합쳐진 글자로 변환하여 구성한다.

let wan = "\u{110B}\u{116a}\u{11ab}"
print(wan)                              // 완
print(wan.characters.count)             // 1
print(wan.unicodeScalars.count)         // 3

따라서 데이터 레벨에서는 서로 다른 문자열들이 글자 레벨에서는 결국 같은 값일 수 있고, 그 때문에 문자열의 길이는 문자열의 스토리지의 크기와 항상 일치하지 않으며, 합자가 나타나는 위치는 미리 알 수 없기 때문에 정수 인덱스를 사용하지 못하게끔하고 있는게 아닌가 하는 생각은 든다. 하지만 그럼에도 불구하고 String 타입은 내부적으로 CharacterView와 UnicodeScalarView를 따로 가져가고 있고, 실질적으로 String.IndexString.CharacterView.Index이기 때문에 정수 인덱스를 사용한 서브 스크립션이 문제가 될 것 같지는 않다.

어쨌든 이 부분은 Swift4에서 보다 편리하고 직관적인 사용이 가능하도록 re-evolve하겠다고 공언하였으니, 한 번 지켜보도록 하자.


  1. 동사원형을 쓰는 경우는 대부분 mutating이며 사본을 만드는 변경은 ~ing, ~ed 등으로 처리한다. 
  2. 이름을 봐선 조만간에 appending(format:_:)으로 바뀔 거 같은 예감이다. 
  3. 물론 원소가 문자열이 아닐 때에는 Element의 타입이 같은 시퀀스를 연결자로 하여 배열의 배열을 결합한 하나의 배열을 만드는 이름이 같은 메소드가 있다. 참고 
  4. 과 같이 한글 자모의 조합으로 만들어지는 낱자들은 유니코드 테이블 상에서 Hangul Syllables라는 이름으로 AC00 ~ D7AF범위를 차지하는 11184자이다. 
  5. , , 과 같이 조합되지 않는 낱개의 자모는 EUC-KR과의 호환을 위해서 별도로 분리하여 Hangul Compatibility Jamo라는 블럭으로 등재되어 있다. 

(Swift) Swift의 String타입 기초 – 01. 문자열 생성

목차

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

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

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

문자열 생성

리터럴

문자열 데이터의 생성은 기본적으로 문자열 리터럴을 사용한다. 많은 언어들과 같이 겹따옴표로 둘러싼 형태로 문자열을 만들 수 있다.

let greet = "Hello World"

Swift의 문자열은 Swift 언어차원에서 제공되는 원시 타입으로 볼 수 있으므로, 기본적으로 문자열 리터럴은 String 타입을 만든다. 하지만 NSString, NSMutableString은 자동으로 문자열 타입과 브릿징된다.

문자열 리터럴은 기본적으로 문자열 타입 데이터를 생성하지만, 변수의 타입에 따라서는 NSString 문자열이 될 수 있다. 글고 이 두 타입은 간단하게 as 연산자를 통해서 언제든 상호교환될 수 있다.

let string: String = "123"
let bridgedString: NSString = string as NS
if let number = Int(bridgedString as String) {
  print("\(bridgedString) could be converted to Int: \(number)")
}

브릿징과 관련된 문제점

3.0.1 버전 기준의 공식문서에 따르면 다음과 같은 내용이 있다.

Swift의 String 타입은 파운데이션의 NSString 클래스와 브릿징됩니다. 파운데이션은 또 한편으로 String 타입을 확장하여 NSString에서 정의한 메소드를 사용할 수 있게 합니다. 이는 파운데이션을 임포트하면, 별다른 캐스팅 없이도 String 타입에서 NSString의 메소드를 사용할 수 있다는 의미입니다.

파운데이션과 코코아에서 String 타입을 사용하는 부분에 관한 더 자세한 정보는 Working with Cocoa Data Types를 참조하세요.

브릿징1은 일종의 공짜 변환인데, 이는 타입 자체가 자동으로 변환되는 것이 아니라 타입과 관련을 맺는 API들이 자동으로 호환 대상 타입을 위한 타입으로 변환된다는 뜻이다.

그리고 여기서 참조하라고 하는 해당 페이지에서는 “You can create an NSString object by casting a String value using the as operator.”라는 설명과 함께 다음과 같은 코드가 있다.

import Foundation
let string: String = "abc"
let bridgedString: NSString = string as NSString

하지만 해당 기능은 실제로 실행되지 않는다. (결과) 단 이 테스트는 Linux 시스템에서 시행한 것으로 과연 macOS상의 Swift 3.0 에서도 같을지는 다시 확인이 필요하다.2

해당 페이지에는 아래와 같은 노트가 붙어있다.

Swift의 String 타입은 인코딩 독립적인 유니코드 문자들로 구성되며, 다양한 유니코드 표기에 의해 이러한 문자들에 접근할 수 있는 방법들을 지원합니다. NSString 클래스는 유니코드 호환 텍스트를 인코딩하며, 일련의 UTF-16 코드 유닛들로 표현됩니다. 문자열의 길이, 특정 문자의 위치, 부분 범위를 나타내는 NSString의 표기들은 모두 16비트 플랫폼엔디언 값으로 표현되며 Swift의 String 타입의 메소드들은 정수나 범위가 아닌 String.Index 기반의 값을 사용하게 됩니다. 원문

리눅스 컴파일러의 문제

위에서 언급한 대로 리눅스 컴파일러는 String, NSString 간의 변환을 바로 할 수 없다. 대신에 NSStringinit(string:)을 사용하여 변환한다.

// needs Foundation
let msg = "hello world"
//: `String` -> `NSString`
let bridgedString: NSString = NSString(string: msg)

//: `NSString` -> `String`
let string: String = String(describing: bridgedString)

인터폴레이션

어떤 스크립트 언어들은 문자열 리터럴 내에 변수를 집어 넣고 변수 값을 확장하여 문자열로 변환하는 기능을 지원한다. 이를 interpolation이라 하는데 Swift는 이를 지원해준다. 단, 이는 암묵적으로 문자열로 변환이 가능한 타입인 경우에만 가능한데, 모든 Swift 기본 타입들은 이를 지원한다. 이를 지원하는 커스텀 타입을 만들기 위해서는 CustomStringConvertible 프로토콜을 따르도록 하면 된다. 인터폴레이션은 문자열 리터럴 내에서 역슬래시와 괄호를 이용한다.

let year = 2016
let month = 11
let day = 26
print("Today is \(year)-\(month)-\(day)")

임의 타입의 값으로부터 생성하기

값으로부터 생성하는 것은 String( )으로 특정 값을 감싸서 문자열로 만드는 것이다. Swift의 기본 타입들은 모두 이 변환이 가능하며, (사실 인터폴레이션이 가능한 타입들은 다 된다고 보면 된다.) 그외에 이를 통한 직변환이 불가능한 타입들은 String(describing:)을 이용해서 변환하면 된다.

포맷으로부터 생성하기(FND)3

NSString은 기본적으로 Objective-C의 문자열 리터럴을 지원하고 있으며, Objective-C 는 C이기 때문에 C의 리터럴 중 하나인 포맷으로부터 문자열을 만드는 것을 지원한다.
printf()와 완전히 동일한 형태로 사용하거나 아니면 인자들을 하나의 배열에 넣어 사용하는 방법도 지원한다. 그외에 파운데이션 확장에는 로케일 정보를 추가하는 방법도 있다.

  • init(format:_:)
  • init(format:arguments:) (Array 타입으로 인자들을 전달한다.)
  • init(format:locale:_:)
  • init(format:locale:argument:)
// needs Foundation
let i = 13.456
let s = String(format: "value: %.4f", i)
// "value: 13.4560"

반복되는 패턴으로 생성하기

줄문자 등을 출력하기 위해서는 init(repeating:count:)를 사용할 수 있다.

// needs Foundation
let line = String(repeating:"-", count: 80)
print(line)
//"--------------------------------------------------------------------------------"

데이터로부터 생성하기

컴퓨터 프로그램 속에 존재하는 모든 것이 데이터이기는 하지만 여기서는 좁은 의미에서는 Data(NSData)로부터 시작해서 C의 문자배열이나 포인터, 혹은 그와 유사하게 Swift 내의 포인터나 Array<UInt8> 속에 들어있어서 결국에 문자열로 변환할 수 있는 여러가지 경우를 생각해보겠다. 여기서의 데이터는 대체로 저장/전송/전달받은 바이트 버퍼를 의미하며, 그 소스가 어디에 어떤 형태로 존재하는가에 따라서 약간 다른 API를 사용한다는 것을 보여준다.

NSData로부터

NSData/Data는 일련의 바이트 데이터를 저장하고 있는 메모리 공간을 객체화하여 감싸고 있는 데이터 타입으로 저수준의 메모리 관리 및 포인터 접근등을 배제하고 데이터를 보관, 저장, 전송하는 고수준 API를 위한 데이터 버퍼에 대한 래퍼타입이다. 4 NSStringNSData로부터 생성가능하듯, Stringinit?(data:encoding:)으로 생성될 수 있다.

// needs Foundation
let data = Data( .. )
let string: String? = String(data:data, encoding: .utf8)

포인터

드물기는 하지만 Swift 에서도 포인터를 간접적인 방법이나마 다룰 때가 있다. 포인터에 대해서 Data 타입으로 래핑한다음에 init?(data:encoding)을 써도 되지만 한번에 init(cString:)을 이용할 수 있다. 이 때 포인터는 CChar 타입(이는 Int8의 다른 이름이다.)이거나 UInt8 타입이다. "hello"의 각 글자의 아스키코드가 104, 101, 108, 108, 111 이므로 이로부터 hello를 생성하는 코드를 통해서 사용법을 살펴보자.

// needs Foundation
let ptr = UnsafeMutablePointer<UInt8>.allocate(capacity: 5)
var q = ptr
// 버퍼내에 각 글자의 코드값 입력
for i in [104, 101, 108, 108, 111] as [UInt8] {
    q.pointee = i
    q += 1
}

let string = String(cString: ptr)
print(string) // "hello"
ptr.deallocate(capacity: 5)

NSString역시 init?(cString:encoding:)을 지원한다. 차이점은 String 타입은 Cstring 이 항상 UTF8 타입임을 상정하는데 반해서 NSString은 인코딩을 선택해야 하며, 해당 데이터로부터 디코딩이 실패하는 경우를 대비하여 옵셔널 타입을 리턴한다는데 있다. 또한 NSString에는 비슷한 init?(utf8String:)도 있다.

포인터 배열

C에서 포인터는 배열의 시작번지로부터 특정 원소를 오프셋으로 참조하는데 사용되며, 이는 UnsafePointer를 이용해서 오프셋을 옮겨가며 액세스하는 것이 가능하다. Swift는 이보다 좀 더 안전하게 특정 영역의 메모리 버퍼를 마치 배열처럼 액세스하게 해주는 UnsafeBufferPointer 타입을 제공한다.

버퍼 포인터의 .baseAddress 를 이용해서 시작 번지를 얻고, 이를 init(cString:)으로 활용하는 방법도 있지만, 버퍼자체를 이용하는 방법이 있는데, NSStringinit?(bytes:encoding:)을 사용하는 것이다. 아래는 그 예제이다.

import Foundation

let v: [UInt8] = [104, 101, 108, 108, 111]
// 반드시 메모리를 할당해줘야 한다. nil을 시작번지로 버퍼를 만들면 안된다.
let ptr = UnsafeMutablePointer<UInt8>.allocate(capacity: 5)
let bPtr = UnsafeMutableBufferPointer<UInt8>(start:ptr, count: 5)
for (i, c) in v.enumerated() {
    bPtr[i] = c
}

// 시작번지를 가져와서...
let string = String(cString:bPtr.baseAddress!)
print(string) // "hello"

if let string2 = String(bytes:bPtr, encoding: .utf8) {
  print(string2) // "hello"
}

동작 코드의 주소 : http://swiftlang.ng.bluemix.net/#/repl/5819913bf9f5f14d876a304c

코드값의 배열

흥미로운 부분은 init?(bytes:encoding:)의 시그니처이다. 이 메소드는 다음과 같이 정의되어 있다.

// needs Foundation
init?<S: Sequence where S.Iterator.Element == UInt8>(bytes: s, encoding: String.Encoding)

따라서 위 예제에서 bPtr은 마치 배열처럼 bPtr[0] 과 같이 각 원소값을 얻을 수 있기 때문에 변환이 가능했는데, 그렇다면 [UInt8] 타입에 대해서도 변환은 바로 가능하다는 점이다.

import Foundation
let v: [UInt8] = [104, 101, 108, 108, 111]
if let string = String(bytes:v, encoding:.utf8) {
    print(string) // "hello"
}

따라서 대소문자 변환이나 개별 문자를 코드값 수준에서 다루는 함수의 경우에 결과를 배열로 만들고 문자열로 변환하는 등의 처리가 가능하다.

코드값 배열의 트릭

Swift에서 C API를 다룰 때 특정한 타입의 포인터를 받는 C 함수가 있다고 하면, 아마 원래의 C함수는 배열 같은 걸 인자로 받았을 것이다.5 만약 UnsafePointer<CChar> 타입을 인자로 받는 API를 호출한다고 하면, Swift는 실제 포인터를 만들어서 넘겨줄 수 도 있지만, 그냥 [CChar] 값을 넘겨주어도 된다. (그러면 내부적으로 Swift 컴파일러가 이 부분을 알아서 처리해준다.) 그렇다면 String.init(cString:)UnsafePointer<UInt8> , UnsafePointer<CChar> 를 인자로 받으니, [UInt8]을 여기에 그냥 사용해도 된다는 소리잖아?

let s = "hello world"
let cv:(UInt8) -> UInt8 = { (97..<122) ~= $0 ? $0 + 1 : $0 }
print(String(cString: s.utf8.map(cv))) // "ifmmp xpsme"

이제 .map{ Character(UnicodeScalar($0)) } 같은 거 쓰지 않아도 된다.

파일, URL 리소스

텍스트 파일이나 웹서버로부터 전달받은 데이터들은 주로 UTF8이나 그외 인코딩으로 텍스트를 인코딩한 이진데이터이다. 위에서 언급한 NSData는 이들 데이터를 메모리로 읽어와서 그 메모리를 NSData로 감싼 것이다. 따라서 파일을 열고 데이터를 읽거나, 네트워크 포트를 열고 외부 시스템으로부터 데이터를 읽어오는 과정을 앞단에 붙여주면 똑같은 방식으로 파일과 URL로부터 문자열을 읽어올 수 있다.

이 때 URL은 로컬 디스크상의 파일 URL일 수도 있고, 네트워크 상의 URL일 수도 있다.

// fileurl
if let url = URL(fileURLWithPath: "./Resources/mytext.txt") {
    if let string = String(contentsOf:url, encoding:.utf8) {
      print(string)
    }
}

if let path = url?.path {
    if let string = String(contentsOfFile: path, encoding: .utf8) {
        print(string)
    }
}

그외

Character 는 한 글자의 유니코드 글자를 나타내는데6 생성시에 문자열과 똑같은 리터럴을 사용하며 한글자짜리 문자열이나 다름없기 때문에 String(ch)와 같이 바로 변환이 가능하다.

문자열은 내부적으로 String.CharacterView, String.UnicodeScalarView, String.UTF8View, String.UTF16View 등의 표현형을 가질 수 있고, 이런 표현형들은 모두 배열과 비슷한 시퀀스 타입이며, 따라서 부분열을 가져다가 문자열로 생성할 수 있다.

let string = "hello world"
let str2 = String(string.unicodeScalars.prefix(8))
print(str2) // "hello wo"

재밌는 점은 [Character] 타입은 즉시 문자열로 변환 가능한데 비해서 [UnicodeScalar] 타입은 그렇지 않다는 것이다. 따라서 [UnicodeScalar] 타입의 데이터를 가지고 있다면 Character 로 변환하는 맵핑을 한 후에 문자열로 변환해야 한다.

// 대소문자 변환
let convertC: (UnicodeScalar) -> UnicodeScalar? = { x in
    if x.value >= 97 && x.value <= 122 {
        return UnicodeScalar(x.value + 65 - 97)
    }
    return x
}

let s = "hello world"
let t1 = String(s.unicodeScalars.map(convertC).flatMap{ $0 }) ]// Fail (use init(describing:))
let t2 = String(s.unicodeScalars.map(convertC).flatMap{ $0 }.map(Character.init))
//"HELLO WORLD"

let m1 = Mirror(reflecting:s.unicodeScalars.map(convertC).flatMap{ $0 } )
let m2 = Mirror(reflecting:s.unicodeScalars.map(convertC).flatMap{ $0 }.map{ Character($0) } )
debugPrint(m1) // Mirror for Array<UnicodeScalar>
debugPrint(m2) // Mirror for Array<Character>

  1. 아무런 별도 코드 없이 자동으로 A 타입이 B 타입으로 취급되는 것. NSString을 써야 하는 곳에는 String을 넣으면 되고, NSString이 리턴되는 메소드들은 Swift 내에서는 String을 호출한다는 의미로 이해하면 된다. 
  2. 이곳에서 관련 답변을 볼 수 있다. 리눅스 상의 Swift 컴파일러는 as 로 캐스팅할 수 없으며, NSString(string:)으로 별도로 생성해야 한다는 내용이 있다. 
  3. (FND)가 붙으면 파운데이션에서만 가능한 방법이라는 의미이다. 
  4. 자세한 것은 NSData 레퍼런스를 참고하자. 
  5. C언어에서는 int sum(int[]) 따위로 배열타입으로 인자를 코딩해도 실제로 컴파일 되는 결과는 const int*이다. 
  6. Character 자체는 하나의 단위로 취급하지만 유니코드 문자 1개는 같은 바이트 길이를 갖지 않음을 명심 또 명심할 것.