[Rust] 슬라이스 타입
포스트
취소

[Rust] 슬라이스 타입


Title


개요

슬라이스는 컬렉션 전체가 아니라 연속된 요소들을 참조하는 기능이다. 슬라이스는 참조 형태이기 때문에 소유권을 갖지 않는다.

여기 간단한 프로그래밍 문제가 있다: 공백으로 구분된 단어들이 포함된 문자열을 입력받아 그 문자열에서 첫 번째 단어를 찾아 반환하는 함수를 작성해보자. 함수가 문자열에서 공백을 찾지 못하면, 문자열 전체가 하나의 단어로 간주되어 전체를 반환해야 한다.

우리는 슬라이스를 사용하지 않고 이 함수를 어떻게 작성하는지 살펴볼 것이다. 이 과정을 통해 슬라이스가 해결할 수 있는 문제를 이해할 수 있다.

1
fn first_word(s: &String) -> ?

first_word 함수는 매개변수로 &String을 받는다. 우리는 소유권을 원하지 않으므로 이 방식이 적절하다. 그런데 어떤 값을 반환해야 할까? 문자열의 일부분을 나타낼 방법이 없다. 여기서 공백으로 표시된 단어의 끝 위치를 인덱스로 반환할 수는 있다. 우리는 다음 예제에서와 같은 시도를 해볼 수 있다.

파일명: src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
// String 매개변수의 바이트 인덱스 값을 반환하는 first_word 함수

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 메서드를 사용해 String을 바이트 배열로 변환한다.

1
    let bytes = s.as_bytes();

다음으로, iter 메서드를 사용해 바이트 배열에 대한 반복자를 생성한다.

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

반복자에 대한 자세한 설명은 나중에 제대로 다룰 것이다. 현재로서는 iter가 컬렉션의 각 요소를 반환하는 메서드이고, enumerateiter의 결과를 감싸 각 요소를 튜플 형태로 반환한다는 것만 알면 된다. enumerate에서 반환되는 튜플의 첫 번째 요소는 인덱스이고, 두 번째 요소는 그 요소에 대한 참조이다. 이 방식은 우리가 직접 인덱스를 계산하는 것보다 편리하다.

enumerate 메서드가 튜플을 반환하기 때문에, 우리는 이 튜플을 각각의 부분으로 나누어 사용할 수 있다. for 문에서는 튜플의 인덱스를 i로, 튜플 안의 바이트를 &item으로 나타내는 패턴을 사용한다. .iter().enumerate() 메서드를 통해 각 요소에 대한 참조를 얻기 때문에, 패턴에서 &를 사용한다.

for 문 안에서는 바이트 리터럴 구문을 사용해 공백을 나타내는 바이트를 찾는다. 공백을 찾으면 해당 위치를 반환하고, 찾지 못하면 s.len()을 사용해 문자열의 전체 길이를 반환한다.

1
2
3
4
5
6
        if item == b' ' {
            return i;
        }
    }

    s.len()

이제 문자열에서 첫 단어의 끝 위치를 찾는 방법을 알게 되었다. 하지만 이 방법에는 문제가 있다. usize를 단독으로 반환하는 것은 &String과의 관련성에서만 의미가 있다. 다시 말해, String과 분리된 값이기 때문에, 이 값이 앞으로도 계속 유효할 것이라는 보장이 없다. first_word 함수를 사용하는 아래 예시 코드에서 이 문제를 확인해 볼 수 있다.

파일명: src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
// `first_word` 함수를 호출한 결과를 저장한 후 `String` 내용을 변경하는 코드

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word는 값 5를 얻는다

    s.clear(); // 이는 String을 비우며, ""와 같게 만든다

    // 여기서 word는 여전히 값 5를 가지고 있지만,
    // 5와 의미있게 사용할 수 있는 문자열은 더 이상 없다.
    // word는 이제 완전히 무효화되었다!
}

이 코드는 에러 없이 컴파일되지만, s.clear()를 호출한 후에도 word를 사용할 수 있다. words의 상태와 연결되어 있지 않기 때문에, word는 여전히 값 5를 가지고 있다. 우리는 변수 s와 함께 이 값 5를 사용해 첫 번째 단어를 추출하려 할 수 있지만, 이는 버그가 될 것이다. word5를 저장한 이후 s의 내용이 변경되었기 때문이다.

word의 인덱스가 s의 데이터와 동기화되지 않는 것에 대한 우려는 지루하고 에러 발생 가능성이 높다! second_word 함수를 작성한다면 이러한 인덱스 관리는 더욱 복잡해질 것이다. 그 함수의 시그니처는 다음과 같다:

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

