소유권을 갖지 않는 또 다른 데이터 타입은 슬라이스 입니다. 슬라이스는 컬렉션(collection) 전체가 아닌 컬렉션의 연속된 일련의 요소들을 참조할 수 있게 합니다.
String
을 입력 받아 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()
}
입력된 String
을 요소별로 보며 그 값이 공백인지 확인해야하므로 as_bytes
메소드를 이용해 바이트 배열로 변환합니다.
iter
메소드를 이용해 바이트 배열의 반복자를 생성하고, enumerate
는 iter
의 결과값을 직접 반환하는 대신 이를 감싸서 튜플의 일부로 만들어 반환합니다. 반환된 튜플의 첫 번째 요소는 인덱스이며, 두 번째 요소는 요소에 대한 참조값입니다. enumerate
메소드가 튜플을 반환하기 때문에 튜플 해체를 위한 패턴을 이용합니다. for
루프 내에서 i
는 튜플 내의 인덱스에 대응하고, &item
은 튜플 내의 한 바이트에 대응하는 패턴입니다. .iter().enumerate()
의 요소에 대한 참조자를 갖는 것이므로 &
을 패턴 내에 사용합니다.
for
문 안에서 바이트 리터럴 문법을 이요해 공백 문자를 나타내는 바이트를 찾고, 공백 문자의 위치를 반환합니다. 공백이 없다면 String
의 길이값을 반환합니다.
이제 first_word
함수를 호출하여 결과를 저장한 뒤 String
의 내용물을 바꾸는 main
함수를 작성해봅시다.
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // word는 5를 갖게 될 것입니다.
s.clear(); // 이 코드는 String을 비워서 ""로 만들게 됩니다.
// word는 여기서 여전히 5를 갖고 있지만, 5라는 값을 의미있게 쓸 수 있는 스트링은 이제 없습니다.
// word는 이제 완전 유효하지 않습니다!
}
word
는 s
의 상태와 전혀 연결되어 있지 않으므로 s.clear()
호출 뒤에 사용해도 여전히 5
를 담고 있습니다. 하지만 s
의 값이 바뀌었기 때문에 첫번째 단어를 추출할 수 없게 됩니다.
이처럼 싱크가 맞지 않는 것은 쉽게 발생할 수 있는 오류입니다. 특정 상태에 있는 데이터로부터 계산되었지만 그 상태와 전혀 묶여있지 않은 더 많은 값들을 갖게 될 것입니다.
Rust는 이러한 문제에 대한 해결책을 갖고 있습니다. 바로 스트링 슬라이스(String Slice) 입니다.
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
스트링 슬라이스는 String
의 일부분에 대한 참조자입니다. [0..5]
코드는 0 이상 5 미만의 연속된 범위를 뜻합니다.
Rust의 ..
범위 문법 사용 시 만약 첫번째 인덱스부터 시작하길 원할 때, 0을 생략할 수 있습니다. 아래의 두 줄은 동일한 표현입니다.
let s = String::from("hello");
let slice = &s[0..2];
let slice = &s[..2];
비슷하게 마지막 바이트까지 포함한다면 끝의 숫자를 생략할 수 있습니다. 아래의 두 줄은 동일한 표현입니다.
let s = String::from("hello");
let len = s.len();
let slice = &s[3..len];
let slice = &s[3..];
또한 전체 스트링의 슬라이스를 만들기 위해 양쪽 값 모두 생략할 수 있습니다. 아래의 두 줄은 동일한 표현입니다.
let s = String::from("hello");
let len = s.len();
let slice = &s[0..len];
let slice = &s[..];
앞서 작성하던 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[..]
}
이제 first_word
함수 호출 시, 해당 데이터와 묶여있는 하나의 값을 반환받게 됩니다. 값은 슬라이스의 시작 위치에 대한 참조자와 슬라이스의 길이로 이루어져 있습니다.
이제 컴파일러가 String
에 대한 참조자들이 유효한 상태로 남아있게끔 보장해줄 것입니다. 앞서 인덱스를 찾은 후 String
을 변경해버려서 인덱스가 유효하지 않게 했던 버그는 코드가 논리적으로 맞지 않지만 어떠한 즉각적인 오류도 보여주지 못했습니다. 하지만 스트링 슬라이스는 이러한 버그를 불가능하게 만들고 컴파일 타임 오류를 발생시켜줄 것입니다.
let s = "Hello, world!";
위 코드에서 s
의 타입은 &str
입니다. 이는 바이너리의 특정 지점을 가리키고 있는 슬라이스입니다. 이는 왜 스트링 리터럴이 불변인지도 설명해줍니다. &str
은 불변 참조자이기 때문입니다.
first_word
함수를 한 번 더 개선시킬 수 있습니다.
fn first_word(s: &String) -> &str {
아래의 코드로도 작성할 수 있습니다. &String
과 &str
둘 모두에 대한 같은 함수를 사용할 수 있도록 해주기 때문입니다.
fn first_word(s: &str) -> &str {
스트링 슬라이스를 갖고 있을 때 바로 넘길 수 있고, String을 갖고있을 때에도 String의 전체 슬라이스를 넘길 수 있습니다. 함수가 String 참조자 대신 스트링 슬라이스를 갖도록 정의하는 것은 API를 어떠한 기능적 손실 없이도 더 일반적이고 유용한 코드로 만들어줍니다.
fn main() {
let my_string = String::from("hello world");
// first_word가 `String`의 슬라이스로 동작합니다.
let word = first_word(&my_string[..]);
let my_string_literal = "hello world";
// first_word가 스트링 리터럴의 슬라이스로 동작합니다.
let word = first_word(&my_string_literal[..]);
// 스트링 리터럴은 *또한* 스트링 슬라이스이기 때문에,
// 아래 코드도 슬라이스 문법 없이 동작합니다!
let word = first_word(my_string_literal);
}
스트링 슬라이스는 스트링에 특정되어 있습니다. 하지만 더 일반적인 슬라이스 타입들도 존재합니다.
배열의 일부를 참조하고 싶다면 아래의 코드를 사용할 수 있습니다.
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
해당 슬라이스는 &[i32]
타입을 갖습니다. 스트링 슬라이스 동작 방법과 똑같이 슬라이스 첫번째 요소에 대한 참조자와 슬라이스의 길이를 저장합니다. 다른 모든 종류의 컬렉션들에 대해 이러한 슬라이스를 이용할 수 있습니다.