/ PROGRAMMING, RUST

Rust - Collections

Rust는 collection이라고 불리는 유용한 자료 구조형을 제공하고 있어요. C++의 STL, Python의 List, Dict 등과 같은 자료 구조들을 생각하시면 됩니다.

Rust 맛보기

Collection의 특징

  • Collection은 여러 타입의 값을 하나의 데이터 구조로 저장할 수 있습니다.

  • Collection 내 데이터는 heap에 저장됩니다.

    • Collection은 크기가 동적으로 변할 수 있습니다.
  • 서로 다른 종류의 collection은 각자 다른 기능을 갖고 있고 성능 관련 cost도 다릅니다.

이번 글에서는 Rust에서 매우 자주 사용되는 세 가지 collection인 Vec, String, HashMap에 대해 알아보겠습니다.

Vector 타입: Vec<T>

같은 타입의 여러 값을 저장할 수 있는 동적 배열입니다. 각 요소들은 메모리 내에서 연속적으로 저장됩니다.

내부적으로는 Vec 은 첫 번째 요소(힙에 위치)를 가리키는 포인터, 벡터의 용량(capacity), 벡터의 길이(length)를 갖고 스택에 저장됩니다. 벡터의 길이가 메모리 용량을 초과할 경우, Rust는 더 큰 메모리를 할당하고 기존 요소들을 새로운 메모리로 복사합니다.

Vec을 생성하려면 Vec::new()를 사용하거나 vec! 매크로를 사용할 수 있습니다.

fn main() {
    let v: Vec<i32> = Vec::new();
    let v = vec![1, 2, 3]; // Vec<i32> 타입
}

벡터는 generic을 사용하여 정의되어 있으므로, 벡터에 사용할 타입은 자유롭게 지정할 수 있습니다. 벡터에 초기 값들을 할당하지 않을 경우 위와 같이 type annotation을 사용하여 요소의 타입을 명시하는 것이 좋습니다.

vec! 매크로를 사용하면 초기화 값들을 지정 가능합니다. 이때는 컴파일러가 타입을 추론할 수 있으므로 type annotation을 생략할 수 있습니다.

Vec 타입은 스택 오퍼레이션처럼 push, pop 메소드를 사용하여 요소를 추가, 삭제할 수 있습니다.

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);

    let third: &i32 = &v[2]; // &와 []를 함께 사용하여 요소에 대한 reference를 얻을 수 있다.
    println!("The third element is {}", third);

    match v.get(2) {
        Some(third) => println!("The third element is {}", third),
        None => println!("There is no third element."),
    }

    v.pop();

    for i in &v {
        println!("{}", i);
    }
}

위와 같이 벡터의 요소에 접근하는 방법은 두 가지가 있습니다. 첫 번째는 인덱싱을 사용하는 방법이고, 두 번째는 더 안전하게 get 메소드를 사용하는 방법입니다. get 메소드는 Option<&T>를 반환하므로, 벡터의 길이를 초과하는 인덱스에 접근할 경우 None을 반환합니다.

요소의 reference를 얻을 때 주의해야 할 점이 있습니다. Immutable reference가 존재하는 동안 Mutable reference는 존재할 수 없다는 Rust 내 borrower checker의 규칙에 따라, 벡터의 요소에 대한 reference가 존재하는 동안에는 벡터의 요소를 추가하거나 삭제할 수 없습니다.

fn main() {
    let mut v = vec![1,2,3,4,5];
    let first = &v[0]; // immutable reference
    v.push(6); // error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
    println!("The first element is: {}", first);
}

그런데 first는 단지 첫 번째 요소를 가리키는 reference입니다. 그런데 왜 첫 번째 요소를 변경하는 것도 아닌, 벡터의 끝부분에 새 요소를 추가하는 것조차 허용되지 않는 것일까요?

이는 벡터에 push()로 요소를 추가할 때, capacity를 초과하며 새로운 메모리 공간이 할당될 수 있는 가능성 때문입니다. 이때는 벡터의 모든 요소가 새로운 메모리 공간에 복사되어야 하는 것이죠. 즉 모든 요소에 대한 reference가 무효화되어야 한다는 뜻입니다.

벡터 순회

먼저 for 반복문을 사용하여 벡터의 요소를 순회할 수 있습니다.

fn main() {
    let v = vec![100, 32, 57];
    for i in &v {
        println!("{}", i);
    }
}

위와 같이 벡터의 reference를 사용하여 순회할 경우, 벡터의 요소에 대한 immutable reference를 얻게 됩니다. 만약 벡터의 요소를 변경하고 싶다면 mut 키워드를 사용하여 벡터의 reference를 mutable reference로 만들어야 합니다.

