본문 바로가기

슬라이스

문자열과 배열의 연속 구간을 소유권 없이 참조하는 슬라이스를 사용해 원본 컬렉션과 안전하게 연결된 API를 설계합니다.

슬라이스(slice)는 컬렉션 전체가 아니라 그 안의 연속된 원소 구간을 가리키는 참조입니다. 참조의 한 종류이므로 소유권이 없습니다. 이 글에서는 슬라이스가 왜 필요한지 작은 문제로 확인한 뒤, 문자열 슬라이스 &str과 배열 슬라이스 &[T]를 살펴봅니다.

슬라이스가 필요한 까닭

공백으로 구분한 단어들의 문자열을 받아 첫 단어를 돌려주는 함수를 만든다고 합시다. 공백이 없으면 문자열 전체가 한 단어이므로 문자열 전체를 돌려줍니다. 슬라이스 없이 이 함수의 시그니처를 생각해 보면 다음과 같습니다.

fn first_word(s: &String) -> ?

물음표 자리는 아직 정하지 않은 반환 타입이므로 이 줄은 컴파일되지 않으며, 생각을 정리하려고 적은 것입니다. first_word는 소유권이 필요 없으므로 &String을 받습니다. 그런데 무엇을 돌려줘야 할까요? 문자열의 일부를 나타낼 방법이 아직 없으므로, 일단 단어가 끝나는 곳, 곧 공백의 인덱스를 돌려주기로 합니다.

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을 한 원소씩 보며 공백인지 확인해야 하므로, 먼저 as_bytes 메서드로 String의 내용을 바이트의 배열로 봅니다. 새 배열을 만들거나 바이트를 복사하지 않고 원래 내용을 빌려 보는 것입니다. 그다음 iter 메서드로 바이트를 하나씩 꺼내는 반복자(iterator)를 만들고, enumerate로 각 원소에 인덱스를 붙입니다. 반복자는 반복자 기반 데이터 처리에서 자세히 다루며, 지금은 iter가 컬렉션의 원소를 하나씩 돌려주고 enumerate가 그 결과를 인덱스와 함께 튜플로 감싼다는 것만 알면 됩니다.

enumerate가 돌려주는 튜플의 첫째는 인덱스, 둘째는 원소의 참조이며, for의 패턴 (i, &item)이 이 튜플을 분해합니다. 패턴의 &는 원소의 참조 &u8에서 u8 값을 꺼내 item에 넣습니다. 패턴은 match 제어 흐름 구조에서 더 다룹니다. 반복문 안에서는 바이트 리터럴 b' '로 공백 바이트를 찾아 그 인덱스를 돌려주고, 끝까지 찾지 못하면 s.len()으로 문자열의 길이를 돌려줍니다.

이제 첫 단어가 끝나는 인덱스를 구할 수 있지만 문제가 있습니다. 돌려주는 usize는 그 &String과 함께일 때만 뜻이 있는 숫자인데, String과 따로 떨어진 값이어서 나중에도 맞는 값이라는 보장이 없습니다.

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

    let word = first_word(&s); // word는 5를 받음

    s.clear(); // String을 비워 ""로 만듦

    // word는 여전히 5이지만, 5를 써서 의미 있게
    // 꺼낼 문자열이 더 이상 없음
}

이 코드는 아무 문제 없이 컴파일되며, s.clear() 뒤에 word를 써도 마찬가지입니다. word는 s와 아무 관계가 없으므로 계속 5를 담고 있습니다. 하지만 그 5로 s에서 첫 단어를 꺼내려 하면 오류가 됩니다. s의 내용은 이미 바뀌었기 때문입니다. word가 s의 지금 내용과 맞는지 계속 신경 써야 한다면 번거롭고 실수하기 쉽습니다.

두 번째 단어를 구하는 함수까지 만든다면 더 나빠집니다. 시작과 끝 두 인덱스가 필요하므로 시그니처는 fn second_word(s: &String) -> (usize, usize)가 되고, 특정 시점의 데이터에서만 맞는 숫자가 더 늘어납니다. 서로 관련 없는 변수 세 개를 계속 맞춰 둬야 하는 셈입니다. 러스트는 이 문제를 문자열 슬라이스로 해결합니다.