이제 시작 인덱스와 끝 인덱스를 추적하며, 특정 상태의 데이터에서 계산된 값들이 그 상태와 전혀 연결되어 있지 않기 때문에, 관련 없는 세 개의 변수를 동기화해야 한다.

다행히도, Rust는 이 문제에 대한 해결책을 제공한다: 바로 문자열 슬라이스이다.


문자열 슬라이스

문자열 슬라이스는 String의 일부를 참조한다. 예를 들면, 아래와 같이 사용할 수 있다:

1
2
3
4
    let s = String::from("hello world");

    let hello = &s[0..5];
    let world = &s[6..11];

여기서 hello는 전체 String이 아닌, String의 일부인 [0..5] 구간을 참조한다. 슬라이스는 대괄호 안에 [starting_index..ending_index] 형태로 범위를 지정하여 생성한다. starting_index는 슬라이스의 시작 위치이고, ending_index는 슬라이스의 마지막 위치보다 하나 더 큰 값이다. 슬라이스 데이터 구조는 시작 위치와 길이를 저장한다. 여기서 길이는 ending_index에서 starting_index를 뺀 값이다. 예를 들어, let world = &s[6..11];에서 world 슬라이스는 s6번 인덱스 바이트를 가리키는 포인터와 길이 값 5를 포함한다.

[그림 1-1]에서 이 구조를 시각적으로 볼 수 있다.

slices00

[그림 1-1] String의 일부를 참조하는 문자열 슬라이스

Rust의 범위 구문 ..을 사용하여, 0번 인덱스에서 시작하는 경우에는 두 점 앞의 값을 생략할 수 있다. 즉, 아래 두 예시는 동일하다:

1
2
3
4
let s = String::from("hello");

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

마찬가지로, 슬라이스가 String의 마지막 바이트를 포함하는 경우에는 뒤쪽 숫자를 생략할 수 있다. 이는 아래 두 예시가 동일하다는 것을 의미한다:

1
2
3
4
5
6
let s = String::from("hello");

let len = s.len();

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

두 값을 모두 생략하여 전체 문자열의 슬라이스를 취하는 것도 가능하다. 따라서 아래 두 예시는 동일하다:

1
2
3
4
5
6
let s = String::from("hello");

let len = s.len();

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

주의: 문자열 슬라이스 범위 인덱스는 유효한 UTF-8 문자 경계에서만 설정해야 한다. 여러 바이트 문자의 중간에 문자열 슬라이스를 생성하려고 시도하면 프로그램이 에러와 함께 종료될 수 있다. 이 섹션에서는 문자열 슬라이스를 소개하기 위해 ASCII 문자만을 가정하고 있다.

이 정보를 기반으로, first_word 함수를 수정하여 문자열 슬라이스를 반환하도록 하자. 문자열 슬라이스를 나타내는 타입은 &str로 표현된다.

파일명: src/main.rs

1
2
3
4
5
6
7
8
9
10
11
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 함수를 호출하면, 기본 데이터와 연결된 단일 값이 반환된다. 이 값은 슬라이스의 시작 지점을 가리키는 참조와 슬라이스 내 요소의 개수로 구성된다.

슬라이스를 반환하는 방식은 second_word 함수에도 적용할 수 있다:

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

이 방식을 사용하면, 컴파일러가 String 내 참조들이 유효하게 유지되도록 보장한다. 이는 API를 훨씬 더 간단하고 실수하기 어렵게 만든다. 이전 코드의 버그를 기억해보자. 첫 단어의 끝 인덱스를 얻었지만, 이후 문자열을 지워 인덱스가 무효화되었다. 이 코드는 논리적으로 잘못되었으나 즉각적인 오류는 나타나지 않았다. 문제는 빈 문자열로 첫 단어 인덱스를 계속 사용하려 할 때 발생했다. 슬라이스는 이러한 버그를 불가능하게 만들고, 코드에 문제가 있다는 것을 훨씬 빨리 알려준다. first_word의 슬라이스 버전을 사용하면 컴파일 타임에 에러가 발생한다:

파일명: src/main.rs

아래 코드는 컴파일되지 않는다.

1
2
3
4
5
6
7
8
9
fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // 에러!

    println!("첫 번째 단어는: {}", word);
}

컴파일러 에러 메시지는 다음과 같다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
에러[E0502]: 's'를 가변으로 빌릴 수 없습니다. 이미 불변으로 빌려진 상태입니다.
  --> *src/main.rs*:18:5
   |
16 |     let word = first_word(&s);
   |                           -- 여기에서 불변 참조가 발생함
17 |
18 |     s.clear(); // 에러!
   |     ^^^^^^^^^ 여기에서 가변 참조가 발생함
19 |
20 |     println!("첫 번째 단어는: {}", word);
   |                                       ---- 여기에서 불변 참조가 사용됨

