본문 바로가기

UTF-8 문자열

String의 생성·추가·결합을 익히고 UTF-8의 바이트·스칼라 값·문자소 차이로 인덱싱이 제한되는 이유를 이해합니다.

소유권이 뭔가요?에서 문자열을 다루었지만 이 글에서 더 깊이 살펴봅니다. 러스트를 처음 배우는 사람은 대개 세 가지가 겹쳐 문자열에서 막힙니다. 일어날 수 있는 오류를 드러내려는 러스트의 성향, 많은 프로그래머의 생각보다 복잡한 자료 구조인 문자열, 그리고 UTF-8입니다. 그래서 다른 언어에서 넘어오면 러스트의 문자열이 어려워 보입니다.

문자열을 컬렉션 장에서 다루는 까닭은, 문자열이 바이트의 컬렉션으로 구현되어 있고 그 바이트를 텍스트로 해석할 때 쓰는 메서드를 제공하기 때문입니다. 이 글에서는 만들기, 고치기, 읽기처럼 모든 컬렉션이 하는 일을 String으로 해 보고, 사람과 컴퓨터가 String의 데이터를 해석하는 방식이 달라 인덱스로 글자를 꺼내기 어렵다는 점도 살펴봅니다. 아래 그림은 String과 &str의 소유 관계, 그리고 바이트와 스칼라 값과 문자소라는 서로 다른 단위를 한눈에 보여 줍니다.

String과 문자열 슬라이스의 소유권 차이, UTF-8 바이트와 스칼라 값과 문자소 단위, 인덱싱과 안전한 범위 접근을 하나의 선택 흐름으로 정리한 다이어그램

OWN · BORROW · DECODE · SLICE

문자열은 먼저 소유권을, 다음으로 관찰 단위를 고른다

화면의 한 글자와 저장소의 한 바이트는 같은 단위가 아닙니다. 그래서 Rust 문자열 API는 소유 여부와 UTF-8 해석 단위를 호출자가 명시하게 합니다.

소유한 UTF-8 바이트와 빌린 유효 범위String가a는EA B0 80 61네바이트이며오프셋경계0,3,4.슬라이스get0..3은같은버퍼첫세바이트를빌려가를본다.1은가중간이므로get0..1은None.화살표는소유/대여,괄호는스칼라별바이트범위를표시한다.소유빌림String: 가alen = 4바이트get(0..3)&str: 가len = 3바이트EA0B018026134가 · 3바이트a · 1바이트
소유한 UTF-8 바이트와 빌린 유효 범위String가a는EA B0 80 61네바이트이며오프셋경계0,3,4.슬라이스get0..3은같은버퍼첫세바이트를빌려가를본다.1은가중간이므로get0..1은None.화살표는소유/대여,괄호는스칼라별바이트범위를표시한다.소유빌림String: 가alen = 4바이트get(0..3)&str: 가len = 3바이트EA0B018026134가 · 3바이트a · 1B

보조 설명 · 아래 원래 예제와 비교 조건도 함께 확인합니다.

소유한 UTF-8

String · 늘리고 바꿀 버퍼

표준 라이브러리가 제공하는 소유 타입입니다. 바이트 버퍼를 관리하면서 내용이 항상 유효한 UTF-8이라는 불변식을 지킵니다.

push는 char 하나를, push_str은 빌린 &str을 덧붙입니다. +는 왼쪽 String을 소비합니다.

빌린 UTF-8

&str · 유효한 범위를 보는 뷰

다른 저장소의 UTF-8 구간을 빌립니다. 문자열 리터럴도 이 타입이지만, 모든 &str이 리터럴인 것은 아닙니다.

소유권을 옮기지 않고 읽거나 전달할 때 선택합니다. 범위의 시작과 끝은 모두 문자 경계여야 합니다.

단위가 API를 결정

