Skip to content

4.3. The Slice Type


슬라이스(Slices)를 사용하면 전체 컬렉션이 아닌 컬렉션의 연속된 요소 시퀀스를 참조할 수 있습니다. 슬라이스는 일종의 참조이므로 소유권이 없습니다.

공백으로 구분된 단어 문자열을 받아 해당 문자열에서 찾은 첫 번째 단어를 반환하는 함수를 작성하는 작은 프로그래밍 문제를 예로 들어보겠습니다. 함수가 문자열에서 공백을 찾지 못하면 전체 문자열이 한 단어여야 하므로 전체 문자열을 반환해야 합니다.

슬라이스를 사용하지 않고 이 함수의 시그니처를 작성하는 방법을 살펴보고 슬라이스로 해결할 수 있는 문제를 이해해 보겠습니다:

fn first_word(s: &String) -> ?

first_word 함수에는 매개변수로 &String이 있습니다. 우리는 소유권을 원하지 않으므로 괜찮습니다. 하지만 무엇을 반환해야 할까요? 문자열의 일부에 대해 이야기할 수 있는 방법은 없습니다. 하지만 공백으로 표시된 단어 끝의 인덱스를 반환할 수 있습니다. 목록 4-7에 표시된 것처럼 시도해 보겠습니다.

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()
}

목록 4-7: The first_word function that returns a byte index value into the String parameter

String을 요소별로 살펴보고 값이 공백인지 확인해야 하므로 as_bytes 메서드를 사용하여 String을 바이트 배열로 변환합니다.

    let bytes = s.as_bytes();

다음으로, iter 메서드를 사용하여 바이트 배열에 대한 이터레이터(Iterator)를 생성합니다:

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

이터레이터에 대해서는 13장에서 더 자세히 설명하겠습니다. 지금은 iter가 컬렉션의 각 요소를 반환하는 메서드이고 enumerateiter의 결과를 래핑하고 대신 각 요소를 튜플의 일부로 반환한다는 것만 알아두세요. enumerate에서 반환되는 튜플의 첫 번째 요소는 인덱스이고 두 번째 요소는 요소에 대한 참조입니다. 인덱스를 직접 계산하는 것보다 조금 더 편리합니다.

enumerate 메서드는 튜플을 반환하므로 패턴을 사용하여 해당 튜플을 구조 파괴할 수 있습니다. 패턴에 대해서는 6장에서 더 자세히 설명하겠습니다. for 루프에서는 튜플의 인덱스에 i를, 튜플의 단일 바이트에 &item을 사용하는 패턴을 지정합니다. .iter().enumerate()에서 요소에 대한 참조를 가져오기 때문에 패턴에 &를 사용합니다.

for 루프 내부에서는 바이트 리터럴 구문을 사용하여 공백을 나타내는 바이트가 있는지 검색합니다. 공백을 찾으면 그 위치를 반환합니다. 그렇지 않으면 s.len()을 사용하여 문자열의 길이를 반환합니다.

        if item == b' ' {
            return i;
        }
    }

    s.len()

이제 문자열에서 첫 번째 단어 끝의 인덱스를 찾을 수 있는 방법이 생겼지만 문제가 있습니다. usize를 자체적으로 반환하고 있지만, 이는 &String의 컨텍스트에서만 의미 있는 숫자일 뿐입니다. 즉, String과는 별개의 값이기 때문에 향후에도 여전히 유효하다는 보장이 없습니다. 목록 4-7의 first_word 함수를 사용하는 목록 4-8의 프로그램을 고려해 보겠습니다.

src/main.rs
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를 사용해도 컴파일됩니다. words의 상태와 전혀 연결되어 있지 않기 때문에 word에는 여전히 5라는 값이 포함됩니다. 이 값 5를 변수 s와 함께 사용하여 첫 번째 단어를 추출할 수 있지만, 5word에 저장한 이후 s의 내용이 변경되었으므로 버그가 됩니다.

word의 인덱스가 s의 데이터와 동기화되지 않는 것에 대해 걱정해야 하는 것은 지루하고 오류가 발생하기 쉽습니다. second_word 함수를 작성하면 이러한 인덱스 관리는 훨씬 더 까다로워집니다. 그 함수의 시그니처는 다음과 같아야 합니다:

fn second_word(s: &String) -> (usize, usize) {

이제 시작 인덱스 종료 인덱스를 추적하고 있으며, 특정 상태의 데이터에서 계산되었지만 해당 상태와 전혀 관련이 없는 값이 훨씬 더 많이 있습니다. 동기화 상태를 유지해야 하는 관련 없는 변수 세 개가 떠다니고 있습니다.

다행히도 Rust에는 이 문제를 해결할 수 있는 문자열 슬라이스라는 솔루션이 있습니다.

문자열 슬라이스

문자열 슬라이스(String Slice)String의 일부에 대한 참조로, 다음과 같이 생겼습니다:

    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는 길이 값이 5s의 인덱스 6에 있는 바이트에 대한 포인터를 포함하는 슬라이스가 됩니다.

그림 4-6은 이를 다이어그램으로 보여줍니다.

001

그림 4-6: String slice referring to part of a String

Rust의 .. 범위 구문을 사용하면 인덱스 0에서 시작하려면 두 마침표 앞의 값을 삭제하면 됩니다. 즉, 이들은 동일합니다:

let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];

마찬가지로 슬라이스에 String의 마지막 바이트가 포함된 경우 후행 숫자를 삭제할 수 있습니다. 즉, 이들은 동일합니다:

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[..];

Note

