#10 컬렉션

Pt J·2020년 8월 20일
0

[完] Rust Programming

목록 보기
12/41
post-thumbnail

이 시리즈는 Rust 공식문서를 통해 공부한 흔적임을 밝힙니다.

컬렉션은 하나의 자료형에 여러 개의 값을 포함한다.
이것은 튜플과 달리 힙 메모리에 저장되어 크기를 유동적으로 변화시킬 수 있다.
Rust에는 몇 가지 컬렉션이 존재하는데 각각의 특징이 있어 필요에 따라 선택적으로 사용한다.
범용적으로 사용되는 몇 가지 컬렉션을 알아볼텐데
추가적으로 더 다양한 컬렉션을 알아보고 싶다면 공식문서를 참고하도록 하자.

벡터 Vector

벡터는 동일한 자료형의 값들을 연속된 공간에 저장하는 컬렉션이다.
벡터 자료형은 Vec<T>로 표기하며 Vec의 연관함수 Vec::new를 통해 생성한다.
이 때, T에 해당하는 자료형이 무엇인지 알리기 위해 자료형을 명시해주어야 한다.

예를 들어, 다음과 같이 i32 값을 담는 벡터를 생성할 수 있다.

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

초기값을 갖는 벡터를 생성할 경우 자료형을 유추할 수 있기에 이를 명시하지 않아도 된다.
초기값을 갖는 벡터를 생성하고자 한다면 vec! 메크로를 사용할 수 있다.

예를 들어, 1, 2, 3을 갖는 Vec<i32> 벡터를 생성하고자 한다면 다음과 같이 작성할 수 있다.

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

벡터에 어떤 값을 추가하고자 한다면 push 메서드를 사용한다.
벡터이름.push(넣고싶은값);의 형태로 사용할 수 있다.

벡터는 그것이 정의된 범위를 벗어나면 자동으로 drop 메서드가 호출되며
그것이 가진 모든 값이 함께 메모리에서 해제된다.

벡터에서 값을 읽는 방법은 두 가지가 있다.
&벡터이름[인덱스]의 형태로 읽는 방법과 벡터이름.get(인덱스)의 형태로 읽는 것이다.
&벡터이름[인덱스]는 해당 값의 참조를 반환하며
만약 인덱스가 벡터의 범위를 넘었을 경우 오류가 발생한다.
벡터이름.get(인덱스)Option<&T>를 반환하며
인덱스가 벡터의 범위 내에 있다면 Some이, 그렇지 않다면 None이 반환된다.

우리는 지난 번에 가변 참조와 불변 참조는 공존할 수 없다는 걸 배웠는데
이로 인해 가변 벡터의 값을 참조하고 있을 땐 벡터에 값을 추가하는 등의 작업을 할 수 없다.
참조하는 값과 벡터에 수정이 이루어지는 부분이 다르더라도 제한되는데
이는 벡터가 값을 추가하다가 공간이 모자랄 경우 더 넓은 메모리 공간으로 이동하는데
그럴 경우 존재하던 참조가 잘못된 메모리를 가리키게 되기 때문이다.

벡터 순회

벡터가 가진 값에 순차적으로 접근하고자 한다면
각각의 값에 일일이 접근하는 것보다 for를 통해 순회하는 게 좋다.

peter@hp-laptop:~/rust-practice$ mkdir chapter08
peter@hp-laptop:~/rust-practice$ cd chapter08
peter@hp-laptop:~/rust-practice/chapter08$ cargo new vector_iterate
     Created binary (application) `vector_iterate` package
peter@hp-laptop:~/rust-practice/chapter08$ cd vector_iterate/
peter@hp-laptop:~/rust-practice/chapter08/vector_iterate$ vi src/main.rs

