이 문서는 2판 번역본입니다.

최신 2021 에디션 문서는 https://doc.rust-kr.org 에서 확인하실 수 있습니다.

스트링

4장에서 스트링에 관한 이야기를 했습니다만, 지금은 좀 더 깊이 살펴보겠습니다. 새로운 러스트인들은 흔히들 스트링 부분에서 막히는데 이는 세 가지 개념의 조합으로 인한 것입니다: 가능한 에러를 꼭 노출하도록 하는 러스트의 성향, 많은 프로그래머의 예상보다 더 복잡한 데이터 구조인 스트링, 그리고 UTF-8입니다. 다른 언어들을 사용하다 왔을 때 이 개념들의 조합이 러스트의 스트링을 어려운 것처럼 보이게 합니다.

스트링이 컬렉션 장에 있는 이유는 스트링이 바이트의 컬렉션 및 이 바이트들을 텍스트로 통역할때 유용한 기능을 제공하는 몇몇 메소드로 구현되어 있기 때문입니다. 이번 절에서는 생성, 갱신, 값 읽기와 같은 모든 컬렉션 타입이 가지고 있는, String에서의 연산에 대해 이야기 해보겠습니다. 또한 String을 다른 컬렉션들과 다르게 만드는 부분, 즉 사람과 컴퓨터가 String 데이터를 통역하는 방식 간의 차이로 인해 생기는 String 인덱싱의 복잡함을 논의해보겠습니다.

스트링이 뭔가요?

먼저 스트링이라는 용어가 정확히 무엇을 뜻하는 것인지 정의해보겠습니다. 러스트는 핵심 언어 기능 내에서 딱 한가지 스트링 타입만 제공하는데, 이는 바로 스트링 슬라이스인 str이고, 이것의 참조자 형태인 &str을 많이 봤죠. 4장에서는 스트링 슬라이스에 대해 얘기했고, 이는 다른 어딘가에 저장된 UTF-8로 인코딩된 스트링 데이터의 참조자입니다. 예를 들어, 스트링 리터럴은 프로그램의 바이너리 출력물 내에 저장되어 있으며, 그러므로 스트링 슬라이스입니다.

String 타입은 핵심 언어 기능 내에 구현된 것이 아니고 러스트의 표준 라이브러리를 통해 제공되며, 커질 수 있고, 가변적이며, 소유권을 갖고 있고, UTF-8로 인코딩된 스트링 타입입니다. 러스트인들이 “스트링”에 대해 이야기할 때, 그들은 보통 String과 스트링 슬라이스 &str 타입 둘 모두를 이야기한 것이지, 이들 중 하나를 뜻한 것은 아닙니다. 이번 절은 대부분 String에 관한 것이지만, 두 타입 모두 러스트 표준 라이브러리에서 매우 많이 사용되며 String과 스트링 슬라이스 모두 UTF-8로 인코딩되어 있습니다.

