【C#】Dictionaryの使い方と落とし穴

に公開
使い方と落とし穴シリーズ一覧

関連記事

一言

  • Dictionary を通してマッピング構造に慣れてこ~
  • Unity でもよく使います

Dictionary を使用するケース

  • キーと値のマッピングがしたい
    • ハッシュ検索により最善 O(1), 最悪 O(n)で値を取得
  • 高速に存在チェックをしたい
    • ContainsKey, TryGetValue が配列走査より速い
  • 重複を許さない集合を扱いつつ付加情報も持たせたい
    • HashSet<T> + List<T> での管理よりシンプル

Dictionary の使い方

基本 (生成, 追加, 取得, 削除)

//生成 (容量指定で再ハッシュを抑えられる)
var id2Enemy = new Dictionary<int, Enemy>(128);

//追加
id2Enemy[100] = new Enemy("Slime");        //重複キーなら上書き
id2Enemy.Add(200, new Enemy("Ogre"));      //重複キーなら例外
id2Enemy.TryAdd(300, new Enemy("Dragon")); //重複キーでも安全

//取得
Enemy ogre = id2Enemy[200];                    //キーがなければ KeyNotFoundException
if (id2Enemy.TryGetValue(300, out var dragon)) //キーがあるならtrueで、out変数に値が代入
{
    Use(dragon);
}

//削除
id2Enemy.Remove(200); //戻り値の bool で成否判定も可
id2Enemy.Clear();     //要素のみ消える (容量はそのまま)

存在チェック

if (id2Enemy.ContainsKey(id) == false)
{
    Debug.LogWarning($"未知のID:{id}");
    return;
}

列挙

//キー,値の集合を取り出す
foreach (int id in id2Enemy.Keys) { ... }
foreach (Enemy e in id2Enemy.Values) { ... }

//タプルで取り出すことも可能 (C#7.0以降)
foreach (var (id, enemy) in id2Enemy)
{
    Debug.Log($"{id}:{enemy.Name}");
}

容量管理

  • 初期化時、 おおよその要素数を指定して再ハッシュ(再確保)を抑えられる
  • EnsureCapacity() で後から容量見積もりが可能
  • 不要なメモリは TrimExcess() で解放できる (容量に対する要素数が90%未満時)
id2Enemy.EnsureCapacity(1000);
id2Enemy.TrimExcess();

オイラはこんな落とし穴に出会った

参照型キーで IEquatable<T> 未実装

参照比較になり、論理的に同じでもヒットしない

class Pos { public int X, Y; }
var dict = new Dictionary<Pos, string>();
dict[new Pos{X=0,Y=0}] = "origin";
Console.WriteLine(dict.ContainsKey(new Pos{X=0,Y=0})); //false

回避策

  • IEquatable<T> を実装 or record class で自動実装

可変オブジェクトをキーに使う

キーのハッシュコードが途中で変わると検索不能になる

class MutableKey
{
    public int Id; //可変
}

var key  = new MutableKey(){ Id = 1 };
var dict = new Dictionary<MutableKey, string> {{ key, "foo" }};
key.Id = 2;
bool exists = dict.ContainsKey(key); //false

回避策

  • キーは イミュータブル にする (record, struct, readonly)
  • 変えたいなら一度 RemoveAdd で再登録

追加時の重複キー例外

Add() でキーが重複すると ArgumentException 発生

dict.Add(1, "first");
dict.Add(1, "second"); //ArgumentException

回避策

  • 上書き or TryAdd()
dict.TryAdd(1, "second"); //bool で結果確認
dict[1] = "second";

foreach 中に要素を変更

  • foreach は列挙子を用いて Dictionary を読み取る

foreach 中に要素が変更されると、列挙子の整合性が崩れる
そのため、実行時に InvalidOperationException が発生する

foreach (var kv in dict)
{
    if (kv.Key.StartsWith("tmp_"))
    {
        dict.Remove(kv.Key); //InvalidOperationException
    }
}

回避策

  • ToList() でコピー後に削除 or forループ + Keys.ToArray()

Clear() でメモリ解放と勘違い

Clear() は Count だけリセットし容量は残る

回避策

  • 必要に応じ TrimExcess() を呼ぶ or 再生成

SortedDictionary

SortedDictionary<TKey, TValue>とは

  • キー順にソートされた状態を保つ Dictionary

  • ハッシュの代わりに二分探索木を導入している

    • そのため、値取得は最善で O(log n)
    • ハッシュベースでないため GetHashCode は無関係(代わりに IComparer<TKey> が関与)
  • 使用例

var sorted = new SortedDictionary<string, int>();
sorted["slime"]  = 2;
sorted["ogre"]   = 5;
sorted["dragon"] = 1;

foreach (var kv in sorted)
{
    //dragon → ogre → slime の順で出力される
    Console.WriteLine($"{kv.Key}: {kv.Value}");
}
  • この例で降順にする場合、以下のクラスを SortedDictionary のコンストラクタに追加
class DescendingStringComparer : IComparer<string>
{
    public int Compare(string? x, string? y)
        => string.Compare(y, x, StringComparison.Ordinal);
}

参考

Discussion