문자열 슬라이스

아래 그림은 두 방법의 차이를 보여 줍니다. 같은 첫 단어를 나타내더라도 usize는 원래 문자열과 떨어진 숫자여서 문자열을 비워도 5가 그대로 남지만, 문자열 슬라이스 &str은 원래 문자열의 구간을 빌린 것이어서 그 슬라이스를 뒤에서 쓰는 한 문자열을 비울 수 없습니다.

인덱스는 분리되지만 슬라이스는 원본과 유효성을 묶는다

hello world의 word=5가 s.clear 뒤에도 5로 남는 실제 전후 상태를 추적할 수 있다. &str 버전은 후속 word 사용을 포함한 별도 코드에서 clear가 컴파일 거부되므로 성공한 실행 뒤 실패를 수행하는 배치를 피한다.

String에서 첫 단어의 끝 인덱스 usize만 반환하면 원본을 비운 뒤에도 숫자 5가 남지만, 문자열 슬라이스 &str을 반환하면 이후 사용까지 공유 대여가 유지되어 중간의 clear 가변 대여가 E0502로 거부되는 비교 흐름

DETACHED INDEX · BORROWED RANGE

독립 인덱스와 슬라이스는 원본 변경을 다르게 다룬다

같은 첫 단어를 나타내더라도 usize는 원본 상태와 분리되고, &str은 원본의 범위와 유효성 관계를 함께 표현합니다.

원본에서 독립 정수와 빌린 구간으로 갈리는 두 대안 비교기존desktop과 같은7노드6간선이다. 원본에서 usize 정책과 &str 정책으로 각각 분기하고 각3노드경로는 별도프로그램사본이다. usize5→clear허용→5남음, borrowed hello→clear의변경대여요구→word후속사용시E0502. 실패 노드는 실행후의결과가 아니라 컴파일거부다. 전체/부분 슬라이스와 마지막사용뒤변경허용은 원설명을 보존했다.원본 s: String"hello world"독립 usize 사례끝 인덱스 반환word: usize = 5원본을 빌리지 않음s.clear() 허용정수 5는 s와 독립정수 5가 남음변경된 문자열과의관계가 타입에 없음빌린 &str 사례문자열 구간 반환word: &str = hello원본에서 공유 대여s.clear() 요구변경 대여 &mut sword를 뒤에서 사용E0502 거부두 대여가 겹침실행 전 거부usize&str두 사본의 정책 비교word 마지막 사용 뒤 clear는 별도
usize 인덱스 반환과 &str 슬라이스 반환 비교 원본 String에서 왼쪽은 독립 정수 5를 반환해 clear 이후에도 낡은 값이 남고, 오른쪽은 hello 슬라이스를 반환해 word가 나중에 사용되는 동안 clear가 요구하는 가변 대여와 충돌하여 E0502로 거부됩니다. 원본 s: String"hello world"끝 인덱스 반환word: usize = 5원본을 빌리지 않는 정수문자열 슬라이스 반환word: &str = "hello"원본의 범위를 공유 대여s.clear() · 허용정수 5는 s를 빌리지 않음원본 변경과 독립s.clear()는 &mut s 필요word를 뒤에서 사용하면그 지점까지 공유 대여 활성word = 5 · 그대로 남음바뀐 s와의 관계는 타입에 없음E0502 · 컴파일 거부공유 대여와 가변 대여가 겹침

usize · 원본과 분리

  1. first_word(&s)가 끝 위치 5만 반환합니다.

  2. 정수는 s를 빌리지 않으므로 s.clear()가 허용됩니다.

  3. word == 5는 남지만 바뀐 s와의 관계는 타입에 없습니다.

&str · 원본에 묶인 대여

  1. first_word(&s)가 "hello" 범위를 공유 대여합니다.

  2. word를 뒤에서 쓰면 그 지점까지 대여가 활성입니다.

  3. 중간의 s.clear()는 &mut s가 필요해 E0502로 거부됩니다.

