키와 값을 연결하여 저장하는 해시 맵

마지막으로 살펴볼 일반적인 컬렉션은 _해시 맵_이다. HashMap<K, V> 타입은 키 타입 K와 값 타입 V를 연결하여 저장한다. 이때 _해시 함수_를 사용하여 키와 값을 메모리에 배치하는 방식을 결정한다. 많은 프로그래밍 언어가 이러한 데이터 구조를 지원하지만, 종종 해시, , 객체, 해시 테이블, 딕셔너리, 연관 배열 등 다양한 이름으로 불린다.

해시 맵은 벡터처럼 인덱스가 아닌 임의의 타입을 가진 키를 사용해 데이터를 조회하고자 할 때 유용하다. 예를 들어, 게임에서 각 팀의 점수를 해시 맵으로 관리할 수 있다. 이때 키는 팀 이름이고, 값은 각 팀의 점수다. 팀 이름을 알면 해당 팀의 점수를 조회할 수 있다.

이 섹션에서는 해시 맵의 기본 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);
}
Listing 8-20: 새로운 해시 맵을 생성하고 키와 값을 추가하기

먼저 표준 라이브러리의 컬렉션 부분에서 HashMapuse로 가져와야 한다. 세 가지 일반적인 컬렉션 중에서 이 해시 맵이 가장 덜 사용되기 때문에, 프리루드에서 자동으로 범위에 포함되지 않는다. 또한 해시 맵은 표준 라이브러리에서 지원이 적다. 예를 들어, 해시 맵을 생성하는 내장 매크로가 없다.

벡터와 마찬가지로, 해시 맵도 데이터를 힙에 저장한다. 이 HashMapString 타입의 키와 i32 타입의 값을 가진다. 벡터처럼 해시 맵도 동일한 타입을 가진다. 모든 키는 같은 타입이어야 하고, 모든 값도 같은 타입이어야 한다.

해시 맵에서 값 접근하기

해시 맵에서 값을 가져오려면 키를 get 메서드에 전달하면 된다. 아래 예제는 Blue 팀의 점수를 가져오는 방법을 보여준다.

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);
}
Listing 8-21: 해시 맵에 저장된 Blue 팀의 점수에 접근하기

이 예제에서 score는 Blue 팀과 연결된 값을 가지며, 결과는 10이 된다. get 메서드는 Option<&V>를 반환한다. 만약 해당 키에 대한 값이 해시 맵에 없다면, getNone을 반환한다. 이 프로그램은 Option을 처리하기 위해 copied를 호출해 Option<&i32> 대신 Option<i32>를 얻고, unwrap_or를 사용해 scores에 키에 대한 항목이 없을 경우 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!
}
Listing 8-22: 키와 값이 해시 맵에 삽입된 후 해시 맵의 소유가 됨을 보여줌

insert 호출을 통해 field_namefield_value 변수가 해시 맵으로 이동한 후에는 이 변수들을 더 이상 사용할 수 없다.

만약 해시 맵에 값의 참조를 삽입한다면, 값은 해시 맵으로 이동하지 않는다. 참조가 가리키는 값은 해시 맵이 유효한 동안 최소한 그만큼 유효해야 한다. 이 문제에 대해서는 10장의 “라이프타임을 사용한 참조 유효성 검사”에서 더 자세히 다룰 것이다.

해시 맵 업데이트

키와 값 쌍의 개수는 늘어날 수 있지만, 각 고유 키는 한 번에 하나의 값만 가질 수 있다(반대의 경우는 아니다. 예를 들어 Blue 팀과 Yellow 팀 모두 scores 해시 맵에 10이라는 값을 저장할 수 있다).

해시 맵의 데이터를 변경하려면, 이미 값이 할당된 키를 어떻게 처리할지 결정해야 한다. 기존 값을 완전히 무시하고 새로운 값으로 교체할 수 있다. 아니면 기존 값을 유지하고 새로운 값을 무시하거나, 키에 값이 없을 때만 새로운 값을 추가할 수도 있다. 또는 기존 값과 새로운 값을 결합할 수도 있다. 각각의 방법을 어떻게 구현하는지 살펴보자!

값 덮어쓰기

해시 맵에 키와 값을 추가한 후, 같은 키에 다른 값을 추가하면 해당 키에 연결된 값이 교체된다. Listing 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:?}");
}
Listing 8-23: 특정 키에 저장된 값을 교체하는 예제

이 코드는 {"Blue": 25}를 출력한다. 원래 값인 10은 덮어쓰여졌다.

키와 값을 키가 없는 경우에만 추가하기

특정 키가 이미 해시 맵에 값과 함께 존재하는지 확인한 후, 다음과 같은 동작을 수행하는 경우가 많다: 키가 해시 맵에 존재하면 기존 값을 그대로 유지하고, 키가 존재하지 않으면 해당 키와 값을 삽입한다.