이 에러에 대한 자세한 정보를 얻으려면 `rustc --explain E0502`를 시도하세요.
에러: 이전 에러로 인해 `ownership` 컴파일할 수 없습니다.

무언가에 대한 불변 참조가 있다면, 동시에 가변 참조를 취할 수 없다는 참조 규칙을 기억해보자. clearString을 잘라내야 하므로 가변 참조가 필요하다. clear 호출 후에 println!word의 참조를 사용하기 때문에, 그 시점에서 불변 참조가 여전히 활성화되어 있어야 한다. Rust는 clear의 가변 참조와 word의 불변 참조가 동시에 존재하는 것을 허용하지 않아, 컴파일이 실패한다. Rust는 우리의 API를 사용하기 쉽게 만드는 것뿐만 아니라, 컴파일 시간에 전체 에러 범주를 제거했다!


문자열 리터럴을 슬라이스로 사용하기

이전에 우리는 문자열 리터럴이 실행 파일 내부에 저장된다는 것을 배웠다. 이제 슬라이스에 대해 알게 되었으니, 문자열 리터럴에 대해 더 정확히 이해할 수 있다:

1
let s = "Hello, world!";

여기서 s의 타입은 &str이다. 이는 실행 파일의 특정 부분을 가리키는 슬라이스를 의미한다. 이것이 바로 문자열 리터럴이 불변인 이유이다; &str은 불변 참조이기 때문이다.


문자열 슬라이스를 매개변수로 사용하기

리터럴과 String 값을 슬라이스로 변환할 수 있다는 사실을 알게 되었으니, first_word 함수의 시그니처를 더 개선할 수 있다:

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

Rust 개발자들은 아래와 같은 시그니처를 사용할 것이다. 이 방식은 &String 값과 &str 값 모두에 동일한 함수를 적용할 수 있다는 장점이 있다.

1
2
3
// `s` 매개변수의 타입으로 문자열 슬라이스를 사용하여 `first_word` 함수 개선하기

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

문자열 슬라이스가 있다면, 이를 직접 전달할 수 있다. String이 있다면, String의 슬라이스나 String에 대한 참조를 전달할 수 있다. 이러한 유연성은 함수의 활용도를 높인다.

String에 대한 참조가 아닌 문자열 슬라이스를 취하는 함수를 정의함으로써, 우리의 API는 더 일반적이고 유용하게 사용될 수 있으며, 기능적 손실도 없어진다.

파일명: src/main.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fn main() {
    let my_string = String::from("hello world");

    // `first_word`는 `String`의 부분적 혹은 전체적인 슬라이스에서 작동한다
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word`는 `String`에 대한 참조에서도 작동한다, 이는 `String`의 전체 슬라이스와 동등하다
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word`는 문자열 리터럴의 부분적 혹은 전체적인 슬라이스에서 작동한다
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // 문자열 리터럴은 이미 문자열 슬라이스이므로,
    // 슬라이스 구문 없이도 이것이 작동한다!
    let word = first_word(my_string_literal);
}

다른 슬라이스들

문자열 슬라이스는 문자열에 특화되어 있지만, 더 일반적인 슬라이스 타입도 존재한다. 다음과 같은 배열을 예로 들 수 있다:

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

문자열의 일부를 참조하듯이, 배열의 일부를 참조할 수도 있다. 이는 다음과 같이 수행된다:

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

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

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

이 슬라이스는 &[i32] 타입이며, 문자열 슬라이스처럼 첫 번째 요소에 대한 참조와 길이를 저장한다. 이러한 슬라이스는 다양한 종류의 컬렉션에 활용될 수 있다. 추후 벡터에 대해 자세히 다룰 때, 이 컬렉션들에 대해 더 깊이 논의할 것이다.


정리

Rust 프로그램의 메모리 안전성은 소유권, 참조, 그리고 슬라이스 개념을 통해 컴파일 타임에 보장된다. Rust 언어는 다른 시스템 프로그래밍 언어처럼 메모리 사용을 제어할 수 있지만, 데이터의 소유자가 유효 범위 밖으로 벗어나면 자동으로 데이터를 정리하므로 추가 코드 작성과 디버깅이 필요 없다.

소유권은 Rust의 다양한 부분에 영향을 미친다. 따라서 이 책의 나머지 부분에서 이 개념들에 대해 지속적으로 논의할 것이다. 이제 다음장으로 넘어가 데이터 조각들을 구조체로 묶는 방법을 살펴보자.


출처: rust-lang book

이 포스트는 저작권자의 CC BY-NC-ND 4.0 라이센스를 따릅니다.

[Rust] 참조와 빌림

[Solana] 블록체인이란?