Rust #8: 8장 공통 컬렉션

·

7 min read

개요

이 장은 Rust에서 대표적으로 사용하는 벡터(Vector, Vec), 문자열(String), 해시맵(Hash Map, HashMap)을 통해 Rust에서 제공하는 컬렉션에 대해 이해하는 시간을 갖도록 합시다.

벡터로 값 목록 저장하기

Rust에서는 변경할 수 있는 연속적인 값을 벡터라 하며 Vec<T> 컬렉션 유형입니다. 벡터는 동일한 유형의 값만 저장할 수 있으며 리스트 라고도 불리어 집니다.

새 벡터 만들기

다음과 같이 코딩해 봅시다.

let v: Vec<i32> = Vec::new();
// let v = Vec::<i32>::new();

Rust에서는 새로운 할당을 약속처럼 new() 메소드를 통해 합니다.

다음의 코드처럼 초기 값을 가지는 벡터를 생성할 수 도 있습니다.

let mut v = vec![1, 2, 3];

유추할 수 있는 값이 주어지면 Rust는 똑똑하게 적절한 유형을 찾아줍니다. 위의 vec 매크로는 컴파일 시 해석 되어서 코드로 변환 되는데요, 대략 다음의 코드처럼 변환됩니다.

// let mut v = vec![1, 2, 3];
// 실제 vec! 동작과는 동일하지 않는 예시입니다.
let mut v: Vec<i32> = Vec::new();
v.push(1);
v.push(2);
v.push(3);

벡터의 요소 읽기

다른 프로그래밍 언어와 유사하게 인덱싱을 통한 접근과 메소드를 통한 접근을 제공합니다.

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

    let third = &v[2];
    println!("{}", &third);

    let some_third = v.get(2);
    match some_third {
        Some(n) => println!("{}", n),
        None => println!("None!"),
    }
}

차이점은 get()메소드의 경우 Option 열거형을 반환한다는 점입니다.

다음의 코드를 통해 get() 메소드의 이점을 확인할 수 있습니다.

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

    let does_not_exist = v.get(100);
    let does_not_exist = &v[100];
}

v.get()을 통해서는 인덱스 범위를 벗어났지만 오류가 발생하지 않고 Option.None을 반환하는 반면, v[]로 인덱싱 한 경우 런타임 에러가 발생합니다.

다음의 코드는 변수에 mut 키워드를 주었을 때 발생하는 에러 유형을 살펴보겠습니다.

let mut v = vec![1, 2, 3, 4, 5];

let first = &v[0];

v.push(6);

println!("{}", first);

이 코드는 컴파일 오류가 발생합니다. 왜냐하면, first 변수가 v 벡터의 특정 원소를 참조하고 있는데, v.push(6)에 의해서 목록이 변형되기 때문입니다.

벡터의 값 반복

참조와 역참조를 이용해 Rust에서는 벡터 순회 시 값의 변형을 효율적으로 처리합니다.

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

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

출력

[150, 82, 107]

보시는 바와 같이 벡터의 값을 꺼내와 그 값에 50을 더하는 간결한 코드입니다.

열거형을 사용하여 여러 유형 저장

열거형도 벡터의 요소 유형이 될 수 있습니다.

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

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

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

출력

[Int(3), Text("blue"), Float(10.12)]

문자열이 있는 UTF-8 인코딩 텍스트 저장

문자열도 컬렉션이라고 할 수 있습니다. 문자열을 문자의 연속된 집합으로 정의 내릴 수 있기 때문인데요, 실제로 문자열은 다음처럼 정의 되어 있습니다.

pub struct String {
    vec: Vec<u8>,
}

문자열이란 무엇입니까?

Rust에서 사용하는 문자열 인코딩 방식은 UTF-8 입니다. UTF-8은 인터넷이 활성화된 꽤 최근에 대중적으로 사용하게 된 인코딩 방식인데, 그런 이유로 오래된 프로그래밍 언어는 문자열 인코딩 방식으로 UTF-16 또는 좀 더 오래된 언어의 경우 ANSI 인코딩 방식을 사용합니다. Rust는 다른 프로그래밍 언어에 비해 최근에 등장한 언어에 속하는데 그 때문인지 Rust의 문자는 UTF-8 인코딩 방식을 사용합니다.