src/main.rs

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

    for i in &v {
        println!("{}", i);
    }
}
peter@hp-laptop:~/rust-practice/chapter08/vector_iterate$ cargo run
   Compiling vector_iterate v0.1.0 (/home/peter/rust-practice/chapter08/vector_iterate)
    Finished dev [unoptimized + debuginfo] target(s) in 0.24s
     Running `target/debug/vector_iterate`
1
2
3
4
5
peter@hp-laptop:~/rust-practice/chapter08/vector_iterate$ 

가변성을 가진 채 순회하고 싶다면 for i in &mut v로 순회하면 된다.

서로 다른 자료형?

벡터는 동일한 자료형의 값들을 저장한다고 했다.
그런데 정말 서로 다른 자료형의 값들을 벡터에 저장하는 방법은 없는걸까?

아주 없는 것은 아니다. 우리에겐 열거형이 있다.
열것값들은 서로 다른 자료형을 담고 있을 수 있지만 같은 자료형으로 취급된다.
이걸 사용하면 모든 자료형을 담진 못하더라도 정의된 몇 가지 자료형을 담을 수 있다.
물론 사용할 땐 match 표현식을 통해 적절한 처리를 해주어야 한다.

벡터에 대해 더 자세히 알고 싶다면 공식 API 문서를 참고하도록 하자.

문자열 String

문자열은 바이트의 컬렉션으로 구현되어 있으며 문자를 UTF-8 형식으로 저장한다.

String은 Rust 언어 명세에서 정의된 게 아니라 표준 라이브러리가 제공하는 자료형이다.
Rust 언어 명세에는 문자열 슬라이스 str만 정의되어 있는데
이것은 보통 값을 대여한 &str의 형태로 사용된다.
슬라이스 타입에 대해 다룰 때도 이야기했지만
문자열 리터럴은 바이너리 실행 파일의 일련의 바이트에 대한 참조다.
이것은 UTF-8 형식으로 인코딩되어 있는 바이트로, 사용할 땐 디코딩하여 읽어온다.
UTF-8 형식으로 인코딩되는 것은 String도 마찬가지다.

Rust의 표준 라이브러리에는 String 외에도
OsString, OsStr, CString, CStr 등의 자료형이 존재한다.
String으로 끝나는 녀석들은 값을 소유하고 Str로 끝나는 녀석들은 값을 대여한다.
이 녀석들에 대해서는 자세히 설명하지 않겠다.

문자열도 벡터처럼 String의 연관함수 String::new를 통해 빈 문자열을 생성할 수 있다.
그리고 Display 크레이트를 구현한 자료형의 메서드 to_stirng을 통해
초기값이 설정된 문자열을 생성할 수도 있다.
혹은 String의 연관함수 String::from을 사용할 수도 있는데 둘은 동일한 기능을 수행한다.

문자열은 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를 사용할 수 있다.
전자는 문자열 단위로 덧붙이며 후자는 문자 단위로 덧붙인다.
혹은, + 연산자를 이용할 수도 있는데 이것은
fn add(self, s:&str) -> String와 비슷한 형태로 작성되어
첫번째 문자열은 소유권을 결과 문자열에게 넘기고 두번째 문자열은 대여하여 사용한다.
이 때, 두번째 문자열은 &str이지만 &String을 전달해도 컴파일러가 이를 변환해준다.
&문자열&문자열[..]로 변환하는 것이다.

보다 복잡한 문자열의 결합에는 format! 매크로를 사용할 수 있다.
이것은 println!처럼 {}이 포함된 문자열과 그것에 들어갈 값으로 구성된다.

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

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

문자열의 인덱스

대부분의 프로그래밍 언어에서는 인덱스를 통해 문자열의 특정 문자에 접근할 수 있다.
그러나 Rust에서는 이를 시도하면 오류를 만날 수 있다.
그렇다, Rust는 인덱스를 통해 문자열의 특정 문자에 접근하는 걸 지원하지 않는다.

