비욘드 JS: 러스트 - collections

dante Yoon·2022년 12월 25일
0

beyond js

목록 보기
8/20
post-thumbnail

글을 작성하며

javascript를 사용하며 가장 많이 사용하는 데이터 구조는 object literal, map, set, array 입니다. 그리고 object literal을 array로 바꾸거나 map을 Iteration으로 바꾸거나 하는 작업을 매우 능숙하게 할 수 있다면 데이터를 다루는데 있어 큰 어려움을 겪지 않습니다.

오늘 배울 러스트의 collections는 standard library에 포함된 데이터 구조들입니다.

공식문서의 Module collections

Vec

벡터는 연속적으로 같은 타입의 값을 저장할 수 있는 콜렉션 타입입니다.

pub struct Vec<T, A = Global>
where
    A: Allocator,
{ /* private fields */ }

벡터 타입을 선언할 때 초기 값을 함께 사용하지 않으면 type annotation 을 사용해 :Vec<32>와 같이 어떤 타입의 값을 벡터가 가지고 있을지 러스트 컴파일러에게 알려줘야 합니다.

use std::vec::Vec;

fn main() {
    let v: Vec<i32> = Vec::new();
}

러스트 컴파일러에게 타입을 추론하게 할 수도 있습니다. 다음 벡터 타입 선언을 보세요.

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

i32 타입의 값들을 이용해 패턴 바인딩 했기 때문에 러스트는 v가 Vec<i32>라는 사실을 잘 추론할 수 있습니다.

reading, update, remove

벡터 타입 v를 업데이트 하려면 mut 키워드를 사용해 변수를 선언한 뒤 push 메소드를 사용해야 합니다.

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

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

아래 코드에서는 get 메소드를 이용해 벡터의 세번째 엘리먼트의 값을 가져왔습니다.
&v[2]를 보면 &[] 문법을 통해 엘리먼트의 reference를 가져올 수 있다는 사실을 알 수 있습니다.

코드를 한번 읽어보신 후에 get 메소드에 대해 이야기해보겠습니다.

use std::vec::Vec;

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

    println!("the third element is {}", third);

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

get 메소드를 사용하면 Option<&T> 타입을 얻을 수 있고 패턴 매칭을 이용할 수 있습니다.

러스트에서 인덱싱 참조와 get 메소드 두 가지 방법을 제공하는 이유는 범위 외부의
값을 조회할 때 프로그램이 어떻게 행동할지에 대한 선택지를 주기 위해서입니다.

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

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

does_not_exist 와 같이 값을 참조할 시에 panic이 발생합니다.

thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 100', src/main.rs:6:27

get 메소드를 사용해 out of bound 참조를 하게 되면 None 을 리턴 하고 panic이 발생하지 않습니다.

사용자에게 값을 입력받아 벡터 엘리먼트를 참조한다고 했을 때 index 참조를 사용하게 되면 프로그램에 에러가 발생할 수 있겠죠?

값을 삭제하기 위해서는 remove, 없앤 값을 expression으로 사용하기 위해서는 swap_remove를 사용합니다.

두 메소드 모두 벡터의 마지막 엘리먼트를 지울 때는 O(1) 시간복잡도를, 다른 위치의 엘리먼트를 삭제할 때는 O(n)의 시간복잡도를 가집니다.

또한 여러 개의 값을 삭제하기 위해서는 drain 메소드를 사용합니다.

fn main() {
    let mut numbers = vec![1,2,3,4,5];
    numbers.remove(1);
    assert_eq!(numbers, [1,3,4,5]);
    let removed_element = numbers.swap_remove(1);
    let removed_elements: Vec<i32> = numbers.drain(1..3).collect();
    println!("{}", removed_element);
}

Vector 순회하기

아래 코드에서 for loop를 사용해 i32 타입의 엘리먼트의 immutable reference를 가져오고 있습니다.

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

다음과 같이 mutable vector를 순회하며 모든 element를 변경할 수도 있습니다.
아래에서 사용한 코드의 *는 dereference operator로 += 연산을 하기 전 i의 value를 가져오기 위함입니다.

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

백터에 다른 타입의 값을 저장할 수는 없을까

