Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

슬라이스 타입

슬라이스(slices)컬렉션 안에서 연속된 요소 시퀀스를 참조할 수 있게 해 줍니다. 슬라이스는 참조의 한 종류이므로 소유권을 갖지 않습니다.

작은 프로그래밍 문제를 하나 생각해 봅시다. 공백으로 구분된 여러 단어를 담고 있는 문자열을 받아서, 그 문자열에서 첫 번째 단어를 반환하는 함수를 작성한다고 합시다. 함수가 문자열 안에서 공백을 찾지 못하면, 문자열 전체가 하나의 단어라는 뜻이므로 문자열 전체를 반환해야 합니다.

Note: 슬라이스를 소개하는 목적상, 이 절에서는 ASCII만 다룬다고 가정합니다. UTF-8 처리에 대한 더 자세한 내용은 8장의 [“문자열에 UTF-8 텍스트 저장하기”] strings 절에서 설명합니다.

먼저 슬라이스를 사용하지 않고 이 함수의 시그니처를 어떻게 작성할지를 살펴보며, 슬라이스가 해결해 줄 문제를 이해해 봅시다.

fn first_word(s: &String) -> ?

first_word 함수는 &String 타입의 매개변수를 가집니다. 소유권은 필요 없으므로 이 점은 괜찮습니다. (관용적인 러스트에서는 함수가 정말 필요하지 않은 한 인수의 소유권을 가져가지 않으며, 왜 그런지는 계속 진행하면서 분명해질 것입니다.) 하지만 무엇을 반환해야 할까요? 우리는 문자열의 일부 에 대해 적절히 말할 방법이 없습니다. 다만 단어 끝 위치를 나타내는 인덱스, 즉 공백의 위치를 반환할 수는 있습니다. 목록 4-7처럼 그렇게 해 봅시다.

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

fn main() {}
Listing 4-7: String 매개변수 안의 바이트 인덱스 값을 반환하는 first_word 함수

String 을 요소 하나씩 순회하며 값이 공백인지 확인해야 하므로, as_bytes 메서드를 사용해 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()
}

fn main() {}

그다음 iter 메서드로 바이트 배열의 반복자를 만듭니다.

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

fn main() {}

반복자는 13장에서 더 자세히 다룹니다. 지금은 iter 가 컬렉션 안의 각 요소를 반환하는 메서드이고, enumerateiter 의 결과를 감싸 각 요소를 튜플의 일부로 반환한다는 점만 알면 됩니다. enumerate 가 반환하는 튜플의 첫 번째 요소는 인덱스이고, 두 번째 요소는 해당 요소에 대한 참조입니다. 인덱스를 직접 계산하는 것보다 조금 더 편리합니다.

enumerate 메서드가 튜플을 반환하므로, 패턴을 사용해 그 튜플을 구조분해할 수 있습니다. 패턴은 6장에서 더 다룹니다. 여기서 for 루프에서는 튜플 안의 인덱스에 대해 i, 단일 바이트에 대해 &item 을 갖는 패턴을 지정합니다. .iter().enumerate() 로부터 요소에 대한 참조를 받기 때문에, 패턴에도 & 를 씁니다.

for 루프 안에서는 바이트 리터럴 문법을 사용해 공백을 나타내는 바이트를 찾습니다. 공백을 찾으면 그 위치를 반환합니다. 그렇지 않으면 s.len() 을 사용해 문자열 길이를 반환합니다.

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

fn main() {}

이제 문자열 안에서 첫 번째 단어 끝 인덱스를 알아낼 수는 있지만, 문제가 하나 있습니다. 우리는 그저 usize 하나만 반환하고 있습니다. 그런데 이 숫자는 &String 이라는 맥락 속에서만 의미가 있습니다. 다시 말해 String 과 완전히 분리된 값이기 때문에, 앞으로도 그 값이 여전히 유효하리라는 보장이 없습니다. 목록 4-8의 프로그램은 목록 4-7의 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()
}

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 s no longer has any content that we
    // could meaningfully use with the value 5, so word is now totally invalid!
}
Listing 4-8: first_word 호출 결과를 저장한 뒤 String 내용을 바꾸기