문자열은 내부적으로 Vec<u8>를 한 번 감싼 형태의 자료형이다.
각각의 u8 값은 1바이트를 의미하며, 그것의 조합으로 하나의 문자가 표현된다.
그런데 인덱스를 통해 값을 받아올 경우 조합된 문자가 아니라 1바이트를 가져오게 된다.
이것만으로는 유의미한 값이 아닐 확률이 높다.
따라서 Rust의 문자열은 이런 식의 접근을 허용하지 않는다.

유니코드 스칼라 값을 단위로 하여 접근하도록 하면 되지 않겠냐는 의문이 들 수 있다.
그러나 유효한 문자를 파악하기 위한 스캔으로 인해 일정한 성능을 보장할 수 없게 된다.

문자열 슬라이스를 이용하여 적절한 범위의 바이트를 읽으면 문자를 뽑아낼 수 있지만
하나의 문자 중간에 있는 인덱스로 범위를 잘못 지정할 경우
오류가 날 수 있다는 점을 주의해야 한다.

문자열 순회

문자열을 문자 단위로 사용하고 싶다면 순회를 통해 각각의 문자에 접근하는 방법이 있다.
String 자료형의 chars 메소드를 사용하면 개별 문자를 순회할 수 있다.
앞서 공식 문서에서 예시로 들었던 여러 hello들 중 임의로 하나를 골라 순회해보자.

peter@hp-laptop:~/rust-practice/chapter08/vector_iterate$ cd ..
peter@hp-laptop:~/rust-practice/chapter08$ cargo new string_iterate
     Created binary (application) `string_iterate` package
peter@hp-laptop:~/rust-practice/chapter08$ cd string_iterate/
peter@hp-laptop:~/rust-practice/chapter08/string_iterate$ vi src/main.rs