슬라이스가 존재한다는 사실만으로 변경이 영구히 막히는 것은 아닙니다. word를 더 이상 사용하지 않으면 공유 대여는 마지막 사용 뒤 끝날 수 있고, 그 이후의 clear()는 허용될 수 있습니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명기존 가로 도표와 같은 7노드와 6간선이다. 원본에서 usize 정책과 &str 정책으로 각각 분기하고 각 3노드 경로는 별도 프로그램 사본이다. usize 5→clear 허용→5 남음, borrowed hello→clear의 변경 대여 요구→word 후속 사용 시 E0502. 실패 노드는 실행 후의 결과가 아니라 컴파일 거부다. 전체/부분 슬라이스와 마지막 사용 뒤 변경 허용은 원래 설명을 보존했다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 단어 인덱스

hello world에서 독립 usize를 받는다.

  • s: hello world
  • word: 5
  • 숫자 5는 원 문자열을 빌리지 않는다.
원본 clear

s를 비워도 독립 숫자는 남는다.

  • s: 빈 문자열
  • word: 5
  • 타입에 원본 구간과의 관계가 없으므로 숫자가 자동 갱신되지 않는다.
정책 비교

같은 후속 사용을 &str 사본과 대조한다.

  • usize: clear 뒤에도 5
  • &str 사본: 후속 word 사용이면 clear 거부
  • 거부된 사본의 clear를 실제 수행한 뒤 오류가 나는 것으로 보이지 않게 한다.

인덱스는 분리되지만 슬라이스는 원본과 유효성을 묶는다

hello world의 word=5가 s.clear 뒤에도 5로 남는 실제 전후 상태를 추적할 수 있다. &str 버전은 후속 word 사용을 포함한 별도 코드에서 clear가 컴파일 거부되므로 성공한 실행 뒤 실패를 수행하는 배치를 피한다.

String에서 첫 단어의 끝 인덱스 usize만 반환하면 원본을 비운 뒤에도 숫자 5가 남지만, 문자열 슬라이스 &str을 반환하면 이후 사용까지 공유 대여가 유지되어 중간의 clear 가변 대여가 E0502로 거부되는 비교 흐름

DETACHED INDEX · BORROWED RANGE

독립 인덱스와 슬라이스는 원본 변경을 다르게 다룬다

같은 첫 단어를 나타내더라도 usize는 원본 상태와 분리되고, &str은 원본의 범위와 유효성 관계를 함께 표현합니다.

원본에서 독립 정수와 빌린 구간으로 갈리는 두 대안 비교기존desktop과 같은7노드6간선이다. 원본에서 usize 정책과 &str 정책으로 각각 분기하고 각3노드경로는 별도프로그램사본이다. usize5→clear허용→5남음, borrowed hello→clear의변경대여요구→word후속사용시E0502. 실패 노드는 실행후의결과가 아니라 컴파일거부다. 전체/부분 슬라이스와 마지막사용뒤변경허용은 원설명을 보존했다.원본 s: String"hello world"독립 usize 사례끝 인덱스 반환word: usize = 5원본을 빌리지 않음s.clear() 허용정수 5는 s와 독립정수 5가 남음변경된 문자열과의관계가 타입에 없음빌린 &str 사례문자열 구간 반환word: &str = hello원본에서 공유 대여s.clear() 요구변경 대여 &mut sword를 뒤에서 사용E0502 거부두 대여가 겹침실행 전 거부usize&str두 사본의 정책 비교word 마지막 사용 뒤 clear는 별도
usize 인덱스 반환과 &str 슬라이스 반환 비교 원본 String에서 왼쪽은 독립 정수 5를 반환해 clear 이후에도 낡은 값이 남고, 오른쪽은 hello 슬라이스를 반환해 word가 나중에 사용되는 동안 clear가 요구하는 가변 대여와 충돌하여 E0502로 거부됩니다. 원본 s: String"hello world"끝 인덱스 반환word: usize = 5원본을 빌리지 않는 정수문자열 슬라이스 반환word: &str = "hello"원본의 범위를 공유 대여s.clear() · 허용정수 5는 s를 빌리지 않음원본 변경과 독립s.clear()는 &mut s 필요word를 뒤에서 사용하면그 지점까지 공유 대여 활성word = 5 · 그대로 남음바뀐 s와의 관계는 타입에 없음E0502 · 컴파일 거부공유 대여와 가변 대여가 겹침

