Raywenderlich/Swift Apprentice

Chapter 9: Strings

헬조선의알파고 2022. 9. 29. 11:12

Version

Swift 5.5, iOS 15, Xcode 13

 

지금까지(so far), 텍스트(text)를 표현하기(representing) 위해 String 유형(type)에 제공(offer)해야 하는 것이 무엇인지 간략하게(briefly) 살펴보았다. 텍스트(text)는 사람들의 이름(names), 주소(addresses), 책의 단어(words)와 같이 어디에나 있는(ubiquitous) 데이터 유형(data type)이다. 이 모든 것은 앱(app)이 처리(handle)해야 할 수 있는 텍스트(text)의 예(examples)이다. String이 어떻게 작동(works)하고 무엇을 할 수 있는지 더 깊이(deeper) 이해(understanding)할 가치(worth)가 있다.

이 장(chapter)에서는 문자열(strings)에 대한 일반적인(general) 지식(knowledge)과 문자열(strings)이 Swift에서 작동하는 방식을 자세히(deepens) 설명한다. Swift는 예측 가능한(predictable) 최대 성능(performance)을 유지(maintaining)하면서 유니코드(Unicode) 문자(characters)를 올바르게(correctly) 처리(handle)하는 몇 안 되는(few) 언어(languages) 중 하나이다.

 

Strings as collections

2장(chapter), "Types & Operations"에서 문자열(string)이 무엇인지, 문자 집합(character sets)과 코드 포인트(code points)가 무엇인지 배웠다. 요약하자면(recap), 그것이 나타내는(represents) 문자(character)에 대한 매핑(mapping) 번호를 정의(define)한다. 이제 String 유형(type)을 더 자세히(deeper) 알아볼 차례이다.

문자열(string)을 문자(characters)의 모음(collection)으로 개념화(conceptualize)하는 것은 매우 쉽다. 문자열(strings)은 컬렉션(collections)이기 때문에 다음과 같은 작업을 수행할 수 있다:

let string = "Matt"
for char in string {
  print(char)
}

그러면 Matt의 모든 문자(character)가 개별적(individually)으로 출력(print out)된다.

다음과 같은(such as) 컬렉션(collection) 연산(operations)을 사용할 수도 있다:

let stringLength = string.count

이는 문자열(string)의 길이(length)를 알려준다.

이제 문자열(string)에서 네 번째 문자(character)를 얻으려(get) 한다고 상상(imagine)해 본다. 다음과 같은 작업을 생각했을 수도 있다:

let fourthChar = string[3]

그러나, 이렇게 하면(if you did this) 다음과 같은 오류(error) 메시지(message)가 표시(receive)된다:

'subscript' is unavailable: cannot subscript String with an Int, see the documentation comment for discussion

왜 그런지 간단하게 대답(short answer)하면 문자(characters)는 고정된 크기(fixed size)가 아니기 때문에, 배열(array)처럼 접근(accessed)할 수 없기 때문이다. 자소 집합(grapheme cluster)이 무엇인지 소개(introducing)하여, 문자열(strings)이 어떻게 작동하는지 자세히 살펴봐야(take a detour) 할 때이다.

 

Grapheme clusters

알다시피(As you know), 문자열(string)은 유니코드(Unicode) 문자(characters)의 모음(collection)으로 구성(made up)된다. 지금까지는 하나의 코드 포인트(code point)가 하나의 문자(character)와 정확히(precisely) 일치하고(equal), 그 반대의 경우(vice versa)도 마찬가지로 생각했다(considered). 그러나(however), "character"라는 용어(term)는 비교적(relatively) 느슨(loose)하다.

의외일 수 있지만(It may come as a surprise), 일부 문자(characters)를 표현(represent)하는 두 가지 방법이 있다. 이 경우의 한 가지 예(example)는 café에 있는 é로, e의 강한(acute) 어조(accent)가 필요하다. 이 문자(character)를 하나 또는 두 개의 문자(characters)로 나타낼(represent) 수 있다.

이를 나타내는(represent) 단일 문자(single character) 코드 포인트(code point)는 233이다. 두 문자(two-character)의 경우(case)는 자체적인 e 뒤에 이전(previous) 문자(character)를 수정(modifies)하는 특수(special) 문자(character)인 강한(acute) 어조(accent) 결합 문자(combining character)가 따라온다(followed).

따라서, 다음 방법(means) 중 하나를 사용하여 e를 강한(acute) 어조(accent)로 나타낼(represent) 수 있다:

 

두 번째 도표(diagram)에서 이 두 문자(characters)의 조합(combination)은 유니코드 표준(Unicode standard)으로 정의된(defined) 자소 집합(grapheme cluster)을 형성(forms)한다. 문자(character)를 생각하면, 아마도 자소 집합(grapheme cluster)을 떠올릴 것이다. 자소 집합(grapheme cluster)은 Swift의 Character 유형(type)으로 표시(represented)된다.

문자(characters)를 결합(combining)하는 다른 예(examples)는 특정(certain) 이모지(emojis)의 피부색(skin color)을 변경(change)하는 데 사용되는 특수 문자(special characters)가 있다.

 

여기에서(here), 엄지손가락을 치켜올리는(thumbs-up) 이모지(emoji) 뒤에 피부색(skin tone)을 결합(combining)한 문자(character)가 이어진다(followed). iOS와 macOS를 포함(including)하여 이를 지원(support)하는 플랫폼(platforms)에서 렌더링(rendered)된 이모지(emoji)는 피부색(skin tone)이 적용된(applied) 엄지손가락을 치켜올리는 단일 문자(single thumbs-up character)이다.

이제 문자열(strings)이 컬렉션(collections)으로 사용될 때, 이것이 의미(means)하는 바를 살펴본다(take a look). 다음 코드(code)를 고려해 본다(consider):

let cafeNormal = "café"
let cafeCombining = "cafe\u{0301}"

cafeNormal.count     // 4
cafeCombining.count  // 4

