Swift 라는 언어를 다루면서 제일 흥미로운 부분 중 하나가 바로 문자열 이었다. 내가 정말 애정하는 공간인 42서울 에서는 Piscine 이라는 선발과정부터 본과정까지 수 없이 많이 다루어야 한다. 그래서 그런지 스위프트의 독특한 문자열 자료구조는 많은 궁금증을 일으켰다.
기본적으로 C
, C++
언어 에서는 문자열은 말 그대로 char
타입의 데이터의 배열 구조 형태로 메모리에 저장된다. 물론 C++
에서는 std::string
이라는 정말 편리한 자료형을 사용할 수 있기때문에 조금은 다르지만 핵심은 여전히 같다고 볼 수 있다.
여기서 char
타입은 Ascii
표준을 준수하기 때문에 한 글자당 1 바이트 만으로도 저장할 수 있었다.
파이썬은 조금 다르다. 우선 문자열 객체로서 메모리에 적재되고, sys.getsizeof()
로 한 글자가 얼마나 메모리에 적재되는지 확인해보면 알파벳은 1바이트, 한글은 유니코드 방식 으로 저장되기 때문에 2바이트로 저장됨을 볼 수 있다.
import sys
en_str = "A"
ko_str = "ㄱ"
print(sys.getsizeof(en_str)) #49 + 1
print(sys.getsizeof(ko_str))
"""
[공식문서 해설]
https://docs.python.org/ko/3/library/sys.html#sys.getsizeof
[참고자료]
https://wikidocs.net/15102
"""
char str[13] = "hello world!";
printf("idx: 6, str: %c\n", str[6]);
std::string str = "hello world!";
std::cout << "idx: 6, str: " << str[6] << std::endl;
str = "hello world!"
print("idx: 6, str: ", str[6])
임의로 인덱스를 정해서 hello world!
문자열에서 한 글자를 조회해서 출력해보았다. 모든 결과는 idx: 6, str: w
로 출력된다.
물론 파이썬의 문자열 객체는 한 글자당 1바이트만 저장하는 시퀀스는 아니지만, 그럼에도 불구하고 여전히 파이썬 또한 Int
자료형 인덱스로 문자열을 접근할 수 있다.
이 질문을 꺼내기 전에 정말 재밌는 영상 하나를 보면 좋을 것 같다. Ascii 코드
와 유니코드(utf-8)
의 기원에 대해 설명해주는 영상인데, 영상 중간중간 디테일이 정말 재밌다.
이 영상이 필요했던 이유는 애플에서 문자열 자료구조를 구현할 때 유니코드 표준을 준수했기 때문이고, 이게 또 문자열 조회와도 연관이 있기 때문이다. 스위프트 공식문서 참고
다시 돌아와서, Swift 에서 문자열을 정수 인덱스로 조회한다면? 아래처럼 오류를 뱉는다.
그러면 Swift 에서는 어떤 방식으로 조회해야 되냐면 아래처럼 String.Index
를 사용해서 접근해야한다.
// 선언
let str = "hello world!"
// 조회
str[str.startIndex] // 'h'
str[str.endIndex] // '!' 가 나올지 알았겠지만 바로 에러 뜨지롱
var idx = str.index(str.startIndex, offsetBy: 6)
str[idx] // 'w'
// 이터레이터(반복문)를 인덱스처럼 뽑아 사용하려면 indicies 메소드 사용해야한다.
for c in str.indices {
print(str[c]) // Index(_rawBits:) 값이 출력된다.
}
이걸 보면 마음속 깊은 곳에서 한 마디가 우러나온다.
아니 왜?
hello world!
라는 문자열에서 w
를 접근하는데 Python 에서는 str[6]
이면 끝났는데 Swift 에서는 str[str.index(str.startIndex, offsetBy: 6)
을 해야 비로소 글자 하나에 접근할 수 있는거다. 겉으로만 보면 str str str...
변수명을 무려 세 번이나 써야된다.
분명히 C
, C++
언어부터 정수 인덱스로 접근하는 효율적인 방법이 이미 있는데, 현대 언어의 패러다임을 섞어놓았다는 Swift
에서는 왜 이런 비효율이 생긴걸까
앞선 언어들의 문자열은 각각 인덱스마다 동일한 크기의 데이터 값을 가지고 있었다. Swift 공식문서 에 따르면, Character
타입의 데이터는 하나의 확장된 문자소 집합(Extended Grapheme Clusters) 으로 표현된다.
Extended Grapheme Clusters
Swift 에서 Character 자료형은 '사람이 읽을 수 있는 하나의 글자' 를 기준으로 한다. 그래서 하나의 글자가 여러 개의 유니코드 스칼라 값을 가질 수 있고, 이에 따라 각 글자마다 메모리가 다를 수 있다.
위의 공식문서에서 하나의 Character
타입이 여러 스칼라 값을 가질 수 있다는 것을 한글로 예를 들었다.
let precomposed: Character = "\u{D55C}" // 한
let decomposed: Character = "\u{1112}\u{1161}\u{11AB}" // ᄒ, ᅡ, ᆫ
// precomposed is 한, decomposed is 한
예시를 보면, 한
으로 합쳐있는 글자는 하나의 유니코드 스칼라 값을 갖는데 아래는 3 개의 유니코드 스칼라 값을 갖는다. 하지만 중요한 점은 둘 다 똑같은 하나의 Character
라는 것이다.
이런 이유 때문에 다른 언어처럼 str[6]
으로 접근하려 했다면, 구체적으로 어딜 접근하려했는지 의도가 명확하지 않게 되어버린다.
왜냐면, 사실 str[6]
에서 6
이라는 숫자는 메모리를 배열처럼 점유해서 그 중 6번째 인덱스를 보는 것이기 때문이다. Swift 처럼 한 글자마다 여러 개의 유니코드 스칼라 값을 가질 수 있다면 각 글자마다 메모리가 다르기 때문에 정확히 i 번째는 아닌 것이다.
인덱스로 접근을 못한다고 했을 때 또 하나의 의문점 이 든다. 바로, String.Index
를 리턴하는index(_:offsetBy:)
메소드 이다. 해당 메소드는 Swift 가 제공하는 첫 번째 파라미터로 부터 offsetBy:
만큼 떨어진 위치를 반환한다.
여기서 "offsetBy: 만큼 떨어진"
이 라는 것이 무슨 말인지 중요하다. 왜냐면 인덱스로 조회하지 못한다고 했는데, 이 메소드를 사용하면 어쨌든 인덱스로 조회하는 '것 처럼' 보여지지 않는가.
위 공식문서에 따르면 String 자료형에서 Index 라는 구조체는 BidirectionalCollection 이라는 프로토콜을 채택한 구조체이다.
String.Index
구조체가 BidirectionalCollection
이라는 프로토콜을 채택한 것은 문자열의 인덱스 탐색 구조가 양방향 순회방식
이라는 것이다. 그 말인 즉슨, 무조건 자신의 앞, 뒤로만 탐색할 수 있다는 것이다.
보통은 문자열을 Random Access 방법으로 임의의 거리만큼 떨어진 원소를 O(1)
의 시간복잡도로 참조할 수 있는 조회 성능을 가진다. 하지만, 애플은 각 글자마다 유연한 메모리 크기를 가져간 대신 이런 성능을 포기한 것 같다.
BidirectionalCollection
프로토콜로 인덱스 접근을 구현함으로써 O(n)
의 시간복잡도가 필요해졌다.
이러한 이유로, 문자열의 시작점으로 부터 임의의 거리만큼 떨어진 원소를 참조하기 위해서는 이전 단계를 모두 거쳐야 하는 것이다.
이러한 접근 방식으로 구현되어 각각의 문자소 집합 만큼 떨어진 distance
를 계산한다. 물론, 원한다면 utf-8, utf-16, unicodeScalars
방식으로 계산할 수도 있겠다.
Swift 는 다양한 방식의 언어와 특수문자들에 대응하기 위해 확장된 문자소 집합(Extended Grapheme Clusters) 을 채택해서 한 글자당 유연한 메모리 크기를 갖게하고, 임의의 지점으로 부터 그 크기만큼 떨어진 원소를 Bidirectional 하게 탐색하여 문자열의 원소에 접근하는 것이다.
Swift 의 이런 문자 저장 구조는 한정된 메모리 내에서 전 세계 사람들에게 서비스 해야되는 고민들 속에서 나온 구조라고 생각된다.
물론 그래도 별로다.