usize · 원본과 분리

  1. first_word(&s)가 끝 위치 5만 반환합니다.

  2. 정수는 s를 빌리지 않으므로 s.clear()가 허용됩니다.

  3. word == 5는 남지만 바뀐 s와의 관계는 타입에 없습니다.

&str · 원본에 묶인 대여

  1. first_word(&s)가 "hello" 범위를 공유 대여합니다.

  2. word를 뒤에서 쓰면 그 지점까지 대여가 활성입니다.

  3. 중간의 s.clear()는 &mut s가 필요해 E0502로 거부됩니다.

슬라이스가 존재한다는 사실만으로 변경이 영구히 막히는 것은 아닙니다. word를 더 이상 사용하지 않으면 공유 대여는 마지막 사용 뒤 끝날 수 있고, 그 이후의 clear()는 허용될 수 있습니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명기존 가로 도표와 같은 7노드와 6간선이다. 원본에서 usize 정책과 &str 정책으로 각각 분기하고 각 3노드 경로는 별도 프로그램 사본이다. usize 5→clear 허용→5 남음, borrowed hello→clear의 변경 대여 요구→word 후속 사용 시 E0502. 실패 노드는 실행 후의 결과가 아니라 컴파일 거부다. 전체/부분 슬라이스와 마지막 사용 뒤 변경 허용은 원래 설명을 보존했다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 단어 인덱스

hello world에서 독립 usize를 받는다.

  • s: hello world
  • word: 5
  • 숫자 5는 원 문자열을 빌리지 않는다.
원본 clear

s를 비워도 독립 숫자는 남는다.

  • s: 빈 문자열
  • word: 5
  • 타입에 원본 구간과의 관계가 없으므로 숫자가 자동 갱신되지 않는다.
정책 비교

같은 후속 사용을 &str 사본과 대조한다.

  • usize: clear 뒤에도 5
  • &str 사본: 후속 word 사용이면 clear 거부
  • 거부된 사본의 clear를 실제 수행한 뒤 오류가 나는 것으로 보이지 않게 한다.

문자열 슬라이스는 String의 일부를 가리키는 참조입니다.

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

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

hello는 String 전체가 아니라 [0..5]로 지정한 일부를 가리키는 참조입니다. [시작..끝]은 시작 인덱스부터 끝 인덱스 바로 앞까지의 구간이며, 슬라이스는 그 구간이 시작하는 곳을 가리키는 포인터와 길이를 저장합니다. 길이는 끝에서 시작을 뺀 값이므로, world는 s의 인덱스 6, 곧 일곱 번째 바이트를 가리키는 포인터와 길이 5를 가진 슬라이스입니다. 다음 그림이 이 모습입니다.

String의 일부 바이트 범위를 포인터와 길이로 참조하는 문자열 슬라이스

범위 문법 ..에서 인덱스 0부터 시작한다면 앞의 숫자를 생략할 수 있습니다. 같은 방식으로 String의 마지막 바이트까지 포함한다면 뒤의 숫자를 생략할 수 있고, 둘 다 생략하면 문자열 전체를 가리킵니다.

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

let slice = &s[0..2];
let slice = &s[..2]; // 위와 같음

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..]; // 위와 같음

let slice = &s[0..len];
let slice = &s[..]; // 위와 같음