Swift는 문자열(string)을 자소 집합(grapheme clusters)의 모음(collection)으로 간주(considers)하기 때문에, 두 총계(counts)는 모두 4 이다. 또한 문자열(string)의 길이(length)를 확인(evaluating)하는 것은 얼마나 많은 자소 집합(grapheme clusters)이 있는지 확인(determine)하기 위해 모든 문자(characters)를 살펴봐야 하기 때문에 선형 시간(linear time)이 걸린다는 것을 알 수 있다(notice). 문자열(string)이 메모리(memory)에 얼마나 큰 공간을 차지하는지는 단순히(simply) 보는 것만으로는 알(know) 수 없다.

Note: 백슬래시(backslash) 문자(character) \이스케이프 문자(escape character)이다. 여기서 \u 다음에 오는 것이 중괄호(braces)로 묶인 16진수(hexadecimal)의 유니코드 코드 포인트(Unicode code point)임을 나타내기(indicate) 위해 u 뒤에 사용한다. 위 코드의 강한(acute) 어조(accent) 결합 문자(combining character)는 이 구문(syntax)을 사용해 작성한다. 이 약어(shorthand)를 사용하여 모든 유니코드(Unicode) 문자(character)를 작성할 수 있다. 키보드(keyboard)에 이 문자(character)를 입력(type)할 방법이 없기 때문에 결합 문자(combining character)를 사용 했다.

그러나(however), unicodeScalars 뷰(view)로 문자열(string)의 기본(underlying) 유니코드 코드 포인트(Unicode code points)에 접근(access)할 수 있다. 이 뷰(view)는 컬렉션(collection) 그 자체이기도 하다. 따라서 다음을 수행할 수 있다:

cafeNormal.unicodeScalars.count     // 4
cafeCombining.unicodeScalars.count  // 5

이 경우(in this case), 예상한(expect) 대로의 총계(counts) 차이(difference)를 확인할 수 있다.

다음과 같이 이 유니코드 스칼라(Unicode scalars) 뷰(view)를 반복(iterate)할 수 있다:

for codePoint in cafeCombining.unicodeScalars {
  print(codePoint.value)
}

예상대로(expected) 다음 숫자(numbers) 목록(list)이 출력(print)된다:

99
97
102
101
769

 

Indexing strings

앞에서 보았듯이(as you saw earlier), 특정(certain) 문자(character)를 얻기(get) 위해 문자열(string)을 인덱싱(indexing)하는 것은(즉, 자소 집합(grapheme cluster)을 의미한다) 정수(integer) 첨자(subscript)를 사용하는 것만큼 간단(simple)하지 않다. Swift는 내부(under)에서 무슨 일이 일어나는지 알기 원하므로, 좀 더(bit) 장황한(verbose) 구문(syntax)이 필요(requires)하다.

문자열(strings)을 인덱싱(index)하려면, 특정(specific) 문자열 인덱스(string index) 유형(type)에 대해 작업(operate)해야 한다. 예를 들어(for example), 다음과 같이 문자열(string)의 시작(start)을 나타내는(represents) 인덱스(index)를 얻을(obtain) 수 있다:

let firstIndex = cafeCombining.startIndex

플레이그라운드(playground)에서 firstIndex를 옵션 클릭(option-click)하면, 정수(integer)가 아닌 String.Index 유형(type)임을 알 수(notice) 있다.

이 값(value)을 사용하여 다음과 같이 해당 인덱스(index)에서 Character(grapheme cluster, 자소 집합)를 얻을(obtain) 수 있다:

let firstChar = cafeCombining[firstIndex]

이 경우(in this case), firstChar는 물론(of course) c가 된다. 이 값(value)의 유형(type)은 자소 집합(grapheme cluster)인 Character이다.

마찬가지로(similarly), 다음과 같이 마지막(last) 자소 집합(grapheme cluster)을 얻을 수 있다:

let lastIndex = cafeCombining.endIndex
let lastChar = cafeCombining[lastIndex]

하지만 이렇게 하면, 콘솔(console)에서 치명적인 오류(fatal error)를 확인할 수 있다(그리고 코드에선 EXC_BAD_INSTRUCTION를 확인할 수 있다):

Fatal error: String index is out of bounds

이 오류(error)는 endIndex가 문자열의 끝(the end of the string) 보다 한 칸 뒤의(past) 인덱스를 반환하기 때문에 발생하기 때문에 발생(happens)한다. 마지막(last) 문자(character)를 얻으려면(obtain) 다음을 수행해야 한다:

let lastIndex = cafeCombining.index(before: cafeCombining.endIndex)
let lastChar = cafeCombining[lastIndex]

마지막 인덱스(end index) 바로 앞(just before)의 인덱스(index)를 얻은(obtaining) 다음, 해당 인덱스(index)의 문자(character)를 얻는다(obtaining). 또는(alternatively), 다음과 같이 첫 번째(first) 문자(character)에서 오프셋(offset)할 수 있다:

let fourthIndex = cafeCombining.index(cafeCombining.startIndex,
                                      offsetBy: 3)
let fourthChar = cafeCombining[fourthIndex]

이 경우(in this case), fourthChar는 예상대로(expected) é이다.

그러나 알다시피(but as you know), 이 경우(case) é는 여러(multiple) 코드 포인트(code points)로 구성(made up)된다. String에서 unicodeScalars 뷰(view)를 사용한 것과 동일한 방식(same way)으로, Character 유형(type)에서도 이러한 코드 포인트(code points)에 접근(access)할 수 있다. 다음과 같이 할 수 있다:

fourthChar.unicodeScalars.count // 2
fourthChar.unicodeScalars.forEach { codePoint in
  print(codePoint.value)
}

이번에는 forEach 함수(function)를 사용(using)하여 유니코드 스칼라(Unicode scalars) 뷰(view)를 반복(iterate)한다. 총계(count)는 예상대로(expected) 2이고, 반복문(loop)은 다음을 출력(prints out)한다:

101
769

 

Equality with combining characters

문자(characters)를 결합(combining)하면, 문자열(strings)의 동일성(equality)이 조금 더 까다로워(trickier)진다. 예를 들어(for example), 다음과 같이  é 단일 문자(single character)와 결합 문자(combining character)를 사용한 café 라는 단어(word)를 고려(consider)해 본다:

 