필요한 결과가 바이트인지, 스칼라 값인지, 사용자 글자인지 먼저 정한다

  1. 저장 단위 · 바이트

    len은 바이트 수를 반환하고 bytes와 as_bytes는 UTF-8의 원시 u8 단위를 드러냅니다. "Здравствуйте"는 24바이트입니다.

  2. 언어 단위 · 유니코드 스칼라 값

    chars는 Rust의 char 단위로 디코딩합니다. 같은 러시아어 예시는 12개의 스칼라 값이지만, 결합 문자가 있으면 이 수도 화면의 글자 수와 다릅니다.

  3. 사용자 단위 · 문자소 클러스터

    사람이 한 글자로 인식하는 단위입니다. é는 e와 결합 악센트로 이루어져 3바이트, 2개의 스칼라 값, 1개의 문자소입니다. 표준 라이브러리는 문자소 반복을 제공하지 않으므로 검증된 외부 크레이트를 고릅니다.

  4. 접근 단위 · 정수 인덱스 대신 명시적 API

    s[0]은 반환 단위가 모호해 컴파일되지 않습니다. 범위 문법은 바이트 오프셋을 쓰며 역순·범위 밖·문자 중간 경계에서 패닉합니다. 부재나 잘못된 경계를 값으로 처리하려면 s.get(0..4)의 Option<&str>을 사용합니다.

경계 점검: s = "Здравствуйте"라면 &s[0..4]는 "Зд"입니다. 유효한 슬라이스는 범위 안의 순서 있는 바이트 구간이어야 하고 시작·끝이 모두 문자 경계여야 합니다. &s[0..1]은 문자 중간을 잘라 패닉하므로 외부 입력에는 get이나 is_char_boundary로 정책을 드러냅니다.

문자열이 뭔가요?

먼저 문자열(string)이라는 말이 무엇을 뜻하는지 정합니다. 러스트 언어의 핵심에 있는 문자열 타입은 하나뿐인데, 보통 빌린 형태 &str로 쓰는 문자열 슬라이스 str입니다. 슬라이스에서 본 문자열 슬라이스는 다른 곳에 저장된 UTF-8 문자열 데이터의 참조입니다. 예를 들어 문자열 리터럴은 프로그램의 실행 파일에 저장되므로 문자열 슬라이스입니다.

String 타입은 언어의 핵심이 아니라 표준 라이브러리가 제공하며, 늘어날 수 있고, 바꿀 수 있고, 데이터를 소유하는 UTF-8 문자열 타입입니다. 러스트 사용자가 문자열이라고 말할 때는 둘 가운데 하나가 아니라 String과 &str을 함께 뜻할 때가 많습니다. 이 글은 주로 String을 다루지만, 두 타입 모두 표준 라이브러리에서 아주 많이 쓰이며 둘 다 UTF-8로 인코딩되어 있습니다.

새로운 문자열 생성하기

String은 바이트 벡터에 몇 가지 보장과 제한, 기능을 더한 것으로 구현되어 있어서, Vec<T>에서 쓰는 연산 상당수를 String에서도 쓸 수 있습니다. 새 인스턴스를 만드는 new 함수가 그 예입니다.

    let mut s = String::new();

이 줄은 비어 있는 새 문자열 s를 만들며, 그 뒤에 데이터를 넣을 수 있습니다. 처음부터 넣을 데이터가 있는 경우가 많은데, 그럴 때는 to_string 메서드를 씁니다. 이 메서드는 문자열 리터럴처럼 Display 트레이트를 구현한 타입이면 어디에나 쓸 수 있습니다.

    let data = "initial contents";

    let s = data.to_string();

    // 리터럴에 바로 써도 됨
    let s = "initial contents".to_string();

이 코드는 initial contents를 담은 문자열을 만듭니다. 문자열 리터럴로 String을 만들 때 String::from을 써도 됩니다.

    let s = String::from("initial contents");

문자열은 쓰임이 아주 많아서 문자열에 쓸 수 있는 API도 여럿 있습니다. 몇몇은 겹쳐 보이지만 각각 쓸 곳이 있으며, 여기서 String::from과 to_string은 같은 일을 하므로 어느 쪽을 쓸지는 스타일과 읽기 쉬움의 문제입니다. 문자열은 UTF-8로 인코딩되므로, 올바르게 인코딩된 데이터라면 무엇이든 담을 수 있습니다.

    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를 이용하여 문자열 추가하기

push_str 메서드로 문자열 슬라이스를 덧붙여 String을 키울 수 있습니다. 아래 s를 mut으로 선언한 것은, push_str이 새 String을 만들지 않고 s의 내용 뒤에 직접 덧붙이기 때문입니다.

    let mut s = String::from("foo");
    s.push_str("bar");

이 두 줄을 실행하면 s에는 foobar가 들어 있습니다. push_str은 매개변수의 소유권을 가져올 필요가 없으므로 문자열 슬라이스를 받습니다. 그래서 다음처럼 s2의 내용을 s1에 덧붙인 뒤에도 s2를 쓸 수 있습니다.

    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