여기서는 ASCII 글자만 썼으므로 문제가 없었지만, 문자열 슬라이스의 범위는 바이트 단위이고 시작과 끝 모두 여러 바이트로 된 글자의 중간이 아닌 곳이어야 합니다. 여러 바이트로 된 글자의 중간을 자르려 하면 프로그램이 패닉으로 멈추며, 실패를 값으로 다루려면 s.get(0..2)처럼 get 메서드를 씁니다. UTF-8 글자를 다루는 방법은 UTF-8 문자열에서 다룹니다. 문자열 슬라이스의 타입은 &str이며, 이제 first_word가 슬라이스를 돌려주도록 고칩니다.

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

단어가 끝나는 인덱스를 찾는 방법은 앞과 같지만, 공백을 찾으면 그 인덱스 대신 문자열의 처음부터 그 인덱스 앞까지의 슬라이스를 돌려줍니다. 이 구현은 바이트 b' ' 하나만 구분자로 보므로 탭 같은 다른 공백 문자는 단어를 나누지 않습니다. 이제 돌려주는 값은 원래 데이터와 떨어진 숫자가 아니라 원래 데이터를 가리키는 참조이며, 두 번째 단어를 구하는 함수도 fn second_word(s: &String) -> &str로 같은 방식으로 만들 수 있습니다.

이렇게 하면 앞에서 문자열을 비운 뒤 인덱스가 쓸모없어지던 문제도 사라집니다. 이전 코드는 논리적으로 틀렸는데도 오류가 나지 않았고, 비워진 문자열에 그 인덱스를 써 볼 때에야 문제가 드러났습니다. 슬라이스를 쓰면 이런 오류를 컴파일할 때 알 수 있습니다. 슬라이스를 돌려주는 first_word를 앞과 같이 잘못 쓰면 컴파일되지 않습니다.

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

참조와 대여의 규칙에 따르면, 어떤 값의 불변 참조가 있는 동안에는 가변 참조를 만들 수 없습니다. clear는 String을 비워야 하므로 가변 참조가 필요한데, clear 뒤의 println!이 word의 불변 참조를 쓰므로 그 불변 참조는 그때까지 유지되어야 합니다. 두 대여가 겹치므로 컴파일러가 거부합니다. 이처럼 러스트는 API를 쓰기 쉽게 해 줄 뿐 아니라, 이런 종류의 오류를 모두 컴파일할 때 없애 줍니다.

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

앞에서 문자열 리터럴은 실행 파일 안에 저장된다고 했습니다. 슬라이스를 알았으니 문자열 리터럴을 제대로 이해할 수 있습니다.

let s = "Hello, world!";

여기서 s의 타입은 &str로, 실행 파일 안의 그 위치를 가리키는 슬라이스입니다. 그래서 문자열 리터럴은 불변입니다. &str이 불변 참조이기 때문입니다.

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

문자열 리터럴과 String 모두 슬라이스로 만들 수 있다는 것을 알면, first_word를 더 낫게 고칠 수 있습니다. 지금의 시그니처는 fn first_word(s: &String) -> &str이지만, 러스트에 익숙한 사람은 다음처럼 매개변수를 &str로 받아 &String과 &str 모두에 쓸 수 있게 합니다.

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

문자열 슬라이스는 그대로 넘기면 되고, String은 그 슬라이스나 참조를 넘기면 됩니다. &String을 넘겨도 되는 것은 역참조 강제 변환(deref coercion)이라는 기능 덕분이며, Deref와 역참조 강제 변환에서 다룹니다. String의 참조 대신 문자열 슬라이스를 받도록 함수를 정의하면, 기능은 그대로이면서 더 여러 곳에 쓸 수 있는 API가 됩니다.

src/main.rs
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[..]);
    // String의 참조에도 쓸 수 있으며, 이는 String 전체의 슬라이스와 같음
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // 문자열 리터럴의 일부나 전체의 슬라이스에도 쓸 수 있음
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // 문자열 리터럴은 이미 문자열 슬라이스이므로
    // 슬라이스 문법 없이도 쓸 수 있음
    let word = first_word(my_string_literal);
}

아래 표는 &str을 받는 first_word에 넘길 수 있는 값을 정리한 것입니다.