물론(of course) 이 두 문자열(strings)은 논리적으로(logically) 동일(equal)하다. 화면(on-screen,)에 출력(printed)될 때 동일한 문자(glyph)를 사용하고 똑같이 보인다. 그러나 컴퓨터(computer) 내부(inside)에서는 다른(different) 방식(ways)으로 표현(represented)된다. 많은 프로그래밍 언어(programming languages)는 코드 포인트(code points)를 하나씩 비교(comparing)하며 작동(work)하기 때문에 이러한 문자열(strings)을 같지 않은(unequal) 것으로 간주(consider)한다.

그러나(however), Swift는 이러한 문자열(strings)을 기본적으로(default) 동일(equal)한 것으로 간주(considers)한다. 실제로 확인해 본다.

let equal = cafeNormal == cafeCombining

이 경우(in this case), 두 문자열(strings)이 논리적으로(logically) 동일하기 때문에 equaltrue이다.

Swift의 문자열(string) 비교(comparison)는 정규화(canonicalization)라는 기술(technique)을 사용한다. 동일성(equality)을 확인(checking)하기 전에, Swift는 두 문자열(strings)을 모두 정규화(canonicalizes)한다. 즉, 동일한 특수(special) 문자(character) 표현(representation)을 사용하도록 변환(converted to)된다.

두 문자열(strings)이 동일한 방식(style)으로 변환(get converted to)되는 것이 중요할 뿐, Swift가 단일 문자(single character)나 결합 문자(combining character)를 사용하여 정규화(canonicalization)를 수행하는 방법은 여기서 중요하지 않다(it doesn’t matter). 정규화(canonicalization)가 완료(complete)되면, Swift는 개별 문자(individual characters)를 비교(compare)하여 동일성(equality)을 확인(check)할 수 있다.

특정(particular) 문자열(string)에 몇 개의 문자(characters)가 있는지 확인(considering)할 때도 동일한 정규화(canonicalization)가 적용된다. 앞에서 본 것과 같이 단일(single) é 문자(character)를 사용한 cafée에 결합 강조 문자(combining accent character)를 사용한 café의 길이(length)는 동일하다.

 

Strings as bi-directional collections

때로는(sometimes), 문자열(string)을 반전(reverse) 하고 싶을 때가 있다. 이는 역방향(backward)으로 반복(iterate)하는 것과 같다. 다행히(fortunately), Swift에는 이를 간단하게 수행할 수 있는 reversed() 메서드(method)가 있다:

let name = "Matt"
let backwardsName = name.reversed()

하지만, backwardName의 유형(type)은 String이 아니라 ReversedCollection<String> 이다. 이러한 유형(type) 변경(changing)은 Swift의 똑똑한(smart) 최적화(optimization)이다. 구체적인(concrete) String가 아닌, 역 컬렉션(reversed collection)이다. 추가적인(additional) 메모리(memory) 사용(usage)없이, 컬렉션(collection)을 다른 방식으로 사용할 수 있도록 컬렉션(collection) 주변을 둘러싼 얇은(thin) 래퍼(wrapper)로 생각하면 된다.

다른 문자열(string)과 마찬가지로, 역방향(backwards) 문자열(string)의 모든 Character에 접근(access)할 수 있다:

let secondCharIndex = backwardsName.index(backwardsName.startIndex,
                                          offsetBy: 1)
let secondChar = backwardsName[secondCharIndex] // "t"

String 유형(type)을 원한다면, 다음과 같이 역 컬렉션(reversed collection)에서 String을 초기화(initializing)하면 된다:

let backwardsNameString = String(backwardsName)

이렇게 하면 역 컬렉션(reversedcollection)에서 새 String이 생성(create)된다. 그러나 이렇게 하면 고유한 메모리 주소(own memory storage)가 있는 원본(original) 문자열(string)의 역 복사본(reversed copy)을 생성하게 된다. 역 컬렉션(reversed collection) 영역(domain)에 머무르면 메모리 공간(memory space)이 절약(save)되므로, 전체 역(reversed) 문자열(string)이 필요하지 않는 경우에 좋은(fine) 선택이다.

 

Raw strings

원시 문자열(raw string)은 특수 문자(special characters)나 문자열(string) 보간(interpolation)을 피하(avoid)려는 경우에 유용(useful)하다. 대신(instead), 입력(type)한 전체(complete) 문자열(string)이 그대로 반영 된다. 이를 설명(illustrate)하기 위해, 다음 원시 문자열(raw string)을 고려해 본다:

let raw1 = #"Raw "No Escaping" \(no interpolation!). Use all the \ you want!"#
print(raw1)

원시 문자열(raw string)을 나타내려면(denote) 문자열(string)을 # 기호(symbols)로 묶는다(surround). 이 코드(code)는 다음을 출력(prints)한다:

Raw "No Escaping" \(no interpolation!). Use all the \ you want!

# 기호(symbols)를 사용(use)하지 않으면 이 문자열(string)은 보간(interpolation)을 사용하려 하고, "no interpolation!"이 유효(valid)하지 않기 때문에 컴파일(compile)되지 않는다. 코드(code)에 #을 포함(include)하고 싶다면, 그렇게 할 수도 있다. 다음과 같이 시작(beginning)과 끝(end)이 일치(match)하는 한, 원하는 만큼의 # 기호(symbols)를 사용할 수 있다:

let raw2 = ##"Aren’t we "# clever"##
print(raw2)

출력(prints)은 다음과 같다:

Aren’t we "# clever

원시 문자열(raw strings)에 보간(interpolation)을 사용(use)하려면 다음과 같이 사용하면 된다:

let can = "can do that too"
let raw3 = #"Yes we \#(can)!"#
print(raw3)

출력(prints)은 다음과 같다:

Yes, we can do that too!

