Chapter 4.3. Slice 타입
Ownership을 가지지 않는 또 다른 데이터 타입으로 _slice_가 있다. Slice는 데이터 모음의 전체가 아닌 일정 부분만을 가리키는 reference이다.
여기 작은 프로그래밍 문제가 주어졌다고 해보자. String 하나를 받아서 그 첫 번째 단어를 반환하는 함수를 만들어보자. 만약 함수가 string의 공백 문자를 찾지 못한다면, string 전체가 한 단어일테니까 전체 string을 반환해주면 된다.
이 함수의 signature부터 생각해보자:
fn first_word(s: &String) -> ?
이 함수 first_word
는 &String
을 parameter로 받는다. 우리는 ownership을 원치 않으므로 괜찮은 일이다. 하지만 무엇을 반환해줘야 하는가? 우리는 아직 String의 일부분에 대해 다루는 법을 이야기해 본 적이 없다. 그래도 첫 단어의 index를 내보내줄 수는 있겠다. 일단 그렇게 해보자, 예제 4-7로 볼 수 있다:
예제 4-7: String
parameter를 받아서 byte index 값을 반환하는 first_word
함수
// 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()
}
String
원소를 하나씩 돌아가면서 그 값이 공백 문자인지 확인하기 위해서, String
을 as_bytes
method를 통해 byte 배열로 변환해준다.
let bytes = s.as_bytes();
다음엔, iter
method를 이용해서 그 byte 배열을 순회하는 iterator를 만든다.
for (i, &item) in bytes.iter().enumerate() {
챕터 13에서 iterator에 대해 더 자세하게 알아볼 것이다. 지금은 iter
method가 어느 데이터 모음의 각 원소들을 제공한다는 것과, enumerate
가 iter
의 결과물을 토대로 각 원소를 tuple의 일부로써 (index, &element)
tuple로 반환한다는 것만 알아두자. Index를 직접 셈하는 것보다 더욱 편리한 방법이다.
enumerate
method는 tuple을 반환하기 때문에, Rust에서 보편적으로 가능한 tuple을 분해하는 패턴을 사용할 수 있다. for
loop에서 i
가 index이고, &item
이 byte 한 단위가 되도록 사용할 수 있었다. .iter().enumerate()
가 원소의 reference를 반환하므로, &
를 붙여서 &item
으로 맞춰주었다.
for
loop 안에서 byte literal syntax를 이용해 공백 문자를 나타내는 byte가 있는지 찾을 것이다. 만약 찾는다면, 그 자리를 반환하면 된다. 못 찾았다면, s.len()
를 사용해서 string의 전체 길이를 반환할 것이다.
if item == b' ' {
return i;
}
}
s.len()
이제 우리는 string의 첫 단어의 끝 index를 알려주는 방법을 구현했지만, 아직 문제가 있다. 이 함수는 독립적인 usize
데이터를 반환하는데, 이는 &String
문맥이 살아있을 때에만 의미가 있는 것이다. 즉, 함수의 결과물은 그 String
과는 독립적인 값이기 때문에, 나중에도 그 값이 이치에 맞는 값인지 보장할 수 없다. 아래 예제 4-8에서 위 예제 4-7의 first_word
함수를 사용하는 것을 보자.
예제 4-8: first_word
함수의 결과를 저장한 후에, String
내용을 바꾸기
// Filename: src/main.rs
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // word는 여전히 값 5를 갖고 있다
s.clear(); // String s가 비워져서 ""와 같아진다
// word는 여전히 값 5를 가지고 있지만, 그 값을 의미있게 사용할 수 있는
// string이 없다. 따라서 word는 완전 잘못된 값이다!
}
이 ㅍ로그램은 에러 없이 정상적으로 컴파일 되고, s.clear()
이후에 word
변수를 사용한다 하더라도 마찬가지다. word
변수는 s
의 상태와 연관성이 전혀 없기 때문에, word
는 계속 정수 값 5
를 가지고 있게 된다. 이 값 5
를 이용해서 s
변수의 첫 번째 단어를 추출할 수 있겠지만, word
의 값이 5
로 저장된 후 s
의 내용이 바뀐다면 버그가 일어나게 될 것이다.
word
의 index 값과 s
내용을 동기화하려고 걱정해야 한다면, 이는 굉장히 지저분하고 에러가 일어나기 쉬운 코드가 될 것이다! 이러한 index들을 관리하는 것은 second_word
함수를 또 만들어야 한다면, 더욱 위험한 일이 된다. second_word
의 signature는 아래처럼 만들게 될 것이다.
fn second_word(s: &String) -> (usize, usize) {
이제 시작과 끝의 index를 모두 쫓아다녀야 한다. s
데이터의 특정 상황에서 계산되었지만 그 상황에 엮여있지 않은 값이 더 늘은 것이다. s
와 동기화가 필요한, 하지만 실제로 관계가 없는 변수 3개가 둥둥 떠다니고 있게 된다.
다행히 Rust는 이 문제를 string slice를 통해서 해결할 수 있다.
String Slice
_String slice_는 String
의 일부를 가리킬 수 있는 reference이다. 아래 예시를 보자.
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
추가적인 [0..5]
부분을 제외하면 String
전체의 reference를 취하는 방식과 똑같다. String slice는 String
전체의 reference가 아니라 String
의 일부를 가리키는 reference다. start..end
syntax는 start
부터 end
까지 (end
는 포함하지 않음)의 범위를 의미한다. end
index까지 포함시키는 범위를 나타내려면 ..
대신 ..=
를 사용하면 된다.
let s = String::from("hello world");
let hello = &s[0..=4];
let world = &s[6..=10];
..
와 ..=
를 구분할 때 도움이 되길 바라는 의미에서, =
는 마지막 값을 포함시킨다는 뜻임을 언급하겠다.
[starting_index..ending_index]
괄호를 사용해서 starting_index
가 시작 지점이고, ending_index
가 끝 지점의 다음 지점인 slice를 생성할 수 있다. Rust가 내부적으로 작동하는 방식에 대해 말하자면, slice 데이터 구조는 시작 지점과 slice의 길이를 저장하는데, 그 길이는 ending_index - starting_index
가 된다. let world = &s[6..11]
의 경우를 생각해 봤을 때, world
변수는 시작 지점이 s
의 7번째 byte이고, 그 길이가 5인 slice가 될 것이다.
그림 4-6을 통해 이를 도표로 설명해 본다.
그림 4-6: String의 일부를 가리키는 string slice
데이터의 처음부터 시작하는 slice를 만들고자 한다면, Rust의 ..
syntax 앞에 오는 값은 없어도 된다. 즉 아래 두 변수는 같다:
let s = String::from("hello");
let slice = &s[0..2];
let slice = &s[..2];
마찬가지로 String
의 마지막까지 오는 slice를 만들고 싶다면, ..
의 끝 값이 없어도 된다. 따라서 아래 두 변수도 같다:
let s = String::from("hello");
let len = s.len();
let slice = &s[3..len];
let slice = &s[3..];
전체 string을 slice로 가지고 싶다면 ..
의 양 끝 값을 모두 없애면 된다. 그래서 아래 두 변수도 같다:
let s = String::from("hello");
let len = s.len();
let slice = &s[0..len];
let slice = &s[..];
참고: string slice의 index 범위 값들은 정당한 UTF-8 문자열 범위 내에서 계산되어야 한다. 만약 multibyte 문자의 중간에서 끊어지는 string slice를 만들고자 한다면, 그 프로그램은 에러와 함께 종료될 것이다. 여기서는 string slice의 개념을 소개하는 차원에서 ASCII 문자만을 사용했다. 챕터 8에서 UTF-8 문자를 다루는 것에 대해 더 다룰 것이다.
지금까지 배운 것을 토대로, first_word
함수가 slice를 반환하도록 다시 만들어보자. String slice 타입은 &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[..]
}
예제 4-7과 마찬가지로, 공백 문자가 처음으로 나타날 때의 index를 찾는다. 그리고 공백을 찾았을 경우, string의 처음부터 그 공백 문자 전까지를 범위로 갖는 string slice를 반환한다.
이제 first_word
를 호출한다면, 본래 데이터와 연결되어 있는 하나의 값을 반환할 것이다. 그 반환값은 slice의 시작 지점과 그 slice가 몇 개의 원소를 가지고 있는지를 담고 있을 것이다.
second_word
함수에서도 slice를 반환하게 만들 수 있을 것이다.
fn second_word(s: &String) -> &str {
이제 컴파일러가 원본 String
이 살아있는 지를 보장할 것이기 때문에, 우리는 엉망진창으로 만들기 훨씬 어려워졌지만 간단한 API를 가지게 되었다. 예제 4-8에서 봤던 버그를 생각해보자. 첫 단어의 끝 index를 받은 뒤, string의 내용을 지웠기 때문에 그 index 값은 문제가 있게 되었었다. 그 코드는 논리적으로 잘못되었지만, 이와 관련된 에러를 바로 알려주지 않았다. 그 첫 단어 index 값을 비워진 string에 사용했을 시점에 가서야 그 문제가 드러나는 것이다. Slice를 통해서 이러한 버그가 발생하는 것이 불가능해지고, 우리 코드에 어떤 문제가 있는 지 훨씬 일찍 알려주도록 만든다. first_word
의 slice 버전에서 같은 버그를 유발한다면 컴파일 시점에서 에러를 던질 것이다.
// Filename: src/main.rs
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
s.clear(); // Error!
}
해당 컴파일 에러는 다음과 같다.
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:6:5
|
4 | let word = first_word(&s);
| - immutable borrow occurs here
5 |
6 | s.clear(); // Error!
| ^ mutable borrow occurs here
7 | }
| - immutable borrow ends here
이미 immutable reference를 가지고 있다면, 같은 목적의 mutable reference를 가질 수 없다는 borrowing 규칙을 기억하자. clear
는 String
을 잘라내기 때문에, mutable reference를 취하려 할 것이고, 이는 에러로 이어진다. Rust는 우리가 사용할 API를 쉽게 만들었을 뿐 아니라, 일정 에러 그룹 전체를 컴파일 시점에서 제거해버렸다!
String Literal은 Slice다
String literal은 binary 실행 파일에 직접 저장된다고 이야기했었다. 이제 slice에 대해 알게 되었으므로, string literal에 대해 더 잘 알 수 있다.
let s = "Hello, world!";
s
의 타입은 &str
이다: Binary 실행 파일의 특정 어딘가를 가리키는 slice인 것이다. 이는 왜 string literal이 immutable인지를 설명해주기도 한다. &str
이 immutable reference였기 때문이다.
Parameter로 쓰이는 String Slice
first_word
함수의 signature는 원래 아래와 같았다.
fn first_word(s: &String) -> &str {
이제 string literal이나 String
의 slice를 이용해서 이 함수를 더 좋게 만들 수 있다.
숙련된 Rustacean이라면 slice를 통해서 String
과 &str
을 모두 다룰 수 있게 할 것이다. 그 signature는 아래와 같다.
fn first_word(s: &str) -> &str {
만약 string slice를 다뤄야 한다면, 그 slice를 직접 건네주면 된다. 반면에 String
이 온다면, 그 String
전체의 slice를 건네주면 된다. String
reference 대신 String slice를 취하는 함수를 사용하면, 똑같은 기능을 구현할 수 있으면서도 우리의 API가 더 일반적이게 된다.
// Filename: src/main.rs
fn main() {
let my_string = String::from("hello world");
// first_word는 `String`의 slice를 받을 수 있다
let word = first_word(&my_string[..]);
let my_string_literal = "hello world";
// first_word는 string literals의 slice를 받을 수도 있다
let word = first_word(&my_string_literal[..]);
// String literal은 *그 자체로* 이미 string slice이기 때문에,
// slice syntax 없이 바로 사용할 수 있다!
let word = first_word(my_string_literal);
}
다른 종류의 Slice
이미 알고 있겠지만, string slice는 string에 한정된 slice이다. 더 일반적인 slice 타입도 있을 것이다. 아래 배열을 생각해보자.
let a = [1, 2, 3, 4, 5];
이전에 string의 일부를 가리키고 싶었던 것처럼, 위 배열의 일부도 아래처럼 가리킬 수 있다.
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
이 slice
의 타입은 &[i32]
이다. 그래도 string slice에서 했던 방식과 똑같이, slice가 시작하는 첫 원소의 위치와 slice의 길이를 저장하는 방식으로 작동한다. 이는 정수 배열 뿐만 아니라 다른 모든 데이터 모음에 대해서도 이와 같은 slice를 사용하게 될 것이다. 챕터 8에서 vector에 대해 이야기할 때 이에 대해 더 자세히 이야기해 볼 것이다.
Summary
Ownership, borrowing, slice의 개념은 Rust 프로그램이 컴파일 시점에서 메모리의 안전을 보장한다. Rust은 여타 시스템 프로그래밍 언어가 메모리를 관리하는 것과 똑같은 방식을 제공하기도 하지만, 데이터의 주인(owner)이 scope 밖으로 나갈 때 그 데이터를 자동적으로 청소하기 때문에 당신이 이를 위해 추가적인 코드를 작성하고 디버깅을 할 필요가 없음을 의미한다.
Ownership은 Rust의 다른 많은 부분이 작동하는 데에 영향을 끼치기 때문에, 앞으로 이 책에서 다룰 모든 부분에서 이 개념을 계속 이야기하게 될 것이다. 다음 챕터 5에서는 데이터의 여러 조각을 모아서 사용하는 struct
에 대해 알아볼 것이다.
'Programming > Rust' 카테고리의 다른 글
쪼잔한 Rust 16. 두렵지 않은 Concurrency (1) | 2018.09.11 |
---|---|
쪼잔한 Rust 6. Enum과 패턴 맞추기 (1) | 2018.09.03 |
쪼잔한 Rust 4.2. 참조와 대여 (1) | 2018.08.14 |
쪼잔한 Rust 4.1. 소유권이 무엇이냐 (2) | 2018.08.09 |
쪼잔한 Rust 3. 일반적인 프로그래밍 개념들 (0) | 2018.07.20 |