문자열에 UTF-8 텍스트 저장하기
우리는 4장에서 문자열을 이미 이야기했지만, 여기서는 더 깊이 들여다보겠습니다. 새로운 Rustacean이 문자열에서 자주 막히는 이유는 대개 세 가지가 겹치기 때문입니다. 러스트는 잠재적 오류를 잘 드러내는 언어이고, 문자열은 많은 프로그래머가 생각하는 것보다 복잡한 자료구조이며, 거기에 UTF-8 까지 얽혀 있습니다. 다른 프로그래밍 언어에서 넘어올 때 이 요소들이 합쳐져 꽤 어렵게 느껴질 수 있습니다.
문자열은 바이트들의 컬렉션에, 그 바이트를 텍스트로 해석할 때 유용한 기능을 제공하는
메서드가 덧붙은 형태로 구현되기 때문에, 여기서는 컬렉션의 맥락에서 문자열을 다룹니다.
이 절에서는 String 에 대해, 생성·갱신·읽기처럼 모든 컬렉션 타입이 공통으로 갖는
연산을 설명합니다. 또한 사람이 String 데이터를 이해하는 방식과 컴퓨터가 그것을
해석하는 방식의 차이 때문에, String 이 다른 컬렉션과 어떻게 다른지도 살펴봅니다.
문자열 정의하기
먼저 러스트에서 문자열 이라는 말을 정확히 무엇으로 뜻하는지 정의해 봅시다. 러스트
코어 언어 자체에는 문자열 타입이 하나뿐인데, 그것은 문자열 슬라이스 str 이고
대개는 빌린 형태인 &str 로 보게 됩니다. 4장에서 설명했듯, 문자열 슬라이스는
어딘가에 저장된 UTF-8 인코딩 문자열 데이터에 대한 참조입니다. 예를 들어 문자열
리터럴은 프로그램 바이너리 안에 저장되므로 문자열 슬라이스입니다.
반면 String 타입은 코어 언어에 내장된 것이 아니라 표준 라이브러리가 제공하는
타입으로, 크기를 키울 수 있고, 가변이며, 소유권을 가지는 UTF-8 인코딩 문자열
타입입니다. Rustacean이 러스트에서 “문자열”이라고 말할 때는 String 을 뜻할 수도
있고 문자열 슬라이스 &str 을 뜻할 수도 있습니다. 이 절은 주로 String 에 초점을
맞추지만, 러스트 표준 라이브러리에서는 String 과 문자열 슬라이스 둘 다 매우
자주 사용되며, 둘 다 UTF-8 인코딩이라는 점을 기억하세요.
새 문자열 만들기
String 은 추가적인 보장과 제약, 기능이 붙은 바이트 벡터를 감싼 래퍼(wrapper)
형태로 구현되어 있기 때문에, Vec<T> 에서 가능한 많은 연산이 String 에도
가능합니다. Vec<T> 와 String 에서 똑같이 동작하는 함수의 한 예가 인스턴스를
만드는 new 함수입니다. 목록 8-11을 보세요.
fn main() {
let mut s = String::new();
}
String 만들기이 한 줄은 s 라는 새 빈 문자열을 만듭니다. 그다음 우리는 이 안에 데이터를 넣을
수 있습니다. 흔히는 문자열을 만들 때 이미 초기 데이터가 있는 경우가 많습니다. 그럴 때는
문자열 리터럴처럼 Display 트레이트를 구현하는 모든 타입에서 사용할 수 있는
to_string 메서드를 사용합니다. 목록 8-12가 두 가지 예를 보여 줍니다.
fn main() {
let data = "initial contents";
let s = data.to_string();
// The method also works on a literal directly:
let s = "initial contents".to_string();
}
to_string 메서드로 String 만들기이 코드는 initial contents 를 담은 문자열을 만듭니다.
문자열 리터럴로부터 String 을 만들 때는 String::from 함수도 사용할 수 있습니다.
목록 8-13의 코드는 to_string 을 쓴 목록 8-12와 같은 의미입니다.
fn main() {
let s = String::from("initial contents");
}
String::from 으로 String 만들기문자열은 정말 많은 곳에서 쓰이기 때문에, 러스트는 문자열과 함께 사용할 수 있는 다양한
범용 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<T> 와 마찬가지로 더 많은 데이터를 밀어 넣으면 길이가 늘어나고,
내용도 바뀔 수 있습니다. 또한 + 연산자나 format! 매크로를 사용해 여러
String 값을 편하게 이어 붙일 수도 있습니다.
push_str 와 push 로 덧붙이기
목록 8-15처럼 push_str 메서드를 사용해 문자열 슬라이스를 덧붙이면 String 을
키울 수 있습니다.
fn main() {
let mut s = String::from("foo");
s.push_str("bar");
}
push_str 로 문자열 슬라이스를 String 에 덧붙이기이 두 줄 뒤에 s 는 foobar 를 담게 됩니다. push_str 메서드가 문자열 슬라이스를
받는 이유는, 매개변수의 소유권을 꼭 가져오고 싶지는 않기 때문입니다. 예를 들어
목록 8-16의 코드에서는 s1 에 s2 의 내용을 붙인 뒤에도 s2 를 계속 사용하고
싶습니다.
fn main() {
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(s2);
println!("s2 is {s2}");
}
String 에 덧붙인 뒤에도 원래 문자열을 계속 사용하기만약 push_str 이 s2 의 소유권을 가져갔다면 마지막 줄에서 s2 를 출력할 수
없었을 것입니다. 하지만 이 코드는 우리가 기대한 대로 잘 동작합니다!
push 메서드는 문자 하나를 매개변수로 받아 String 에 추가합니다. 목록 8-17은
push 로 String 에 문자 l 을 추가하는 예입니다.
fn main() {
let mut s = String::from("lo");
s.push('l');
}
push 를 사용해 String 값에 문자 하나 추가하기그 결과 s 는 lol 을 담게 됩니다.
+ 또는 format! 으로 이어 붙이기
대부분의 경우 두 개 이상의 기존 문자열을 합치고 싶어질 것입니다. 그 방법 중 하나는
목록 8-18처럼 + 연산자를 사용하는 것입니다.
fn main() {
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}
+ 연산자로 두 String 값을 이어 새 String 만들기문자열 s3 는 Hello, world! 를 담게 됩니다. 덧셈 뒤에 s1 이 더 이상 유효하지
않은 이유와, s2 에는 참조를 사용한 이유는 + 연산자가 내부적으로 호출하는 메서드
시그니처를 보면 알 수 있습니다. + 연산자는 add 메서드를 사용하며, 시그니처는
대략 다음과 같습니다.
fn add(self, s: &str) -> String {
표준 라이브러리 안에서는 add 가 제네릭과 연관 타입을 사용해 정의되어 있습니다.
여기서는 String 값으로 이 메서드를 호출할 때 실제로 대입되는 구체 타입만 적어 둔
것입니다. 제네릭은 10장에서 다룹니다. 이 시그니처에는 + 연산자의 미묘한 부분을
이해하는 데 필요한 단서가 담겨 있습니다.
첫째, s2 앞에 & 가 있습니다. 즉 두 번째 문자열은 참조 형태로 첫 번째 문자열에
더해집니다. 이는 add 함수의 s 매개변수 때문입니다. String 에는 문자열 슬라이스를
더할 수는 있지만, 두 개의 String 값 자체를 그대로 더할 수는 없습니다. 그런데
잠깐, &s2 의 타입은 &String 이지 &str 이 아닙니다. 그런데도 목록 8-18이
컴파일되는 이유는 무엇일까요?
이는 컴파일러가 &String 인수를 &str 로 강제(coerce)할 수 있기 때문입니다.
add 메서드를 호출할 때 러스트는 역참조 강제를 사용해서 &s2 를 &s2[..] 로
바꿉니다. 역참조 강제는 15장에서 더 자세히 다룹니다. add 가 s 매개변수의
소유권을 가져가지 않기 때문에, 이 연산 이후에도 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! 버전의 코드는 훨씬 읽기 쉽고, 매크로가 생성한 코드는 참조를 사용하므로
호출 과정에서 어떤 인수의 소유권도 가져가지 않습니다.
문자열 인덱싱
많은 다른 프로그래밍 언어에서는 문자열 안의 개별 문자를 인덱스로 접근하는 것이 흔하고
유효한 연산입니다. 하지만 러스트에서 인덱싱 문법으로 String 의 일부에 접근하려 하면
오류를 받습니다. 목록 8-19의 잘못된 코드를 보세요.
fn main() {
let s1 = String::from("hi");
let h = s1[0];
}
String 에 인덱싱 문법을 사용하려 시도하기이 코드는 다음과 같은 오류를 냅니다.
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
--> src/main.rs:3:16
|
3 | let h = s1[0];
| ^ string indices are ranges of `usize`
|
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`
= note: you can use `.chars().nth()` or `.bytes().nth()`
for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
= help: the following other types implement trait `SliceIndex<T>`:
`usize` implements `SliceIndex<ByteStr>`
`usize` implements `SliceIndex<[T]>`
= note: required for `String` to implement `Index<{integer}>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 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" 를 저장하는 벡터는 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 로 인코딩하는 데 필요한 바이트 수가 24이기 때문입니다. 이 문자열의 각 유니코드 스칼라 값은 2바이트를 차지합니다. 따라서 문자열 바이트 인덱스는 항상 유효한 유니코드 스칼라 값 하나와 대응되지 않습니다. 이를 보여 주기 위해 다음 잘못된 러스트 코드를 생각해 봅시다.
let hello = "Здравствуйте";
let answer = &hello[0];
여러분은 이미 answer 가 첫 글자 З 가 되지 않을 것이라는 점을 알고 있습니다.
UTF-8 로 인코딩하면 З 의 첫 번째 바이트는 208, 두 번째는 151 이기 때문에,
answer 는 겉보기에 208 이어야 할 것처럼 보일 수 있습니다. 하지만 208 은 그
자체로 유효한 문자가 아닙니다. 어떤 사용자가 이 문자열의 첫 글자를 요구했을 때
208 을 돌려주는 것은 거의 분명히 원하는 동작이 아닙니다. 그런데 러스트가 바이트
인덱스 0에서 실제로 볼 수 있는 것은 그 208 하나뿐입니다. 문자열에 라틴 문자만
들어 있더라도 사람은 보통 바이트 값을 원하지 않습니다. 만약 &"hi"[0] 가 유효한
코드라서 바이트 값을 반환한다면, 그것은 h 가 아니라 104 를 반환했을 것입니다.
그래서 러스트는 예기치 않은 값을 반환해 나중에야 드러날 버그를 만드는 대신, 이 코드를 아예 컴파일하지 않음으로써 개발 초기 단계에서 오해를 예방합니다.
바이트, 스칼라 값, 그리고 그래핌 클러스터
UTF-8 과 관련해 또 한 가지 중요한 점은, 문자열을 러스트의 관점에서 바라보는 방식이 실제로는 세 가지나 있다는 것입니다. 바이트, 스칼라 값, 그래핌 클러스터입니다 (그래핌 클러스터는 사람이 보통 글자 라고 부르는 것과 가장 가깝습니다).
데바나가리 문자로 쓰인 힌디어 단어 “नमस्ते” 를 보면, 이것은 다음과 같은 u8 값
벡터로 저장됩니다.
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
즉 18바이트이고, 컴퓨터는 최종적으로 이런 식으로 데이터를 저장합니다. 이것을
유니코드 스칼라 값, 곧 러스트의 char 타입 관점에서 보면 다음과 같이 보입니다.
['न', 'म', 'स', '्', 'त', 'े']
여기에는 char 값이 여섯 개 있지만, 네 번째와 여섯 번째는 글자가 아닙니다.
그 자체만으로는 의미가 없는 발음 부호입니다. 마지막으로 그래핌 클러스터로 보면,
사람이 힌디어 단어를 네 글자라고 보는 것과 비슷한 결과를 얻습니다.
["न", "म", "स्", "ते"]
러스트는 컴퓨터가 저장한 원시 문자열 데이터를 이렇게 여러 방식으로 해석할 수 있도록 해 줍니다. 그래서 데이터가 어떤 인간 언어인지와 상관없이, 각 프로그램이 자신에게 필요한 해석 방식을 선택할 수 있습니다.
러스트가 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` profile [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/collections`
thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
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}");
}
}
이 코드는 다음을 출력합니다.
З
д
반대로 여러분의 도메인에서는 raw byte가 더 적절할 수도 있습니다. 그럴 때는 bytes
메서드를 사용할 수 있습니다.
#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
println!("{b}");
}
}
이 코드는 이 문자열을 구성하는 4개의 바이트를 출력합니다.
208
151
208
180
다만 유효한 유니코드 스칼라 값 하나가 1바이트보다 큰 경우도 많다는 점을 반드시 기억하세요.
데바나가리 문자 예처럼 그래핌 클러스터를 문자열에서 꺼내는 일은 복잡하기 때문에, 표준 라이브러리는 이 기능을 제공하지 않습니다. 만약 꼭 필요하다면 crates.io에 이 기능을 제공하는 크레이트들이 있습니다.
문자열의 복잡성 다루기
정리하면, 문자열은 복잡합니다. 프로그래밍 언어마다 이 복잡성을 프로그래머에게
어떻게 보여 줄지에 대해 서로 다른 선택을 합니다. 러스트는 String 데이터를
정확하게 다루는 것을 모든 프로그램의 기본 동작으로 삼기로 선택했습니다. 그래서
프로그래머는 UTF-8 데이터를 처리할 때 처음부터 조금 더 많은 생각을 해야 합니다.
이 선택은 다른 언어보다 문자열의 복잡성을 더 많이 드러내지만, 나중에 개발 과정에서
비 ASCII 문자와 관련된 오류를 처리하느라 고생할 일을 줄여 줍니다.
좋은 소식은 표준 라이브러리가 String 과 &str 위에 다양한 기능을 제공하여,
이런 복잡한 상황을 올바르게 처리하는 데 도움을 준다는 점입니다. 문자열 검색을 위한
contains, 문자열 일부를 다른 문자열로 바꾸는 replace 같은 유용한 메서드가
있으니 문서를 꼭 확인해 보세요.
이제 조금 덜 복잡한 주제로 넘어가 봅시다. 해시 맵입니다!