Rust - Collections
Rust는 collection이라고 불리는 유용한 자료 구조형을 제공하고 있어요. C++의 STL, Python의 List, Dict 등과 같은 자료 구조들을 생각하시면 됩니다.
Rust 맛보기
- 1. Rust - 입문하기
- 2. Rust - 타입 모음
- 3. Rust - Memory Ownership
- 4. Rust - Control Flow
- 5. Rust - Structured Data Types
- 6. Rust - Project Organization
- 7. Rust - Error Handling
- 8. Rust - Collections
- 9. Rust - Generics
- 10. Rust - Test Automation
- 11. Rust - Functional Programming
- 12. Rust - Memory Management
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의 특성을 갖는 문자열 타입이죠. String
과 str
은 모두 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
self
와 s
는 각각 첫 번째 스트링과 두 번째 스트링을 의미합니다. 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
인덱싱하기
String
은 Vec<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
반복문을 사용할 수 있습니다. 이때 key
와 value
를 모두 가져올 수 있습니다.
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);
}
참고 문헌
-
고려대학교 컴퓨터학과 오상은 교수님의 시스템 프로그래밍(COSE322) 과목 강의자료
Rust 맛보기