원시 문자열(raw strings)을 재미있게 사용하는 방법이 하나 더 있다. 때때로(from time to time) 프로그램(programs)에서 ASCII 아트(art)를 사용해야 할 수도 있다. ASCII 아트(art)는 간단한(simple) 문자(characters)를 사용하여 그림(picture)을 그리는(draw out) 것이다. 문제(problem)는 ASCII 아트(art)가 백슬래시(backslash) 문자(character)인 \를 포함(contain)하는 경우가 많은데, 이는 앞서(earlier) 보았듯이 일반적으로(usually) 이스케이프(escape) 문자(character)이다. 모든 \가 이스케이프(escapes) 처리(treated)되기 때문에, 원시 문자열(raw strings)은 ASCII 아트(art)에 적합(good for)하다.

다음과 같은 ASCII 아트(art)를 직접 사용(try out)해 볼 수 있다:

let multiRaw = #"""
  _____         _  __ _
 / ____|       (_)/ _| |
| (_____      ___| |_| |_
 \___ \ \ /\ / / |  _| __|
 ____) \ V  V /| | | | |_
|_____/ \_/\_/ |_|_|  \__|
"""#
print(multiRaw)

깔끔하게(neat) 적용되다.

Swift 팀(team)은 모든 것을 원시 문자열(raw strings)로 생각한 것 같다.

 

Substrings

문자열(strings)을 조작(manipulating)할 때 자주(often) 수행하는 또 다른 작업은 하위 문자열(substrings)을 생성(generate)하는 것이다. 즉, 문자열(string)의 일부를 자체 값(value)으로 추출(pull out)한다. Swift에서는 이를 다양한 인덱스(indices)의 범위(range)를 사용하는 아래 첨자(subscript)로 수행(done)할 수 있다.

예를 들어(for example), 다음 코드를 고려(consider)해 본다:

let fullName = "Matt Galloway"
let spaceIndex = fullName.firstIndex(of: " ")!
let firstName = fullName[fullName.startIndex..<spaceIndex] // "Matt"

이 코드(code)는 첫 번째(first) 공백(space)을 나타내는 인덱스(index)를 찾는다(해당 부분이 존재(exists)한다는 걸 알고 있으므로, 여기서는 강제 언래핑(force unwrap)을 사용했다). 그런 다음 범위(range)를 사용하여 시작(start) 인덱스(index)와 공백(space)의 인덱스(index, 공백 제외) 사이(between)의 자소 집합(grapheme clusters)을 찾는다(find).

지금이야말로 이전(before)에 볼 수 없었던 새로운 유형(type)의 범위(range)인 개방형 범위(open-ended range)을 소개(introduce)할 최적(excellent)의 시간이다. 이 유형(type)의 범위(range)는 하나의 인덱스(index)만 사용하고, 다른 하나의 인덱스는 컬렉션(collection)의 시작(start) 또는 끝(end)이라고 가정(assumes)한다.

코드(code)의 마지막(last) 행(line)은 개방형 범위(open-ended range)를 사용하여, 다시 작성(rewritten)할 수 있다:

let firstName = fullName[..<spaceIndex] // "Matt"

여기에선 fullName.startIndex를 생략(omit)했지만, Swift는 의미(mean)하는 바를 추론(infer)할 것이다.

마찬가지로(similarly), 다음과 같이 단측 범위(one-sided range)를 사용하여, 특정(certain) 인덱스(index)에서 시작(start)하여 컬렉션(collection)의 끝(end)으로 이동할 수도 있다:

let lastName = fullName[fullName.index(after: spaceIndex)...]
// "Galloway"

부분 문자열(substrings)에서 짚고(point out) 넘어가야 할 흥미로운(interesting) 점이 있다. 유형(type)을 확인해(look at) 보면 String이 아닌 String.SubSequence 유형(type)임을 알 수 있다. 이 String.SubSequenceSubstringtypealias일 뿐이다. 즉, Substring은 실제 유형(actual type)이고 String.SubSequence는 별칭(alias)이다.

반전된 문자열(reversed string)과 마찬가지로, 다음을 수행하여 이 SubstringString으로 만들 수 있다:

let lastNameString = String(lastName)

이러한 추가적인(extra) Substring 유형(type)을 사용하는 이유는 정교한(cunning) 최적화(optimization)를 위해서 이다. Substring은 나누어진(sliced from) 상위(parent) String과 저장위치(storage)를 공유(shares)한다. 즉, 문자열(string)을 자르는(slicing) 과정(process)에서 추가(extra) 메모리(memory)를 사용하지 않는다. 그런 다음 부분 문자열(substring)을 String로 사용해야 하는 경우, 명시적으로(explicitly) 새(new) 문자열(string)을 생성(create)하면 메모리(memory)가 이 새 문자열(string)에 대한 새로운 버퍼(buffer)에 복사(copied into)된다.

Swift의 설계자들(designers)은 이러한 복사(copying) 동작(behavior)을 기본으로(by default) 할 수 있었다. 그러나(however) 별도(separate)의 Substring 유형(type)을 사용함으로써, Swift는 무슨 일이 일어나고 있는지(happening) 매우 분명(explicit)하게 보여준다. 좋은 소식(news)은 String과 Substring이 거의 모든 기능(capabilities)을 공유(share)한다는 것이다. String이 필요한(requires) 다른(another) 함수(function)에 Substring을 반환(return)하거나 전달(pass)할 때까지 어떤 유형(type)을 사용 중인지 인식(realize)하지 못할 수도 있다. 이 경우(in this case) Substring에서 새 String을 명시적으로(explicitly) 초기화(initialize)하기만 하면 된다.

다행히도(hopefully) Swift는 문자열(strings)에 대한 분명한(clear) 의견(opinionated)이 있고, 이를 구현(implements)하는 방식에 있어 매우 신중(deliberate)하다. 문자열(strings)은 복잡한(complex beasts)하고, 자주(frequently) 사용되기 때문에 이에 대해 알고 있는 것은 중요(important)하다. 절제한 표현(understatement)으로 API를 올바르게 맞추는 것은 중요하다.

 

Character properties

이 장(chapter)의 앞부분(earlier)에서 Character 유형(type)을 접했다(encountered). 이 유형(type)의 흥미로운(interesting) 속성들(properties)을 살펴보면서, 문자(character)를 확인(introspect)하고 그 의미(semantics)에 대해 배울 수 있다.

몇 가지 속성(properties)을 살펴보도록(look at) 한다.

첫 번째(first)는 단순히(simply) 문자(character)가 ASCII 문자 집합(character set)에 속하는지(belongs to) 확인(finding out)하는 것이다. 다음과 같이 확인(achieve)할 수 있다:

let singleCharacter: Character = "x"
singleCharacter.isASCII
Note: ASCII는 정보 교환을 위한 미국 표준 코드(American Standard Code for Information Interchange)이다. 1960년대 Bell Labs에서 문자열(strings)을 나타내기(representing) 위해 개발한(developed) 고정 너비 7비트 코드(fixed-width 7-bit code)이다. 그 역사(history)와 중요성(importance) 때문에 표준 8비트 유니코드 인코딩(standard 8-bit Unicode encoding, UTF-8)은 ASCII의 상위 집합(superset)으로 만들어(created) 졌다. 이 장(chapter)의 뒷부분(later)에서 UTF-8에 대해 더 자세히 배울(learn) 것이다.

이 경우(in this case), "x"가 실제로(indeed) ASCII 문자 집합(character set)에 있으므로 결과(result)는 true이다. 그러나(however), "party face" 이모지(emoji)인 "🥳"에 대해 이 작업을 수행하면 false가 된다.

다음으로, 공백(whitespace)이 있는지 확인(checking)한다. 프로그래밍 언어(programming languages)에서 종종(often) 공백(whitespace)이 의미(meaning)를 갖기 때문에 유용(useful)할 수 있다.

다음과 같이 확인해 볼 수 있다:

let space: Character = " "
space.isWhitespace

여기서도 결과(result)는 true이다.

다음은 16진수(hexadecimal digit)여부를 확인(checking)하는 것이다. 이는 일부 텍스트(text)를 구문 분석(parsing)하여 어떤 것이 유효한(valid) 16진수(hexadecimal)인지 알고 싶을 때 유용(useful)할 수 있다. 다음과 같이 확인해 볼 수 있다:

let hexDigit: Character = "d"
hexDigit.isHexDigit

결과(result)는 true이지만 "s"를 확인(check)하도록 변경(changed)하면 false가 된다.

마지막으로(finally) 소개할 강력한(powerful) 속성(property)을 사용하면 문자(character)를 숫자 값(numeric value)으로 변환(convert)할 수 있다. 이는 문자(character) "5"를 숫자(number) 5로 변환(converting)하는 것과 같이 간단하게(simple) 보일지 모르지만, 라틴 문자가 아닌 문자(non-Latin characters)에서도 동작(works)한다. 예를 들면(for example) 다음과 같다:

let thaiNine: Character = "๙"
thaiNine.wholeNumberValue

이 경우(in this case)에는 태국어(Thai) 문자(character) 9이므로, 결과(result)는 9다. 깔끔하다(neat).

이는 Character 속성(properties)의 일부(scratching the surface)일 뿐이다. 여기에서 하나하나 살펴보기에는 너무나 많다. 이러한 내용을 추가(added)한 Swift evolution proposal에서 더 많은 것을 확인해 볼 수 있다.

 

Encoding

지금까지 문자열(strings)이 무엇인지 배우고(learned) 이를 작업(work)하는 방법을 살펴(explored)보았지만, 문자열(strings)이 저장(stored)되거나 인코딩(encoded)되는 방법은 다루지(touched) 않았다.

문자열(strings)은 유니코드 코드 포인트(Unicode code points)의 모음(collection)으로 구성(made up)된다. 이러한 코드 포인트(code points)의 범위(range)는 0에서 1114111(16진수(hexadecimal)로 0x10FFFF)까지이다. 즉, 코드 포인트(code point)를 나타내는(represent) 데 필요한 최대 비트(bits) 수는 21이다.

그러나(however), 라틴(Latin) 문자(characters)만 포함(contains)된 텍스트(text)처럼 작은(low) 코드 포인트(code points)만 사용하는 경우에는 코드 포인트(code point)당 8비트(bits)만 사용하면 된다.

대부분(most)의 프로그래밍 언어(programming languages)에서 숫자(numeric) 유형(types)은 8비트(bits), 16비트(bits), 32비트(bits)와 같이 주소 지정이 가능한(addressable) 2의 거듭제곱(powers) 비트(bits) 크기(sizes)로 제공된다. 이것은 컴퓨터(computers)가 꺼지(off)거나 켜진(on) 수십억(billions) 개의 트랜지스터(transistors)로 구성(made of)되어 있기 때문이다. 이것들은 2의 배수(powers)를 좋아한다.

문자열(strings)을 저장(store)하는 방법을 선택(choosing)할 때, 모든 개별(individual) 코드 포인트(code point)를 UInt32와 같은 32비트(bit) 유형(type)을 지정할 수 있습니다. 이러한 String 유형(type)은 [UInt32](UInt32 배열(array))으로 구성(backed by)된다. 여기서 각각의 UInt32코드 단위(code unit)라고도 한다. 그러나(however), 문자열(string)이 작은(low) 코드 포인트(code points)만 사용하는 경우, 이러한 비트(bits)가 모두 필요(needed)한 것은 아니기 때문에 공간(space)을 낭비(wasting)하게 된다.

이러한 문자열(strings) 저장(store) 방법들을 문자열(string) 인코딩(encoding)이라고 한다. 위(above)에서 설명한(described) 이 특정한(particular) 방식(scheme)을 UTF-32라고 한다. 그러나(however), 메모리(memory) 사용량(usage)이 비효율적(inefficient)이기 때문에 거의 사용되지 않는다.

 

UTF-8

이보다 더 보편(common)적인 방식(scheme)인 UTF-8가 있다. 이는 대신 8비트(bit) 코드 단위(code units)를 사용한다. UTF-8이 인기(popularity) 있는 이유(reason) 중 하나는 유서 깊은(venerable) 영어 전용의 7비트(bit) ASCII 인코딩(encoding)과 완벽(fully)하게 호환(compatible)되기 때문이다. 하지만 8비트(bits) 이상이 필요한 코드 포인트(code points)는 어떻게 저장(store)할 수 있는지 궁금할 것이다. 여기에(herein) 인코딩(encoding)의 마법(magic)이 있다(lies).

코드 포인트(code point)에 최대 7비트(bits)가 필요한(requires) 경우, 하나의 코드 단위(code unit)로 표시(represented)되며 ASCII와 동일(identical)하다. 그러나 7비트(bits)를 넘어서는 코드 포인트(code points)의 경우, 최대 4개의 코드 단위(code units)를 사용하여 해당 코드 포인트(code point)를 나타내는(represent) 방식(scheme)이 적용(uses)된다.

8~11비트(bits)의 코드 포인트(code points)의 경우, 두 개의 코드 단위(code units)가 사용된다. 첫 번째 코드 단위(code unit)의 초기(initial) 3비트(bits)는 110이다. 나머지(remaining) 5비트(bits)는 코드 포인트(code point)의 처음 5비트(bits)이다. 두 번째 코드 단위(code unit)의 초기(initial) 2비트(bits)는 10이다. 나머지(remaining) 6비트(bits)는 코드 포인트(code point)의 나머지(remaining) 6비트(bits)이다.

예를 들어(for example), 코드 포인트(code point) 0x00BD는 ½ 문자(character)를 나타낸다(represents). 이진법(binary)으로 이것은 10111101이며 8비트(bits)를 사용한다. UTF-8에서 이는 1100001010111101의 두 가지 코드 단위(code units)로 구성(comprise)된다.

이를 설명(illustrate)하기 위해 다음 도식표(diagram)를 고려(consider)해 본다:

 

물론(of course) 11비트(bits)보다 큰 코드 포인트(code points)도 지원(supported)한다. 다음 방식(scheme)에 따라 12~16비트(bit) 코드 포인트(code points)는 3개의 UTF-8 코드 단위(code units)를 사용하고, 17~21비트 코드 포인트(code points)는 4개의 UTF-8 코드 단위(code units)를 사용한다:

 

각 x는 코드 포인트(code points)의 비트(bits)로 대체(replaced with)된다. Swift에서는 utf8 뷰(view)를 사용해 UTF-8 문자열(string) 인코딩(encoding)에 접근(access)할 수 있다. 예를 들어(for example), 다음 코드(code)를 고려해(consider) 본다:

let char = "\u{00bd}"
for i in char.utf8 {
  print(i)
}

utf8 뷰(view)는 unicodeScalars 뷰(view)와 마찬가지로 컬렉션(collection)이다. 그 값(values)은 문자열(string)을 구성(make up)하는 UTF-8 코드 단위(code units)이다. 이 경우(in this case)에는 단일 문자(single character), 즉 위에서 논의한(discussed) 문자이다.

위 코드(code)는 다음을 출력(print)한다:

194
189

계산기(calculator)를 꺼낸다면(pull out, 또는 환상적인(fantastic) 암산 실력(arithmetic mind)을 가지고 있다면), 예상한 대로(expected) 각각(respectively) 1100001010111101임을 확인(validate)할 수 있다.

이제 이 섹션(section)의 뒷부분(later)에서 다시 참조(refer)할 더 복잡한(complicated) 예(example)를 고려해 본다(consider). 다음과 같은 문자열(string)을 사용한다:

+½⇨🙃

그리고 포함(contains)된 UTF-8 코드 단위(code units)를 반복(iterate)한다:

let characters = "+\u{00bd}\u{21e8}\u{1f643}"
for i in characters.utf8 {
  print("\(i) : \(String(i, radix: 2))")
}

이번에는 print 문(statement)으로 10진수(decima)와 2진수(binary)를 모두 출력(print out)한다. 분할(split) 자소 집합(grapheme clusters)에 개행(newlines)을 추가하여 다음을 출력(prints)한다:

43 : 101011

194 : 11000010
189 : 10111101

226 : 11100010
135 : 10000111
168 : 10101000

240 : 11110000
159 : 10011111
153 : 10011001
131 : 10000011

이것이 실제로(indeed) 올바른지(correct) 자유롭게 확인(verify)해 본다. 첫 번째 문자(character)는 하나의 코드 단위(code unit)를 사용하고 두 번째 문자는 두 개의 코드 단위(code units)를 사용하는 식이다.

따라서(therefore) UTF-8은 UTF-32보다 훨씬 더 간결(compact)하다. 이 문자열(string)의 경우 10바이트(bytes)를 사용하여 4개의 코드 포인트(code points)를 저장했다. UTF-32에서 이것은 16바이트(bytes)이다(코드 단위(code unit)당 4바이트(bytes), 코드 포인트(code point)당 1 코드 단위(code unit), 4개의 코드 포인트(code points)).

하지만(though) UTF-8에도 단점(downside)이 있다. 특정(certain) 문자열(string) 작업(operations)을 처리(handle)하려면 모든 바이트(byte)를 검사(inspect)해야 한다. 예를 들어(for example), n번째 코드 포인트(code point)로 건너뛰려면(jump) n-1개의 코드 포인트(code points)를 지날(past) 때까지 모든 바이트(byte)를 검사(inspect)해야 한다. 얼마나 멀리 건너 뛰어야(jump) 하는지 알 수 없기 때문에, 단순히 버퍼(buffer)로 건너뛸(jump) 수 없다.

 

UTF-16

또 다른 유용한 인코딩(encoding)으로, UTF-16이 있다. 짐작(guessed)했겠듯이, 16비트(bit) 코드 단위(code units)를 사용한다.

이는 최대 16비트(bits)인 코드 포인트(code point)가 하나의 코드 단위(code unit)를 사용한다는 것을 의미한다. 그러나 17~21비트의 코드 포인트(code points)는 어떻게 표현(represented)하는지 궁금할 것이다. 여기서는 서로게이트 쌍(surrogate pairs)으로 알려진 방식(scheme)을 사용한다. 이는 두 개의 UTF-16 코드 단위(code units)로, 서로 옆에 있을 때(next to each other) 16비트(bits) 이상의 범위(range)에서 코드 포인트(code point)를 나타낸다(represent).

유니코드(Unicode) 내에는 이러한 대리 쌍(surrogate pair) 코드 포인트(code points)용도로 예약된(reserved for) 공간(space)이 있다. 그들은 하위(low) 서로게이트와 상위(high) 서로게이트(surrogates)로 나뉜다(split into). 상위(high) 서로게이트(surrogates)의 범위(range)는 0xD800 ~ 0xDBFF이고 하위(low) 서로게이트(surrogates) 범위(range)는 0xDC00 ~ 0xDFFF이다.

거꾸로(backward)된 것처럼 들릴 수도 있지만, 여기에서의 높고(high) 낮음(low)은 해당 서로게이트(surrogate)가 나타내는(represented) 원래(original) 코드 포인트(code point)의 비트(bits)를 나타낸다(represented).

앞에서(earlier) 사용한 문자열(string)에서 거꾸로 얼굴(upside-down face) 이모지(emoji)를 가져온다(take). 코드 포인트(code point)는 0x1F643이다. 이 코드 포인트(code point)에 대한 서로게이트 쌍(surrogate pairs)을 찾으려면(find out), 다음과 같은 알고리즘(algorithm)을 적용(apply)한다:

  1. 주어진 문자에서 0x10000를 빼면(subtract) 0xF643이 되고, 이는 이진수(binary)로 0000 1111 0110 0100 0011이다.
  2. 해당 20비트(bits)를 두 개로 나눈다(split). 그러면 0000 1111 0110 0100 0011가 된다.
  3. 첫 번째(first)를 가져와 0xD800을 더하면(add) 0xD83D이 된다. 이것이 상위 서로게이트(high surrogate)이다.
  4. 두 번째(second)를 가져와 0xDC00을 더하면(add) 0xDE43이 된다. 이것이 하위 서로게이트(low surrogate)이다.

따라서 UTF-16에서 거꾸로 얼굴(upside-down face) 이모지(emoji)는 0xD83D 다음에 0xDE43이 오는 코드 단위(code unit)로 표시(represented)된다. 깔끔하다(neat).

UTF-8과 마찬가지로(Just as with), Swift에서 다음과 같이 utf16 뷰(view)를 사용해 UTF-16 코드 단위(code units)에 접근(access)할 수 있다:

for i in characters.utf16 {
  print("\(i) : \(String(i, radix: 2))")
}

이 경우(in this case), 분할(split) 자소 집합(grapheme clusters)에 개행(newlines)이 추가되어 다음과 같이 출력(printed)된다:

43 : 101011

189 : 10111101

8680 : 10000111101000

55357 : 1101100000111101
56899 : 1101111001000011

보다시피(as you can see), 둘 이상의 코드 단위(code unit)를 사용해야 하는 유일한 코드 포인트(code point)는 마지막의 거꾸로 얼굴(upside-down face) 이모지(emoji)뿐 이다. 예상대로(as expected) 값(values)은 정확(correct)하다.

따라서 여기에서는 UTF-16을 사용해도, 문자열(string)이 UTF-8과 동일한 10바이트(bytes, 5 코드 단위(code units), 코드 단위(code unit)당 2바이트(bytes))를 사용한다. 그러나(however), UTF-8과 UTF-16의 메모리 사용량(memory usage)은 종종(often) 다르다(different). 예를 들어(for example), 7비트(bits) 이하의 코드 포인트(code points)로 구성(comprised)된 문자열(strings)은 UTF-16에서 UTF-8에서보다 두 배(twice)의 공간(space)을 차지(take up)한다.

7비트(bits) 이하의 코드 포인트(code points)로 구성(made up)된 문자열(string)의 경우, 문자열(string)은 해당 범위(range)에 포함된 라틴 문자(Latin characters)로만(entirely) 구성(made up)되어야 한다. 심지어(even) "£"기호(sign)도 이 범위(range)에 없다. 따라서 종종(often) UTF-16과 UTF-8의 메모리 사용량(memory usage)은 비슷(comparable)하다.

Swift 문자열(string) 뷰(views)는 String 유형(type) 인코딩(encoding)을 독립적(agnostic)이게 하며, Swift는 이를 수행하는 유일한 언어(languages) 중 하나이다. 내부적으론(internally) UTF-8, C 언어 호환(compatible), NULL 종료(terminated) 문자열(strings)을 사용하는데, 이것이 메모리 사용량(memory usage)과 작업(operations) 복잡성(complexity) 사이의 최적 지점(sweet spot)이기 때문이다.

 

Converting indexes between encoding views

앞서 보았듯이(as you saw earlier), 인덱스(indexes)를 사용하여 문자열(string)의 자소 집합(grapheme clusters) 접근(access)할 수 있다. 예를 들어(for example), 위(above)와 동일한 문자열(string)을 사용하여 다음(following)을 수행할 수 있다:

let arrowIndex = characters.firstIndex(of: "\u{21e8}")!
characters[arrowIndex] // ⇨

여기에서 arrowIndexString.Index 유형(type)이며 해당 인덱스(index)에서 Character를 얻는(obtain) 데 사용된다.

unicodeScalars, utf8, utf16 뷰(views)에서 이 인덱스(index)를 자소 집합(grapheme cluster)의 시작(start)과 관련된(relating) 인덱스(index)로 변환(convert)할 수 있다. 다음과 같이 String.IndexsamePosition(in:) 메서드(method)를 사용하여 이를 수행한다:

if let unicodeScalarsIndex = arrowIndex.samePosition(in: characters.unicodeScalars) {
  characters.unicodeScalars[unicodeScalarsIndex] // 8680
}

if let utf8Index = arrowIndex.samePosition(in: characters.utf8) {
  characters.utf8[utf8Index] // 226  
}

if let utf16Index = arrowIndex.samePosition(in: characters.utf16) {
  characters.utf16[utf16Index] // 8680
}

unicodeScalarsIndexString.UnicodeScalarView.Index 유형(type)이다. 이 자소 집합(grapheme cluster)은 단 하나의 코드 포인트(code point)로 표시(represented)되므로, unicodeScalars 뷰(view)에서 반환된(returned) 스칼라(scalar) 역시 하나의 코드 포인트(code point)이다. 앞서 살펴본 e와 '가 결합(combined)된 Character처럼 두 개의 코드 포인트(code points)로 구성(made up)된 경우, 위의 코드(code)에서 반환(returned)되는 스칼라(scalar)는 "e"일 것이다.

마찬가지로(likewise), utf8IndexString.UTF8View.Index 유형(of)이며, 해당 인덱스(index)의 값(value)은 이 코드 포인트(code point)를 나타내는(represent) 데 사용되는 첫 번째(first) UTF-8 코드 단위(code unit)이다. String.UTF16View.Index 유형(type)의 utf16Index도 마찬가지(same)이다.

 

Challenges

다음 단계로 넘어가기 전에(before moving on) 문자열(strings)에 대한 지식(knowledge)을 확인(test)하기 위한 몇 가지 챌린지(challenges)가 있다. 스스로 해결(solve)해 보려고 하는 것이 가장 좋지만, 막힌다면(get stuck) 다운로드(download)나 책의 소스 코드 링크(source code link)에서 해답(solutions)을 참고할 수 있다.

 

Challenge 1: Character count

문자열(string)을 받아(takes), 문자열의 각(each) 문자(character) 수(count)를 출력(prints out)하는 함수(function)를 작성한다. 추가적으로 각(each) 문자(character)를 수(count)에 따라 정렬(ordered)하여 멋진(nice) 히스토그램(histogram)으로 출력(print)한다.

Hint: # 문자(characters)를 사용하여 막대(bars)를 그릴 수 있다.

 

Challenge 2: Word count

문자열(string)에 몇 개의 단어(words)가 있는지 알려주는(tells) 함수(function)를 작성(write)한다. 문자열(string)을 분할(splitting)하지 않고 이를 수행한다.

Hint: 문자열(string)을 직접 반복(iterating)한다.

 

Challenge 3: Name formatter

"Galloway, Matt"처럼 문자열(string)을 받아 "Matt Galloway" 문자열(string)을 반환(returns)하는 함수(function)를 작성(write)한다. 즉, 문자열(string)이 "<LAST_NAME>, <FIRST_NAME>"에서 "<FIRST_NAME> <LAST_NAME>" 으로 되어야 한다.

 

Challenge 4: Components

주어진 문자열(split)을 구분된 덩어리(chunks)로 분할(split)하여 해당 결과(results)를 배열(array)로 반환(return)하는 components(separatedBy:)라는 문자열(string) 메서드(method)가 있다.

이번 챌린지(challenge)는 이것을 직접 구현하는 것이다.

Hint: String에는 해당 문자열(string)의 모든 인덱스(indices, String.Index 유형(type))를 반복(iterate)할 수 있는 indices 뷰(view)가 있다. 이것을 사용 한다.

 

Challenge 5: Word reverser

문자열(string)을 받아(takes), 각(each) 개별(individual) 단어(word)를 역순으로(reversed) 반환(returns)하는 함수(function)를 작성(write)한다.

예를 들어(for example) 문자열(string)이 "My dog is called Rover"인 경우, 결과 문자열(resulting string)은 "yM god si dellac revoR"이 된다.

공백(space)을 찾을(find) 때까지 문자열(string)의 indices를 반복(iterating)한 다음, 그 앞(before)의 것을 반대로(reversing) 한다. 문자열(string)을 반복(iterate)하면서 계속(continually)하여 결과(result) 문자열(string)을 작성(build up)한다.

Hint: 챌린지(challenge) 4와 비슷한(similar) 작업을 수행하지만, 매번(each time) 단어(word)를 바꿔야(reverse) 한다. 이전(previous) 챌린지(challenge)에서 작성한(created) 함수(function)를 사용하는 것보다, 메모리 사용(memory usage) 면(terms)에서 이것이 더 나은(better) 이유를 자신(yourself)이나 가까운 가족(family member)에게 설명(explain)해 본다.

 

Key points

  • 문자열(strings)은 Character 유형(types)의 컬렉션(collections)이다.
  • Character자소 집합(grapheme cluster)이며 하나 이상의 코드 포인트(code points)로 구성(made up of)된다.
  • 결합 문자(combining character)는 앞선(previous) 문자(character)를 어떤 식으로든(in some way) 변경(alters)하는 문자(character)이다.
  • 특수(special, 정수가 아닌(non-integer)) 인덱스(indexes)를 사용하여, 특정(certain) 자소 집합(grapheme cluster)에 대한 문자열(string) 첨자(subscript)를 활용할 수 있다.
  • Swift는 정규화(canonicalization)를 사용하여 문자 결합(combining characters)을 간주(accounts)한 문자열(strings) 비교(comparison)를 수행한다.
  • 문자열(string)을 잘라내면(slicing) 상위(parent) String과 저장위치(storage)를 공유(shares)하는 Substring 유형(type)의 하위 문자열(substring)이 생성(yields)된다.
  • String을 초기화(initializing)하고 Substring을 전달(passing)하여, SubstringString으로 변환(convert)할 수 있다.
  • Swift String에는 문자열(string)을 구성(make up)하는 개별(individual) 유니코드 코드 포인트(Unicode code points)의 컬렉션(collection)인 unicodeScalars 뷰(view)가 있다.
  • 문자열(string)을 인코딩(encode)하는 방법은 여러 가지(multiple)가 있다. UTF-8와 UTF-16이 가장 많이 사용(popular)된다.
  • 인코딩(encoding)의 개별 부분(individual parts)을 코드 단위(code units)라고 한다. UTF-8은 8비트(bit) 코드 단위(code units)를 사용하고, UTF-16은 16비트(bit) 코드 단위(code units)를 사용한다.
  • Swift String에는 지정된 인코딩(encoding)에서 개별(individual) 코드 단위(code units)를 얻을(obtain) 수 있는 컬렉션(collections)인 utf8utf16라는 뷰(views)가 있다.