문자열은 문자의 집합입니다. Rust의 문자열은 String 및 문자열 슬라이스를 의미하는 &str로 사용할 수 있습니다. 그리고 문자형은 char인데, char는 UTF-8 인코딩에 의해 문자를 표현합니다.

새 문자열 만둘기

다음과 같은 방법으로 새 문자열을 만들 수 있습니다.

let mut s = String::new();

다음과 같이 문자열 리터럴에서 String을 생성할 수 있고,

    let data = "initial contents";
    let s = data.to_string();

    let s = "initial contents".to_string();

String::from()을 통해 생성할 수도 있습니다.

let s = String::from("initial contents");

Rust의 문자열은 UTF-8 인코딩을 사용하므로 다음과 같이 다양한 언어를 표현할 수 있습니다.

    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שָׁלוֹם");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");

문자열 업데이트

push_str과 push로 문자열 추가

다음의 코드와 같이 문자열을 추가할 수 있습니다.

let mut s = String::from("foo");
s.push_str("bar");

다음의 코드를 통해 push_str()에 전달된 문자열의 소유권이 여전히 유효함을 알 수 있습니다.

    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2); // &str 이므로 소유권을 가져가지 않음
    println!("s2 is {}", s2);

단일 문자를 추가하는 것도 어렵지 않습니다.

let mut s = String::from("lo");
s.push('l');

s는 "lol"이 됩니다.

+ 연산자 또는 format! 매크로와의 연결

다음의 코드를 보시죠.

    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2;

s1은 소유권을 잃고 더이상 사용할 수 없게 됩니다. s3는 결합 메소드의 형태에 따라 참조형이여야 합니다.

// 이것은 정확한 표준 라이브러리의 정의는 아닙니다.
fn add(self, s: &str) -> String { ... }

세개 이상의 문자열 결합도 가능합니다.

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

let s = s1 + "-" + &s2 + "-" + &s3;

결합 키워드로 인해 가독성이 조금은 떨어졌습니다. 이것을 다음의 코드로 변경할 수 있습니다.

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

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

조금 더 읽기 쉬운 코드가 되었습니다.

문자열로 인덱싱

다음의 코드는 컴파일 오류가 발생합니다.

let s1 = String::from("hello");
let h = s1[0];

Rust의 문자는 UTF-8 인코딩을 사용하며 가변입니다. 문자열은 이런 문자의 집합으로 이루어졌기 때문에 인덱스 접근을 허용하면 자칫 치명적인 성능 문제가 발생하는 코드를 작성할 수 있게 됩니다. 이로 인해서 Rust의 문자열에는 인덱스 접근을 지원하지 않습니다.

내부 표현

좀 더 자세히 살펴 봅시다. 다음의 코드를 보면,

let hello = String::from("Hola");

hello의 바이트 길이는 4바이트입니다. 다음으로 다음의 코드를 보면,

let hello = String::from("Здравствуйте");

hello의 바이크 길이가 총 12바이트일것 같지만, 실제로는 총 24바이트의 길이를 가지게 됩니다.

바이트 및 스칼라 값과 그래프 클러스터! 오 저런!

실제로 다양한 나라의 언어에서 UTF-8을 이용해 Rust의 char 유형의 유니코드 스칼라 값이 최종 문자열이 되지 않습니다.

['न', 'म', 'स', '्', 'त', 'े']

이 char 목록은, 최종적으로 다음의 문자들이 됩니다.

["न", "म", "स्", "ते"]

Rust는 이런 특징 때문에 문자열의 인덱스를 제공하지 않는 것으로 결정이 되었습니다.

슬라이싱 문자열

그러므로 슬라이싱 문자열을 조심스럽게 사용해야 합니다.