src/main.rs

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

    for c in hello.chars() {
        println!("{}", c);
    }
}
peter@hp-laptop:~/rust-practice/chapter08/string_iterate$ cargo run
   Compiling string_iterate v0.1.0 (/home/peter/rust-practice/chapter08/string_iterate)
    Finished dev [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/string_iterate`
З
д
р
а
в
с
т
в
у
й
т
е
peter@hp-laptop:~/rust-practice/chapter08/string_iterate$ 

Rust의 문자열은 이렇게 다루기 좀 복잡하긴 하지만
ASCII로 표현하는 것보다 훨씬 다양한 문자의 표현이 가능하다는 장점이 있다.

해시 맵 Hash Map

해시 맵은 Key와 Value로 구성된 쌍을 가지는 컬렉션이다.
HashMap<K, V>로 표기하며, K 자료형의 값에 V 자료형의 값을 매핑하여 저장한다.
어떻게 저장할지 결정하는 과정에 해시 함수가 사용된다.

벡터, 문자열에 비해 해시 맵은 사용 빈도가 낮고 표준 라이브러리의 지원도 낮아
해시 맵은 Prelude에 포함되어 있지 않다.
즉, 해시 맵을 사용하기 위해서는 use 키워드를 통해 현재 범위로 가져와야 한다.

use std::collections::HashMap;

해시 맵은 인덱스가 아닌 Key를 통해 자료를 읽어온다.
Key는 어떤 자료형이든 상관 없지만
하나의 해시 맵은 동일한 자료형의 Key와 동일한 자료형의 Value를 사용한다.
물론 Key와 Value의 자료형은 서로 다를 수 있다.

해시 맵의 선언도 다른 컬렉션과 마찬가지로 HashMap::new 연관함수를 사용한다.
그리고 insert 메서드를 통해 값을 넣을 수 있는데 순서대로 Key, Value가 인자로 전달된다.

해시 맵에 여러 개의 자료를 넣고 싶다면 insert 보다
벡터와 collect 메소드를 사용하는 게 나을 수 있다.
Key를 저장하는 벡터와 Value를 저장하는 벡터를 각각 만들어
Key벡터.iter().zip(Value벡터.iter()).connect()를 통해 해시 맵으로 변환할 수 있다.
반환값이 HashMap임을 명시해주어야 하는데
Key와 Value의 자료형은 벡터의 자료형을 통해 유추할 수 있으므로
HashMap<_,_>와 같이 작성하는 것으로 충분하다.

peter@hp-laptop:~/rust-practice/chapter08/string_iterate$ cd ..
peter@hp-laptop:~/rust-practice/chapter08$ cargo new hashmap_connect
     Created binary (application) `hashmap_connect` package
peter@hp-laptop:~/rust-practice/chapter08$ cd hashmap_collect/
peter@hp-laptop:~/rust-practice/chapter08/hashmap_collect$ vi src/main.rs

src/main.rs

use std:: collections::HashMap;

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

    let mut score: HashMap<_, _> =
        teams.iter().zip(initial_scores.iter()).collect();
}

해시 맵이 가진 자료에 접근하기 위해서는 get 메서드를 사용하는데
해시맵이름.get(Key이름)을 통해 Option<&V>를 반환 받을 수 있다.
이것은 해당 Key의 존재 유무에 따라 Some 또는 None을 반환한다.

소유권

해시 맵은 Copy와 Move로 값을 가져 온다.
즉, i32를 비롯하여 Copy 트레이트를 구현하는 자료형의 경우 Copy하지만
String을 비롯하여 그렇지 않은 자료형의 경우 Move로 소유권을 가져 온다.
따라서 소유권이 이전되는 자료의 경우 해시 맵에 넣은 후에 원래 변수를 접근할 수 없다.

해시 맵 순회

해시 맵은 벡터에서처럼 for를 통해 순회할 수 있는데
Key와 Value 쌍을 임의의 순서로 접근한다.

앞서 작성한 collect 예제에 순회를 통한 출력을 추가해보자.

peter@hp-laptop:~/rust-practice/chapter08/hashmap_collect$ vi src/main.rs

src/main.rs

use std:: collections::HashMap;

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

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

    for (t, s) in &score {
        println!("{}: {}", t, s);
    }
}

해시 맵 수정

해시 맵은 앞서 살펴본 컬렉션들과 달리 수정할 때 고려사항이 있다.
기존에 존재하는 Key와 같은 이름을 가진 자료를 넣으려고 하면 어떻게 할 것인가.
이미 있는 이름이라고 거절해야 하나? 기존의 자료를 덮어 씌워야 하나?
Rust는 두 가지 모두를 지원한다.
그리고 추가적으로, 기존의 값에 변형을 가해 저장하는 것도 지원한다.

먼저, 기존의 자료를 덮어씌우고자 한다면 그냥 insert 메서드를 사용하면 된다.
값을 추가할 때와 같이 insert 메서드를 사용하면 가장 최근에 넣은 것으로 덮어 씌워진다.

그리고 자료가 존재하지 않을 때만 추가하고 싶다면
해시맵이름.entry(Key이름).or_insert(Value이름)을 통해 추가한다.
이것은 자료가 존재할 경우에는 아무것도 하지 않는다.
이것은 Value의 가변 참조를 반환하는데
반환값 앞에 *을 붙여 수정을 가하면 수정된 값을 저장할 수 있다.
이를 통해 자료가 존재하지 않으면 인자로 전달한 값으로 저장하고
기존에 존재했는지 여부와 상관없이 이제는 존재하는 Value값에 변형을 가할 수 있다.

해시 함수

Rust는 기본적으로 SipHash라는 암호학적으로 강력한 해시 함수를 사용한다.
해시 함수는 BuildHasher 트레이트를 구현하는 다른 함수로 대체할 수 있다.
이것은 https://crates.io 에서 찾아볼 수 있을 것이다.
SipHash에 대해 알고 싶다면 공식 문서가 제공하는 자료를 참고하자.

이 포스트의 내용은 공식문서의 8장 Common Collections에 해당합니다.

profile
Peter J Online Space - since July 2020

0개의 댓글