s2 is bar

push_str이 s2의 소유권을 가져갔다면 마지막 줄에서 s2의 값을 출력할 수 없었을 것입니다. push 메서드는 char 하나를 받아 String에 덧붙입니다. 다음 코드를 실행하면 s에는 lol이 들어 있습니다.

    let mut s = String::from("lo");
    s.push('l');

char 하나는 유니코드 스칼라 값 하나이며, 뒤에서 보듯이 사용자가 보는 글자 하나와 늘 같지는 않습니다.

+ 연산자나 format! 매크로를 이용한 접합

이미 있는 두 문자열을 합치고 싶을 때도 많습니다. 한 가지 방법은 + 연산자입니다.

    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는 제네릭과 연관 타입으로 정의되어 있는데, 위의 시그니처는 String으로 이 메서드를 부를 때 그 제네릭 자리에 구체적인 타입을 넣은 모습입니다. 제네릭은 9장에서 다룹니다. 이 시그니처에서 +의 까다로운 점을 알 수 있습니다.

첫째, s2 앞에 &가 있으므로 첫 문자열에 둘째 문자열의 참조를 더하는 것입니다. add의 매개변수 s 때문이며, String에는 &str만 더할 수 있고 String 두 개를 바로 더할 수는 없습니다. 그런데 &s2의 타입은 &String이지 &str이 아닌데도 컴파일되는 것은, &String 인수가 &str로 강제(coerce)될 수 있기 때문입니다. add를 부를 때 러스트가 역참조 강제 변환(deref coercion)으로 &s2를 &s2[..]로 바꾸며, 이는 Deref와 역참조 강제 변환에서 다룹니다. add가 매개변수의 소유권을 가져가지 않으므로 s2는 덧셈 뒤에도 유효한 String입니다.

둘째, 시그니처를 보면 self에 &가 없으므로 add는 self의 소유권을 가져갑니다. 그래서 s1은 add로 이동해 그 뒤로는 유효하지 않습니다. let s3 = s1 + &s2;는 두 문자열을 복사해 새 문자열을 만드는 것처럼 보이지만, 실제로는 s1의 소유권을 가져와 s2의 내용을 복사해 덧붙인 뒤 결과의 소유권을 돌려줍니다. 복사를 많이 하는 것처럼 보여도 그렇지 않으며, 복사하는 것보다 효율적입니다.

여러 문자열을 이어 붙여야 하면 +는 다루기 불편해집니다.

    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! 매크로를 씁니다.

    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의 일부에 접근하면 오류가 납니다.