또한 러스트 표준 라이브러리는 OsString, OsStr, CString, 그리고 CStr과 같은 몇가지 다른 스트링 타입도 제공합니다. 심지어 어떤 라이브러리 크레이트들은 스트링 데이터를 저장하기 위해 더 많은 옵션을 제공할 수 있습니다. *String/*Str이라는 작명과 유사하게, 이들은 종종 소유권이 있는 타입과 이를 빌린 변형 타입을 제공하는데, 이는 String/&str과 비슷합니다. 이러한 스트링 타입들은, 예를 들면 다른 종류의 인코딩이 된 텍스트를 저장하거나 다른 방식으로 메모리에 저장될 수 있습니다. 여기서는 이러한 다른 스트링 타입은 다루지 않겠습니다; 이것들을 어떻게 쓰고 어떤 경우에 적합한지에 대해 알고 싶다면 각각의 API 문서를 확인하시기 바랍니다.

새로운 스트링 생성하기

Vec에서 쓸 수 있는 많은 연산들이 String에서도 마찬가지로 똑같이 쓰일 수 있는데, new 함수를 이용하여 스트링을 생성하는 것으로 아래의 Listing 8-11과 같이 시작해봅시다:

#![allow(unused)]
fn main() {
let mut s = String::new();
}

Listing 8-11: 비어있는 새로운 String 생성하기

이 라인은 우리가 어떤 데이터를 담아둘 수 있는 s라는 빈 스트링을 만들어 줍니다. 종종 우리는 스트링에 담아두고 시작할 초기값을 가지고 있을 것입니다. 그런 경우, to_string 메소드를 이용하는데, 이는 Display 트레잇이 구현된 어떤 타입이든 사용 가능하며, 스트링 리터럴도 이 트레잇을 구현하고 있습니다. Listing 8-12에서 두 가지 예제를 보여주고 있습니다:

#![allow(unused)]
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();
}

Listing 8-12: to_string 메소드를 사용하여 스트링 리터럴로부터 String 생성하기

이 코드는 initial contents를 담고 있는 스트링을 생성합니다.

또한 스트링 리터럴로부터 String을 생성하기 위해서 String::from 함수를 이용할 수도 있습니다. Listing 8-13의 코드는 to_string을 사용하는 Listing 8-12의 코드와 동일합니다:

#![allow(unused)]
fn main() {
let s = String::from("initial contents");
}

Listing 8-13: String::from 함수를 사용하여 스트링 리터럴로부터 String 생성하기

스트링이 너무나 많은 것들에 사용되기 때문에, 스트링을 위해 다양한 제네릭 API들을 사용할 수 있으며, 다양한 옵션들을 제공합니다. 몇몇은 쓸모없는 것처럼 느껴질 수도 있지만, 다 사용할 곳이 있습니다! 지금의 경우, String::from.to_string은 정확히 똑같은 일을 하며, 따라서 어떤 것을 사용하는가는 여러분의 스타일에 따라 달린 문제입니다.

스트링이 UTF-8로 인코딩되었음을 기억하세요. 즉, 아래의 Listing 8-14에서 보는 것처럼 우리는 인코딩된 어떤 데이터라도 포함시킬 수 있습니다:

#![allow(unused)]
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");
}

Listing 8-14: 스트링에 다양한 언어로 인삿말 저장하기

위의 모두가 유효한 String 값입니다.

스트링 갱신하기

String은 크기가 커질 수 있으며 이것이 담고 있는 내용물은 Vec의 내용물과 마찬가지로 더 많은 데이터를 집어넣음으로써 변경될 수 있습니다. 추가적으로, + 연산자나 format! 매크로를 사용하여 편리하게 String 값들을 서로 접합(concatenation)할 수 있습니다.

push_strpush를 이용하여 스트링 추가하기

Listing 8-15와 같이 스트링 슬라이스를 추가하기 위해 push_str 메소드를 이용하여 String을 키울 수 있습니다:

#![allow(unused)]
fn main() {
let mut s = String::from("foo");
s.push_str("bar");
}

Listing 8-15: push_str 메소드를 사용하여 String에 스트링 슬라이스 추가하기

s는 위의 두 라인 뒤에 “foobar”를 담게 될 것입니다. push_str 메소드는 스트링 슬라이스를 파라미터로 갖는데 이는 파라미터의 소유권을 가져올 필요가 없기 때문입니다. 예를 들어, Listing 8-16의 코드는 s1s2의 내용물을 추가한 뒤 s2를 더 이상 쓸 수 없게 되었다면 불행했을 경우를 보여주고 있습니다:

#![allow(unused)]
fn main() {
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(&s2);
println!("s2 is {}", s2);
}

Listing 8-16: 스트링 슬라이스를 String에 붙인 이후에 스트링 슬라이스를 사용하기

만일 push_str 함수가 s2의 소유권을 가져갔다면, 마지막 줄에서 그 값을 출력할 수 없었을 것입니다. 하지만, 이 코드는 우리가 기대했던 대로 작동합니다!

push 메소드는 한 개의 글자를 파라미터로 받아서 String에 추가합니다. Listing 8-17은 push 메소드를 사용하여 String에 l을 추가하는 코드를 보여주고 있습니다:

#![allow(unused)]
fn main() {
let mut s = String::from("lo");
s.push('l');
}

Listing 8-17: push를 사용하여 String 값에 한 글자 추가하기

위의 코드를 실행한 결과로 slol을 담고 있을 것입니다.

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

종종 우리는 가지고 있는 두 개의 스트링을 조합하고 싶어합니다. 한 가지 방법은 아래 Listing 8-18와 같이 + 연산자를 사용하는 것입니다:

#![allow(unused)]
fn main() {
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1은 여기서 이동되어 더이상 쓸 수 없음을 유의하세요
}

Listing 8-18: + 연산자를 사용하여 두 String 값을 하나의 새로운 String 값으로 조합하기

위의 코드 실행 결과로서, 스트링 s3Hello, world!를 담게 될 것입니다. s1이 더하기 연산 이후에 더이상 유효하지 않은 이유와 s2의 참조자가 사용되는 이유는 + 연산자를 사용했을 때 호출되는 함수의 시그니처와 맞춰야 하기 때문입니다 + 연산자는 add 메소드를 사용하는데, 이 메소드의 시그니처는 아래처럼 생겼습니다:

fn add(self, s: &str) -> String {

이는 표준 라이브러리에 있는 정확한 시그니처는 아닙니다: 표준 라이브러리 내에서 add는 제네릭을 이용하여 정의되어 있습니다. 여기서는 제네릭에 구체 타입(concrete type)을 대입한 add의 시그니처를 보는 중인데, 이는 우리가 String 값으로 이 메소드를 호출했을때 생깁니다. 제네릭에 대한 내용은 10장에서 다룰 것입니다. 이 시그니처는 교묘한 + 연산자를 이해하는데 필요한 단서를 줍니다.

첫번째로, s2&를 가지고 있는데, 이는 add 함수의 s 파라미터 때문에 첫번째 스트링에 두번째 스트링의 참조자를 더하고 있음을 뜻합니다: 우리는 String&str만 더할 수 있고, 두 String을 더하지는 못합니다. 하지만, 잠깐만요 - &s2의 타입은 &String이지, add의 두번째 파라미터에 명시한것처럼 &str은 아니죠. 왜 Listing 8-18의 예제가 컴파일될까요? &s2add 호출에 사용할 수 있는 이유는 &String 인자가 &str강제될 수 있기 때문입니다 - add 함수가 호출되면, 러스트는 역참조 강제(deref coercion) 라 불리는 무언가를 사용하는데, 이는 add 함수내에서 사용되는 &s2&s2[..]로 바뀌는 것으로 생각할 수 있도록 해줍니다. 역참조 강제에 대한 것은 15장에서 다룰 것입니다. add가 파라미터의 소유권을 가져가지는 않으므로, s2는 이 연산 이후에도 여전히 유효한 String일 것입니다.

두번째로, 시그니처에서 addself의 소유권을 가져가는 것을 볼 수 있는데, 이는 self&안 가지고 있기 때문입니다. 즉 Listing 8-18의 예제에서 s1add 호출로 이동되어 이후에는 더 이상 유효하지 않을 것이라는 의미입니다. 따라서 let s3 = s1 + &s2;가 마치 두 스트링을 복사하여 새로운 스트링을 만들 것처럼 보일지라도, 실제로 이 구문은 s1의 소유권을 가져다가 s2의 내용물의 복사본을 추가한 다음, 결과물의 소유권을 반환합니다. 달리 말하면, 이 구문은 여러 복사본을 만드는 것처럼 보여도 그렇지 않습니다: 이러한 구현은 복사보다 더 효율적입니다.

만일 여러 스트링을 접하고자 한다면, +의 동작은 다루기 불편해 집니다.:

#![allow(unused)]
fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = s1 + "-" + &s2 + "-" + &s3;
}

이 지점에서 stic-tac-toe가 될 것입니다. 모든 +" 문자들과 함께 보면 어떤 결과가 나올지 알기 어렵습니다. 더 복잡한 스트링 조합을 위해서는 format! 매크로를 사용할 수 있습니다:

#![allow(unused)]
fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = format!("{}-{}-{}", s1, s2, s3);
}

이 코드 또한 stic-tac-toe을 설정합니다. format! 매크로는 println!과 똑같은 방식으로 작동하지만, 스크린에 결과를 출력하는 대신 결과를 담은 String을 반환해줍니다. format!을 이용한 버전이 훨씬 읽기 쉽고, 또한 어떠한 파라미터들의 소유권도 가져가지 않습니다.

스트링 내부의 인덱싱

다른 많은 프로그래밍 언어들에서, 인덱스를 이용한 참조를 통해 스트링 내부의 개별 문자들에 접근하는 것은 유효하고 범용적인 연산에 속합니다. 그러나 러스트에서 인덱싱 문법을 이용하여 String의 부분에 접근하고자 하면 에러를 얻게 됩니다. 아래 Listing 8-19와 같은 코드를 생각해봅시다:

let s1 = String::from("hello");
let h = s1[0];

Listing 8-19: 스트링에 인덱싱 문법을 사용하는 시도

이 코드는 아래와 같은 에러를 출력합니다:

error: the trait bound `std::string::String: std::ops::Index<_>` is not
satisfied [--explain E0277]
  |>
  |>     let h = s1[0];
  |>             ^^^^^
note: the type `std::string::String` cannot be indexed by `_`

에러와 노트 부분이 이야기해 줍니다: 러스트 스트링은 인덱싱을 지원하지 않는다고. 그렇지만 왜 안되는 걸까요? 이 질문에 답하기 위해서는 러스트가 어떻게 스트링을 메모리에 저장하는지에 관하여 살짝 이야기해야 합니다.

내부적 표현

StringVec<u8>을 감싼 것입니다(wrapper). Listing 8-14에서 보았던 몇가지 적절히 인코딩된 UTF-8 예제 스트링을 살펴봅시다. 첫번째로, 이것입니다:

#![allow(unused)]
fn main() {
let len = String::from("Hola").len();
}

이 경우, len은 4가 되는데, 이는 스트링 “Hola”를 저장하고 있는 Vec이 4바이트 길이라는 뜻입니다. UTF-8로 인코딩되면 각각의 글자들이 1바이트씩 차지한다는 것이죠. 그런데 아래 예제는 어떨까요?

#![allow(unused)]
fn main() {
let len = String::from("Здравствуйте").len();
}

이 스트링의 길이가 얼마인지 묻는다면, 여러분은 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를 반환합니다. 기대치 않은 값을 반환하고 즉시 발견하기 힘들지도 모를 버그를 야기하는 것을 방지하기 위해, 러스트는 이러한 코드를 전혀 컴파일하지 않고 이러한 오해들을 개발 과정 내에서 일찌감치 방지합니다.

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

UTF-8에 대한 또다른 지점은, 실제로는 러스트의 관점에서 문자열을 보는 세 가지의 의미있는 방식이 있다는 것입니다: 바이트, 스칼라 값, 그리고 문자소 클러스터(우리가 글자라고 부르는 것과 가장 근접한 것)입니다.

데바가나리 글자로 쓰여진 힌디어 “नमस्ते”를 보면, 이것은 궁극적으로 아래와 같이 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을 인덱스로 접근하여 문자를 얻지 못하도록 하는 마지막 이유는 인덱스 연산이 언제나 상수 시간(O(1))에 실행될 것으로 기대받기 때문입니다. 그러나 String을 가지고 그러한 성능을 보장하는 것은 불가능한데, 그 이유는 러스트가 스트링 내에 얼마나 많은 유효 문자가 있는지 알아내기 위해 내용물의 시작 지점부터 인덱스로 지정된 곳까지 훑어야 하기 때문입니다.

스트링 슬라이싱하기

스트링 인덱싱의 리턴 타입이 어떤 것이 (바이트 값인지, 캐릭터인지, 문자소 클러스터인지, 혹은 스트링 슬라이스인지) 되어야 하는지 명확하지 않기 때문에 스트링의 인덱싱은 종종 나쁜 아이디어가 됩니다. 따라서, 여러분이 스트링 슬라이스를 만들기 위해 정말로 인덱스를 사용하고자 한다면 러스트는 좀 더 구체적으로 지정하도록 요청합니다. 여러분의 인덱싱을 더 구체적으로 하고 스트링 슬라이스를 원한다는 것을 가리키기 위해서, []에 숫자 하나를 사용하는 인덱싱보다, []와 범위를 사용하여 특정 바이트들이 담고 있는 스트링 슬라이스를 만들 수 있습니다:

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

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

여기서 s는 스트링의 첫 4바이트를 담고 있는 &str가 될 것입니다. 앞서 우리는 이 글자들이 각각 2바이트를 차지한다고 언급했으므로, 이는 s가 “Зд”이 될 것이란 뜻입니다.

만약에 &hello[0..1]라고 했다면 어떻게 될까요? 답은 다음과 같습니다: 러스트는 벡터 내에 유효하지 않은 인덱스에 접근했을 때와 동일한 방식으로 런타임에 패닉을 발생시킬 것입니다.

thread 'main' panicked at 'index 0 and/or 1 in `Здравствуйте` do not lie on
character boundary', ../src/libcore/str/mod.rs:1694

여러분은 스트링 슬라이스를 만들기 위하여 범위를 이용하는 방법을 조심스럽게 사용해야 하는데, 이는 여러분의 프로그램을 죽게 만들 수도 있기 때문입니다.

스트링 내에서 반복적으로 실행되는 메소드

다행히도, 스트링의 요소에 접근하는 다른 방법이 있습니다.

만일 개별적인 유니코드 스칼라 값에 대한 연산을 수행하길 원한다면, 가장 좋은 방법은 chars 메소드를 이용하는 것입니다. chars를 “नमस्ते”에 대해 호출하면 char타입의 6개의 값으로 나누어 반환하며, 여러분은 각각의 요소에 접근하기 위해 이 결과값에 대해 반복(iterate)할 수 있습니다:

#![allow(unused)]
fn main() {
for c in "नमस्ते".chars() {
    println!("{}", c);
}
}

이 코드는 다음을 출력할 것입니다:

न
म
स
्
त
े

bytes 메소드는 가공되지 않은 각각의 바이트를 반환하는데, 여러분의 문제 범위에 따라 적절할 수도 있습니다:

#![allow(unused)]
fn main() {
for b in "नमस्ते".bytes() {
    println!("{}", b);
}
}

이 코드는 이 String을 구성하는 아래처럼 시작되는 18 바이트를 출력합니다:

224
164
168
224
// ... etc

하지만 유효한 유니코드 스칼라 값이 하나 이상의 바이트로 구성될지도 모른다는 것을 확실히 기억해주세요.

스트링으로부터 문자소 클러스터를 얻는 방법은 복잡해서, 이 기능은 표준 라이브러리를 통해 제공되지 않습니다. 여러분이 원하는 기능이 이것이라면 crates.io에서 사용 가능한 크레이트가 있습니다.

스트링은 그렇게 단순하지 않습니다

종합하면, 스트링은 복잡합니다. 다른 프로그래밍 언어들은 이러한 복잡성을 프로그래머에게 어떻게 보여줄지에 대해 각기 다른 선택을 합니다. 러스트는 String 데이터의 올바른 처리가 모든 러스트 프로그램에 대한 기본적인 동작이 되도록 선택했는데, 이는 솔직히 프로그래머들이 UTF-8 데이터를 처리하는데 있어 더 많은 생각을 해야한다는 의미입니다. 이러한 거래는 다른 프로그래밍 언어들에 비해 더 복잡한 스트링을 노출시키지만, 한편으로는 여러분의 개발 생활 주기 후반에 비 ASCII 캐릭터를 포함하는 에러를 처리해야 하는 것을 막아줄 것입니다.

이것보다 살짝 덜 복잡한 것으로 옮겨 갑시다: 해쉬맵이요!