fn main() {
    let mut v = vec![100, 32, 57];
    for i in &mut v {
        *i += 50;
    }
}

한 벡터 내에 서로 다른 타입의 값을 저장하고 싶다면, enum을 사용하여 다양한 타입을 가질 수 있는 enum을 정의하고, 이를 벡터에 저장할 수 있습니다.

#[derive(Debug)]
enum SpreadsheetCell {
    Int(i32),
    Float(f64),
    Text(String),
}

fn main() {
    let row = vec![
        SpreadsheetCell::Int(3),
        SpreadsheetCell::Text(String::from("blue")),
        SpreadsheetCell::Float(10.12),
    ];

    for i in &row {
        println!("{:?}", i);
    }
}

한편 벡터가 scope를 벗어나 drop되면, 당연하게도 내부의 모든 요소들도 함께 drop됩니다.

Vec<T>에 정의된 다양한 메소드는 Rust 공식 문서에서 확인할 수 있습니다.

String 타입

이전 게시글에서도 간단히 다뤄봤었죠.

Rust의 core language에 유일하게 정의된 문자열 타입은 String Slice에 해당하는 str 입니다. String 타입은 표준 라이브러리에 의해 제공되는, core language로 구현된 growable, mutable, owned의 특성을 갖는 문자열 타입이죠. Stringstr은 모두 UTF-8 인코딩을 사용합니다.

사실 String 타입은 바이트 벡터의 wrapper로써 구현되어 있습니다. 따라서 Vec<T>에 사용 가능한 메소드들을 대부분 사용할 수 있습니다.

let mut s = String::new(); // 새 String 생성
let data = "initial contents";
let s = data.to_string(); // string slice(&str)로부터 String 생성
let s = "initial contents".to_string();
let s = String::from("initial contents");

String 타입의 연산

String 타입은 push_str, push, + 연산자 등을 사용하여 문자열을 추가할 수 있습니다.

let mut s = String::from("foo");
s.push_str("bar");
println!("{}", s); // "foobar"
s.push('l');
println!("{}", s); // "foobarl"

push_str 메소드는 문자열 슬라이스를 인자로 받아 String에 추가합니다.

push 메소드는 char 타입의 단일 문자를 추가합니다.

+ 연산자를 사용하여 두 개의 String을 결합할 수도 있습니다. 이때는 ownership(소유권)에 특히 주의해야 합니다.

let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1은 이후 사용 불가

s3을 선언할 때 s1의 소유권이 넘어가게 되므로, s1은 이후 사용할 수 없습니다. 이는 + 연산자가 add 메소드를 호출하며, add 메소드는 self의 소유권을 가져가기 때문입니다.

add 메소드의 프로토타입은 다음과 같습니다.

fn add(self, s: &str) -> String

selfs는 각각 첫 번째 스트링과 두 번째 스트링을 의미합니다. s의 타입은 String Slice, 즉 &str 이므로 +을 사용할 때 두 번째 스트링의 reference를 사용하는 것입니다. 또한 add 내로 self의 소유권이 넘어간 이후 메소드가 종료됨에 따라 self의 메모리는 해제됩니다.

이때 &String 타입이 &str 타입으로 변환되는 현상은 deref coercion이라고 부릅니다. 다음에 자세히 다뤄 봅시다.

그런데 3개 이상의 문자열을 결합하고 싶을 때에는 + 연산자가 다루기 불편해집니다. 이때 format! 매크로를 사용하면 편리합니다.

format! 매크로는 reference만을 사용하기 때문에 각 parameter의 소유권을 가져가지 않습니다.

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = format!("{}-{}-{}", s1, s2, s3); // "{s1}-{s2}-{s3}" 도 가능

String 인덱싱하기

StringVec<u8>의 wrapper이므로, [] 연산자를 사용하여 인덱싱할 수 없습니다. 이는 String이 UTF-8 인코딩을 사용하기 때문입니다. [] 연산은 Vec<u8> 타입에서 바이트 단위로 인덱싱이 이루어지므로, 각 문자의 크기가 1바이트를 초과할 수 있는 UTF-8 인코딩된 문자열에서는 문자의 일부만을 반환할 수 있기 때문입니다. Rust는 이런 현상을 방지하기 위해 String 타입에서 [] 연산자를 사용한 인덱싱을 시도하면 컴파일 에러를 발생시킵니다.

비록 인덱싱과 비슷한 문법인 [..]를 통해 slicing은 가능하더라도 말이죠.