src/main.rs (컴파일되지 않음)
fn main() {
    let s1 = String::from("hi");
    let h = s1[0];
}
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()`

오류와 그 도움말은 러스트의 문자열이 정수 인덱스를 지원하지 않으며, 문자열의 인덱스는 usize의 범위라는 것을 알려 줍니다. 왜 그런지 알려면 러스트가 문자열을 메모리에 저장하는 방식을 알아야 합니다.

내부적 표현

String은 Vec<u8>을 감싼 것입니다. 앞에서 본 올바르게 인코딩된 UTF-8 문자열 몇 개를 봅니다.

    let hello = String::from("Hola");

이때 len은 4로, 문자열 "Hola"를 담은 벡터가 4바이트라는 뜻입니다. UTF-8로 인코딩하면 이 글자들은 하나에 1바이트씩입니다. 하지만 다음 줄은 의외일 수 있습니다. 첫 글자는 숫자 3이 아니라 키릴 문자 Ze입니다.

    let hello = String::from("Здравствуйте");

이 문자열의 길이를 물으면 12라고 답할지 모르지만, 러스트의 답은 24입니다. 이것은 Здравствуйте를 UTF-8로 인코딩한 바이트 수이며, 이 문자열의 유니코드 스칼라 값은 하나에 2바이트씩 차지하기 때문입니다. 그래서 문자열의 바이트 인덱스가 언제나 올바른 유니코드 스칼라 값에 대응하지는 않습니다. 스칼라 값의 크기에 따라 UTF-8이 1바이트에서 4바이트까지 쓰는 규칙은 문자와 바이트: 유니코드와 UTF-8에서 자세히 다룹니다. 다음과 같은 올바르지 않은 러스트 코드를 생각해 봅니다.

let hello = "Здравствуйте";
let answer = &hello[0];

answer가 첫 글자 З가 아니라는 것은 이미 알 것입니다. З를 UTF-8로 인코딩하면 첫 바이트는 208, 둘째 바이트는 151이므로 answer가 208이어야 할 것 같지만, 208은 그 자체로 올바른 글자가 아닙니다. 사용자가 문자열의 첫 글자를 요청했을 때 208을 돌려주는 것은 원하는 결과가 아니지만, 러스트가 인덱스 0에 가진 데이터는 그것뿐입니다. 라틴 문자만 있는 문자열이라도 사용자는 대개 바이트 값을 원하지 않으며, &"hello"[0]도 104를 돌려주는 것이 아니라 컴파일 오류입니다. 정말 첫 바이트가 필요하다면 "hello".as_bytes()[0]처럼 바이트로 본다는 것을 밝히며, 이 식의 값이 104입니다.

그래서 러스트는 예상하지 못한 값을 돌려주어 바로 드러나지 않는 오류를 만들지 않도록, 이런 코드를 아예 컴파일하지 않고 개발 초기에 오해를 막습니다.

바이트와 스칼라 값과 문자소 클러스터! 이런!

UTF-8에 관해 알아 둘 또 한 가지는, 러스트의 관점에서 문자열을 보는 방식이 세 가지라는 것입니다. 바이트, 스칼라 값, 그리고 사람이 글자라고 부르는 것에 가장 가까운 문자소 클러스터(grapheme cluster)입니다. 데바나가리 문자로 쓴 힌디어 단어 नमस्ते를 보면, 이것은 결국 다음 u8 값의 벡터로 저장됩니다.

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

컴퓨터가 이 데이터를 저장하는 방식인 18바이트입니다. 이것을 러스트의 char 타입, 곧 유니코드 스칼라 값으로 보면 다음과 같습니다.

['न', 'म', 'स', '्', 'त', 'े']

char 값은 여섯 개이지만, 넷째와 여섯째는 글자가 아니라 그 자체로는 뜻이 없는 발음 구별 부호입니다. 마지막으로 문자소 클러스터로 보면, 사람이 힌디어 단어를 이루는 글자라고 부르는 것에 가까운 묶음이 됩니다. 어디서 문자소를 나눌지는 유니코드의 분할 규칙이 정하는데, Unicode 15.1부터 추가된 UAX #29의 GB9c 규칙은 비라마(virama)로 이어진 인도계 결합 자음을 나누지 않으므로 다음 세 묶음이 됩니다.

["न", "म", "स्ते"]

공식 Book은 이보다 앞선 규칙에 따라 स्와 ते를 나눈 네 묶음을 보여 주므로, 문자소를 다루는 크레이트를 쓸 때는 그 크레이트가 따르는 유니코드 버전도 확인합니다. 러스트는 컴퓨터가 저장한 원래의 문자열 데이터를 여러 방식으로 해석하는 방법을 제공하므로, 데이터가 어떤 언어이든 프로그램마다 필요한 해석을 고를 수 있습니다.

러스트가 String의 인덱스로 글자를 꺼내지 못하게 하는 마지막 까닭은, 인덱스 연산은 언제나 상수 시간(O(1))에 끝나리라 기대되는데 String에서는 그것을 보장할 수 없기 때문입니다. 올바른 글자가 몇 개 있는지 알려면 처음부터 그 인덱스까지 내용을 훑어야 합니다.

문자열 슬라이싱하기

문자열을 인덱스로 꺼낼 때 돌려줄 것이 바이트인지, 글자인지, 문자소 클러스터인지, 문자열 슬라이스인지 분명하지 않으므로 대개 좋은 생각이 아닙니다. 그래서 인덱스로 문자열 슬라이스를 꼭 만들어야 한다면, 러스트는 숫자 하나 대신 범위를 적어 무엇을 원하는지 분명히 하라고 요구합니다.

let hello = "Здравствуйте";

let s = &hello[0..4];

s는 문자열의 처음 4바이트를 담은 &str입니다. 앞에서 이 글자들은 하나에 2바이트라고 했으므로 s는 Зд입니다. &hello[0..1]처럼 글자의 중간까지만 자르려 하면, 벡터의 범위 밖 인덱스에 접근할 때처럼 실행 중에 패닉이 납니다.

src/main.rs
fn main() {
    let hello = "Здравствуйте";

    let answer = &hello[0..1];
}
thread 'main' (6017738) panicked at src/main.rs:4:19:
end byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2 of string)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

괄호 안의 숫자는 스레드의 ID로 실행할 때마다 다릅니다. 끝 바이트 인덱스 1이 З가 차지한 0부터 2 사이의 중간이라는 오류입니다. 범위로 문자열 슬라이스를 만들면 프로그램이 멈출 수 있으므로 조심해서 써야 하며, 패닉 대신 값으로 다루려면 get을 씁니다. str::get 문서에 따르면 get은 범위가 글자의 중간이거나 문자열의 길이를 넘으면 None을 돌려줍니다.

src/main.rs
fn main() {
    let owned = String::from("가a");
    let view = owned.as_str();
    println!("bytes: {}; scalars: {}", view.len(), view.chars().count());

    for (start, end) in [(0, 3), (0, 1), (3, 4), (4, 4), (4, 5)] {
        println!("{start}..{end}: {:?}", view.get(start..end));
    }

    let decorated = format!("{owned}!");
    println!("owned: {owned}; decorated: {decorated}");
}
bytes: 4; scalars: 2
0..3: Some("가")
0..1: None
3..4: Some("a")
4..4: Some("")
4..5: None
owned: 가a; decorated: 가a!

그림에 있는 예와 같은 문자열 "가a"를 썼습니다. 가는 UTF-8로 3바이트, a는 1바이트이므로 바이트 수는 4, 스칼라 값은 2개이며, 글자를 자르지 않는 위치는 0, 3, 4입니다. 0..1은 길이 안에 있지만 가의 중간을 자르므로 None이고, 4..5는 길이를 넘으므로 None이며, 4..4는 끝 위치에서 만든 빈 문자열입니다. format!은 owned를 빌려 쓰므로 그 뒤에도 owned를 출력할 수 있습니다.

문자열에 대한 반복을 위한 메서드

문자열의 조각을 다루는 가장 좋은 방법은 글자를 원하는지 바이트를 원하는지 분명히 하는 것입니다. 유니코드 스칼라 값 하나하나를 원한다면 chars 메서드를 씁니다. Зд에 chars를 부르면 char 두 개로 나누어 주므로, 반복하며 각 원소를 다룰 수 있습니다.

for c in "Зд".chars() {
    println!("{c}");
}
З
д

bytes 메서드는 원래의 바이트를 하나씩 돌려주며, 다루는 문제에 따라 이쪽이 알맞을 수도 있습니다. chars가 char를 돌려준 것과 달리, bytes는 UTF-8로 저장된 각 바이트를 u8 값으로 돌려줍니다.

for b in "Зд".bytes() {
    println!("{b}");
}
208
151
208
180

이 코드는 이 문자열을 이루는 네 바이트를 출력합니다. 다만 올바른 유니코드 스칼라 값 하나가 여러 바이트일 수 있다는 점을 기억해야 합니다. 데바나가리 문자처럼 문자열에서 문자소 클러스터를 얻는 일은 복잡해서 표준 라이브러리가 제공하지 않으며, 필요하다면 crates.io에 있는 크레이트를 씁니다.

문자열은 그렇게 단순하지 않습니다

정리하면 문자열은 복잡합니다. 언어마다 이 복잡함을 프로그래머에게 어떻게 보여 줄지 다르게 고릅니다. 러스트는 모든 러스트 프로그램이 String 데이터를 올바르게 다루는 것을 기본으로 정했으므로, 프로그래머는 UTF-8 데이터를 다루는 방법을 미리 더 생각해야 합니다. 그 대신 개발 후반에 ASCII가 아닌 글자 때문에 생기는 오류를 다룰 일이 줄어듭니다.

다행히 표준 라이브러리에는 이런 복잡한 상황을 올바르게 다루도록 돕는 String과 &str의 기능이 많습니다. 문자열을 찾는 contains나 문자열의 일부를 바꾸는 replace 같은 쓸모 있는 메서드를 문서에서 꼭 찾아보세요.

더 깊이: UTF-8이 아닌 바이트를 만났을 때

String과 &str이 언제나 올바른 UTF-8이라는 것은 편의가 아니라 표준 라이브러리 전체가 기대는 조건입니다. 그래서 바이트를 String으로 바꾸는 String::from_utf8은 먼저 UTF-8인지 검사하고, 올바르지 않으면 까닭과 함께 넘겨받은 Vec<u8>을 담은 Err를 돌려줍니다. 성공하면 벡터를 복사하지 않고 그대로 String으로 씁니다. 검사를 건너뛰는 from_utf8_unchecked는 unsafe 함수인데, 문서는 이 조건을 어기면 나머지 표준 라이브러리가 String을 UTF-8이라고 가정하므로 그 String을 쓰는 뒤쪽 코드에서 메모리 안전성 문제가 생길 수 있다고 경고합니다.

깨진 바이트가 섞여 있어도 일단 읽어야 한다면 String::from_utf8_lossy를 씁니다. 이 함수는 올바르지 않은 UTF-8 부분을 대체 문자 U+FFFD로 바꾸며, 결과를 Cow<str>로 돌려줍니다. 입력이 이미 올바른 UTF-8이면 새로 할당하지 않고 빌린 값을 돌려주고, 대체 문자를 넣어야 할 때만 새 String을 만듭니다. 운영체제가 주는 파일 이름이나 환경 변수처럼 처음부터 UTF-8이라는 보장이 없는 문자열은, String 대신 UTF-8 조건이 없는 OsString으로 다룹니다.

정리

  • &str은 다른 곳에 있는 UTF-8 문자열을 빌린 슬라이스이고, String은 늘어날 수 있고 데이터를 소유하는 UTF-8 문자열입니다.
  • String::new, String::from, to_string으로 만들고, push_str로 문자열 슬라이스를, push로 char 하나를 덧붙입니다.
  • s1 + &s2는 s1의 소유권을 가져가고 s2는 빌리며, 여러 문자열을 이을 때는 소유권을 가져가지 않는 format!이 읽기 쉽습니다.
  • String은 정수 인덱스로 글자를 꺼낼 수 없는데, UTF-8에서 바이트와 글자가 일대일로 대응하지 않기 때문입니다.
  • 문자열은 바이트, 스칼라 값(char), 문자소 클러스터 세 가지로 볼 수 있으며, 문자소는 표준 라이브러리가 제공하지 않습니다.
  • 범위 슬라이스 &s[a..b]는 글자의 중간을 자르면 패닉을 내고, s.get(a..b)는 그때 None을 돌려줍니다.
  • 글자는 chars, 바이트는 bytes로 반복합니다.

연습 문제

1. 바이트와 스칼라 값

"é나"에서 é는 미리 조합된 U+00E9 하나로 2바이트, 나는 U+B098 하나로 3바이트입니다. len()과 chars().count(), 그리고 get(0..2), get(2..5), get(1..2), get(2..4), get(5..5)의 결과는 무엇인가요?

해설 보기

바이트 수 len()은 5, 스칼라 값의 수 chars().count()는 2이며, 글자를 자르지 않는 위치는 0, 2, 5입니다. 그래서 get(0..2)는 Some("é"), get(2..5)는 Some("나"), get(1..2)는 é의 중간에서 시작하므로 None, get(2..4)는 나의 중간에서 끝나므로 None, get(5..5)는 끝 위치의 빈 문자열 Some("")입니다. chars().count()는 스칼라 값을 세므로, 모든 문자열에서 사람이 세는 글자 수와 같지는 않습니다.

2. + 뒤에 쓸 수 있는 문자열

let joined = owned + "!"; 다음 줄에서 owned를 출력할 수 있나요? format!("{owned}!")를 썼을 때와 무엇이 다른가요?

해설 보기

출력할 수 없습니다. +는 add(self, s: &str)를 부르므로 왼쪽의 owned가 이동해 joined를 만드는 데 쓰이고, 그 뒤에 owned를 쓰면 E0382 오류가 납니다. format!은 인수를 빌려 새 String을 만들므로 그 뒤에도 owned를 쓸 수 있습니다.

3. 첫 글자 꺼내기

let name = String::from("Ünal");에서 첫 글자 Ü를 꺼내고 싶습니다. &name[0..1]과 name.chars().next() 가운데 무엇을 써야 하나요?

해설 보기

name.chars().next()를 씁니다. Ü는 UTF-8로 2바이트이므로 &name[0..1]은 글자의 중간을 잘라 패닉이 납니다. chars().next()는 첫 유니코드 스칼라 값을 Some('Ü')로 돌려줍니다. 다만 Ü가 U와 결합 부호 두 개로 이루어진 문자열이라면 chars().next()는 U만 돌려주므로, 사람이 보는 글자 단위가 필요하면 문자소를 다루는 크레이트를 씁니다.

다음 글 해시맵의 키와 값에서는 키와 값을 짝지어 저장하는 해시 맵을 살펴봅니다.