문자열 슬라이스 범위 인덱스는 유효한 UTF-8 문자 경계에서 발생해야 합니다. 멀티바이트 문자 중간에 문자열 슬라이스를 만들려고 하면 프로그램이 오류와 함께 종료됩니다. 문자열 슬라이스를 소개하기 위해 이 섹션에서는 ASCII만 사용한다고 가정하고, UTF-8 처리에 대한 자세한 내용은 8장의 "문자열로 인코딩된 텍스트 저장하기" 섹션을 참조하세요.

이 모든 정보를 염두에 두고 슬라이스를 반환하도록 first_word를 다시 작성해 보겠습니다. "문자열 슬라이스"를 나타내는 유형은 &str로 작성됩니다:

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에서와 같은 방식으로 공백의 첫 번째 발생을 찾아 단어 끝에 대한 인덱스를 얻습니다. 공백을 찾으면 문자열의 시작과 공백의 인덱스를 시작 및 끝 인덱스로 사용하여 문자열 슬라이스를 반환합니다.

이제 first_word를 호출하면 기초 데이터에 연결된 단일 값을 반환합니다. 이 값은 슬라이스의 시작 지점에 대한 참조와 슬라이스에 있는 요소의 수로 구성됩니다.

슬라이스를 반환하는 것은 second_word 함수에서도 작동합니다:

fn second_word(s: &String) -> &str {

이제 컴파일러가 String에 대한 참조가 유효한 상태로 유지되도록 보장하기 때문에 엉망이 되기 훨씬 어려운 간단한 API를 갖게 되었습니다. 목록 4-8의 프로그램에서 인덱스를 첫 번째 단어의 끝으로 가져온 다음 문자열을 지워 인덱스가 유효하지 않은 버그를 기억하시나요? 이 코드는 논리적으로 올바르지 않았지만 즉각적인 오류는 표시되지 않았습니다. 비어 있는 문자열로 첫 단어 인덱스를 계속 사용하려고 하면 나중에 문제가 나타날 수 있습니다. 슬라이스를 사용하면 이러한 버그가 불가능해지며 코드에 문제가 있음을 훨씬 더 빨리 알 수 있습니다. first_word의 슬라이스 버전을 사용하면 컴파일 타임 오류가 발생합니다:

src/main.rs
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

차용 규칙에서 무언가에 대한 불변 참조가 있으면 변경 가능한 참조도 가져올 수 없다는 점을 기억하세요. clearString을 잘라내야 하므로 변경 가능한 참조를 가져와야 합니다. clear 호출 뒤의 println!word의 참조를 사용하므로 변경 불가능한 참조는 그 시점에서도 여전히 활성 상태여야 합니다. Rust는 clear의 변경 가능한 참조와 word의 변경 불가능한 참조가 동시에 존재하는 것을 허용하지 않으며, 컴파일이 실패합니다. Rust를 통해 API를 더 쉽게 사용할 수 있게 되었을 뿐만 아니라 컴파일 시 전체 오류 클래스도 제거되었습니다.

슬라이스로서의 문자열 리터럴

문자열 리터럴이 바이너리 안에 저장되는 것에 대해 이야기한 것을 기억하세요. 이제 슬라이스에 대해 알았으니 문자열 리터럴을 제대로 이해할 수 있습니다:

let s = "Hello, world!";

여기서 s의 유형은 &str입니다. 이는 바이너리의 특정 지점을 가리키는 슬라이스입니다. 이것이 문자열 리터럴이 불변인 이유이기도 합니다. &str은 불변 참조입니다.

매개변수로서의 문자열 리터럴

리터럴과 String 값의 슬라이스를 가져올 수 있다는 것을 알면 first_word에 대한 개선 사항이 하나 더 추가되는데, 이것이 바로 시그니처입니다:

fn first_word(s: &String) -> &str {

경험이 많은 러스타시언이라면 목록 4-9에 표시된 시그니처를 대신 작성할 텐데, 이는 &String 값과 &str 값 모두에 동일한 함수를 사용할 수 있기 때문입니다.

fn first_word(s: &str) -> &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를 더 일반적이고 유용하게 만들 수 있습니다:

src/main.rs
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);
}

다른 슬라이스

문자열 슬라이스는 상상할 수 있듯이 문자열에만 해당됩니다. 하지만 더 일반적인 슬라이스 유형도 있습니다. 이 배열을 살펴보겠습니다:

let a = [1, 2, 3, 4, 5];

문자열의 일부를 참조하는 것처럼 배열의 일부를 참조하고 싶을 수도 있습니다. 이렇게 하면 됩니다:

let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);

이 슬라이스의 유형은 &[i32]입니다. 첫 번째 요소와 길이에 대한 참조를 저장하여 문자열 슬라이스와 동일한 방식으로 작동합니다. 이런 종류의 슬라이스는 다른 모든 종류의 컬렉션에 사용할 수 있습니다. 이러한 컬렉션에 대해서는 8장에서 벡터에 대해 이야기할 때 자세히 설명하겠습니다.

요약

소유권, 차용, 슬라이스라는 개념은 컴파일 시 Rust 프로그램에서 메모리 안전을 보장합니다. Rust 언어는 다른 시스템 프로그래밍 언어와 동일한 방식으로 메모리 사용량을 제어할 수 있지만, 데이터 소유자가 범위를 벗어나면 해당 데이터가 자동으로 정리되므로 이러한 제어 기능을 얻기 위해 추가 코드를 작성하고 디버깅할 필요가 없습니다.

소유권은 Rust의 다른 많은 부분이 작동하는 방식에 영향을 미치므로 이 책의 나머지 부분에서 이러한 개념에 대해 자세히 설명하겠습니다. 5장으로 넘어가서 struct에서 데이터 조각을 함께 그룹화하는 방법을 살펴보겠습니다.


References