슬라이스 타입
슬라이스 타입 (The Slice Type)
슬라이스(slice)는 컬렉션에 들어 있는 요소들의 연속된 일부분을 참조할 수 있게 해줘요. 슬라이스는 일종의 참조라서 소유권을 갖지 않아요. 소유권 때문에 고민하던 문제를 해결하는 과정에서 슬라이스가 왜 필요한지 자연스럽게 따라가 보죠.
출처: The Rust Book
여기 작은 프로그래밍 문제가 하나 있어요. "공백으로 구분된 단어들로 이루어진 문자열을 받아서, 그 문자열에서 첫 번째 단어를 찾아 반환하는 함수"를 작성해 보세요. 함수가 문자열에서 공백을 찾지 못한다면 문자열 전체가 한 단어라는 뜻이므로, 문자열 전체를 반환해야 해요.
참고로, 슬라이스를 소개하는 이번 절에서는 단순함을 위해 ASCII만 가정해요. UTF-8 처리에 대한 더 자세한 논의는 8장의 "문자열로 UTF-8 인코딩된 텍스트 저장하기" 절에서 다룰게요.
슬라이스를 사용하지 않고 이 함수의 시그니처를 어떻게 작성할지 먼저 생각해 보면서, 슬라이스가 해결할 문제를 이해해 볼게요.
fn first_word(s: &String) -> ?
first_word 함수는 타입이 &String인 매개변수를 가져요. 우리는 소유권이 필요 없으니 이러면 문제없어요. (관용적인 Rust에서 함수는 꼭 필요한 경우가 아니면 인자의 소유권을 가져가지 않아요. 그 이유는 계속 공부해 가면서 자연스럽게 알게 될 거예요.) 그런데 무엇을 반환해야 할까요? 문자열의 일부를 표현할 방법이 우리한테는 딱히 없어요. 대신 공백으로 표시된 단어의 끝 인덱스를 반환할 수는 있겠죠. Listing 4-7처럼 한번 그렇게 해볼게요.
Filename: src/main.rs
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {}
Listing 4-7: String 매개변수의 바이트 인덱스 값을 반환하는 first_word 함수
String을 요소 하나하나 훑으면서 값이 공백인지 확인해야 하므로, as_bytes 메서드를 사용해 String을 바이트 배열로 변환해요.
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {}
다음으로, iter 메서드를 사용해 바이트 배열에 대한 반복자(iterator)를 만들어요.
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {}
반복자에 대한 자세한 내용은 13장에서 다룰게요. 지금은 iter가 컬렉션의 각 요소를 반환하는 메서드이고, enumerate가 iter의 결과를 감싸서 각 요소를 튜플의 일부로 반환한다는 정도만 알면 돼요. enumerate가 반환하는 튜플의 첫 번째 요소는 인덱스이고, 두 번째 요소는 요소에 대한 참조예요. 인덱스를 직접 계산하는 것보다 훨씬 편리하죠.
enumerate 메서드가 튜플을 반환하므로, 패턴을 사용해 그 튜플을 분해할 수 있어요. 패턴에 대한 자세한 내용은 6장에서 다루겠지만, for 루프에서 튜플의 인덱스에는 i, 단일 바이트에는 &item이라는 패턴을 지정했어요. .iter().enumerate()로부터 요소에 대한 참조를 받기 때문에 패턴에 &를 사용한 거예요.
for 루프 안에서 바이트 리터럴 구문을 사용해 공백을 나타내는 바이트를 찾아요. 공백을 찾으면 그 위치를 반환하고, 그렇지 않으면 s.len()으로 문자열의 길이를 반환해요.
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {}
이제 문자열에서 첫 번째 단어의 끝 인덱스를 알아내는 방법을 갖게 됐지만, 여기서 문제가 생겨요. 우리는 usize만 반환하고 있는데, 이 숫자는 &String이라는 문맥 안에서만 의미를 가져요. 다시 말해, 이 값은 String과 분리된 값이기 때문에 나중에도 여전히 유효할 거라는 보장이 없어요. Listing 4-7의 first_word 함수를 사용하는 Listing 4-8의 프로그램을 살펴볼게요.
Filename: src/main.rs
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // word will get the value 5
s.clear(); // this empties the String, making it equal to ""
// word still has the value 5 here, but s no longer has any content that we
// could meaningfully use with the value 5, so word is now totally invalid!
}
Listing 4-8: first_word 함수 호출 결과를 저장한 뒤 String 내용 바꾸기
이 프로그램은 에러 없이 컴파일돼요. 그리고 s.clear()를 호출한 뒤 word를 사용한다 하더라도 에러 없이 컴파일돼요. word가 s의 상태와 전혀 연결되어 있지 않으니 word는 여전히 값 5를 담고 있기 때문이죠. 변수 s와 함께 그 값 5를 사용해 첫 번째 단어를 추출해 낼 수도 있겠지만, 그건 버그가 돼요. word에 5를 저장한 이후 s의 내용이 바뀌었으니까요.
word의 인덱스가 s의 데이터와 어긋나지 않을까 걱정해야 한다는 건 지루하고 오류가 나기 쉬운 일이에요! second_word 함수를 작성한다면 이런 인덱스 관리는 더더욱 취약해져요. 그 시그니처는 이렇게 생겨야 할 거예요.
fn second_word(s: &String) -> (usize, usize) {
이제 시작 인덱스와 끝 인덱스 둘 다 추적해야 하고, 특정 상태의 데이터로 계산했지만 그 상태와 전혀 연결되지 않은 값이 그만큼 더 많아졌어요. 서로 동기화해서 유지해야 하는, 아무 관련 없는 변수 세 개가 여기저기 떠다니게 된 거죠.
다행히 Rust에는 이 문제를 해결하는 방법이 있어요. 바로 문자열 슬라이스(string slice)입니다.
문자열 슬라이스
문자열 슬라이스는 String 요소들의 연속된 일부분에 대한 참조예요. 생김새는 이렇게 생겼어요.
fn main() {
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
}
hello는 String 전체에 대한 참조가 아니라, 추가된 [0..5] 부분에 지정된 String의 일부에 대한 참조예요. 대괄호 안에 [starting_index..ending_index]처럼 범위를 지정해서 슬라이스를 만들어요. 여기서 *starting_index*는 슬라이스의 첫 번째 위치이고, *ending_index*는 슬라이스의 마지막 위치보다 하나 더 큰 값이에요. 내부적으로 슬라이스 데이터 구조는 시작 위치와 슬라이스의 길이를 저장하는데, 그 길이는 *ending_index*에서 *starting_index*를 뺀 값과 일치해요. 그러니까 let world = &s[6..11];의 경우 world는 s의 인덱스 6에 있는 바이트를 가리키는 포인터와 길이 값 5를 담은 슬라이스가 돼요.
Figure 4-7이 이 내용을 그림으로 보여줘요.
Figure 4-7: String의 일부를 참조하는 문자열 슬라이스
Rust의 .. 범위 구문을 사용하면 인덱스 0에서 시작할 때 점 두 개 앞의 값을 생략할 수 있어요. 다시 말해 다음 두 코드는 같아요.
#![allow(unused)]
fn main() {
let s = String::from("hello");
let slice = &s[0..2];
let slice = &s[..2];
}
마찬가지로, 슬라이스가 String의 마지막 바이트까지 포함한다면 뒤의 숫자를 생략할 수 있어요. 그렇다면 다음 두 코드는 같아요.
#![allow(unused)]
fn main() {
let s = String::from("hello");
let len = s.len();
let slice = &s[3..len];
let slice = &s[3..];
}
두 값을 모두 생략해서 문자열 전체의 슬라이스를 취할 수도 있어요. 그럼 다음 두 코드는 같아요.
#![allow(unused)]
fn main() {
let s = String::from("hello");
let len = s.len();
let slice = &s[0..len];
let slice = &s[..];
}
주의할 점: 문자열 슬라이스의 범위 인덱스는 유효한 UTF-8 문자 경계에 있어야 해요. 멀티바이트 문자 중간에서 문자열 슬라이스를 만들려고 하면 프로그램이 에러와 함께 종료돼요.
이 모든 정보를 염두에 두고, first_word를 슬라이스를 반환하도록 다시 작성해 볼게요. "문자열 슬라이스"를 뜻하는 타입은 &str로 적어요.
Filename: src/main.rs
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {}
단어 끝의 인덱스를 얻는 방법은 Listing 4-7에서 했던 것과 같아요. 공백이 처음 나타나는 위치를 찾죠. 공백을 찾으면 문자열의 시작과 공백의 인덱스를 시작·끝 인덱스로 사용해 문자열 슬라이스를 반환해요.
이제 first_word를 호출하면 기본 데이터에 연결된 단일 값을 돌려받아요. 그 값은 슬라이스 시작점에 대한 참조와 슬라이스에 포함된 요소의 개수로 구성되지요.
second_word 함수에서도 슬라이스를 반환하는 방식이 잘 동작할 거예요.
fn second_word(s: &String) -> &str {
이제 컴파일러가 String에 대한 참조가 계속 유효한지 확인해 주기 때문에, 망치기 훨씬 어려운 직관적인 API를 갖게 됐어요. Listing 4-8의 프로그램에서 했던 버그를 기억하나요? 첫 번째 단어 끝의 인덱스를 얻었는데 문자열을 비워서 그 인덱스가 무효해진 상황이었죠. 그 코드는 논리적으로 틀렸지만 즉각적인 에러는 보이지 않았어요. 그 문제는 빈 문자열에 첫 번째 단어 인덱스를 계속 사용하려고 하면 나중에야 드러나요. 슬라이스는 이런 버그를 아예 불가능하게 만들고, 코드에 문제가 있다는 걸 훨씬 더 이르게 알려줘요. 슬라이스 버전의 first_word를 사용하면 컴파일 타임에 에러가 발생해요.
Filename: src/main.rs
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
s.clear(); // error!
println!("the first word is: {word}");
}
컴파일러 에러는 이렇게 나와요.
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:18:5
|
16 | let word = first_word(&s);
| -- immutable borrow occurs here
17 |
18 | s.clear(); // error!
| ^^^^^^^^^ mutable borrow occurs here
19 |
20 | println!("the first word is: {word}");
| ---- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
대여(borrow) 규칙에서 배웠듯이, 어떤 값에 대해 불변 참조를 갖고 있다면 가변 참조를 동시에 가질 수 없어요. clear는 String을 잘라내야 하므로 가변 참조가 필요해요. clear 호출 뒤의 println!이 word의 참조를 사용하므로, 그 시점에도 불변 참조가 여전히 활성 상태여야 해요. Rust는 clear의 가변 참조와 word의 불변 참조가 동시에 존재하는 것을 허용하지 않아서 컴파일이 실패해요. Rust는 우리 API를 더 쓰기 쉽게 만들었을 뿐만 아니라, 컴파일 타임에 오류의 한 종류 전체를 없애버렸어요!
문자열 리터럴로서의 슬라이스
문자열 리터럴이 바이너리 안에 저장된다고 이야기했던 것을 기억하세요. 슬라이스에 대해 배웠으니 이제 문자열 리터럴을 제대로 이해할 수 있어요.
#![allow(unused)]
fn main() {
let s = "Hello, world!";
}
여기서 s의 타입은 &str이에요. 즉 바이너리의 특정 지점을 가리키는 슬라이스인 거죠. 이것이 바로 문자열 리터럴이 불변인 이유이기도 해요. &str은 불변 참조니까요.
매개변수로서의 문자열 슬라이스
리터럴과 String 값 모두의 슬라이스를 취할 수 있다는 사실은 first_word를 한 단계 더 개선할 수 있게 해줘요. 바로 그 시그니처 말이에요.
fn first_word(s: &String) -> &str {
경험이 더 많은 러스트 개발자(Rustacean)는 Listing 4-9의 시그니처를 대신 작성할 거예요. 이렇게 하면 같은 함수를 &String 값과 &str 값 양쪽에 사용할 수 있거든요.
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let my_string = String::from("hello world");
// `first_word` works on slices of `String`s, whether partial or whole.
let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
// `first_word` also works on references to `String`s, which are equivalent
// to whole slices of `String`s.
let word = first_word(&my_string);
let my_string_literal = "hello world";
// `first_word` works on slices of string literals, whether partial or
// whole.
let word = first_word(&my_string_literal[0..6]);
let word = first_word(&my_string_literal[..]);
// Because string literals *are* string slices already,
// this works too, without the slice syntax!
let word = first_word(my_string_literal);
}
Listing 4-9: s 매개변수의 타입에 문자열 슬라이스를 사용해 first_word 함수 개선하기
문자열 슬라이스를 갖고 있다면 그것을 바로 넘길 수 있어요. String을 갖고 있다면 String의 슬라이스나 String에 대한 참조를 넘길 수 있죠. 이런 유연성은 15장의 "함수와 메서드에서 Deref 강제(coercion) 사용하기" 절에서 다룰 역참조 강제(deref coercion) 기능을 활용한 거예요.
String에 대한 참조 대신 문자열 슬라이스를 받도록 함수를 정의하면 기능을 잃지 않으면서도 API를 더 일반적이고 유용하게 만들 수 있어요.
Filename: src/main.rs
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let my_string = String::from("hello world");
// `first_word` works on slices of `String`s, whether partial or whole.
let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
// `first_word` also works on references to `String`s, which are equivalent
// to whole slices of `String`s.
let word = first_word(&my_string);
let my_string_literal = "hello world";
// `first_word` works on slices of string literals, whether partial or
// whole.
let word = first_word(&my_string_literal[0..6]);
let word = first_word(&my_string_literal[..]);
// Because string literals *are* string slices already,
// this works too, without the slice syntax!
let word = first_word(my_string_literal);
}
다른 슬라이스
문자열 슬라이스는 짐작할 수 있듯이 문자열에 특화된 거예요. 하지만 더 일반적인 슬라이스 타입도 있어요. 다음 배열을 살펴볼게요.
#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
}
문자열의 일부를 참조하고 싶을 때가 있듯이, 배열의 일부를 참조하고 싶을 때가 있을 거예요. 이렇게 하면 돼요.
#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);
}
이 슬라이스의 타입은 &[i32]예요. 문자열 슬라이스와 똑같이 첫 요소에 대한 참조와 길이를 저장하는 방식으로 동작해요. 이런 종류의 슬라이스는 여러 다른 컬렉션에서도 쓰게 될 거예요. 이 컬렉션들에 대해서는 8장에서 벡터를 다룰 때 자세히 설명할게요.
요약
소유권, 대여(borrowing), 슬라이스의 개념은 컴파일 타임에 Rust 프로그램의 메모리 안전성을 보장해 줘요. Rust 언어는 다른 시스템 프로그래밍 언어와 같은 방식으로 메모리 사용을 제어할 수 있게 해줘요. 하지만 데이터의 소유자가 스코프를 벗어날 때 그 데이터를 자동으로 정리해 준다는 사실 덕분에, 이 제어를 얻기 위해 추가 코드를 작성하고 디버깅할 필요가 없어요.
소유권은 Rust의 많은 다른 부분이 동작하는 방식에도 영향을 주기 때문에, 이 책의 나머지 부분에서 이 개념들을 계속해서 다룰 거예요. 이제 5장으로 넘어가 struct로 데이터 조각을 묶는 방법을 살펴볼게요.