슬라이스
문자열과 배열의 연속 구간을 소유권 없이 참조하는 슬라이스를 사용해 원본 컬렉션과 안전하게 연결된 API를 설계합니다.
슬라이스(slice)는 컬렉션 전체가 아니라 그 안의 연속된 원소 구간을 가리키는 참조입니다. 참조의 한 종류이므로 소유권이 없습니다. 이 글에서는 슬라이스가 왜 필요한지 작은 문제로 확인한 뒤, 문자열 슬라이스 &str과 배열 슬라이스 &[T]를 살펴봅니다.
슬라이스가 필요한 까닭
공백으로 구분한 단어들의 문자열을 받아 첫 단어를 돌려주는 함수를 만든다고 합시다. 공백이 없으면 문자열 전체가 한 단어이므로 문자열 전체를 돌려줍니다. 슬라이스 없이 이 함수의 시그니처를 생각해 보면 다음과 같습니다.
fn first_word(s: &String) -> ?물음표 자리는 아직 정하지 않은 반환 타입이므로 이 줄은 컴파일되지 않으며, 생각을 정리하려고 적은 것입니다. first_word는 소유권이 필요 없으므로 &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()
}String을 한 원소씩 보며 공백인지 확인해야 하므로, 먼저 as_bytes 메서드로 String의 내용을 바이트의 배열로 봅니다. 새 배열을 만들거나 바이트를 복사하지 않고 원래 내용을 빌려 보는 것입니다. 그다음 iter 메서드로 바이트를 하나씩 꺼내는 반복자(iterator)를 만들고, enumerate로 각 원소에 인덱스를 붙입니다. 반복자는 반복자 기반 데이터 처리에서 자세히 다루며, 지금은 iter가 컬렉션의 원소를 하나씩 돌려주고 enumerate가 그 결과를 인덱스와 함께 튜플로 감싼다는 것만 알면 됩니다.
enumerate가 돌려주는 튜플의 첫째는 인덱스, 둘째는 원소의 참조이며, for의 패턴 (i, &item)이 이 튜플을 분해합니다. 패턴의 &는 원소의 참조 &u8에서 u8 값을 꺼내 item에 넣습니다. 패턴은 match 제어 흐름 구조에서 더 다룹니다. 반복문 안에서는 바이트 리터럴 b' '로 공백 바이트를 찾아 그 인덱스를 돌려주고, 끝까지 찾지 못하면 s.len()으로 문자열의 길이를 돌려줍니다.
이제 첫 단어가 끝나는 인덱스를 구할 수 있지만 문제가 있습니다. 돌려주는 usize는 그 &String과 함께일 때만 뜻이 있는 숫자인데, String과 따로 떨어진 값이어서 나중에도 맞는 값이라는 보장이 없습니다.
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가 컴파일 거부되므로 성공한 실행 뒤 실패를 수행하는 배치를 피한다.
DETACHED INDEX · BORROWED RANGE
독립 인덱스와 슬라이스는 원본 변경을 다르게 다룬다
같은 첫 단어를 나타내더라도 usize는 원본 상태와 분리되고, &str은 원본의 범위와 유효성 관계를 함께 표현합니다.
usize · 원본과 분리
first_word(&s)가 끝 위치5만 반환합니다.정수는
s를 빌리지 않으므로s.clear()가 허용됩니다.word == 5는 남지만 바뀐s와의 관계는 타입에 없습니다.
&str · 원본에 묶인 대여
first_word(&s)가"hello"범위를 공유 대여합니다.word를 뒤에서 쓰면 그 지점까지 대여가 활성입니다.중간의
s.clear()는&mut s가 필요해E0502로 거부됩니다.
word를 더 이상 사용하지 않으면 공유 대여는 마지막 사용 뒤 끝날 수 있고, 그 이후의 clear()는 허용될 수 있습니다.이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 기존 가로 도표와 같은 7노드와 6간선이다. 원본에서 usize 정책과 &str 정책으로 각각 분기하고 각 3노드 경로는 별도 프로그램 사본이다. usize 5→clear 허용→5 남음, borrowed hello→clear의 변경 대여 요구→word 후속 사용 시 E0502. 실패 노드는 실행 후의 결과가 아니라 컴파일 거부다. 전체/부분 슬라이스와 마지막 사용 뒤 변경 허용은 원래 설명을 보존했다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 단어 인덱스 hello world에서 독립 usize를 받는다. |
|
|
| 원본 clear s를 비워도 독립 숫자는 남는다. |
|
|
| 정책 비교 같은 후속 사용을 &str 사본과 대조한다. |
|
|
인덱스는 분리되지만 슬라이스는 원본과 유효성을 묶는다
hello world의 word=5가 s.clear 뒤에도 5로 남는 실제 전후 상태를 추적할 수 있다. &str 버전은 후속 word 사용을 포함한 별도 코드에서 clear가 컴파일 거부되므로 성공한 실행 뒤 실패를 수행하는 배치를 피한다.
DETACHED INDEX · BORROWED RANGE
독립 인덱스와 슬라이스는 원본 변경을 다르게 다룬다
같은 첫 단어를 나타내더라도 usize는 원본 상태와 분리되고, &str은 원본의 범위와 유효성 관계를 함께 표현합니다.
usize · 원본과 분리
first_word(&s)가 끝 위치5만 반환합니다.정수는
s를 빌리지 않으므로s.clear()가 허용됩니다.word == 5는 남지만 바뀐s와의 관계는 타입에 없습니다.
&str · 원본에 묶인 대여
first_word(&s)가"hello"범위를 공유 대여합니다.word를 뒤에서 쓰면 그 지점까지 대여가 활성입니다.중간의
s.clear()는&mut s가 필요해E0502로 거부됩니다.
word를 더 이상 사용하지 않으면 공유 대여는 마지막 사용 뒤 끝날 수 있고, 그 이후의 clear()는 허용될 수 있습니다.이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 기존 가로 도표와 같은 7노드와 6간선이다. 원본에서 usize 정책과 &str 정책으로 각각 분기하고 각 3노드 경로는 별도 프로그램 사본이다. usize 5→clear 허용→5 남음, borrowed hello→clear의 변경 대여 요구→word 후속 사용 시 E0502. 실패 노드는 실행 후의 결과가 아니라 컴파일 거부다. 전체/부분 슬라이스와 마지막 사용 뒤 변경 허용은 원래 설명을 보존했다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 단어 인덱스 hello world에서 독립 usize를 받는다. |
|
|
| 원본 clear s를 비워도 독립 숫자는 남는다. |
|
|
| 정책 비교 같은 후속 사용을 &str 사본과 대조한다. |
|
|
문자열 슬라이스는 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를 가진 슬라이스입니다. 다음 그림이 이 모습입니다.
범위 문법 ..에서 인덱스 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가 슬라이스를 돌려주도록 고칩니다.
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를 앞과 같이 잘못 쓰면 컴파일되지 않습니다.
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가 됩니다.
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에 넘길 수 있는 값을 정리한 것입니다.
BORROW THE VIEW · KEEP THE OWNER
문자열 뷰를 받는 API는 여러 저장 형태를 빌린다
fn first_word(s: &str)는 저장 형태가 아니라 읽을 문자열 뷰를 요구합니다. 호출 폭은 넓어져도 바이트 범위와 유효성 전제는 사라지지 않습니다.
| 원본·호출 | 함수 경계에서 일어나는 일 | 범위·소유권 결과 |
|---|---|---|
소유한
|
&String이 역참조 강제 변환으로 &str에 맞춰집니다. |
String의 소유권은 호출자에게 남고, 함수는 전체 문자열을 공유 대여합니다. |
|
범위 표현식이 먼저 &str을 만들고 그 값을 그대로 전달합니다. |
바이트 범위가 경계 안에 있고 시작·끝이 UTF-8 문자 경계여야 합니다. 일반 인덱싱은 위반 시 panic합니다. |
문자열 리터럴
|
리터럴 표현식은 이미 프로그램 데이터의 &'static str입니다. |
함수가 소유권을 받지 않으며, 필요하면 더 짧은 참조 수명으로 사용할 수 있습니다. |
다른 연속 시퀀스
|
문자열용 &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바이트입니다.
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장을 마칩니다. 다음 장의 구조체 정의 및 인스턴스화에서는 여러 데이터를 이름 붙인 묶음으로 만드는 구조체를 살펴봅니다.