이 프로그램은 오류 없이 컴파일되고, s.clear() 호출 뒤에 word 를 사용하더라도 마찬가지입니다. words 의 상태와 전혀 연결되어 있지 않기 때문에, word 안에는 여전히 값 5 가 들어 있습니다. 우리는 그 값 5 와 변수 s 를 사용해 첫 번째 단어를 꺼내려고 시도할 수 있겠지만, 5word 에 저장한 이후 s 의 내용이 바뀌었으므로 이것은 버그가 됩니다.

word 안의 인덱스가 s 안의 데이터와 어긋나지 않도록 계속 신경 써야 하는 것은 성가시고 오류를 만들기 쉽습니다. second_word 함수를 작성하면 이런 인덱스 관리는 더 취약해집니다. 시그니처는 다음처럼 되어야 할 것입니다.

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

이제 시작 인덱스 끝 인덱스를 함께 추적해야 하고, 모두 특정 시점의 데이터에서 계산된 값이지만 그 상태와는 전혀 연결되지 않은 값들입니다. 서로 관련 없는 변수 셋이 떠다니며 서로 동기화되어야 하는 상황이 된 것입니다.

다행히 러스트에는 이 문제를 해결하는 방법이 있습니다. 바로 문자열 슬라이스입니다.

문자열 슬라이스

문자열 슬라이스(string slice)String 의 연속된 일부 요소에 대한 참조이며, 다음처럼 생겼습니다.

fn main() {
    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]; 에서 worlds 의 인덱스 6의 바이트를 가리키는 포인터와 길이 값 5 를 가진 슬라이스가 됩니다.

그림 4-7은 이를 도식으로 보여 줍니다.

Three tables: a table representing the stack data of s, which points
to the byte at index 0 in a table of the string data "hello world" on
the heap. The third table represents the stack data of the slice world, which
has a length value of 5 and points to byte 6 of the heap data table.

그림 4-7: String 의 일부를 가리키는 문자열 슬라이스

러스트의 .. 범위 문법에서는 인덱스 0부터 시작하고 싶다면 두 점 앞의 값을 생략할 수 있습니다. 즉 다음 둘은 같습니다.

#![allow(unused)]
fn main() {
let s = String::from("hello");

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

마찬가지로 슬라이스가 String 의 마지막 바이트를 포함한다면 뒤쪽 숫자를 생략할 수 있습니다. 즉 다음 둘도 같습니다.

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
}

문자열 전체를 슬라이스로 가져오고 싶다면 두 값 모두 생략할 수 있습니다. 따라서 다음 둘 역시 같습니다.

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

Note: 문자열 슬라이스 범위 인덱스는 반드시 유효한 UTF-8 문자 경계에 있어야 합니다. 멀티바이트 문자의 한가운데에서 문자열 슬라이스를 만들려고 하면 프로그램은 오류와 함께 종료됩니다.

이제 이 정보를 바탕으로 first_word 를 슬라이스를 반환하도록 다시 작성해 봅시다. “문자열 슬라이스” 를 뜻하는 타입은 &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[..]
}

fn main() {}

단어 끝 인덱스를 찾는 방식은 목록 4-7과 동일합니다. 즉, 첫 번째 공백을 찾습니다. 공백을 찾으면 문자열의 시작부터 공백 인덱스까지를 시작과 끝 인덱스로 하는 문자열 슬라이스를 반환합니다.

이제 first_word 를 호출하면, 우리는 기반 데이터와 연결된 하나의 값을 돌려받습니다. 이 값은 슬라이스 시작 위치에 대한 참조와 슬라이스에 포함된 요소 수로 이루어져 있습니다.

슬라이스를 반환하는 방식은 second_word 함수에도 잘 맞습니다.

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

