소개
The Slice Type에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 기술을 연습할 수 있습니다.
이 랩에서는 공백으로 구분된 단어 문자열을 입력받아 해당 문자열에서 처음 발견되는 단어를 반환하는 함수를 작성하여 프로그래밍 문제를 해결하고, 하위 문자열을 나타내기 위해 인덱스를 사용하는 것의 한계와 Rust 에서 문자열 슬라이스를 사용하여 이 문제를 해결하는 방법에 대해 논의할 것입니다.
This tutorial is from open-source community. Access the source code
The Slice Type에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 기술을 연습할 수 있습니다.
이 랩에서는 공백으로 구분된 단어 문자열을 입력받아 해당 문자열에서 처음 발견되는 단어를 반환하는 함수를 작성하여 프로그래밍 문제를 해결하고, 하위 문자열을 나타내기 위해 인덱스를 사용하는 것의 한계와 Rust 에서 문자열 슬라이스를 사용하여 이 문제를 해결하는 방법에 대해 논의할 것입니다.
Slices는 전체 컬렉션 대신 컬렉션 내의 연속된 요소 시퀀스를 참조할 수 있게 해줍니다. 슬라이스는 일종의 참조이므로 소유권을 갖지 않습니다.
다음은 작은 프로그래밍 문제입니다. 공백으로 구분된 단어 문자열을 입력받아 해당 문자열에서 처음 발견되는 단어를 반환하는 함수를 작성합니다. 함수가 문자열에서 공백을 찾지 못하면 전체 문자열이 하나의 단어여야 하므로 전체 문자열을 반환해야 합니다.
슬라이스를 사용하지 않고 이 함수의 시그니처를 작성하는 방법을 살펴보고, 슬라이스가 해결할 문제를 이해해 보겠습니다.
fn first_word(s: &String) -> ?
first_word 함수는 &String을 매개변수로 받습니다. 소유권을 원하지 않으므로 괜찮습니다. 하지만 무엇을 반환해야 할까요? 문자열의 일부에 대해 이야기할 방법이 실제로 없습니다. 하지만 공백으로 표시된 단어의 끝 인덱스를 반환할 수 있습니다. Listing 4-7 에 표시된 대로 시도해 보겠습니다.
파일 이름: src/main.rs
fn first_word(s: &String) -> usize {
1 let bytes = s.as_bytes();
for (2 i, &item) in 3 bytes.iter().enumerate() {
4 if item == b' ' {
return i;
}
}
5 s.len()
}
Listing 4-7: String 매개변수에 대한 바이트 인덱스 값을 반환하는 first_word 함수
String을 요소별로 살펴보고 값이 공백인지 확인해야 하므로 as_bytes 메서드 [1]를 사용하여 String을 바이트 배열로 변환합니다.
다음으로, iter 메서드 [3]를 사용하여 바이트 배열에 대한 반복자를 생성합니다. 13 장에서 반복자에 대해 자세히 논의할 것입니다. 지금은 iter가 컬렉션의 각 요소를 반환하는 메서드이고, enumerate가 iter의 결과를 래핑하여 각 요소를 튜플의 일부로 반환한다는 것을 알아두세요. enumerate에서 반환된 튜플의 첫 번째 요소는 인덱스이고, 두 번째 요소는 요소에 대한 참조입니다. 이는 인덱스를 직접 계산하는 것보다 약간 더 편리합니다.
enumerate 메서드는 튜플을 반환하므로 패턴을 사용하여 해당 튜플을 분해할 수 있습니다. 6 장에서 패턴에 대해 더 자세히 논의할 것입니다. for 루프에서 튜플의 인덱스에 대해 i를, 튜플의 단일 바이트에 대해 &item을 갖는 패턴을 지정합니다 [2]. .iter().enumerate()에서 요소에 대한 참조를 얻으므로 패턴에서 &를 사용합니다.
for 루프 내에서 바이트 리터럴 구문 [4]을 사용하여 공백을 나타내는 바이트를 검색합니다. 공백을 찾으면 위치를 반환합니다. 그렇지 않으면 s.len() [5]을 사용하여 문자열의 길이를 반환합니다.
이제 문자열에서 첫 번째 단어의 끝 인덱스를 찾는 방법이 있지만 문제가 있습니다. usize를 단독으로 반환하지만 &String의 컨텍스트에서만 의미 있는 숫자입니다. 즉, String과 별도의 값이므로 미래에도 유효하다는 보장이 없습니다. Listing 4-8 의 first_word 함수를 사용하는 프로그램을 생각해 보세요.
// 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!
}
Listing 4-8: first_word 함수를 호출한 결과 저장 후 String 내용 변경
이 프로그램은 오류 없이 컴파일되며, s.clear()를 호출한 후 word를 사용하더라도 마찬가지입니다. word는 s의 상태와 전혀 연결되지 않으므로 word는 여전히 값 5를 포함합니다. 변수 s와 함께 값 5를 사용하여 첫 번째 단어를 추출하려고 할 수 있지만, word에 5를 저장한 이후 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];
전체 String에 대한 참조 대신, hello는 [0..5] 비트로 지정된 String의 일부에 대한 참조입니다. [starting_index..ending_index]를 지정하여 대괄호 안의 범위를 사용하여 슬라이스를 생성합니다. 여기서 starting_index는 슬라이스의 첫 번째 위치이고 ending_index는 슬라이스의 마지막 위치보다 하나 더 큽니다. 내부적으로 슬라이스 데이터 구조는 시작 위치와 슬라이스의 길이를 저장하며, 이는 ending_index에서 starting_index를 뺀 값에 해당합니다. 따라서 let world = &s[6..11];의 경우, world는 s의 인덱스 6 에 있는 바이트에 대한 포인터와 길이 값 5를 포함하는 슬라이스가 됩니다.
그림 4-6 은 이를 다이어그램으로 보여줍니다.
그림 4-6: 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[..];
참고: 문자열 슬라이스 범위 인덱스는 유효한 UTF-8 문자 경계에서 발생해야 합니다. 멀티바이트 문자 중간에 문자열 슬라이스를 생성하려고 하면 프로그램이 오류와 함께 종료됩니다. 문자열 슬라이스를 소개하기 위해 이 섹션에서는 ASCII 만 가정하고 있습니다. UTF-8 처리에 대한 자세한 내용은 "문자열로 UTF-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[..]
}
Listing 4-7 에서와 마찬가지로, 첫 번째 공백을 찾아 단어의 끝에 대한 인덱스를 얻습니다. 공백을 찾으면 문자열의 시작과 공백의 인덱스를 시작 및 종료 인덱스로 사용하여 문자열 슬라이스를 반환합니다.
이제 first_word를 호출하면 기본 데이터에 연결된 단일 값을 다시 받습니다. 값은 슬라이스의 시작점에 대한 참조와 슬라이스의 요소 수로 구성됩니다.
슬라이스를 반환하는 것은 second_word 함수에도 작동합니다.
fn second_word(s: &String) -> &str {
이제 컴파일러가 String에 대한 참조가 유효하도록 보장하므로 엉망으로 만들기가 훨씬 더 어려운 간단한 API 가 있습니다. Listing 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}");
}
다음은 컴파일러 오류입니다.
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의 참조를 사용하므로 불변 참조가 해당 시점에도 활성 상태여야 합니다. Rust 는 clear의 가변 참조와 word의 불변 참조가 동시에 존재하지 못하도록 하며, 컴파일이 실패합니다. Rust 는 API 를 사용하기 쉽게 만들었을 뿐만 아니라 컴파일 시 전체 오류 클래스를 제거했습니다!
이진 파일 내부에 문자열 리터럴이 저장된다는 것을 기억하세요. 이제 슬라이스에 대해 알았으므로 문자열 리터럴을 제대로 이해할 수 있습니다.
let s = "Hello, world!";
여기서 s의 유형은 &str입니다. 즉, 이진 파일의 특정 지점을 가리키는 슬라이스입니다. 이것이 또한 문자열 리터럴이 불변인 이유입니다. &str은 불변 참조입니다.
리터럴과 String 값의 슬라이스를 가져올 수 있다는 것을 알면 first_word에 대한 또 다른 개선 사항을 얻을 수 있으며, 그것은 바로 시그니처입니다.
fn first_word(s: &String) -> &str {
더 경험이 많은 Rust 개발자는 Listing 4-9 에 표시된 시그니처를 대신 작성할 것입니다. 이는 &String 값과 &str 값 모두에서 동일한 함수를 사용할 수 있도록 하기 때문입니다.
fn first_word(s: &str) -> &str {
Listing 4-9: s 매개변수의 유형으로 문자열 슬라이스를 사용하여 first_word 함수 개선
문자열 슬라이스가 있으면 이를 직접 전달할 수 있습니다. String이 있으면 String의 슬라이스 또는 String에 대한 참조를 전달할 수 있습니다. 이러한 유연성은 "함수 및 메서드를 사용한 암시적 Deref 강제 변환"에서 다룰 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[..]);
// `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);
}
짐작하시겠지만, 문자열 슬라이스는 문자열에 특화되어 있습니다. 하지만 더 일반적인 슬라이스 유형도 있습니다. 다음 배열을 생각해 보세요.
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 장에서 벡터에 대해 이야기할 때 이러한 컬렉션에 대해 자세히 논의할 것입니다.
축하합니다! 슬라이스 타입 (The Slice Type) 랩을 완료했습니다. LabEx 에서 더 많은 랩을 연습하여 실력을 향상시킬 수 있습니다.