해시 맵으로 키와 값을 연결하기
우리가 자주 쓰는 컬렉션의 마지막은 해시 맵입니다. HashMap<K, V> 타입은 해싱
함수(hashing function) 를 사용해, 타입 K 의 키를 타입 V 의 값에 대응시켜
저장합니다. 이 해싱 함수는 키와 값을 메모리 안에 어떻게 배치할지 결정합니다. 많은
프로그래밍 언어가 이런 자료구조를 지원하지만, hash, map, object, hash
table, dictionary, associative array 등 서로 다른 이름을 쓰기도 합니다.
해시 맵은 벡터처럼 인덱스로 데이터를 찾는 대신, 어떤 타입이든 될 수 있는 키를 통해 데이터를 찾고 싶을 때 유용합니다. 예를 들어 게임에서 각 팀의 점수를 추적할 때, 해시 맵의 키를 팀 이름으로, 값은 그 팀의 점수로 둘 수 있습니다. 특정 팀 이름을 주면 점수를 얻을 수 있습니다.
이 절에서는 해시 맵의 기본 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로 자동 스코프에 들어오지 않습니다. 또한 해시 맵은 표준 라이브러리에서
다른 두 컬렉션보다 직접적인 지원이 적어서, 예를 들어 벡터처럼 전용 매크로가
제공되지는 않습니다.
벡터와 마찬가지로 해시 맵도 데이터를 힙에 저장합니다. 이 HashMap 은 키 타입이
String, 값 타입이 i32 입니다. 벡터처럼 해시 맵도 동질적입니다. 즉 모든 키는
같은 타입이어야 하고, 모든 값도 같은 타입이어야 합니다.
해시 맵 값에 접근하기
해시 맵에서 값을 꺼내려면 목록 8-21처럼 get 메서드에 키를 넘기면 됩니다.
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> 를 반환합니다. 만약 해시 맵 안에 그 키에 대응하는 값이 없다면 get
은 None 을 반환합니다. 이 프로그램은 copied 를 호출해 Option<&i32> 대신
Option<i32> 를 얻고, 이어서 unwrap_or 로 키에 해당하는 항목이 없을 경우
score 를 0으로 설정합니다.
해시 맵의 각 키-값 쌍도 벡터와 비슷하게 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 같은 소유 타입 값은 이동되며, 해시 맵이 그 값의 소유자가 됩니다.
목록 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!
}
field_name 과 field_value 변수는 insert 호출로 해시 맵 안으로 이동되었기
때문에, 그 뒤에는 더 이상 사용할 수 없습니다.
만약 해시 맵에 값 자체가 아니라 참조를 넣는다면, 값은 해시 맵 안으로 이동되지 않습니다. 다만 그 참조가 가리키는 값은 적어도 해시 맵이 유효한 동안 계속 유효해야 합니다. 이 문제는 10장의 [“라이프타임으로 참조의 유효성 검증하기”] validating-references-with-lifetimes 절에서 더 이야기합니다.
해시 맵 갱신하기
키-값 쌍의 수는 늘어나거나 줄어들 수 있지만, 하나의 고유한 키에는 어떤 순간에도
오직 하나의 값만 연결될 수 있습니다(그 반대는 아닙니다. 예를 들어 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 은 덮어써졌습니다.
키가 없을 때만 값 추가하기
특정 키에 값이 이미 있는지 먼저 검사한 뒤, 존재할 때와 없을 때 다르게 처리하는 일도 흔합니다. 예를 들어 키가 이미 있으면 기존 값을 그대로 유지하고, 키가 없으면 그때만 새 키와 값을 추가하고 싶을 수 있습니다.
이를 위해 해시 맵은 entry 라는 특수 API를 제공합니다. entry 는 확인하고 싶은
키를 인수로 받습니다. 그리고 Entry 라는 enum 값을 반환하는데, 이것은 그 위치에
값이 있을 수도 있고 없을 수도 있음을 나타냅니다. 예를 들어 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 메서드 사용하기Entry 에 정의된 or_insert 메서드는, 해당 키가 이미 있다면 그 값에 대한 가변
참조를 반환하고, 없다면 인수로 받은 값을 새 값으로 삽입한 뒤 그 새 값에 대한 가변
참조를 반환하도록 정의되어 있습니다. 이 방식은 우리가 직접 조건 로직을 쓰는 것보다
훨씬 깔끔하며, 대여 검사기와도 더 잘 맞습니다.
목록 8-24의 코드를 실행하면 {"Yellow": 50, "Blue": 10} 이 출력됩니다. 첫 번째
entry 호출은 Yellow 팀이 아직 값을 갖고 있지 않기 때문에 키와 값 50 을 넣습니다.
두 번째 호출은 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 안의 값을 공백 기준으로 나눈 하위 슬라이스에 대한
반복자를 반환합니다. or_insert 메서드는 지정한 키에 대한 값의 가변 참조(&mut V)
를 반환합니다. 여기서는 그 가변 참조를 count 변수에 저장합니다. 따라서 그 값에
대입하려면 먼저 별표(*)로 count 를 역참조해야 합니다. 이 가변 참조는 for
루프 한 반복이 끝나면 스코프를 벗어나므로, 이런 변경은 모두 대여 규칙상 안전하고
허용됩니다.
해싱 함수
기본적으로 HashMap 은 SipHash 라는 해싱 함수를 사용합니다. 이 함수는 해시 테이블을
이용한 서비스 거부(DoS) 공격에 대한 저항성을 제공합니다1.
이것이 사용 가능한 가장 빠른 해싱 알고리즘은 아니지만, 성능이 조금 떨어지는 대신
보안을 얻는다는 점에서 대체로 합리적인 선택입니다. 만약 코드를 프로파일링해 보았더니
기본 해시 함수가 너무 느리다면, 다른 hasher를 지정해 다른 함수로 바꿀 수 있습니다.
Hasher 는 BuildHasher 트레이트를 구현한 타입입니다. 트레이트와 그 구현 방법은
10장에서 다룹니다. 직접 처음부터 hasher를 구현할 필요는
없고, crates.io에는 다양한 흔한 해싱 알고리즘을
구현한 hasher를 제공하는 라이브러리들이 공유되어 있습니다.
정리
벡터, 문자열, 해시 맵은 데이터를 저장하고, 접근하고, 수정해야 할 때 프로그램에서 필요한 기능의 큰 부분을 담당합니다. 이제 여러분은 다음과 같은 문제를 충분히 풀 수 있어야 합니다.
- 정수 리스트가 주어졌을 때, 벡터를 사용해 리스트의 median(정렬했을 때 가운데 값)과 mode(가장 자주 나타나는 값. 해시 맵이 도움이 됩니다)를 구해 보세요.
- 문자열을 Pig Latin 으로 변환해 보세요. 각 단어의 첫 자음은 단어 끝으로 옮기고 ay 를 붙입니다. 그래서 first 는 irst-fay 가 됩니다. 모음으로 시작하는 단어에는 대신 끝에 hay 를 붙입니다(apple 은 apple-hay 가 됩니다). UTF-8 인코딩의 세부도 꼭 염두에 두세요!
- 해시 맵과 벡터를 사용해, 회사의 부서에 직원 이름을 추가할 수 있는 텍스트 인터페이스를 만들어 보세요. 예를 들어 “Add Sally to Engineering” 또는 “Add Amir to Sales” 같은 식입니다. 그런 다음 사용자가 특정 부서의 모든 사람 목록이나, 회사 전체 사람들을 부서별로 알파벳 순서대로 정렬해 조회할 수 있게 해 보세요.
표준 라이브러리 API 문서에는 벡터, 문자열, 해시 맵이 가진 메서드들이 잘 정리되어 있고, 이런 연습문제를 풀 때 큰 도움이 될 것입니다!
이제 점점 더 복잡한 프로그램으로 들어가고 있고, 연산이 실패할 수도 있는 상황도 등장하기 시작합니다. 그래서 지금이 바로 에러 처리를 이야기하기 좋은 시점입니다. 다음 장에서 그 내용을 다루겠습니다!