스트링(문자열)
러스트의 스트링은 좀 복잡함. 스트링이 컬렉션에 포함된 이유는, 스트링은 텍스트로 표현되는 바이트의 집합 +이 바이트를 다루는 유용한 메서드들로 구현되어 있음. 이런 성질은 본질적으로 컬렉션 타입의 것이라 볼 수 있음.
스트링이란?
러스트에는 크게 두 가지 스트링 타입이 존재함: str 과 String.
str:
러스트 코어 존재하는 유일한 스트링 타입임. 레퍼런스 대여 코드에 더 많이 보이기 때문에 보통 &str 형태로 보임. 스트링 슬라이스의 타입이기도 한 이 타입은 그 위치가 어디이든 UTF-8 로 인코딩된 모든 문자열을 참조함. 따라서, 프로그램의 바이너리에 위치하는 스트링 리터럴도 스트링 슬라이스라 할 수 있음.
String:
std 라이브러리에 의해 제공되는 스트링 타입임. 이 타입은 늘어날 수 있고(growable), 뮤터블이며, 소유할 수 있고, UTF-8 임.
러스트 프로그래머가 '문자열(스트링)' 을 이야기할 때, 이는 대부분 &str 과 String 을 동시에 의미함. (둘 중 하나만 떼어놓고 이야기하지 않는다는 의미.) 두 스트링 타입 모두 러스트에서 중요하게 다루는 타입이며, 모두 UTF-8 인코딩을 취한다는 사실을 기억해야 함.
std 는 또한 OsString, OsStr, CString, CStr 과 같은 다른 스트링 타입도 제공함. 이들의 이름은 String/&str 처럼 *String/*str 의 형태를 가짐. 이런 타입들은 다른 인코딩 타입이거나 하는 등 각각의 특징을 가지고 있음. 여기서는 자세히 다루지 않으니, 궁금한 사항은 API 를 보길 바람.
새 스트링 생성
아래는 String 타입의 빈 스트링을 생성함.
let mut s = String::new();
아래는 초기값을 가진 String 타입의 스트링을 생성함.
let date = "initial contents";
let s = data.to_string();
let s = "initial contents".to_string(); // 위와 같은 결과.
아래는 초기값을 가진 또 다른 방법. 위의 방법과 결과적으로 똑같다.
let s = String::from("initial contents");
스트링 값 변경
문자열을 더하는 방법에는 메서드 push_str 과 push 이 있음.
let mut s = String::from("foo");
s.push_str("bar");
push_str 메서드는 파라미터로 스트링 슬라이스를 받음. 때문에 파라미터 원본의 오너십을 가지지 않음.
let mut s1 = String::from("foo");
let s2 = "bar";
s1.push_str(&s2);
push 메서드는 문자 하나를 파라미터로 받음.
let mut = String::from("lo");
s.push('l');
format! 매크로와 '+' 연산자를 이용한 문자열 이어붙이기
다른 언어에서 흔히 그렇듯, 러스트도 '+' 연산자를 이용한 문자열 붙이기가 가능함.
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2 // 여기서 s1 은 '이동' 이 발생함. 변수 s1 은 이제 사용 불가.
s3 는 'Hello, world!' 가 됨. 그런데 주석에서 보다시피, s1 은 이제 버려지고, s2 은 여전히 유효함. 그 이유는, 여기 '+' 연산자는 add 메서드를 사용함. 아래는 그 시그니처.
fn add(self, s: &str) -> String {
이건 사실 std 라이브러리의 정확한 시그니처는 아님. 정확히는 제너릭이 사용되어 있음. 일단 여기서는 중요하지 않으니 패스.
여기서 눈여겨볼 점은 따로 있음. 이 메서드는 두 번째 파라미터로 &str 을 받아서 첫 번째 파라미터인 자기 자신, 즉 String 에 더함. '+' 연산자를 사용할 때 우린 &str 을 String 에 더하는 것이며, String 과 String 을 합칠 수는 없음. 그런데, 위의 예제에서 s2 은 String 임. 그리고 &s2 는 &String 이지, &str 이 아님. 그런데 add 메서드의 파라미터로 쓰일 수 있는 이유는?
컴파일러는 인자값으로 넘어온 &String 를 &str 으로 강제함. add 메서드를 호출하면 러스트는 deref coercion 을 사용하는데, 이는 &s2 를 &s2[...] 로 바꿈. deref coercion 에 대해서는 나중에 자세히 다루니 여기선 패스. 아무튼 그렇고, add 메서드는 파라미터 s 의 오너십을 취하지 않음. 때문에 변수 s2 는 add 후에도 여전히 유효함.
그리고, add 의 시그니처에서 self 는 오너십을 취함. let s3 = s1 + &s2; 은 얼핏 보기에 두 스트링을 복사하여 하나의 새로운 스트링을 생성하는 것처럼 보이지만, 사실은 s1 의 오너십을 취한 뒤 여기에 s2 의 내용을 복사하여 더하고(append), 오너십을 다시 리턴하는 것임. 다시 말해서, 보기와 달리 이 과정에서 많은 복사 작업이 발생하지 않음. add 의 구현 방식은 단순 복사보다 효율적으로 이루어져 있음.
더 많은 수의 스트링을 더할 수도 있음.
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = s1 + "-" + &s2 + "-" + &s3;
이런 작업이 복잡해질 경우, format! 매크로를 사용할 수도 있음.
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{}-{}-{}", s1, s2 ,s3);
위 두 예제에서 s 는 같은 값을 가짐. 그런데 format! 매크로는 오너십을 취하지 않기 때문에, s1, s2, s3 모두 유효함.
스트링 인덱싱
let s1 = String::from("hello");
let h = s1[0];
위 코드는 컴파일 에러를 일으킴. 즉 러스트 스트링은 인덱싱을 지원하지 않음. 이유를 알아보자면,
내부적으로 String 은 Vec<u8> 의 래퍼(wrapper)임.
let len = String::from("Hola").len();
위 코드에서 len 은 4 가 됨. 이는 "Hola" 를 저장하고 있는 Vec 의 길이가 4바이트라는 의미임. UTF-8 에서 각 문자는 1바이트씩 차지함. 그렇다면 다음 코드는?
let len = String::from("Здравствуйте").len();
위 문자열의 첫 문자는 키릴 문자의 대문자 Ze 임. 숫자 3 이 아님. len 의 값은 12가 아닌 24인데, 이는 위 문자열이 UTF-8 에서 24바이트를 취했다는 의미임. 각 유니코드 스칼라 값이 2바이트씩 차지함. 이렇게 러스트 스트링의 인코딩인 UTF-8 에서 스트링의 인덱스가 언제나 하나의 문자의 위치라는 것을 보장할 수 없음.
let hello = "Здравствуйте";
let answer = &hello[0];
answer 의 값은 무엇이 되어야 함? 첫 번째 문자? UTF-8 에서 첫 번째 문자는 208 바이트임. 두 번째는 151임. 그리고 이미 알아봤듯 이 문자열은 문자 하나가 2바이트를 차지함. 때문에 첫 번째 바이트인 208이나, 두 번째 바이트인 151이 각자 단독으로는 유효한 문자가 되지 못함. 때문에 인덱스 0 으로 이 문자를 리턴할 수가 없음. 그래도 어쨌든 첫 번째 인덱스이니 일단 208 을 리턴하도록 한다면, 유저는 첫 번째 인덱싱으로 나온 바이트이니 이 값이 첫 번째 문자의 바이트라고 잘못 인식하게 될 여지가 있고, 이는 유저가 원하는 데이터가 아님. 이런 문제로 인해 러스트는 이런 방식의 스트링 인덱싱 접근을 허용하지 않음.
바이트와 스칼라 값 그리고 문자 집합
데바나가리 스크립트로 쓰려진 힌디 단어 'नमस्ते' 은 Vec 의 u8 값으로 다음과 같이 저장됨.
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135]
이건 18바이트이며 컴퓨터가 최종적으로 데이터를 저장하는 방식임. 이걸 유니코드 스칼라 값으로 본다면 다음과 같음. 이는 러스트에서 char 타입 값임.
['न', 'म', 'स', '्', 'त', 'े']
이렇게 여섯 개의 char 값이 됨. 그런데 4, 6번째 값은 문자가 아님: 이건 diacritic 이라는, 스스로 문자화 될 수 없는 값임. 결국 이 값들을 문자 집합으로 본다면, 사람이 보기에 다음 4개의 문자가 하나의 단어로 되어버림. 4, 6번째 값은 버려지고, 남은 값들로 단어가 구성되어 원래 단어와 값이 달라짐.
러스트는 프로그램이 어떤 언어의 문자든 적절하게 해석할 수 있도록, 컴퓨터가 저장하는 원천 스트링 데이터를 해석하는 여러가지 방법을 제공함.
러스트가 String 에 인덱싱 접근을 허용하지 않는 마지막 이유는, 인덱싱 연산은 언제나 정적 시간(O1(1)) 을 취해야 한다고 여겨지기 때문임. 러스트는 문자열 안의 유효한 문자의 수를 도출하기 위해 문자열의 처음부터 끝까지 순회해야 하기 때문에 String 으로 정적 시간 연산 성능을 보장할 수 없음.
스트링 슬라이싱
문자열 인덱싱 접근은 별로 좋은 방법이 아님. 왜냐하면 리턴 타입이 뭐가 되어야 할지 분명하지 않기 때문. 바이트인지, 문자인지, 문자열인지... 때문에 러스트는 이에 대하여 범위를 지정한 슬라이스를 사용하도록 권함.
let hello = "Здравствуйте";
let s = &hello[0..4];
s 는 &str 타입이 되고, hello 의 처음 4바이트를 참조함. 위 문자가 각 2바이트를 취한다고 했으니, s 는 처음 두 문자를 참조하게 됨.
&hello[0..1] 를 참조한다면? 이건 panic 이 발생함. 이건 무효한 인덱싱 접근과 마찬가지임. 이런 결과가 발생할 수 있으므로 스트링 슬라이싱을 사용함에 주의가 필요함.
문자열 순회 메서드
아래 방법으로 문자열의 각 문자를 순회할 수 있음. char 메서드는 문자열의 각 문자를 나누어줌.
for c in "नमस्ते".chars() {
println!("{}", c);
}
아래는 바이트. bytes 메서드는 문자열의 각 문자를 바이트로 나누어줌.
for c in "नमस्ते".bytes() {
println!("{}", c);
}
아무튼 스트링은 복잡함.