문자열에 UTF-8 텍스트 저장하기
4장에서도 문자열을 다뤄봤지만, 이번에는 좀 더 깊이 살펴보겠습니다. 갓 입문한 러스타시안은 보통 세 가지 이유의 조합에 의해 문자열 부분에서 막힙니다: 발생할 수 있는 에러를 최대한 표시하는 러스트의 성향, 많은 프로그래머의 예상보다 문자열이 복잡한 자료구조라는 점, 그리고 UTF-8이 그 이유입니다. 이 때문에 다른 언어를 사용하다 넘어오면 러스트의 문자열은 어려워 보이죠.
문자열이 컬렉션 장에 있는 이유는 문자열이 바이트의 컬렉션으로
구현되어 있고, 이 바이트들을 텍스트로 통역할 때 유용한 기능을
제공하는 여러 메서드들을 구현해 두었기 때문입니다. 이번 절에서는
생성, 업데이트, 값 읽기와 같은 모든 컬렉션 타입이 가지고 있는,
String
에서의 연산에 대해 이야기해 보겠습니다. 또한
String
을 다른 컬렉션들과 다르게 만드는 부분, 즉 사람과
컴퓨터가 String
데이터를 통역하는 방식 간의 차이로 인해
생기는 String
인덱싱의 복잡함을 논의해 보겠습니다.
문자열이 뭔가요?
먼저 문자열 (string) 이라는 용어가 정확히 무엇을 뜻하는 것인지 정의해 보겠습니다.
러스트 핵심 기능 중 문자열 타입은 오직 하나뿐이며,
일반적으로 참조형인 &str
형태로 사용하는 문자열 슬라이스 str
입니다.
4장에서는 문자열 슬라이스에 대해 얘기했고, 이는 UTF-8으로 인코딩되어
다른 어딘가에 저장된 문자열 데이터의 참조자입니다. 예를 들어, 문자열 리터럴은
프로그램의 바이너리 결과물 안에 저장되어 있으며, 그러므로 문자열 슬라이스입니다.
String
타입은 언어의 핵심 기능에 구현된 것이 아니고 러스트의 표준
라이브러리를 통해 제공되며, 커질 수 있고, 가변적이며, 소유권을 갖고 있고,
UTF-8으로 인코딩된 문자열 타입입니다. 러스타시안들이 ‘문자열’에 대해 이야기할
때는 보통 String
과 문자열 슬라이스 &str
타입 둘 중 무언가를 이야기하는
것이지, 특정한 하나를 뜻하는 것은 아닙니다. 이번 절은 대부분 String
에 관한
것이지만, 두 타입 모두 러스트 표준 라이브러리에서 매우 많이 사용되며 String
과
문자열 슬라이스 모두 UTF-8으로 인코딩되어 있습니다.
새로운 문자열 생성하기
Vec<T>
에서 쓸 수 있는 연산 다수가 String
에서도 똑같이 쓸 수
있는데, 이는 String
이 실제로 바이트 벡터에 더하여 몇 가지 보장,
제한, 기능들을 추가한 래퍼 (wrapper) 로 구현되어 있기 때문입니다.
Vec<T>
와 String
이 같은 방식으로 동작한다는 함수의 예시로 예제 8-11과
같이 새 인스턴스를 생성하는 new
함수가 있습니다.
fn main() { let mut s = String::new(); }
이 라인은 어떤 데이터를 담을 수 있는 s
라는 빈 문자열을 만들어 줍니다.
종종 시작 지점에서 저장해 둘 문자열의 초깃값을 가지고 있을 것입니다.
그럴 때는 to_string
메서드를 이용하는데, 이는 Display
트레이트가 구현된
어떤 타입이든 사용 가능하며, 문자열 리터럴도 이 트레이트를 구현하고 있습니다.
예제 8-12에서 두 가지 예제를 보여주고 있습니다:
fn main() { let data = "initial contents"; let s = data.to_string(); // 이 메서드는 리터럴에서도 바로 작동합니다: let s = "initial contents".to_string(); }
이 코드는 initial contents
를 담고 있는 문자열을 생성합니다.
또한 문자열 리터럴로부터 String
을 생성하기 위해서 String::from
함수를
이용할 수도 있습니다. 예제 8-13의 코드는 to_string
을 사용하는 예제 8-12의
코드와 동일합니다:
fn main() { let s = String::from("initial contents"); }
문자열이 매우 다양한 용도로 사용되기 때문에, 문자열에 다양한 제네릭
API들을 사용할 수 있으며, 이를 통해 다양한 옵션들을 제공할 수 있습니다.
몇몇은 중복되어 보일 수 있지만, 다 사용할 곳이 있습니다! 지금의 경우
String::from
과 to_string
은 동일한 작업을 수행하므로, 따라서 어떤 것을
사용하는가는 스타일과 가독성의 문제입니다.
문자열이 UTF-8으로 인코딩되었음을 기억하세요. 즉, 아래의 예제 8-14처럼 적합하게 인코딩된 모든 데이터를 집어넣을 수 있습니다:
fn main() { let hello = String::from("السلام عليكم"); let hello = String::from("Dobrý den"); let hello = String::from("Hello"); let hello = String::from("שָׁלוֹם"); let hello = String::from("नमस्ते"); let hello = String::from("こんにちは"); let hello = String::from("안녕하세요"); let hello = String::from("你好"); let hello = String::from("Olá"); let hello = String::from("Здравствуйте"); let hello = String::from("Hola"); }
위의 모두가 유효한 String
값입니다.
문자열 업데이트하기
String
은 Vec
의 내용물처럼 더 많은 데이터를 집어넣으면 크기가
커지고 내용물은 변경될 수 있습니다. 또한 +
연산자나 format!
매크로를
사용하여 편리하게 String
값들을 이어붙일 수 있습니다.
push_str
과 push
를 이용하여 문자열 추가하기
예제 8-15처럼 push_str
메서드를 사용하여 문자열 슬라이스를 추가하는
것으로 String
을 키울 수 있습니다:
fn main() { let mut s = String::from("foo"); s.push_str("bar"); }
위의 두 줄이 실행된 후 s
에는 foobar
가 들어있을 것입니다. push_str
메서드는
문자열 슬라이스를 매개변수로 갖는데 이는 매개변수의 소유권을 가져올 필요가
없기 때문입니다. 예를 들어, 예제 8-16의 코드에서는 s2
의 내용물을
s1
에 추가한 후 s2
를 쓰려고 합니다.
fn main() { let mut s1 = String::from("foo"); let s2 = "bar"; s1.push_str(s2); println!("s2 is {s2}"); }
만일 push_str
함수가 s2
의 소유권을 가져갔다면, 마지막 줄에서 이 값을
출력할 수 없었을 것입니다. 하지만 이 코드는 기대했던 대로 작동합니다!
push
메서드는 한 개의 글자를 매개변수로 받아서 String
에 추가합니다.
예제 8-17은 push
메서드를 사용하여 String
에 ‘l’을 추가하고
있습니다:
fn main() { let mut s = String::from("lo"); s.push('l'); }
위의 코드를 실행한 결과로 s
는 lol
을 담고 있을 것입니다.
+
연산자나 format!
매크로를 이용한 접합
가지고 있는 두 개의 문자열을 조합하고 싶은 경우도 종종 있습니다. 예제 8-18에
표시된 것처럼 +
연산자를 사용하는 것이 한 가지 방법입니다:
fn main() { let s1 = String::from("Hello, "); let s2 = String::from("world!"); let s3 = s1 + &s2; // s1은 여기로 이동되어 더 이상 사용할 수 없음을 주의하세요 }
문자열 s3
는 Hello, world!
를 담게 될 것입니다. s1
이 더하기 연산
이후에 더 이상 유효하지 않은 이유와 s2
의 참조자가 사용되는 이유는
+
연산자를 사용했을 때 호출되는 함수의 시그니처와 맞춰야 하기 때문입니다.
+
연산자는 add
메서드를 사용하는데, 이 메서드의 시그니처는 아래처럼
생겼습니다:
fn add(self, s: &str) -> String {
표준 라이브러리에는 add
가 제네릭과 연관 타입을 사용하여 정의되어
있습니다. 여기서는 제네릭에 구체 타입 (concrete type) 을 대입하였고,
이는 String
값으로 이 메서드를 호출했을 때 발생합니다. 제네릭에 대한
내용은 10장에서 다룰 것입니다. 이 시그니처는 +
연산자의 까다로운 부분을
이해하는 데 필요한 단서를 줍니다.
먼저 s2
에는 &
가 있는데, 즉 첫 번째 문자열에 두 번째 문자열의
참조자를 더하고 있음을 뜻합니다. 이는 add
함수의 s
매개변수 때문입니다:
String
에는 &str
만 더할 수 있고, 두 String
끼리는 더하지 못합니다.
아니, 잠깐만요. &s2
의 타입은 &String
이지, add
의 두 번째 매개변수에
지정된 &str
은 아니죠. 어째서 예제 8-18가 컴파일되는 걸까요?
&s2
를 add
호출에 사용할 수 있는 이유는 &String
인수가 &str
로 강제될
수 있기 때문입니다. add
함수가 호출되면, 러스트는 역참조 강제 변환 (deref coercion)
을 사용하는데, 이것이 add
함수 내에서 사용되는 &s2
를
&s2[..]
로 바꿉니다. 역참조 강제 변환은 15장에서 더 자세히
다루겠습니다. add
가 매개변수의 소유권을 가져가지는 않으므로, s2
는
이 연산 이후에도 계속 유효한 String
일 것입니다.
두 번째로, 시그니처에서 add
가 self
의 소유권을 가져가는 것을 볼 수 있는데,
이는 self
가 &
를 안 가지고 있기 때문입니다. 즉 예제 8-18에서
s1
이 add
호출로 이동되어 이후에는 더 이상 유효하지 않을 것이라는 의미입니다.
따라서 let s3 = s1 + &s2;
가 마치 두 문자열을 복사하여 새로운 문자열을 만들
것처럼 보일지라도, 실제로 이 구문은 s1
의 소유권을 가져다가 s2
의 내용물의
복사본을 추가한 다음, 결과물의 소유권을 반환합니다. 바꿔 말하면, 이 구문은
여러 복사본을 만드는 것처럼 보여도 그렇지 않습니다: 이러한 구현은 복사보다
더 효율적입니다.
만일 여러 문자열을 접하고자 한다면, +
의 동작은 다루기 불편해
집니다:
fn main() { let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = s1 + "-" + &s2 + "-" + &s3; }
이 시점에서 s
는 tic-tac-toe
가 될 것입니다. +
와 "
문자가 많으면
어떤 결과가 나올지 확인이 어렵습니다. 더 복잡한 문자열 조합에는 대신
format!
매크로를 사용할 수 있습니다:
fn main() { let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = format!("{s1}-{s2}-{s3}"); }
이 코드 또한 s
에 tic-tac-toe
를 설정합니다. format!
매크로는
println!
처럼 작동하지만, 화면에 결과를 출력하는 대신 결과가 담긴
String
을 반환해 줍니다. format!
을 이용한 버전이 훨씬 읽기 쉽고,
format!
매크로로 만들어진 코드는 참조자를 이용하므로 이 호출은
아무 매개변수의 소유권도 가져가지 않습니다.
문자열 내부의 인덱싱
다른 많은 프로그래밍 언어에서, 인덱스를 이용한 참조를 통해 문자열 내부의
개별 문자에 접근하는 것은 유효하고 범용적인 연산에 속합니다. 그러나 러스트에서
인덱싱 문법을 이용하여 String
의 부분에 접근하고자 하면 에러를 얻게 됩니다.
아래 예제 8-19와 같은 코드를 생각해 봅시다:
fn main() {
let s1 = String::from("hello");
let h = s1[0];
}
이 코드는 아래와 같은 에러를 출력합니다:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `String` cannot be indexed by `{integer}`
--> src/main.rs:3:13
|
3 | let h = s1[0];
| ^^^^^ `String` cannot be indexed by `{integer}`
|
= help: the trait `Index<{integer}>` is not implemented for `String`
= help: the following other types implement trait `Index<Idx>`:
<String as Index<RangeFrom<usize>>>
<String as Index<RangeFull>>
<String as Index<RangeInclusive<usize>>>
<String as Index<RangeTo<usize>>>
<String as Index<RangeToInclusive<usize>>>
<String as Index<std::ops::Range<usize>>>
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` due to previous error
에러와 노트 부분이 이야기해 줍니다: 러스트 문자열은 인덱싱을 지원하지 않는다고 하는군요. 그런데 왜 안 되는 걸까요? 이 질문에 답하기 위해서는 러스트가 문자열을 메모리에 저장하는 방법에 대해 설명해야 합니다.
내부적 표현
String
은 Vec<u8>
을 감싼 것입니다. 예제 8-14에서 보았던 적합하게
인코딩된 UTF-8 예제 문자을 몇 가지를 살펴봅시다. 첫 번째로, 이것입니다:
fn main() { let hello = String::from("السلام عليكم"); let hello = String::from("Dobrý den"); let hello = String::from("Hello"); let hello = String::from("שָׁלוֹם"); let hello = String::from("नमस्ते"); let hello = String::from("こんにちは"); let hello = String::from("안녕하세요"); let hello = String::from("你好"); let hello = String::from("Olá"); let hello = String::from("Здравствуйте"); let hello = String::from("Hola"); }
이 경우 len
은 4가 되는데, 이는 문자열 ‘Hola’를 저장하고 있는 Vec
이
4바이트 길이라는 뜻입니다. UTF-8으로 인코딩되면 각각의 글자들이 1바이트씩
차지한다는 것이죠. 그러나 다음 줄은 아마도 여러분을 놀라게 할 수도 있습니다.
(맨 앞의 문자는 아라비아 숫자 3이 아닌, 키릴 문자 Ze입니다.)
fn main() { let hello = String::from("السلام عليكم"); let hello = String::from("Dobrý den"); let hello = String::from("Hello"); let hello = String::from("שָׁלוֹם"); let hello = String::from("नमस्ते"); let hello = String::from("こんにちは"); let hello = String::from("안녕하세요"); let hello = String::from("你好"); let hello = String::from("Olá"); let hello = String::from("Здравствуйте"); let hello = String::from("Hola"); }
이 문자열의 길이가 얼마인지 묻는다면, 여러분은 12라고 답할지도 모릅니다. 실제 러스트의 대답은 24입니다. 이는 "Здравствуйте"를 UTF-8으로 인코딩된 바이트들의 크기인데, 각각의 유니코드 스칼라 값이 저장소의 2바이트를 차지하기 때문입니다. 따라서, 문자열의 바이트 안의 인덱스는 유효한 유니코드 스칼라 값과 항상 대응되지는 않을 것입니다. 이를 설명하기 위해 다음과 같은 유효하지 않은 러스트 코드를 고려해 보겠습니다:
let hello = "Здравствуйте";
let answer = &hello[0];
여러분은 이미 answer
가 첫 번째 글자인 З
이 아닐 것이란 점을 알고 있습니다.
UTF-8으로 인코딩될 때, З
의 첫 번째 바이트는 208
이고, 두 번째는 151
이므로,
answer
는 사실 208
이 되어야 하지만, 208
은 그 자체로는 유효한 문자가
아닙니다. 208
을 반환하는 것은 이 문자열의 첫 번째 글자를 요청했을 때
예상한 것이 아닙니다. 하지만 그게 러스트가 인덱스 0에 가지고 있는
유일한 데이터죠. 라틴 글자들만 있는 경우일지라도, 일반적으로 바이트 값의
반환이 사용자들이 원하는 것은 아닐 겁니다. : &"hello"[0]
는 h
가
아니라 104
를 반환합니다.
따라서 예상치 못한 값을 반환하고 즉시 발견되지 않을 수 있는 버그를 방지하기 위해서, 러스트는 이러한 코드를 전혀 컴파일하지 않고 이러한 오해들을 개발 과정 내에서 일찌감치 방지한다는 것이 정답입니다.
바이트와 스칼라 값과 문자소 클러스터! 이런!
UTF-8에 대한 또 다른 요점은, 실제로는 러스트의 관점에서 문자열을 보는 세 가지 관련 방식이 있다는 것입니다: 바이트, 스칼라 값, 그리고 문자소 클러스터 (grapheme cluster, 우리가 글자라고 부르는 것과 가장 근접한 것) 입니다.
데바나가리 (Devanagari) 글자로 쓰인 힌디어 ‘नमस्ते’를 보면, 이것은 궁극적으로 아래와
같은 u8
값들의 Vec
으로 저장됩니다:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
이건 18바이트이고 컴퓨터가 이 데이터를 궁극적으로 저장하는 방법입니다.
만일 이를 유니코드 스칼라 값, 즉 러스트의 char
타입인 형태로
본다면, 아래와 같이 보이게 됩니다:
['न', 'म', 'स', '्', 'त', 'े']
여섯 개의 char
값이 있지만, 네 번째와 여섯 번째는 글자가 아닙니다: 그
자체로는 이해할 수 없는 발음 구별 부호입니다. 마지막으로, 이 문자열을
문자소 클러스터로 본다면, 이 힌디 단어를 구성하는 네 글자를 알아낼 수
있습니다:
["न", "म", "स्", "ते"]
러스트는 컴퓨터가 저장하는 원시 문자열 (raw string) 을 번역하는 다양한 방법을 제공하여, 데이터가 담고 있는 것이 무슨 언어든 상관없이 각 프로그램이 필요로 하는 통역방식을 선택할 수 있도록 합니다.
러스트가 String
을 인덱스로 접근하여 문자를 얻지 못하도록 하는 마지막 이유는
인덱스 연산이 언제나 상수 시간(O(1))에 실행될 것으로 기대받기 때문입니다.
그러나 String
을 가지고 그러한 성능을 보장하는 것은 불가능한데, 그 이유는
러스트가 문자열 내에 유효한 문자가 몇 개 있는지 알아내기 위해 내용물을
시작 지점부터 인덱스로 지정된 곳까지 훑어야 하기 때문입니다.
문자열 슬라이싱하기
문자열 인덱싱의 반환 타입이 어떤 것이 (바이트 값인지, 캐릭터인지, 문자소 클러스터인지, 혹은 문자열 슬라이스인지) 되어야 하는지 명확하지 않기 때문에 문자열의 인덱싱은 종종 좋지 않은 생각이 됩니다. 따라서 문자열 슬라이스를 만들기 위해 정말로 인덱스를 사용하고자 한다면 러스트는 좀 더 구체적인 지정을 요청합니다.
[]
에 숫자 하나를 사용하는 인덱싱이 아니라 []
와 범위를 사용하여
특정 바이트들이 담고 있는 문자열 슬라이스를 만들 수 있습니다:
#![allow(unused)] fn main() { let hello = "Здравствуйте"; let s = &hello[0..4]; }
여기서 s
는 문자열의 첫 4바이트를 담고 있는 &str
가 됩니다. 앞서 우리는
이 글자들이 각각 2바이트를 차지한다고 언급했으므로, 이는 s
가 ‘Зд’이 될 것이란
뜻입니다.
만약에 &hello[0..1]
처럼 문자 바이트의 일부를 슬라이스를 얻으려고 한다면,
러스트는 벡터 내에 유효하지 않은 인덱스에 접근했을 때와 동일한 방식으로
런타임에 패닉을 발생시킬 것입니다.
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/collections`
thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
범위를 지정하여 문자열 슬라이스를 생성하는 것은 프로그램을 죽게 만들 수도 있기 때문에 주의깊게 사용되어야 합니다.
문자열에 대한 반복을 위한 메서드
문자열 조각에 대한 연산을 하는 가장 좋은 방법은 명시적으로 문자를 원하는 것인지
아니면 바이트를 원하는 것인지 지정하는 것입니다. 개별적인 유니코드 스칼라 값에 대해서는
chars
메서드를 사용하세요. ‘Зд’에 대해 chars
함수를 호출하면 각각을 분리하여 char
타입의 두 개의 값을 반환하고, 이 결과에 대한 반복을 통하여 각 요소에 접근할 수
있습니다:
#![allow(unused)] fn main() { for c in "Зд".chars() { println!("{c}"); } }
이 코드는 다음을 출력할 것입니다:
З
д
다른 방법으로 bytes
메서드는 각 원시 바이트를 반환하는데, 문제의
도메인이 무엇인가에 따라 적절할 수도 있습니다:
#![allow(unused)] fn main() { for b in "Зд".bytes() { println!("{b}"); } }
위의 코드는 이 문자열을 구성하는 네 개의 바이트를 출력합니다:
208
151
208
180
하지만 유효한 유니코드 스칼라 값이 하나 이상의 바이트로 구성될지도 모른다는 것을 확실히 기억해 주세요.
데바나가리 문서와 같은 문자열로부터 문자소 클러스터를 얻는 방법은 복잡해서, 이 기능은 표준 라이브러리를 통해 제공되지 않습니다. 여러분이 원하는 기능이 이것이라면 crates.io에 사용 가능한 크레이트가 있습니다.
문자열은 그렇게 단순하지 않습니다
요약하자면, 문자열은 복잡합니다. 프로그래밍 언어마다 이러한 복잡성을
프로그래머에게 표현하는 방법에 대해 다른 선택을 합니다. 러스트는
String
데이터의 올바른 처리가 모든 러스트 프로그램의 기본 동작으로
선택했는데, 이는 프로그래머가 UTF-8 데이터를 처리할 때 미리 더 많은
생각을 해야 함을 의미합니다. 이러한 절충안은 다른 프로그래밍 언어보다
문자열의 복잡성을 더 많이 노출시키지만, 한편으로는 여러분의 개발 수명
주기 후반에 ASCII 아닌 문자와 관련된 에러를 처리해야 할 필요가 없도록
해줍니다.
좋은 소식은 표준 라이브러리에 이런 복잡한 상황을 올바르게 처리하는 데
도움이 될 String
및 &str
타입 기반의 기능을 다양하게 제공한다는
점입니다. 문자열 검색을 위한 contains
와 문자열 일부를 다른 문자열로
바꾸는 replace
같은 유용한 메서드들에 대해 알아보려면 꼭 문서를
확인해 보세요.
이것보다 살짝 덜 복잡한 것으로 옮겨 갑시다: 해시맵이요!