러스트 언어 입문 시리즈 - 4편: 컬렉션, 슬라이스, 이터레이터를 통한 실습 예제

지난 글에서 러스트의 소유권(Ownership), 빌림(Borrowing), 라이프타임(Lifetime) 개념을 살펴보았습니다. 이번에는 이러한 개념들을 실제로 활용해보는 실습 예제를 통해, 러스트의 컬렉션(Collection), 슬라이스(Slice), 그리고 함수형 프로그래밍 스타일을 지원하는 이터레이터(Iterator) 등을 다뤄보겠습니다.

 

또한 C++에서 std::vector, std::array, std::map 등을 사용하던 경험과 비교해보며, 러스트가 어떤 식으로 자료 구조를 제공하고 메모리 안전성을 유지하는지 살펴보겠습니다.

벡터(Vector): 동적 크기 배열

C++에서 std::vector로 동적 배열을 관리하던 것처럼, 러스트에서도 Vec<T> 타입을 통해 크기가 가변적인 배열을 다룰 수 있습니다. Vec<T>는 힙에 데이터를 저장하며, 소유권 규칙이 적용됩니다.

fn main() {
    let mut numbers = vec![1, 2, 3];
    numbers.push(4);
    println!("numbers: {:?}", numbers);

    let first = numbers[0];
    println!("첫 번째 원소: {}", first);

    // 소유권과 참조 규칙을 생각해봅시다.
    let second_ref = &numbers[1]; 
    println!("두 번째 원소 참조: {}", second_ref);
    // numbers.push(5); // 가변 참조가 아니더라도, 불변 참조가 활성을 가지고 있는 동안 변경 불가. 컴파일 에러 발생
}

C++ 벡터와 비슷하지만, 중요한 점은 참조를 빌리는 동안 벡터를 변경하는 것은 불가능하다는 점입니다. 이 규칙을 통해 댕글링 포인터나 사이즈 변경에 따른 참조 무효화를 방지합니다. C++에서 벡터에 원소를 푸시하며 기존 참조가 무효화되는 상황을 줄이기 위해 신경 써야 하는 점들을 러스트가 언어 차원에서 해결한 것입니다.

슬라이스(Slice)

슬라이스는 컬렉션의 일부 구간에 대한 비소유 참조입니다. C++17 이후 std::span을 떠올릴 수 있습니다. 러스트 슬라이스는 [시작..끝] 범위로 컬렉션의 특정 부분을 가리킵니다. 슬라이스는 소유권을 갖지 않고, 단지 데이터를 빌려온 참조 형태로 존재합니다.

fn main() {
    let arr = [10, 20, 30, 40, 50];
    let slice = &arr[1..4]; // arr 중 인덱스 1부터 3까지 참조
    println!("slice: {:?}", slice);

    // slice는 arr의 일부를 참조하므로, arr보다 오래 살아서는 안됩니다.
    // 라이프타임 규칙으로 인해 arr가 유효한 동안만 slice도 유효합니다.

    // 가변 슬라이스도 가능합니다(가변 참조 기반):
    let mut vec = vec![1, 2, 3, 4];
    let slice_mut = &mut vec[1..3];
    slice_mut[0] = 20;
    println!("변경 후 vec: {:?}", vec);
}

슬라이스는 길이 정보까지 함께 저장하므로, 별도의 사이즈 인자가 필요하지 않습니다(C++ span과 유사). 또한 슬라이스를 사용하면 해당 범위 내에서 안전한 인덱싱이 이루어져 런타임 범위 체크가 가능해집니다. C++ 포인터와 달리 슬라이스는 항상 유효 범위를 아는 안전한 참조이므로 실수로 범위를 넘어가는 접근을 할 수 없습니다.

문자열 슬라이스 &str vs String

러스트에는 String과 &str 타입이 있습니다. String은 힙에 저장되는 가변적 문자열이며, &str는 문자열 슬라이스로서 불변 참조 형태의 문자열을 나타냅니다.

fn main() {
    let greeting = String::from("Hello, Rust");
    let greet_slice = &greeting[0..5]; // "Hello"
    println!("{}", greet_slice);

    // greeting이 살아있는 동안 greet_slice는 유효합니다.
    // C++에서 문자열의 일부를 포인터로 참조할 때 생기는 수명 문제를
    // 러스트에서는 안전한 슬라이스로 관리할 수 있습니다.
}