벡터에 항상 같은 타입의 값을 저장해야 한다는 사실은 벡터를 다루는데 불편한 점을 가져다줍니다. 우리는 이것을 enum을 사용함으로 해결할 수 있습니다.

fn main() {
    enum SpreadsheetCell {
        Int(i32),
        Float(f64),
        Text(String)
    }

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

Strings

가능한 한 많은 오류를 노춣하려는 러스트의 경향, 다른 프로그래밍 언어에 비해 복잡한 데이터 구조, UTF-8은 러스트를 처음 공부하는 많은 사람들이 String을 어려워하는 이유입니다.

String은 byte들의 collection이며 각 바이트가 text로 해석되었을 때 함께 사용할 수 있는 유용한 기능들을 제공하는 타입입니다.

String이 무엇인가?

러스트에서 String literal ("hello world"와 같은)는 string slice 타입, 즉 &str 를 의미하며 러스트에서 작성하는 문자열은 모두 &str 입니다. 그리고 read only하고 immutable 합니다.

String 타입은 러스트의 standard library에 의해 제공되며 mutable하고 growable 합니다. 그리고 UTF-8 encoded 한 값입니다.

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

let mut hello = String::new();
hello.push_str("he");
hello.push('l');
hello.push('l');
hello.push('o');

create a string

아래에서 String::from()to_string 메소드는 동일한 역할을 합니다.

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

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

string은 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");

update

mutable한 String 타입을 이용합니다.
String 타입은 Vec<T> 벡터 타입과 동일하게 사이즈가 늘어날 수 있고 값이 변경될 수 있습니다.

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

또환 +, format! macro를 사용해 String 값을 붙일 수있습니다.

let s1 = String::from("hello");
let s2 = String::from("world");

// Concatenate two strings using the + operator
let s3 = s1 + &s2;

// Use the format! macro to concatenate strings
let s4 = format!("{}-{}", s1, s2);

+ operator는 add 메소드를 사용합니다.
이 메소드의 시그니처는 다음과 유사합니다.

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

standard library에서 add 메소드는 generic,그리고 associated type을 사용합니다.

associated type

생각이 안날 수 있어 빨리 정리해보겠습니다.

associated type은 trait 선언 시 사용하는 타입 플레이스 홀더입니다.

trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

실제 Iterator를 구현할 때 아래와 같이 Item 타입을 i32로 구체화 시켜 선언했습니다.

struct MyIterator {
    // implementation details go here
}

impl Iterator for MyIterator {
    type Item = i32;

    fn next(&mut self) -> Option<i32> {
        // implementation goes here
    }
}

s2&를 사용했는데요, second string의 reference를 first string에 더한다는 뜻입니다. add 함수의 s argument는 &str 이기 때문에 String value를 넘기는게 아니라 &str를 넘겨야 합니다. 근데 &s2는 &String인데 어떻게 이 연산이 가능할까요?

근데 왜 &str이 아닌 &String을 넘길 수 있을까요?

argument 타입이 &str임에도 불구하고 &String 타입을 넘길 수 있는 이유는 add 함수를 호출할 때 러스트 컴파일러는 deref coercion을 사용하기 때문입니다.

deref coercion

deref coercion은 reference와 smart pointer 간의 자동 형변환을 의미하며 러스트 고유의 언어 특성입니다.
특정 값이 reference나 smart pointer이고 이 값을 특정 함수의 reference / smart pointer 타입의 argument로 넘겨야 할때 이러한 자동 형변환이 가능하게 됩니다.

deref coercion은 Deref trait의 deref 메소드를 자동으로 호출해 값을 reference로 변경하거나 DerefMut trait의 deref_mut 메소드를 호출해 값을 mutable reference로 변경합니다.

다음 예제는 Box<T> 값을 함수의 &T reference로 넘기는 것을 보여줍니다.

use std::ops::Deref;

fn foo(x: &i32) {
    // implementation goes here
}

let x = Box::new(5);
foo(&x);

러스트 컴파일러는 자동으로 Deref::deref 메소드에 Box<i32> 값을 사용해 &i32 reference 타입으로 자동 형변환합니다.

아래 예제는 mutable references와 smart pointer간의 형변환을 보여줍니다.

use std::ops::{Deref, DerefMut};

fn bar(x: &mut i32) {
    // implementation goes here
}

let mut x = Box::new(5);
bar(&mut x);

&mut x 는 Box<i32> 를 가르키는데, 함수 bar의 x argument 타입은 &mut i32 타입이 들어오는 것을 기대하고 있습니다. 러스트 컴파일러는 DerefMut::deref_mut 메소드를 사용해 Box<i32>를 &mut i32 reference로 변경합니다.

String 타입 indexing

String 타입을 indexing하는 것은 불가능합니다.

   let s1 = String::from("hello");
   let h = s1[0];
$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `String` cannot be indexed by `{integer}`
 --> src/main.rs:3:13
  |
3 |     let h = s1[0];
  |             ^^^^^ `String` cannot be indexed by `{integer}`
  |
  = help: the trait `Index<{integer}>` is not implemented for `String`
  = help: the following other types implement trait `Index<Idx>`:
            <String as Index<RangeFrom<usize>>>
            <String as Index<RangeFull>>
            <String as Index<RangeInclusive<usize>>>
            <String as Index<RangeTo<usize>>>
            <String as Index<RangeToInclusive<usize>>>
            <String as Index<std::ops::Range<usize>>>
            <str as Index<I>>

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` due to previous error

왜 indexing이 불가능할까?

String 타입은 내부적으로 Vec<u8> 타입으로 wrapping 되어 있습니다.

아래 경우에서 "Hola"는 4 바이트 이기 때문에 len은 4입니다.

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

하지만 아래에서 글자는 12자임에도 불구하고 Rust는 아래의 len을 24라고 해석합니다.
왜냐하면 "Здравствуйте"를 UTF-8로 인코딩 했을 때의 총 바이트 수가 24이기 때문입니다. 각 unicode scalar 값이 각 글자 당 2바이트씩 차지하기 때문이죠. 따라서 string의 인덱싱이 유효한 UTF-8 인코딩 값을 가르킨다고 보장할 수 없습니다.

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

다음 코드에서 answer은 3이 아닙니다. 글자가 UTF-8로 인코딩 되었을 떄 3의 첫 바이트는 200이며 두번쨰 바이트는 151입니다. 따라서 answer은 200이어야 하나 그자체로 유효한 문자열이 아닙니다.

let hello = "Здравствуйте";
let answer = &hello[0];

러스트 컴파일러는 이러한 상황을 사전에 방지해 버그를 예방하게 하기 위해 이런 인덱싱을 허용하지 않습니다.

String 정리하기

String 타입은 mutable하고 string slice인 &str은 immutable합니다.
strint slice를 변경하고 싶으면 String 타입으로 먼저 변경하거나 (to_string) 새로운 String 값을 선언해서 사용해야 합니다.

다음 코드를 보면 string 타입을 순회하는 것을 볼 수 있습니다.

// Create a new, empty String
let mut s = String::new();

// Create a String from a string slice
let s1 = String::from("hello");

// Concatenate two strings using the + operator
let s2 = String::from("world");
let s3 = s1 + &s2;

// Use the format! macro to concatenate strings
let s4 = format!("{}-{}", s1, s2);

// Push characters onto an existing String
s.push('h');
s.push_str("ello");

// Access the characters in a String using indexing
let c = s[0];

// Get a string slice from a String using slicing syntax
let slice = &s[0..2];

// Iterate over the characters in a String
for c in s.chars() {
    println!("{}", c);
}

for b in "Зд".bytes() {
    println!("{b}");
    // 208
    // 151
    // 208
    // 180
}

보시듯이 러스트의 String 타입은 생각보다 간단하지 않습니다. 다른 언어와 다르게 러스트 컴파일러는 String 데이터를 다룸으로 일어날 수 있는 가능한 버그와 에러를 최소화 하기 위해 String 데이터를 다루는데 있어 복잡한 규칙을 추가했습니다 .

Hash Maps

마지막 콜렉션 타입은 해쉬맵입니다.
HashMap<K ,V> 은 키 타입 K와 값 타입 V를 매핑한 것으로 hashing function을 사용합니다. 타입스크립트의 Record<K,V와 유사한 형태죠.

creating new Hash Map

use std::collections::HashMap;

let mut scores = HashMap::new();

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

해쉬 맵은 Vector 타입과 다르게 간편하게 사용할 수 있는 매크로가 제공되지 않습니다.
다만 벡터와 마찬가지로 heap에 데이터가 저장됩니다. 위의 예제에서 해쉬맵은 String 타입 키와 i32 타입의 값으로 이뤄져 있습니다. 벡터와 마찬가지로 동일 타입의 키/값만 가질 수 있습니다.

accessing Values

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().unwrap_or(0);

위의 코드에서 score는 Blue 팀에 대한 스코어 값을 가질 수 있습니다. get 메소드는 Option<&V> 타입이므로 조회하는 키 값이 HashMap에 존재하지 않는다면 None을 리턴합니다.

.copied() 메소드 호출을 통해 Option<&i32> 대신 Option<i32> 값을 가져올 수 있고 unwrap_or 메소드 호출을 통해 값이 없으면 0이라는 값을 패턴바인딩했습니다.

각 키/밸류를 다음 처럼 순회할 수도 있습니다.

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);
}
// Yellow: 50
// Blue: 10

Ownership

Copy trait을 구현한 i32와 같은 타입에 대해서 해쉬맵에 매핑되는 각 값들은 복사되어 해쉬맵에 매핑됩니다. String 값과 같이 힙에 저장되고 누군가에게 소유되는 값들 Owned values copy가 아니라 move되어 해쉬맵이 해당 String 값에 대한 오너쉽을 가집니다.

use std::collections::HashMap;

fn main() {

   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!("String: {}", field_name);
}

field_name이 moved 된 이후에 borrowed 되었기 때문에 위 코드에서의 println! 매크로 문은 러스트 컴파일러에 의해 에러로 표기 됩니다.


error[E0382]: borrow of moved value: `field_name`
  --> src/main.rs:10:28
   |
4  |     let field_name = String::from("Favorite color");
   |         ---------- move occurs because `field_name` has type `String`, which does not implement the `Copy` trait
...
8  |     map.insert(field_name, field_value);
   |                ---------- value moved here
9  |
10 |     println!("String: {}", field_name);
   |                            ^^^^^^^^^^ value borrowed here after move
   |
   = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0382`.

update

해쉬맵에는 중복 키 값을 허용하지 않으므로 특정 키에 대한 write 연산이 시도될 때 기존 값을 덮어씌울지 아니면 새로운 value를 무시할지 정의해야 합니다.

update - overwriting

중복 키값에 대한 insert 메소드를 여러 번 호출하면 다음 처럼 해당 키 값이 덮어씌워집니다.

use std::collections::HashMap;

let mut scores = HashMap::new();

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

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

위의 예제에서 코드는 {"Blue": 25}로 출력됩니다.
값 10이 25로 덮어씌여진 것입니다.

update - ignore

러스트의 해쉬맵은 entry라고 하는 특별한 api를 제공합니다. 키 값을 검사하여 해당 값이 이미 해쉬맵에 존재하는지 아닌지를 Entry 타입으로 표현하는 메소드입니다.

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);
// {"Blue": 10, "Yellow": 50}

