Java Tips | コレクション:BiMap実装

Java Java
スポンサーリンク

BiMap は「キーと値の両方から引ける Map」

普通の Map<K, V> は「キー → 値」の片方向だけです。
でも業務では、「値からも逆引きしたい」場面がよく出てきます。

HTTPステータスコード 200 → “OK”
“OK” → 200 も知りたい

社員番号 12345 → “田中”
“田中” → 12345 も知りたい(重複しない前提なら)

こういうときに欲しくなるのが BiMap(バイマップ)
つまり「キー → 値」と「値 → キー」を両方サポートする Map です。

Java標準には BiMap はありませんが、
Map<K, V>Map<V, K> を組み合わせれば、
実務で十分使える BiMap を自作できます。


基本設計:「2つの Map を常に同期させる」

片方は key→value、もう片方は value→key

BiMap のコアアイデアはとてもシンプルです。

片方の Map で「キー → 値」を管理する。
もう片方の Map で「値 → キー」を管理する。
put / remove のたびに、両方を同時に更新する。

この「2つの Map を常に矛盾なく保つ」ことが、BiMap 実装の一番大事なポイントです。

もう一つ重要なのは、
「値も一意(ユニーク)である」 という前提です。
同じ値に対して複数のキーがぶら下がると、「値 → キー」が一意に決まりません。

BiMap は「キーも値も一意」という世界で使う道具だ、と覚えておいてください。


最小限の BiMap 実装

コード全体をまず見てみる

import java.util.HashMap;
import java.util.Map;

public class BiMap<K, V> {

    private final Map<K, V> forward = new HashMap<>();
    private final Map<V, K> backward = new HashMap<>();

    public void put(K key, V value) {
        // 既存のキーがあれば、古い値との対応を削除
        if (forward.containsKey(key)) {
            V oldValue = forward.get(key);
            backward.remove(oldValue);
        }
        // 既存の値があれば、古いキーとの対応を削除
        if (backward.containsKey(value)) {
            K oldKey = backward.get(value);
            forward.remove(oldKey);
        }
        // 新しい対応を両方に登録
        forward.put(key, value);
        backward.put(value, key);
    }

    public V getByKey(K key) {
        return forward.get(key);
    }

    public K getByValue(V value) {
        return backward.get(value);
    }

    public void removeByKey(K key) {
        V value = forward.remove(key);
        if (value != null) {
            backward.remove(value);
        }
    }

    public void removeByValue(V value) {
        K key = backward.remove(value);
        if (key != null) {
            forward.remove(key);
        }
    }

    public boolean containsKey(K key) {
        return forward.containsKey(key);
    }

    public boolean containsValue(V value) {
        return backward.containsKey(value);
    }

    public int size() {
        return forward.size();
    }

    public boolean isEmpty() {
        return forward.isEmpty();
    }
}
Java

ここから、重要な部分を一つずつかみ砕いていきます。


重要ポイント1:put で「両方の Map の整合性」を必ず保つ

既存のキー・値があった場合の扱いが肝

put の中身を丁寧に見ていきます。

public void put(K key, V value) {
    if (forward.containsKey(key)) {
        V oldValue = forward.get(key);
        backward.remove(oldValue);
    }
    if (backward.containsKey(value)) {
        K oldKey = backward.get(value);
        forward.remove(oldKey);
    }
    forward.put(key, value);
    backward.put(value, key);
}
Java

ここでやっていることは、次のような流れです。

キーがすでに使われていたら、そのキーに紐づいていた「古い値」を backward から消す。
値がすでに使われていたら、その値に紐づいていた「古いキー」を forward から消す。
最後に、新しい key↔value の対応を forward / backward 両方に登録する。

これにより、常に次の条件が保たれます。

forward に (k → v) があるとき、backward には必ず (v → k) がある。
backward に (v → k) があるとき、forward には必ず (k → v) がある。
キーも値も、それぞれ一意である。

この「put の中で整合性を全部面倒見る」設計がとても大事です。
呼び出し側は「BiMap に put すれば、キーと値の両方の世界がきれいに保たれる」と信じて使えます。


重要ポイント2:remove も「片方を消したら、もう片方も必ず消す」

片側だけ残ると BiMap ではなくなる

removeByKey を見てみます。

public void removeByKey(K key) {
    V value = forward.remove(key);
    if (value != null) {
        backward.remove(value);
    }
}
Java

forward からキーを消したあと、
対応する値があれば backward からも消しています。

removeByValue も同じ構造です。

public void removeByValue(V value) {
    K key = backward.remove(value);
    if (key != null) {
        forward.remove(key);
    }
}
Java

ここでのポイントは、
「どちら側から消しても、必ずもう片方も追いかけて消す」
というルールを BiMap の中に閉じ込めていることです。