이제는 훨씬 단순하고 실수하기 어려운 API를 갖게 되었습니다. 컴파일러가 String 안으로 들어가는 참조들이 유효하게 유지되는지 보장해 주기 때문입니다. 목록 4-8의 버그를 떠올려 보세요. 우리는 첫 번째 단어 끝 인덱스를 얻은 뒤 문자열을 비워서, 그 인덱스가 무효가 되었습니다. 그 코드는 논리적으로 잘못되었지만 즉시 오류를 드러내지는 않았습니다. 빈 문자열과 함께 첫 번째 단어 인덱스를 계속 쓰려는 시점에야 문제가 표면화되었을 것입니다. 하지만 슬라이스는 이런 버그를 불가능하게 만들고, 우리 코드에 문제가 있음을 훨씬 더 빨리 알려 줍니다. first_word 의 슬라이스 버전을 사용하면 컴파일 시점 오류가 납니다.

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

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` (bin "ownership") due to 1 previous error

대여 규칙에서 보았듯이, 어떤 것에 대한 불변 참조가 있다면 동시에 그 값에 대한 가변 참조를 가질 수 없습니다. clearString 을 잘라내야 하므로 가변 참조를 얻어야 합니다. 그런데 clear 호출 뒤의 println!word 안의 참조를 사용하므로, 그 시점까지는 불변 참조가 여전히 살아 있어야 합니다. 러스트는 clear 안의 가변 참조와 word 안의 불변 참조가 동시에 존재하는 것을 허용하지 않고, 컴파일에 실패합니다. 러스트는 API를 더 쓰기 쉽게 만들었을 뿐 아니라, 컴파일 시점에 하나의 버그 범주 전체를 제거해 준 것입니다.

문자열 리터럴은 슬라이스이다

앞에서 문자열 리터럴이 바이너리 안에 저장된다고 이야기했습니다. 이제 슬라이스를 알게 되었으니 문자열 리터럴도 정확히 이해할 수 있습니다.

#![allow(unused)]
fn main() {
let s = "Hello, world!";
}

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

문자열 슬라이스를 매개변수로 받기

문자열 리터럴과 String 값 모두에서 슬라이스를 취할 수 있다는 사실을 알게 되면, first_word 에 대해 한 가지 더 개선할 수 있습니다. 바로 시그니처입니다.

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

경험 많은 Rustacean이라면 목록 4-9에 나온 시그니처를 사용할 것입니다. 이 시그니처는 &String 값과 &str 값 모두에 같은 함수를 적용할 수 있게 해 주기 때문입니다.

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

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);
}
Listing 4-9: 매개변수 s 의 타입으로 문자열 슬라이스를 사용해 first_word 함수 개선하기

이미 문자열 슬라이스가 있다면 그대로 전달하면 됩니다. String 이 있다면 그 String 의 슬라이스를 넘기거나, String 에 대한 참조를 넘길 수 있습니다. 이런 유연성은 역참조 강제(deref coercion)를 활용한 것으로, 이는 15장의 “함수와 메서드에서 역참조 강제 사용하기” 절에서 다룹니다.

String 에 대한 참조 대신 문자열 슬라이스를 받도록 함수를 정의하면, 기능을 잃지 않으면서도 API를 더 일반적이고 유용하게 만들 수 있습니다.

Filename: src/main.rs
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

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

다른 슬라이스들

상상할 수 있듯, 문자열 슬라이스는 문자열에만 특화된 것입니다. 하지만 더 일반적인 슬라이스 타입도 있습니다. 다음 배열을 보세요.

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
}

문자열의 일부를 가리키고 싶을 수 있는 것처럼, 배열의 일부를 가리키고 싶을 수도 있습니다. 그럴 때는 다음처럼 씁니다.

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];

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

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

이 슬라이스의 타입은 &[i32] 입니다. 문자열 슬라이스와 똑같은 방식으로 동작하며, 첫 번째 요소에 대한 참조와 길이를 저장합니다. 이런 종류의 슬라이스는 다른 여러 컬렉션에서도 사용하게 됩니다. 벡터를 다루는 8장에서 이런 컬렉션을 자세히 설명합니다.

정리

소유권, 대여, 슬라이스 개념은 컴파일 시점에 러스트 프로그램의 메모리 안전성을 보장해 줍니다. 러스트는 다른 시스템 프로그래밍 언어와 같은 수준의 메모리 사용 제어권을 제공하면서도, 데이터의 소유자가 스코프를 벗어날 때 그 데이터를 자동으로 정리하게 함으로써 그 제어를 얻기 위해 추가 코드를 쓰고 디버깅할 필요를 줄여 줍니다.

소유권은 러스트의 다른 많은 부분이 작동하는 방식에 영향을 주므로, 책의 나머지 부분에서도 이 개념들을 계속 다루게 됩니다. 이제 5장으로 넘어가 struct 로 여러 데이터 조각을 한데 묶는 방법을 살펴봅시다.