Programming/Rust

쪼잔한 Rust 4.3. Slice 타입

동건 2018. 8. 19. 13:31

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 원소를 하나씩 돌아가면서 그 값이 공백 문자인지 확인하기 위해서, Stringas_bytes method를 통해 byte 배열로 변환해준다.

let bytes = s.as_bytes();

다음엔, iter method를 이용해서 그 byte 배열을 순회하는 iterator를 만든다.

for (i, &item) in bytes.iter().enumerate() {

챕터 13에서 iterator에 대해 더 자세하게 알아볼 것이다. 지금은 iter method가 어느 데이터 모음의 각 원소들을 제공한다는 것과, enumerateiter의 결과물을 토대로 각 원소를 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을 통해 이를 도표로 설명해 본다.


The Rust book, Chapter 4.3. Figure 4-6: String slice referring to part of a String그림 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 규칙을 기억하자. clearString을 잘라내기 때문에, 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에 대해 알아볼 것이다.

반응형