first_word가 &str을 받으면 String 전체 참조, String의 유효한 부분 슬라이스, 문자열 리터럴을 소유권 이동 없이 받을 수 있고, 일반 배열 슬라이스는 별도 타입 &[T]로 같은 범위 계약을 따르는 비교 표

BORROW THE VIEW · KEEP THE OWNER

문자열 뷰를 받는 API는 여러 저장 형태를 빌린다

fn first_word(s: &str)는 저장 형태가 아니라 읽을 문자열 뷰를 요구합니다. 호출 폭은 넓어져도 바이트 범위와 유효성 전제는 사라지지 않습니다.

&str 매개변수에 전달하는 값과 슬라이스 경계 계약
원본·호출 함수 경계에서 일어나는 일 범위·소유권 결과

소유한 String

&s

&String이 역참조 강제 변환으로 &str에 맞춰집니다. String의 소유권은 호출자에게 남고, 함수는 전체 문자열을 공유 대여합니다.

String의 일부

&s[0..6]

범위 표현식이 먼저 &str을 만들고 그 값을 그대로 전달합니다. 바이트 범위가 경계 안에 있고 시작·끝이 UTF-8 문자 경계여야 합니다. 일반 인덱싱은 위반 시 panic합니다.

문자열 리터럴

"hello"

리터럴 표현식은 이미 프로그램 데이터의 &'static str입니다. 함수가 소유권을 받지 않으며, 필요하면 더 짧은 참조 수명으로 사용할 수 있습니다.

다른 연속 시퀀스

&a[1..3]

: &[i32]

문자열용 &str이 아니라 요소 타입을 보존하는 &[T] API를 사용합니다. 원본을 소유하지 않고 데이터 포인터와 요소 수 메타데이터로 구간을 빌리며, 안전한 접근은 범위를 검사합니다.
&String → &str
호출 first_word (&my_string)
계약 역참조 강제 변환으로 전체 문자열을 빌리고 소유권은 호출자에게 남습니다.
String의 부분 &str
호출 first_word(&my_string[0..6])
계약 유효한 바이트 범위와 UTF-8 문자 경계를 지켜야 하며, 일반 인덱싱은 위반 시 panic합니다.
문자열 리터럴
호출 first_word ("hello")
계약 리터럴은 이미 &'static str이고 함수는 소유권을 받지 않습니다.
일반 슬라이스 &[T]
예 &a[1..3]: &[i32]
계약 배열·벡터 같은 연속 시퀀스의 요소 구간을 빌리며, 안전한 접근은 범위를 검사합니다.
&str은 작은 소유 문자열이 아니라 유효한 UTF-8 바이트 구간을 빌리는 참조입니다. 범위가 불확실하면 str::get 계열로 panic 대신 Option을 받을 수 있습니다.

String을 &s로 넘기면 역참조 강제 변환으로 &str이 되어 문자열 전체를 빌려주며, 소유권은 부른 쪽에 남습니다. &s[0..6]처럼 범위를 쓰면 먼저 그 구간의 &str을 만들어 넘기므로, 범위가 문자열 안에 있고 글자의 중간을 자르지 않아야 합니다. 문자열 리터럴은 이미 &str이므로 그대로 넘기며, 넘길 수 있는 값이 넓어져도 범위와 유효성에 대한 조건은 그대로입니다.

그 외 슬라이스

문자열 슬라이스는 문자열에만 쓰지만, 더 일반적인 슬라이스 타입도 있습니다. 다음 배열을 봅니다.

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]이며, 문자열 슬라이스처럼 첫 원소를 가리키는 포인터와 길이를 저장합니다. assert_eq!는 두 값이 같은지 확인해 다르면 패닉을 내는 매크로이며, 인덱스 1부터 3 앞까지인 2와 3이 들어 있으므로 통과합니다. 배열이나 벡터처럼 원소가 메모리에 연이어 놓인 컬렉션은 모두 &[T]나 &mut [T] 슬라이스로 빌릴 수 있으며, 벡터는 벡터와 값 목록에서 다룹니다.