C++에서 std::string_view와 유사한 개념으로 생각하면 됩니다. String은 소유권을 가지며 가변적, &str는 비소유 불변 참조이며 슬라이스 형태로 존재합니다.

맵(Map), 해시맵(HashMap)

러스트에는 표준 라이브러리에 HashMap<K, V>가 제공됩니다. 이는 C++의 std::unordered_map와 유사합니다.

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::new();
    scores.insert("Rust", 95);
    scores.insert("C++", 89);

    match scores.get("Rust") {
        Some(&score) => println!("Rust 점수: {}", score),
        None => println!("Rust 점수 없음"),
    }

    for (lang, score) in &scores {
        println!("언어: {}, 점수: {}", lang, score);
    }
}

중요한 점은 키나 값으로 들어가는 데이터도 소유권/빌림 규칙이 적용된다는 것입니다. 해시맵에 값을 삽입할 때 소유권이 이동하거나 참조를 빌려오는 식으로 작동하여 안전한 메모리 관리를 보장합니다.

이터레이터(Iterator)와 함수형 스타일

C++20 이후 std::ranges를 통해 함수형 스타일의 이터레이션이 가능해졌다면, 러스트는 언어 초기부터 이터레이터 패턴을 지원했습니다. 이터레이터는 소유권 규칙과 결합해 안정적이며, map, filter, collect 등 함수형 패턴을 손쉽게 사용할 수 있습니다.

fn main() {
    let nums = vec![1, 2, 3, 4, 5];

    // 이터레이터를 생성하여 map, filter를 적용
    let processed: Vec<_> = nums.iter()
        .map(|x| x * 2)
        .filter(|x| x % 4 == 0)
        .collect();

    println!("처리 결과: {:?}", processed);
}

여기서 nums.iter()는 불변 참조를 통해 이터레이터를 생성합니다. map, filter는 각각의 값에 대한 함수형 연산을 적용하며, 최종적으로 collect()를 통해 새로운 벡터를 생성합니다. C++에서도 레인지 기반 for, 람다 표현식 등으로 비슷한 패턴을 구현할 수 있지만, 러스트는 이 방식을 더 언어 차원에서 자연스럽게 지원합니다.

또한 이터레이터는 소유권 규칙과 잘 어우러져, 예를 들어 into_iter()를 사용할 경우 벡터의 소유권을 옮기며 요소를 소비(consuming)하는 패턴을 나타낼 수 있습니다. 이는 C++에서 move 이터레이터를 사용할 때와 비슷하지만, 러스트는 이 개념을 더 명확히 언어/라이브러리 수준에서 정립했습니다.

C++와의 비교 정리

  • 벡터와 슬라이스: C++ std::vector와 std::span의 조합과 유사한 구조를 러스트는 Vec<T>와 슬라이스(&[T])로 제공. 슬라이스는 안전한 범위 체크와 라이프타임 기반 관리로 런타임 에러를 줄임.
  • 문자열 관리: C++ std::string + std::string_view 조합을 러스트는 String과 &str로 제공하며 소유권과 불변 참조 개념을 명확히 적용.
  • 해시맵: C++ std::unordered_map와 비슷한 HashMap, 소유권 규칙을 통해 메모리 안전성과 데이터 무효화 문제를 최소화.
  • 이터레이터: C++20 이후의 함수형 스타일 이터레이션과 비슷하지만, 러스트는 초기부터 map/filter/collect 패턴을 지원. 소유권과 결합해 더욱 명확한 메모리 관리 및 안전성 확보.

앞으로의 학습 방향

이제 컬렉션, 슬라이스, 이터레이터를 통한 러스트 프로그래밍의 전반적 감각을 익혔습니다. 다음 글에서는 구조체, 열거형, 패턴 매칭, 그리고 모듈(Module)과 패키지(Crate) 구조를 통해 더 큰 스케일의 프로그램을 조직하는 방법을 다뤄보겠습니다. 이를 통해 C++에서 클래스, enum, 헤더/소스 구조와 어떤 점이 다른지 비교해볼 예정입니다.

유용한 링크와 리소스

반응형