これを忘れると、
「キーからは引けないのに、値からは引ける」
「値からは引けないのに、キーからは引ける」
といった中途半端な状態になり、BiMap の意味が崩れます。


具体例1:HTTPステータスコード ↔ メッセージの BiMap

「コードからもメッセージからも引きたい」典型パターン

HTTPステータスコードとメッセージの対応を BiMap で管理してみます。

public class HttpStatusBiMapExample {

    public static void main(String[] args) {
        BiMap<Integer, String> status = new BiMap<>();

        status.put(200, "OK");
        status.put(404, "Not Found");
        status.put(500, "Internal Server Error");

        System.out.println(status.getByKey(200));      // OK
        System.out.println(status.getByKey(404));      // Not Found

        System.out.println(status.getByValue("OK"));   // 200
        System.out.println(status.getByValue("Not Found")); // 404
    }
}
Java

ここでのポイントは、
「コード → メッセージ」「メッセージ → コード」の両方が自然に書けていることです。

もし BiMap がなければ、
Map<Integer, String>Map<String, Integer> を別々に管理し、
両方に同じデータを入れる必要があります。

BiMap に「整合性の管理」を任せることで、
呼び出し側のコードがかなりスッキリします。


具体例2:ID ↔ 名前の BiMap(Enum 的な使い方)

「ID も名前もユニーク」なマスタデータに向いている

例えば、「権限ID ↔ 権限名」の対応を BiMap で持つ例です。

public class RoleBiMapExample {

    public static void main(String[] args) {
        BiMap<Integer, String> roles = new BiMap<>();

        roles.put(1, "ADMIN");
        roles.put(2, "USER");
        roles.put(3, "GUEST");

        System.out.println(roles.getByKey(1));        // ADMIN
        System.out.println(roles.getByValue("USER")); // 2

        // 値を更新するときも、BiMap が両側を調整してくれる
        roles.put(2, "MEMBER"); // "USER" は消え、2 ↔ MEMBER に置き換わる

        System.out.println(roles.getByValue("USER"));   // null
        System.out.println(roles.getByValue("MEMBER")); // 2
    }
}
Java

ここでの重要ポイントは二つです。

一つ目は、「ID も名前もユニークである」という前提が BiMap と相性が良いこと。
同じ名前を複数のIDに割り当てたいなら BiMap は不適切です。

二つ目は、「値の更新時に古い対応を自動で消してくれる」こと。
roles.put(2, "MEMBER") のとき、
古い "USER"2 の対応を BiMap がきちんと片付けてくれます。


スレッドセーフにしたい場合の考え方

単純に synchronized をかけるパターン

この BiMap はスレッドセーフではありません。
複数スレッドから同時に put / remove / get されるなら、同期が必要です。

一番簡単なのは、メソッドを synchronized にすることです。

public class BiMap<K, V> {

    private final Map<K, V> forward = new HashMap<>();
    private final Map<V, K> backward = new HashMap<>();

    public synchronized void put(K key, V value) {
        // さきほどと同じロジック
    }

    public synchronized V getByKey(K key) {
        return forward.get(key);
    }

    public synchronized K getByValue(V value) {
        return backward.get(value);
    }

    public synchronized void removeByKey(K key) {
        ...
    }

    public synchronized void removeByValue(V value) {
        ...
    }

    public synchronized int size() {
        return forward.size();
    }

    public synchronized boolean isEmpty() {
        return forward.isEmpty();
    }
}
Java

ここでのポイントは、
「forward と backward の両方を一貫性のある状態で扱うために、BiMap 全体をロックする」
という発想です。

片方の Map だけをロックしても意味がありません。
BiMap は「2つで1セット」なので、
「BiMap という箱全体」を同期の単位にするのが素直です。


まとめ:BiMap実装で身につけてほしい感覚

BiMap は、
単に「Map を2つ持ったクラス」ではなく、
「キーと値の両方向の対応を、常に矛盾なく保つためのユーティリティ」 です。

内部に Map<K, V>Map<V, K> を持ち、put / remove で両方を必ず更新する。
キーも値も一意である前提のときに使う(そうでないなら MultiMap など別の道具を選ぶ)。
「コード ↔ 名前」「ID ↔ ラベル」「ステータスコード ↔ メッセージ」など、双方向参照したいマスタデータに当てはめる。
スレッドセーフにしたいなら、BiMap 全体を同期の単位として設計する。

あなたのコードのどこかに、
Map<A, B>Map<B, A> を手で同期させている箇所があれば、
そこを一度「BiMap クラス」にまとめられないか眺めてみてください。

それが、「よく出る双方向パターンに名前を与えて、再利用できる形にする」
エンジニアへの、いい一歩になります。

タイトルとURLをコピーしました