소유권과 대여, 슬라이스는 러스트가 컴파일할 때 메모리 안전성을 보장하는 바탕입니다. 러스트는 다른 시스템 프로그래밍 언어처럼 메모리를 직접 다루게 해 주면서도, 데이터의 소유자가 스코프를 벗어나면 그 데이터를 자동으로 정리하므로 그 일을 위한 코드를 따로 쓰고 고칠 필요가 없습니다. 다만 단어를 어떻게 나눌지나 범위를 어떻게 정할지 같은 프로그램의 논리는 여전히 테스트로 확인해야 합니다.

정리

  • 슬라이스는 컬렉션 안의 연속된 구간을 가리키는 참조이며 소유권이 없습니다.
  • 원래 데이터와 떨어진 인덱스는 데이터가 바뀌면 맞지 않게 되지만, 슬라이스는 원래 데이터를 빌리므로 그것을 쓰는 동안 데이터를 바꿀 수 없습니다.
  • 문자열 슬라이스는 &s[시작..끝]으로 만들고, 시작이 0이거나 끝이 마지막이면 생략할 수 있으며, 범위가 여러 바이트로 된 글자의 중간을 자르면 안 됩니다.
  • 문자열 리터럴의 타입은 &str인 슬라이스이므로 불변입니다.
  • 함수의 매개변수를 &String 대신 &str로 받으면 String과 문자열 리터럴 모두에 쓸 수 있습니다.
  • 배열과 벡터는 &[T] 슬라이스로 빌릴 수 있습니다.

연습 문제

1. 여러 바이트로 된 글자

다음 프로그램은 무엇을 출력하나요? é는 UTF-8로 2바이트입니다.

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

    s
}

fn main() {
    let text = String::from("café au lait");
    let word = first_word(&text);
    println!("{word} {}", word.len());
    println!("{:?}", text.get(..4));
}
해설 보기

café 5와 None을 출력합니다. c, a, f가 한 바이트씩, é가 2바이트이므로 첫 공백은 인덱스 5에 있고, &s[..5]는 글자의 중간을 자르지 않으므로 café가 되며 그 길이는 글자 수 4가 아니라 바이트 수 5입니다. text.get(..4)는 é의 두 바이트 사이를 자르려 하므로 None을 돌려주며, &text[..4]라고 썼다면 패닉이 났을 것입니다.

2. 구분자가 다를 때

first_word에 (가) "hello\tworld", (나) " hello"를 넘기면 각각 무엇을 돌려주나요?

해설 보기

(가)는 탭 바이트가 b' '와 다르고 다른 공백도 없으므로 공백을 찾지 못해 문자열 전체 "hello\tworld"를 돌려줍니다. (나)는 인덱스 0에서 바로 공백을 찾으므로 &s[..0], 곧 빈 문자열을 돌려줍니다. 이 함수의 규칙은 첫 공백 바이트 앞까지이므로, 탭을 구분자로 쓰거나 앞쪽 공백을 건너뛰려면 그 규칙을 함수에 따로 넣고 테스트로 확인해야 합니다.

3. 슬라이스를 다 쓴 뒤에 비우기

다음 main은 컴파일되나요? 컴파일된다면 무엇을 출력하나요? 마지막에 println!("{word}");를 한 줄 더하면 어떻게 되나요?

fn main() {
    let mut text = String::from("hello world");
    let word = first_word(&text);
    println!("{word}");
    text.clear();
    println!("{}", text.len());
}
해설 보기

컴파일되며 hello와 0을 출력합니다. word는 첫 println!에서 마지막으로 쓰이므로 그 뒤의 clear가 가변으로 빌릴 때 겹치는 대여가 없습니다. 마지막에 word를 다시 출력하면 word의 불변 대여가 그 줄까지 이어져 clear의 가변 대여와 겹치므로, E0502 오류로 컴파일되지 않습니다.

이것으로 3장을 마칩니다. 다음 장의 구조체 정의 및 인스턴스화에서는 여러 데이터를 이름 붙인 묶음으로 만드는 구조체를 살펴봅니다.