or_insert 메소드의 리턴 타입인 Entry 타입은 mutable한 reference 이며 Entry key가 존재하는지 안하는지에 대한 값을 가지고 있습니다.

다음 코드는 특정 조건에 따라 값을 업데이트 하는 로직을 보여줍니다.

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

or_insert 메소드는 mutable reference &mut V 타입을 리턴하며 해당 reference에 값을 할당하기 위해서는 dereference *를 먼저 사용해야 합니다.

Hashing Functions

해쉬 맵은 별도 설정 없이 기본적으로 SipHash라는 DDos 공격을 방지할 수 있는 함수를 사용합니다.

use std::collections::HashMap;
use std::hash::BuildHasherDefault;
use std::hash::SipHasher;

fn main() {
    // Create a new HashMap with a custom hasher
    let mut map: HashMap<&str, i32, BuildHasherDefault<SipHasher>> = Default::default();

    // Insert some key-value pairs into the map
    map.insert("red", 1);
    map.insert("green", 2);
    map.insert("blue", 3);

    // Look up the value associated with a key
    let color = map.get("red");
    println!("The value for red is {}", color.unwrap());
    // The value for red is 1
}

글을 마무리하며

오늘은 Vector, String, HashMap 타입에 대해 알아보았습니다. 수고 많으셨습니다!

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글