4.3. The Slice Type
슬라이스(Slices)를 사용하면 전체 컬렉션이 아닌 컬렉션의 연속된 요소 시퀀스를 참조할 수 있습니다. 슬라이스는 일종의 참조이므로 소유권이 없습니다.
공백으로 구분된 단어 문자열을 받아 해당 문자열에서 찾은 첫 번째 단어를 반환하는 함수를 작성하는 작은 프로그래밍 문제를 예로 들어보겠습니다. 함수가 문자열에서 공백을 찾지 못하면 전체 문자열이 한 단어여야 하므로 전체 문자열을 반환해야 합니다.
슬라이스를 사용하지 않고 이 함수의 시그니처를 작성하는 방법을 살펴보고 슬라이스로 해결할 수 있는 문제를 이해해 보겠습니다:
first_word
함수에는 매개변수로 &String
이 있습니다. 우리는 소유권을 원하지 않으므로 괜찮습니다. 하지만 무엇을 반환해야 할까요? 문자열의 일부에 대해 이야기할 수 있는 방법은 없습니다. 하지만 공백으로 표시된 단어 끝의 인덱스를 반환할 수 있습니다. 목록 4-7에 표시된 것처럼 시도해 보겠습니다.
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()
}
목록 4-7: The first_word
function that returns a byte index value into the String
parameter
String
을 요소별로 살펴보고 값이 공백인지 확인해야 하므로 as_bytes
메서드를 사용하여 String
을 바이트 배열로 변환합니다.
다음으로, iter
메서드를 사용하여 바이트 배열에 대한 이터레이터(Iterator)를 생성합니다:
이터레이터에 대해서는 13장에서 더 자세히 설명하겠습니다. 지금은 iter
가 컬렉션의 각 요소를 반환하는 메서드이고 enumerate
는 iter
의 결과를 래핑하고 대신 각 요소를 튜플의 일부로 반환한다는 것만 알아두세요. enumerate
에서 반환되는 튜플의 첫 번째 요소는 인덱스이고 두 번째 요소는 요소에 대한 참조입니다. 인덱스를 직접 계산하는 것보다 조금 더 편리합니다.
enumerate
메서드는 튜플을 반환하므로 패턴을 사용하여 해당 튜플을 구조 파괴할 수 있습니다. 패턴에 대해서는 6장에서 더 자세히 설명하겠습니다. for
루프에서는 튜플의 인덱스에 i
를, 튜플의 단일 바이트에 &item
을 사용하는 패턴을 지정합니다. .iter().enumerate()
에서 요소에 대한 참조를 가져오기 때문에 패턴에 &
를 사용합니다.
for
루프 내부에서는 바이트 리터럴 구문을 사용하여 공백을 나타내는 바이트가 있는지 검색합니다. 공백을 찾으면 그 위치를 반환합니다. 그렇지 않으면 s.len()
을 사용하여 문자열의 길이를 반환합니다.
이제 문자열에서 첫 번째 단어 끝의 인덱스를 찾을 수 있는 방법이 생겼지만 문제가 있습니다. usize
를 자체적으로 반환하고 있지만, 이는 &String
의 컨텍스트에서만 의미 있는 숫자일 뿐입니다. 즉, String
과는 별개의 값이기 때문에 향후에도 여전히 유효하다는 보장이 없습니다. 목록 4-7의 first_word
함수를 사용하는 목록 4-8의 프로그램을 고려해 보겠습니다.
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 there's no more string that
// we could meaningfully use the value 5 with. word is now totally invalid!
}
목록 4-8: Storing the result from calling the first_word
function and then changing the String
contents
이 프로그램은 오류 없이 컴파일되며, s.clear()
를 호출한 후 word
를 사용해도 컴파일됩니다. word
는 s
의 상태와 전혀 연결되어 있지 않기 때문에 word
에는 여전히 5
라는 값이 포함됩니다. 이 값 5
를 변수 s
와 함께 사용하여 첫 번째 단어를 추출할 수 있지만, 5
를 word
에 저장한 이후 s
의 내용이 변경되었으므로 버그가 됩니다.
word
의 인덱스가 s
의 데이터와 동기화되지 않는 것에 대해 걱정해야 하는 것은 지루하고 오류가 발생하기 쉽습니다. second_word
함수를 작성하면 이러한 인덱스 관리는 훨씬 더 까다로워집니다. 그 함수의 시그니처는 다음과 같아야 합니다:
이제 시작 인덱스와 종료 인덱스를 추적하고 있으며, 특정 상태의 데이터에서 계산되었지만 해당 상태와 전혀 관련이 없는 값이 훨씬 더 많이 있습니다. 동기화 상태를 유지해야 하는 관련 없는 변수 세 개가 떠다니고 있습니다.
다행히도 Rust에는 이 문제를 해결할 수 있는 문자열 슬라이스라는 솔루션이 있습니다.
문자열 슬라이스
문자열 슬라이스(String Slice)는 String
의 일부에 대한 참조로, 다음과 같이 생겼습니다:
hello
는 전체 String
에 대한 참조가 아니라 추가 [0..5]
비트에 지정된 String
의 일부에 대한 참조입니다. [starting_index..ending_index]
를 지정하여 괄호 안의 범위를 사용하여 슬라이스를 생성하는데, 여기서 starting_index
는 슬라이스의 첫 번째 위치이고 ending_index
는 슬라이스의 마지막 위치보다 하나 더 많은 위치입니다. 내부적으로 슬라이스 데이터 구조는 슬라이스의 시작 위치와 길이를 저장하는데, 이는 ending_index
에서 starting_index
를 뺀 값에 해당합니다. 따라서 let world = &s[6..11];
의 경우, world
는 길이 값이 5
인 s
의 인덱스 6에 있는 바이트에 대한 포인터를 포함하는 슬라이스가 됩니다.
그림 4-6은 이를 다이어그램으로 보여줍니다.
그림 4-6: String slice referring to part of a String
Rust의 ..
범위 구문을 사용하면 인덱스 0에서 시작하려면 두 마침표 앞의 값을 삭제하면 됩니다. 즉, 이들은 동일합니다:
마찬가지로 슬라이스에 String
의 마지막 바이트가 포함된 경우 후행 숫자를 삭제할 수 있습니다. 즉, 이들은 동일합니다:
두 값을 모두 삭제하여 전체 문자열의 일부를 가져올 수도 있습니다. 따라서 이 값은 동일합니다:
Note
문자열 슬라이스 범위 인덱스는 유효한 UTF-8 문자 경계에서 발생해야 합니다. 멀티바이트 문자 중간에 문자열 슬라이스를 만들려고 하면 프로그램이 오류와 함께 종료됩니다. 문자열 슬라이스를 소개하기 위해 이 섹션에서는 ASCII만 사용한다고 가정하고, UTF-8 처리에 대한 자세한 내용은 8장의 "문자열로 인코딩된 텍스트 저장하기" 섹션을 참조하세요.
이 모든 정보를 염두에 두고 슬라이스를 반환하도록 first_word
를 다시 작성해 보겠습니다. "문자열 슬라이스"를 나타내는 유형은 &str
로 작성됩니다:
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[..]
}
목록 4-7에서와 같은 방식으로 공백의 첫 번째 발생을 찾아 단어 끝에 대한 인덱스를 얻습니다. 공백을 찾으면 문자열의 시작과 공백의 인덱스를 시작 및 끝 인덱스로 사용하여 문자열 슬라이스를 반환합니다.
이제 first_word
를 호출하면 기초 데이터에 연결된 단일 값을 반환합니다. 이 값은 슬라이스의 시작 지점에 대한 참조와 슬라이스에 있는 요소의 수로 구성됩니다.
슬라이스를 반환하는 것은 second_word
함수에서도 작동합니다:
이제 컴파일러가 String
에 대한 참조가 유효한 상태로 유지되도록 보장하기 때문에 엉망이 되기 훨씬 어려운 간단한 API를 갖게 되었습니다. 목록 4-8의 프로그램에서 인덱스를 첫 번째 단어의 끝으로 가져온 다음 문자열을 지워 인덱스가 유효하지 않은 버그를 기억하시나요? 이 코드는 논리적으로 올바르지 않았지만 즉각적인 오류는 표시되지 않았습니다. 비어 있는 문자열로 첫 단어 인덱스를 계속 사용하려고 하면 나중에 문제가 나타날 수 있습니다. 슬라이스를 사용하면 이러한 버그가 불가능해지며 코드에 문제가 있음을 훨씬 더 빨리 알 수 있습니다. first_word
의 슬라이스 버전을 사용하면 컴파일 타임 오류가 발생합니다:
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` due to previous error
차용 규칙에서 무언가에 대한 불변 참조가 있으면 변경 가능한 참조도 가져올 수 없다는 점을 기억하세요. clear
는 String
을 잘라내야 하므로 변경 가능한 참조를 가져와야 합니다. clear
호출 뒤의 println!
은 word
의 참조를 사용하므로 변경 불가능한 참조는 그 시점에서도 여전히 활성 상태여야 합니다. Rust는 clear
의 변경 가능한 참조와 word
의 변경 불가능한 참조가 동시에 존재하는 것을 허용하지 않으며, 컴파일이 실패합니다. Rust를 통해 API를 더 쉽게 사용할 수 있게 되었을 뿐만 아니라 컴파일 시 전체 오류 클래스도 제거되었습니다.
슬라이스로서의 문자열 리터럴
문자열 리터럴이 바이너리 안에 저장되는 것에 대해 이야기한 것을 기억하세요. 이제 슬라이스에 대해 알았으니 문자열 리터럴을 제대로 이해할 수 있습니다:
여기서 s
의 유형은 &str
입니다. 이는 바이너리의 특정 지점을 가리키는 슬라이스입니다. 이것이 문자열 리터럴이 불변인 이유이기도 합니다. &str
은 불변 참조입니다.
매개변수로서의 문자열 리터럴
리터럴과 String
값의 슬라이스를 가져올 수 있다는 것을 알면 first_word
에 대한 개선 사항이 하나 더 추가되는데, 이것이 바로 시그니처입니다:
경험이 많은 러스타시언이라면 목록 4-9에 표시된 시그니처를 대신 작성할 텐데, 이는 &String
값과 &str
값 모두에 동일한 함수를 사용할 수 있기 때문입니다.
목록 4-9: Improving the first_word
function by using a string slice for the type of the s
parameter
문자열 슬라이스가 있다면 이를 직접 전달할 수 있습니다. String
이 있다면 String
의 슬라이스나 String
에 대한 참조를 전달할 수 있습니다. 이러한 유연성은 15장의 "함수와 메서드를 사용한 암시적 디레프 강제성" 섹션에서 다룰 기능인 디레프 강제성(Deref Coercions)을 활용합니다.
String
에 대한 참조 대신 문자열 슬라이스를 취하는 함수를 정의하면 기능을 잃지 않으면서도 API를 더 일반적이고 유용하게 만들 수 있습니다:
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);
}
다른 슬라이스
문자열 슬라이스는 상상할 수 있듯이 문자열에만 해당됩니다. 하지만 더 일반적인 슬라이스 유형도 있습니다. 이 배열을 살펴보겠습니다:
문자열의 일부를 참조하는 것처럼 배열의 일부를 참조하고 싶을 수도 있습니다. 이렇게 하면 됩니다:
이 슬라이스의 유형은 &[i32]
입니다. 첫 번째 요소와 길이에 대한 참조를 저장하여 문자열 슬라이스와 동일한 방식으로 작동합니다. 이런 종류의 슬라이스는 다른 모든 종류의 컬렉션에 사용할 수 있습니다. 이러한 컬렉션에 대해서는 8장에서 벡터에 대해 이야기할 때 자세히 설명하겠습니다.
요약
소유권, 차용, 슬라이스라는 개념은 컴파일 시 Rust 프로그램에서 메모리 안전을 보장합니다. Rust 언어는 다른 시스템 프로그래밍 언어와 동일한 방식으로 메모리 사용량을 제어할 수 있지만, 데이터 소유자가 범위를 벗어나면 해당 데이터가 자동으로 정리되므로 이러한 제어 기능을 얻기 위해 추가 코드를 작성하고 디버깅할 필요가 없습니다.
소유권은 Rust의 다른 많은 부분이 작동하는 방식에 영향을 미치므로 이 책의 나머지 부분에서 이러한 개념에 대해 자세히 설명하겠습니다. 5장으로 넘어가서 struct
에서 데이터 조각을 함께 그룹화하는 방법을 살펴보겠습니다.