fn main() {
    let name = "디모이";

    //let first = &name[0..1]; // 런타임 오류
    let first = &name[0..3];
    println!("{}", first);
}

문자열을 반복하는 방법

문자열을 반복하기위한 방법은 없는것일까요? 다행이 chars()를 통해 가능합니다.

``rust fn main() { let name = "디모이";

//let first = &name[0..1]; // 런타임 오류
for c in name.chars() {
    println!("{}", c);
}

}


바이트로의 접근은 `bytes()`로 가능합니다.

```rust
for b in name.bytes() {
    println!("{}", b);
}

문자열은 그렇게 간단하지 않습니다.

UTF-8 인코딩 방식은 현대에 가장 대표적으로 사용하는 인코딩 방식이지만 간단하지는 않습니다. Rust는 UTF-8 인코딩을 사용하기로 결정하게 되면서 가변 바이트 길이의 문자 처리에 대한 어려움이 생겼지만 반대로 UTF-8기반의 문자열은 별도의 처리 없이 언어 차원에서 처리할 수 있는 장점이 있습니다.

해시맵에 연결된 키로 값 저장

Rust는 키로 값에 접근할 수 있는 HashMap<K, V>를 제공합니다. 다른 프로그래밍 언어에서는 Dictionary라고 하기도 합니다.

새 해시맵 생성

다음의 코드로 새 해시맵을 생성할 수있습니다.

    use std::collections::HashMap;

    let mut scores = HashMap::new();

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

다음의 코드는 키목록과 값 목록에서 해시맵을 만드는 코드입니다.

    use std::collections::HashMap;

    let teams = vec![String::from("Blue"), String::from("Yellow")];
    let initial_scores = vec![10, 50];

    let mut scores: HashMap<_, _> =
        teams.into_iter().zip(initial_scores.into_iter()).collect();

해시맵 및 소유권

해시맵도 Rust 소유권의 영향을 받습니다.

    use std::collections::HashMap;

    let field_name = String::from("Favorite color");
    let field_value = String::from("Blue");

    let mut map = HashMap::new();
    map.insert(field_name, field_value);

    println!("{}", &field_name); // 소유권이 map으로 넘어갔으므로 오류!
    println!("{}", &field_value); // 소유권이 map으로 넘어갔으므로 오류!

해시맵 값 액세스

    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);

벡터와 유사하게 get() 메소드를 통해 Option으로 값을 취할 수 있습니다.

(키, 값)으로 해쉬맵을 순회하는 방법은 다음과 같습니다.

    use std::collections::HashMap;

    let mut scores = HashMap::new();

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

    for (key, value) in &scores {
        println!("{}: {}", key, value);
    }

해시맵 업데이트

해시맵은 동일한 키로 insert()할 경우 값을 갱신하도록 합니다.

    use std::collections::HashMap;

    let mut scores = HashMap::new();

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

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

키에 값이 없는 경우에만 값 삽입

entry()or_insert() 메소드를 이용해 키에 값이 없는 경우에 값을 삽입할 수 있도록 할 수 있습니다.

    use std::collections::HashMap;

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

    scores.entry(String::from("Yellow")).or_insert(50);
    scores.entry(String::from("Blue")).or_insert(50);

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

이전 값을 기반으로 값 업데이트

흥미로운 부분입니다. 다음의 코드를 보시죠.

    use std::collections::HashMap;

    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에서 사용하는 해싱 함수는 가장 빠른 해싱 알로기즘은 아니지만 DoS 공격에 저항할 수 있는 해시 기능을 제공합니다. 이 외에 crates.io에 일반적인 해싱 알고리즘 관련 라이브러리를 확인할 수 있습니다.

정리

벡터, 문자열, 해시맵에 대해 알아보았습니다. 이 세가지 컬렉션은 Rust 언어 뿐만 아니라 대부분의 프로그래밍 언어에서 로직을 구현하기 위한 필수 컬렉션입니다. 본 장을 살펴보면서 Rust의 벡터와 문자열, 해시맵에 대해 익숙해졌기를 바랍니다.