해시 맵에서 키와 값을 함께 저장하기
해시 맵에서 키와 값을 함께 저장하기
이번에는 흔히 쓰는 컬렉션들 가운데 마지막인 **해시 맵(hash map)**을 살펴볼게요. 해시 맵은 HashMap<K, V> 타입으로, 어떤 K 타입의 키가 V 타입의 값을 가리키는 매핑을 저장해요. 보통 다른 언어에서는 hash나 map, object, hash table, dictionary, associative array 같은 이름으로 불리는 그 구조죠. 이름만 다를 뿐 아이디어는 거의 같다고 보시면 돼요.
출처: The Rust Book
유용한 이유
해시 맵은 벡터처럼 인덱스로 데이터를 찾는 대신, 어떤 타입이든 될 수 있는 키로 데이터를 찾고 싶을 때 유용해요. 예를 들어 게임에서 각 팀의 점수를 추적한다고 해볼게요. 팀 이름을 키로, 점수를 값으로 해시 맵에 저장하면, 팀 이름만 주어지면 바로 그 팀의 점수를 꺼낼 수 있어요.
이 절에서는 해시 맵의 기본 API만 다룰 거예요. 표준 라이브러리가 HashMap<K, V>에 정의해 둔 함수에는 더 많은 기능이 숨어 있으니, 궁금하면 언제나 표준 라이브러리 문서를 확인하세요.
새 해시 맵 만들기
빈 해시 맵을 만드는 한 가지 방법은 new를 쓰고, 요소를 추가할 때는 insert를 쓰는 거예요. 아래 리스팅 8-20에서는 Blue와 Yellow라는 이름의 두 팀 점수를 추적하고 있어요. Blue 팀은 10점, Yellow 팀은 50점으로 시작하죠.
fn main() {
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);
}
먼저 표준 라이브러리 컬렉션 부분에서 HashMap을 use로 가져와야 한다는 점을 눈여겨보세요. 세 가지 흔한 컬렉션 중에서 해시 맵이 가장 덜 쓰여서, 프렐루드(prelude)에서 자동으로 스코프 안으로 가져오는 항목에는 들어 있지 않아요. 게다가 표준 라이브러리의 지원도 상대적으로 적은데, 예컨대 해시 맵을 만들기 위한 매크로는 내장되어 있지 않아요.
벡터처럼 해시 맵도 데이터를 힙(heap)에 저장해요. 이 HashMap의 키는 String 타입이고 값은 i32 타입이네요. 이 역시 벡터와 마찬가지로 동질적이에요. 모든 키는 같은 타입이어야 하고, 모든 값도 같은 타입이어야 한다는 뜻이죠.
해시 맵에서 값 접근하기
해시 맵에서 값을 꺼낼 때는 그 값에 해당하는 키를 get 메서드에 넘겨주면 돼요. 리스팅 8-21을 볼게요.
fn main() {
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 팀과 연결된 값, 즉 10이 돼요. get 메서드는 Option<&V>를 반환하는데, 해시 맵에 그 키에 해당하는 값이 없으면 None을 돌려줘요. 이 프로그램은 copied를 호출해 Option<&i32> 대신 Option<i32>를 얻은 다음, scores에 그 키가 없을 때는 unwrap_or로 score를 0으로 만들어 Option을 처리하고 있어요.
벡터에서 했던 것과 비슷하게 for 루프로 해시 맵의 키-값 쌍을 하나씩 순회할 수도 있어요.
fn main() {
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
해시 맵에서의 소유권 관리
i32처럼 Copy 트레이트를 구현한 타입이라면 값이 해시 맵으로 복사돼요. String 같은 소유(owned) 값은 해시 맵으로 이동돼서, 해시 맵이 그 값의 소유자가 되죠. 리스팅 8-22가 이 동작을 보여줘요.
fn main() {
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);
// field_name and field_value are invalid at this point, try using them and
// see what compiler error you get!
}
insert 호출로 field_name과 field_value가 해시 맵으로 이동된 뒤에는 이 변수들을 더는 사용할 수 없어요.
만약 값에 대한 참조를 해시 맵에 넣는다면, 값 자체는 해시 맵으로 이동하지 않아요. 다만 그 참조가 가리키는 값은 해시 맵이 유효한 동안 최소한 그만큼은 유효해야 해요. 이런 문제는 10장의 "라이프타임으로 참조 검증하기"에서 더 자세히 다룰 거예요.
해시 맵 갱신하기
키-값 쌍의 개수는 늘어날 수 있지만, 각 고유 키는 한 번에 하나의 값만 연결할 수 있어요. (반대는 성립하지 않아요. 예를 들어 scores 해시 맵에는 Blue 팀과 Yellow 팀 둘 다 값 10을 가질 수 있죠.)
해시 맵의 데이터를 바꾸려면, 이미 키에 값이 할당되어 있는 경우를 어떻게 처리할지 결정해야 해요. 기존 값을 새 값으로 완전히 교체할 수도 있고, 기존 값을 유지하고 새 값을 무시할 수도 있어요. 이때는 키에 값이 아직 없을 때만 새 값을 추가하면 돼요. 아니면 기존 값과 새 값을 합칠 수도 있고요. 각각 어떻게 하는지 하나씩 볼게요.
값 덮어쓰기
키와 값을 해시 맵에 넣은 다음, 같은 키에 다른 값을 다시 넣으면 그 키에 연결된 값이 교체돼요. 리스팅 8-23의 코드는 insert를 두 번 호출하지만, 두 번 다 Blue 팀의 키에 값을 넣고 있어서 해시 맵에는 결국 하나의 키-값 쌍만 남아요.
fn main() {
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은 덮어써진 거죠.
키가 없을 때만 키와 값 추가하기
특정 키가 이미 해시 맵에 값과 함께 존재하는지 확인하고 나서 행동을 정하는 건 흔한 패턴이에요. 키가 이미 있으면 기존 값을 그대로 두고, 키가 없으면 그 키와 값을 새로 넣는 방식이죠.
해시 맵에는 이를 위한 전용 API인 entry가 있어요. entry는 확인하고 싶은 키를 매개변수로 받아요. 그리고 entry 메서드의 반환값은 Entry라는 이늄인데, 값이 있을 수도 있고 없을 수도 있는 상태를 나타내죠. Yellow 팀의 키에 값이 연결되어 있는지 확인하고, 없다면 값 50을 넣고 싶다고 해볼게요. Blue 팀도 마찬가지로요. entry API를 쓰면 리스팅 8-24처럼 코드를 작성할 수 있어요.
fn main() {
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:?}");
}
Entry의 or_insert 메서드는 이렇게 동작해요. 해당 Entry의 키가 이미 존재하면 그 키의 값에 대한 가변 참조를 반환하고, 존재하지 않으면 매개변수를 그 키의 새 값으로 넣고 새 값에 대한 가변 참조를 반환하죠. 이 방식은 같은 로직을 손으로 직접 쓰는 것보다 훨씬 깔끔하고, 게다가 빌림 검사기(borrow checker)와도 더 잘 어울려요.
리스팅 8-24를 실행하면 {"Yellow": 50, "Blue": 10}이 출력돼요. 첫 번째 entry 호출은 Yellow 팀에 값이 아직 없으므로 키를 값 50과 함께 넣고요, 두 번째 entry 호출은 Blue 팀에 이미 값 10이 있으므로 해시 맵을 바꾸지 않아요.
기존 값을 바탕으로 값 갱신하기
해시 맵의 또 다른 흔한 용법은 키의 값을 찾아서 기존 값을 바탕으로 갱신하는 거예요. 리스팅 8-25는 어떤 텍스트에 각 단어가 몇 번 나오는지를 세는 코드를 보여줘요. 단어를 키로 쓰는 해시 맵을 만들고, 그 단어를 볼 때마다 값을 증가시키는 방식이죠. 단어를 처음 볼 때는 먼저 값 0을 넣어요.
fn main() {
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:?}");
}
이 코드는 {"world": 2, "hello": 1, "wonderful": 1}을 출력해요. 키-값 쌍이 다른 순서로 출력되는 걸 볼 수도 있는데, "해시 맵에서 값 접근하기"에서 말했듯 해시 맵 순회는 임의의 순서로 일어나거든요.
split_whitespace 메서드는 text 값에서 공백으로 구분된 부분 문자열(subslice)들에 대한 반복자(iterator)를 반환해요. or_insert 메서드는 지정된 키에 대한 값의 가변 참조(&mut V)를 반환하죠. 여기서는 그 가변 참조를 count 변수에 저장했기 때문에 그 값에 대입하려면 먼저 별표(*)로 count를 역참조(dereference)해야 해요. 이 가변 참조는 for 루프가 끝나는 지점에서 스코프를 벗어나므로, 이런 변경들은 모두 안전하고 빌림 규칙에 따라 허용되는 거예요.
해시 함수
기본적으로 HashMap은 SipHash라는 해시 함수를 사용하는데, 해시 테이블과 관련된 서비스 거부(DoS, denial-of-service) 공격에 저항력을 제공해요. 1 이게 가장 빠른 해시 알고리즘은 아니지만, 성능이 다소 떨어지는 대신 보안이 좋아지는 이 트레이드오프는 충분히 가치 있어요. 코드를 프로파일링했더니 기본 해시 함수가 목적에 비해 너무 느리다면, 다른 hasher를 지정해 다른 함수로 바꿀 수 있어요. hasher는 BuildHasher 트레이트를 구현한 타입이에요. 트레이트와 그것을 구현하는 방법은 10장에서 다룰 거예요. hasher를 처음부터 직접 구현할 필요는 없어요. crates.io에는 다른 Rust 사용자들이 공유해 둔 라이브러리가 있어서, 흔한 해시 알고리즘을 구현한 hasher를 제공해 주거든요.
정리
벡터, 문자열, 해시 맵은 프로그램에서 데이터를 저장하고, 접근하고, 수정할 때 필요한 많은 기능을 제공해요. 이제 아래 연습문제들을 풀 수 있는 준비가 됐을 거예요.
- 정수 리스트가 주어졌을 때, 벡터를 사용해 그 리스트의 중앙값(정렬했을 때 중간 위치의 값)과 최빈값(가장 자주 나오는 값. 여기서 해시 맵이 유용해요)을 반환하기.
- 문자열을 피그 라틴(Pig Latin)으로 변환하기. 각 단어의 첫 자음을 단어 끝으로 옮기고
ay를 붙여요. 예를 들어first는irst-fay가 되죠. 모음으로 시작하는 단어는 대신 끝에hay를 붙이고요(apple은apple-hay). UTF-8 인코딩에 대한 세부 사항을 잊지 마세요! - 해시 맵과 벡터를 사용해, 사용자가 회사의 부서에 직원 이름을 추가할 수 있는 텍스트 인터페이스를 만들기. 예를 들어 "Add Sally to Engineering"이나 "Add Amir to Sales" 같은 형태로요. 그런 다음 사용자가 한 부서 안의 모든 사람 목록이나, 부서별로 구분된 회사의 모든 사람 목록을 알파벳순으로 꺼낼 수 있게 해요.
표준 라이브러리 API 문서에는 이 연습문제들을 푸는 데 도움이 될 벡터, 문자열, 해시 맵의 메서드들이 설명되어 있어요!
이제 우리는 연산이 실패할 수 있는 더 복잡한 프로그램으로 들어가고 있어요. 그만큼 **에러 처리(error handling)**를 다룰 완벽한 시점이기도 하고요. 다음 절에서 다룰게요.