해시 맵은 이를 위해 entry라는 특별한 API를 제공한다. entry는 확인하려는 키를 인자로 받는다. entry 메서드의 반환 값은 Entry라는 열거형으로, 값이 존재할 수도 있고 없을 수도 있는 상태를 나타낸다. 예를 들어, Yellow 팀의 키에 값이 있는지 확인하고 싶다고 가정해 보자. 값이 없다면 50을 삽입하고, Blue 팀에 대해서도 동일한 작업을 수행한다. entry API를 사용하면 다음과 같이 코드를 작성할 수 있다.

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:?}");
}
Listing 8-24: 키가 이미 값을 가지고 있지 않은 경우에만 삽입하기 위해 entry 메서드 사용

Entryor_insert 메서드는 해당 Entry 키에 대한 값의 가변 참조를 반환하도록 정의되어 있다. 키가 존재하면 그 값의 가변 참조를 반환하고, 키가 존재하지 않으면 인자로 전달된 값을 새 값으로 삽입한 후, 새 값의 가변 참조를 반환한다. 이 기법은 직접 로직을 작성하는 것보다 훨씬 깔끔하며, 빌림 검사기와도 더 잘 동작한다.

Listing 8-24의 코드를 실행하면 {"Yellow": 50, "Blue": 10}가 출력된다. 첫 번째 entry 호출은 Yellow 팀의 키에 50을 삽입한다. Yellow 팀은 이미 값이 없기 때문이다. 두 번째 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:?}");
}
Listing 8-25: 단어와 횟수를 저장하는 해시맵을 사용해 단어의 등장 횟수를 세는 예제

이 코드는 {"world": 2, "hello": 1, "wonderful": 1}를 출력한다. 동일한 키-값 쌍이 다른 순서로 출력될 수도 있다: “해시맵의 값에 접근하기”에서 해시맵을 순회할 때 순서가 임의적이라는 것을 떠올려 보자.

split_whitespace 메서드는 text의 값을 공백으로 구분한 부분 슬라이스의 반복자를 반환한다. or_insert 메서드는 지정된 키에 대한 값의 가변 참조(&mut V)를 반환한다. 여기서는 이 가변 참조를 count 변수에 저장하므로, 값을 할당하려면 먼저 별표(*)를 사용해 count를 역참조해야 한다. 가변 참조는 for 루프가 끝날 때 스코프를 벗어나므로, 이러한 모든 변경은 안전하며 빌림 규칙에 의해 허용된다.

해싱 함수

기본적으로 HashMap은 해시 테이블을 이용한 서비스 거부 공격(DoS)에 대한 내성을 제공하는 _SipHash_라는 해싱 함수를 사용한다. 이 해싱 알고리즘은 가장 빠른 것은 아니지만, 성능 저하를 감수하고 더 나은 보안을 얻는 것은 가치 있는 선택이다. 만약 코드를 프로파일링한 결과 기본 해시 함수가 목적에 비해 너무 느리다고 판단되면, 다른 해시 함수를 사용하도록 설정할 수 있다. _해셔(hasher)_는 BuildHasher 트레이트를 구현한 타입이다. 트레이트와 이를 구현하는 방법에 대해서는 10장에서 자세히 다룬다. 반드시 처음부터 자신만의 해셔를 구현할 필요는 없다. crates.io에는 다른 Rust 사용자들이 공유한 라이브러리가 있으며, 여기서 다양한 일반적인 해싱 알고리즘을 구현한 해셔를 찾을 수 있다.

요약

벡터, 문자열, 해시 맵은 데이터를 저장, 접근, 수정할 때 필요한 다양한 기능을 제공한다. 이제 여러분은 다음과 같은 문제를 해결할 준비가 되었다:

  1. 정수 리스트가 주어졌을 때, 벡터를 사용해 중앙값(정렬했을 때 중간 위치의 값)과 최빈값(가장 자주 나타나는 값; 해시 맵이 유용함)을 반환한다.
  2. 문자열을 피그 라틴으로 변환한다. 각 단어의 첫 번째 자음을 단어 끝으로 옮기고 _ay_를 추가한다. 예를 들어 _first_는 _irst-fay_가 된다. 모음으로 시작하는 단어는 단어 끝에 _hay_를 추가한다(_apple_은 _apple-hay_가 됨). UTF-8 인코딩에 대한 세부 사항을 기억하자!
  3. 해시 맵과 벡터를 사용해 텍스트 인터페이스를 만들어 사용자가 회사의 부서에 직원 이름을 추가할 수 있게 한다. 예를 들어 “Add Sally to Engineering” 또는 “Add Amir to Sales“와 같은 명령을 처리한다. 그런 다음 사용자가 특정 부서의 모든 직원 목록이나 부서별로 정렬된 전체 직원 목록을 조회할 수 있게 한다.

표준 라이브러리 API 문서에는 이러한 문제를 해결하는 데 유용한 벡터, 문자열, 해시 맵의 메서드가 설명되어 있다!

이제 더 복잡한 프로그램을 다루게 되면서 작업이 실패할 가능성이 생긴다. 따라서 오류 처리를 논의하기에 완벽한 시기다. 다음 장에서 이를 자세히 다룰 것이다!