그러나 slicing도 바이트 단위로만 이뤄지기 때문에, 문자열의 일부를 slicing할 때 문자가 깨질 수 있습니다.

let hello = "Здравствуйте"; // 각 문자의 크기가 2바이트임
let s = &hello[0..4]; // 첫 4바이트에 해당하는 "Зд" 반환

또한 [0..1]과 같이 1바이트만 slicing 하려고 시도하면 런타임 에러가 발생합니다.

String 타입의 순회

String 내 부분 문자열 또는 단일 문자를 취급할 경우 byte와 character중 무엇을 사용할지 명확히 하는 것이 좋습니다.

chars 메소드를 사용하면 각 Unicode scalar 값에 대해 순회가 가능합니다.

fn main() {
    let s = String::from("नमस्ते");

    for c in s.chars() {
        println!("{}", c);
    }
}

bytes 메소드를 사용하면 각 바이트에 대해 순회가 가능합니다.

fn main() {
    let s = String::from("नमस्ते");

    for b in s.bytes() {
        println!("{}", b);
    }
}

String 타입에 정의된 다양한 메소드는 Rust 공식 문서에서 확인할 수 있습니다.

HashMap 타입

HashMap은 key-value 쌍을 저장하는 데이터 구조입니다. HashMap은 다른 두 collection과 달리 prelude에 포함되어 있지 않으므로 사용하려면 std::collections::HashMap 모듈을 가져와야 합니다.

.insert(key, value) 메소드로 Hash Map에 key-value 쌍을 추가할 수 있습니다.

Hash Map의 값에 접근하려면 .get(key) 메소드를 사용합니다. 이 메소드는 Option<&V>를 반환하며, key에 해당하는 값이 존재하지 않을 경우 None을 반환합니다. reference가 아닌 값을 담는 Option<V>를 얻으려면 여기에 .copied() 메소드를 사용하면 됩니다.

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

let team_name = String::from("Blue");
let score = scores.get(&team_name).copied();

HashMap의 key는 모두 같은 타입이어야 합니다. 마찬가지로 value도 모두 같은 타입이어야 합니다.

여기서는 key의 타입이 String임을 확인할 수 있습니다. 그런데 String의 소유권은 HashMap에 넘어가게 되는데 어떻게 나중에 key를 사용할 수 있을까요? &String 또는 스트링 슬라이스 &str를 사용하면 됩니다. HashMap은 내부적으로 String의 해시 값을 계산할 때, &str를 대신 사용하기 때문에 나중에 query를 할 때 &str을 사용해도 문제가 없습니다. 위에서 get 메소드에 &team_name을 넘겼던 것이 그 예시입니다.

HashMap의 순회

HashMap을 순회할 때에는 for 반복문을 사용할 수 있습니다. 이때 keyvalue를 모두 가져올 수 있습니다.

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    for (key, value) in &scores { // ownership을 for문에 넘길 필요가 없으므로 reference 사용
        println!("{}: {}", key, value);
    }
}

HashMap의 업데이트

이미 존재하는 key에 대해 다시 insert 메소드를 사용하면, 기존 value가 새로운 value로 대체됩니다.

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Blue"), 25);

    println!("{:?}", scores); // 25
}

실수로 덮어쓰기를 방지하려면, entry 메소드를 사용하여 key에 대한 value가 이미 존재하는지 확인한 후, 존재하지 않을 때만 value를 추가하도록 할 수 있습니다.

use std::collections::HashMap;

fn main() {
    let mut scores = HashMap::new();
    scores.insert(String::from("Blue"), 10);

    scores.entry(String::from("Yellow")).or_insert(50); // Entry enum 반환
    scores.entry(String::from("Blue")).or_insert(50);

    println!("{:?}", scores); // {"Blue": 10, "Yellow": 50}
}

entry 메소드는 Entry enum을 반환합니다. 이 enum은 or_insert 메소드를 가지고 있으며, key에 대한 value가 이미 존재할 경우는 기존 value에 대한 mutable reference를 반환하고, 존재하지 않을 경우는 해당 key 와 인자로 받은 value에 대한 key-value pair를 HashMap에 추가한 후 그 value에 대한 mutable reference를 반환합니다.

or_insert 메소드가 mutable reference를 반환한다는 점을 응용하면, * 연산자로 mutable reference를 dereference하여 value를 변경할 수 있습니다.

use std::collections::HashMap;

fn main() {
    let text = "hello world wonderful world";

    let mut map = HashMap::new();

    for word in text.split_whitespace() {
        let count = map.entry(word).or_insert(0);
        *count += 1;
    }

    println!("{:?}", map);
}

참고 문헌

Rust 맛보기