Rust의 slice(슬라이스)는 배열, 벡터, 문자열 등 메모리상에 연속적으로 저장된 데이터의 일부 구간을 참조하는 타입이고, 컬렉션 참조는 컬렉션(배열 포함) 전체에 대한 참조입니다. 그러나, 모두 소유권을 갖지 않고 데이터를 참조하는 것은 같지만, 그 목적과 내부 구조, 사용 방식에 중요한 차이가 있습니다.
1. 슬라이스(Slice)
가.기본 개념
- 슬라이스는 배열, 벡터, 문자열 등 메모리상에 연속적으로 저장된 데이터의 일부 구간을 참조하는 타입으로, &[T] 또는 &str(문자열 슬라이스)와 같이 참조 형태로 표현된다.
- 슬라이스는 원본 데이터의 소유권을 이동시키지 않으며, 원본 데이터가 유효할 때만 사용할 수 있다.
- 슬라이스는 연속된 메모리 블록의 시작 주소와 길이 정보를 함께 저장하는 fat pointer(두 개의 값: 포인터 + 길이)이다.
- 슬라이스는 항상 연속적인 데이터만 참조할 수 있습니다.
나.문법과 사용법
- 슬라이스는 [start..end] 범위 문법을 사용합니다.
- start나 end를 생략하면 처음이나 끝을 의미합니다.
&a[..3] → 처음부터 3번째 전까지
&a[2..] → 2번째부터 끝까지(끝 포함)
&a[..] → 전체
다. 문자열 슬라이스
- 문자열 슬라이스는 String이나 문자열 리터럴의 일부를 참조하며 타입은 &str 입니다.
- 문자열 일부 참조
let s = String::from(“hello world”);
let hello = &s[0..5]; // “hello”
let world = &s[6..11]; // “world” - 문자열 리터럴
let s = “hello, world”; // &’static str
let part: &str = &s[0..5]; // “hello” 부분만 참조
“hello” 자체도 &str 타입의 슬라이스이므로, 문자열 리터럴의 일부도 참조할 수 있습니다.
라. 특징과 주의점
- 슬라이스는 참조이므로, 원본 데이터가 유효해야 한다.
원본 데이터가 변경(예: String의 clear 등)되면 기존 슬라이스는 무효화되어 컴파일 에러가 발생합다. - 슬라이스는 소유권과 라이프타임 개념을 이해하는 데 중요한 역할을 합니다. -> 아래 ‘3. 슬라이스와 라이프타임’에서 설명
- 슬라이스는 함수의 파라미터로 자주 사용됩니다.
fn main() {
let s = "hello, world";
println!("First two elements: {:?}", first_two(s));
}
fn first_two(slice: &str) -> &str {
if slice.len() < 2 {
panic!("Slice must have at least two elements");
}
&slice[0..2]
}
위 코드에서 s는 문자열 리터럴인 문자열 슬라이스이므로 함수로 넘길 때는 &를 안붙여도 되지만(붙여도 됨), 함수에서 받을 때는 타입을 &str으로 지정하고, 반환도 &slice[0..2]이므로&str 타입으로 지정해야 합니다.
실행 결과는 First two elements: “he”입니다.
2. 컬렉션 참조
가. 개념
- 컬렉션 참조는 컬렉션 전체에 대한 참조입니다. 예를 들어, &Vec, &String, &[T; N] 등입니다. 여기서 &[T; N]은 배열 전체에 대한 참조입니다. 엄격하게 말하면 배열은 컬렉션은 아니지만 “배열 전체를 참조한다”는 측면에서 컬렉션 참조에 포함해서 설명합니다.
- 이 참조는 해당 컬렉션 타입의 모든 메서드와 속성을 사용할 수 있게 해줍니다.
- 컬렉션 참조는 그 자체가 소유한 데이터 전체를 가리키며, 슬라이스처럼 부분만을 가리키지는 않습니다.
- 예시:
let v = vec![1, 2, 3];
let r = &v; // Vec 전체에 대한 참조
벡터 컬렉션 참조의 간단한 예시는 다음과 같습니다.
fn main() {
let v = vec![100, 32, 57]; // Vec<i32> 생성
// 벡터에 대한 불변 참조를 사용하여 각 요소 출력
for i in &v {
println!("{}", i);
}
}
- 여기서 &v는 Vec 타입의 벡터에 대한 불변 참조(&Vec)입니다.
- for i in &v 구문은 벡터의 각 요소에 대한 참조(&i32)를 순회하며 안전하게 접근합니다.
- 벡터 자체를 소유하지않고 참조만 하므로, 함수 인자나 반복문에서 자주 활용됩니다.
나. 예제
fn print_array(arr: &[i32; 3]) {
println!("{:?}", arr);
}
fn main() {
let data = [10, 20, 30];
print_array(&data); // &[i32; 3] 타입의 참조 전달
}
- 위 예제에서 print_array 함수는 크기가 3인 i32 배열에 대한 참조(&[i32; 3])를 인자로 받습니다.
- &data는 [i32; 3] 타입 배열 전체에 대한 참조입니다.
다. 슬라이스와 주요 차이점 비교
구분 | 슬라이스 (&[T], &str) | 컬렉션 참조 (&Vec<T>, &String) |
---|---|---|
대상 | 컬렉션의 일부(연속 구간) | 컬렉션 전체 |
내부 구조 | 포인터 + 길이 (fat pointer) | 포인터 (컬렉션 구조체 전체) |
메서드 사용 | 슬라이스 관련 메서드만 사용 가능 | 컬렉션의 모든 메서드 사용 가능(벡터의 len(), push() 등 |
용도 | 부분 참조, 함수 인자 등 | 전체 참조, 컬렉션의 메서드 활용 |
동적 크기 | 길이가 런타임에 결정됨 | 컬렉션 타입에 따라 다름 |
예시 | &arr[1..3], &vec[..], &s[2..] | &vec, &arr, &String |
&[T; N]은 배열 전체를 참조하고, &[T]는 슬라이스로, 배열의 일부 구간(혹은 전체)을 참조할 수 있습니다.
예를 들어, &arr[1..3]은 &[T] 타입(슬라이스)이지만, &arr은 &[T; N] 타입(컬렉션 참조)입니다. 이처럼 &[T; N]은 정확히 N개 원소를 가진 배열 전체에 대한 참조입니다.
또한 &str과 &String은 문자열 슬라이스와 문자열 참조로 다르지만, &String이 &str으로 자동 변환이 가능하기 때문에 함수 인자로는 &str이 더 범용적이고 권장됩니다.
3. 슬라이스와 라이프타임
가. 개념
Rust에서 라이프타임(lifetime)은 참조가 유효한 기간을 명확하게 지정하는 개념으로, 참조의 유효성에 직접적인 영향을 미칩니다. 라이프타임이 중요한 이유와 그 영향은 다음과같습니다.
- 댕글링 참조 방지
라이프타임의 가장 큰 목적은 댕글링 참조(dangling reference)를 방지하는 것입니다. 댕글링 참조란, 이미 스코프를 벗어나 소멸된 데이터를 참조하는 상황을 의미합니다. Rust는 각 참조자의 라이프타임을 추적하여, 참조가 원본 데이터보다 오래 살아남을 수 없도록 컴파일 타임에 강제합니다. - 안전한 메모리 접근 보장
라이프타임을 통해 참조자가 항상 유효한 메모리만 가리키도록 보장합니다. 예를 들어, 아래와 같은 코드는 컴파일되지 않습니다.
let r; // r의 라이프타임 시작
{
let x = 5; // x의 라이프타임
r = &x; // r이 x를 참조
} // x의 라이프타임 종료, r은 더 이상 유효하지 않음
println!(“{}”, r); // 컴파일 에러!
Rust는 위 코드에서 r이 x보다 오래 살아남으려 하므로 컴파일 에러를 발생시킵니다. - 함수와 구조체에서의 명확한 라이프타임 관계
여러 참조가 얽히는 함수나 구조체에서는, 각각의 참조가 얼마나 오래 유효해야 하는지 명확히 지정해야 합니다. 라이프타임 파라미터를 명시함으로써, 함수가 반환하는 참조가 입력 참조자 중 어느 것과 연관되어 있는지 컴파일러가 알 수 있습니다. - 암묵적 추론과 명시적 지정
대부분의 경우 Rust는 라이프타임을 자동으로 추론하지만, 여러 참조가 얽히거나 복잡한 관계가 있을 때는 명시적으로 라이프타임을 지정해야 합니다. 이를 통해 참조 유효성에 대한 컴파일러의 보장이 더욱 강력해집니다.
나. 예제 1
Rust에서 슬라이스의 라이프타임이 중요한 이유를 보여주는 대표적인 예제는, 두 개의 문자열 슬라이스 중 더 긴 쪽을 반환하는 함수입니다. 이 예제를 통해 슬라이스의 라이프타임을 명확히 지정하지 않으면 컴파일 에러가 발생하고, 올바르게 지정하면 안전하게 참조를 반환할 수 있음을 알 수 있습니다.
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {}", result);
}
// 라이프타임 명시가 반드시 필요!
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
- 이 함수 시그니처에서 ‘a라는 라이프타임 파라미터를 명시했습니다.
- 이 뜻은, x와 y 모두 최소한 ‘a만큼 살아 있어야 하며, 반환되는 슬라이스도 ‘a만큼 살아 있어야 한다는 의미입니다.
- 실제로 함수가 호출될 때, Rust는 x와 y의 라이프타임 중 더 짧은 쪽에 맞춰 반환값의 라이프타임을 제한합니다. 따라서, ‘a와 ‘b를 사용해서
fn longest<‘a, ‘b>(x: &’a str, y: &’b str) -> &’a str
라고 하면 x가 반환될 수도 있고 y가 반환될 수도 있기 때문에 에러가 발생합니다. - 또한 라이프타임을 명시하지 않아도, Rust는 반환되는 참조가 입력 참조자 중 어느 쪽과 연관되어 있는지 알 수 없어 컴파일 오류가 발생합니다. 따라서, 라이프타임을 ‘a로 통일한 것입니다.
이처럼, 슬라이스의 라이프타임을 명확히 지정하지 않으면 댕글링 참조가 생길 수 있고, Rust는 이를 컴파일 타임에 방지합니다.
따라서 라이프타임 명시는 슬라이스가 안전하게 사용될 수 있는 핵심적인 장치입니다.
위 코드에서 as_str()은 String 타입을 &str 타입으로 변환하는 메소드입니다.
다. 예제2
fn main() {
let result;
{
let string1 = String::from("abc");
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
// string1, string2는 여기서 소멸
}
println!("The longest string is {}", result); // 컴파일 에러!
}