Dictionary<TKey, TValue> は一意のキーの下に値を保存し、エントリがいくつあってもほぼ定数時間でキーから値を見つけます。C#のハッシュマップ(連想配列)に当たるもので、名前から番号を引く電話帳、IDからレコードを引くキャッシュ、単語ごとの出現回数などに使います。
辞書の作成と値の読み取り
出力:
12
2.50
2
True
False
2つの初期化子の形は同じことをします。["key"] = value の形(C# 6)はインデクサーを使うので、キーが重複すると上書きされます。{ key, value } の形は Add を呼ぶので、キーが重複するとその行の実行時に例外が投げられます。
ContainsKey はハッシュによる検索なので高速です。ContainsValue は値にインデックスがないので、すべてのエントリを走査しなければなりません。
Add、インデクサー、TryAdd
エントリを入れる方法は3つあり、違いはキーがすでに存在するときの動作だけです。
出力:
26
Caught ArgumentException
True
False
31
Add が重複で例外を投げるのは機能の1つです。一意であるべきなのにそうではなかったデータを表面化させます。「挿入または更新」を意図するならインデクサーを、最初の値を優先したいなら TryAdd(.NET Core 2.0以降)を使います。
キーは null にできません。Add(null, ...) や dict[null] は ArgumentNullException を投げます。値は、値の型が許すなら null にできます。
KeyNotFoundExceptionとTryGetValue
存在しないキーをインデクサーで読むと KeyNotFoundException が投げられます。これは辞書で最もよくあるエラーで、直し方はほぼ常に TryGetValue です。
出力:
Caught KeyNotFoundException
Found ana@example.com
Missing, value is null: True
no email
TryGetValue はハッシュによる検索を1回行い、成功したかどうかを bool で報告します。if (dict.ContainsKey(k)) { var v = dict[k]; } という形も動きますが、キーを2回検索します。キーがない場合、out 変数はその型の既定値(null、0、false)になります。
.NET Core 2.0以降には、キーがないときに代替値を返す GetValueOrDefault(key, fallback) もあります:emails.GetValueOrDefault(103, "no email")。
エントリの更新と削除
出力:
2
True
False
1
0
cart["milk"] += 1 は書き込む前に読むので、辞書にまだ milk がなければ KeyNotFoundException を投げます。Remove は存在しないキーに対して例外を投げずに false を返すので、先に ContainsKey で確認する必要はありません。
ループ:KeyValuePair、Keys、Values
辞書を foreach すると、それぞれ Key と Value を持つ KeyValuePair<TKey, TValue> の要素が得られます。
出力:
Ana: 88
Ben: 72
Chloe: 95
Ana Ben Chloe
Total 255
77
pair.Value は読み取り専用なので、値を更新するにはインデクサーを通して書き込みます。最後のループはキーの List<string> のコピーを反復していて、これは常に安全です。既存の値を上書きしながら scores.Keys を直接反復することは、.NET Core 3.0以降では許されていますが、.NET Frameworkでは InvalidOperationException が投げられます。
同じ辞書を foreach している最中に新しいキーを追加すると、どのバージョンでも InvalidOperationException が投げられます。列挙中の削除は、.NET Frameworkでは例外になり、.NET Core 3.0以降では許されています。どこでも動かす必要があるコードでは、削除するキーを先に集め、ループの後で削除します。
C# 7と.NET Core 2.0以降では、ループのヘッダーで KeyValuePair を分解できます。
foreach (var (name, score) in scores)
{
Console.WriteLine($"{name}: {score}");
}
辞書で数える
出現回数を数えるのは教科書どおりの使い方です。TryGetValue で現在の数を読み(キーがなければ 0)、書き戻します。
出力:
the 3
cat 1
and 2
dog 1
bird 1
同じ形で要素をグループ化できます。Dictionary<string, List<Order>> で、TryGetValue でリストを取り出し、なければ作って保存し、そこに Add します。1回限りの集計やグループ化なら、LINQで1つの式にできます:words.GroupBy(w => w).ToDictionary(g => g.Key, g => g.Count())。LINQを参照してください。
比較子で大文字と小文字を区別しないキーにする
既定では文字列のキーは完全一致で比較され、"Apple" と "apple" は2つのキーです。これを変えるには、コンストラクターに IEqualityComparer<string> を渡します。
出力:
False
text/html
1
HTTPヘッダー、ファイルの拡張子、ユーザー名のような識別子には StringComparer.OrdinalIgnoreCase が適切です。保存する前にすべてのキーで .ToLower() を呼ぶ方法も動きますが、どこか1か所で忘れがちです。
自分で作ったクラスのキーでは、辞書はキーの GetHashCode と Equals を呼びます。それらをオーバーライドしていないクラスは参照で比較されるので、同じフィールドを持つ2つの別々のオブジェクトは異なるキーになります。この2つのメソッドの書き方はHashSetを参照してください。
順序、ソート、SortedDictionary
Dictionary は列挙の順序を何も約束しません。実際には、エントリを追加しただけの辞書は挿入順に列挙されますが、Remove の後は、後から Add したものが空いた枠を再利用して前のほうに現れることがあります。コードがこれに依存してはいけません。
順序が必要なら、使う時点で並べ替えるか、ソート済みのコレクションを使います。
出力:
Cairo 210
Lima 340
Oslo 520
By value, highest first:
Oslo 520
Lima 340
Cairo 210
Berlin, Cairo, Lima, Oslo
SortedDictionary<TKey, TValue> はキーを常にソートされた状態に保つ(平衡木です)ので、検索と挿入はO(1)ではなくO(log n)です。キー順の列挙を頻繁に行うならこれを使い、1回だけ順序が必要なら通常の辞書をLINQで並べ替えます。3つ目の選択肢の SortedList<TKey, TValue> はメモリの使用量が少ないですが、大きくなると挿入が遅くなります。
早見表
| やりたいこと | コード |
|---|---|
| 作成 | new Dictionary<string, int>() |
| 挿入または上書き | d[k] = v |
| 挿入、重複なら例外 | d.Add(k, v) |
| 新しい場合だけ挿入 | d.TryAdd(k, v) |
| 読み取り、なければ例外 | d[k] |
| 安全に読み取る | d.TryGetValue(k, out var v) |
| キーが存在するか | d.ContainsKey(k) |
| 削除 | d.Remove(k)(bool を返す) |
| 要素数 | d.Count |
| キー、値 | d.Keys、d.Values |
| キー順に並べる | d.OrderBy(p => p.Key) または SortedDictionary |
| 大文字と小文字を区別しない | new Dictionary<string, T>(StringComparer.OrdinalIgnoreCase) |
よくある間違い
- 存在しないキーを
d[k]で読む。KeyNotFoundExceptionが投げられます。TryGetValueを使います。 - 存在するかもしれないキーに
Addを呼ぶ。ArgumentExceptionが投げられます。インデクサーかTryAddを使います。 - 辞書を
foreachしている最中にキーを追加する。InvalidOperationExceptionが投げられます。変更を集めて後で適用します。 - 列挙の順序に依存する。 並べ替えるか、
SortedDictionaryを使います。 - 挿入した後にキーのオブジェクトのフィールドを変更する。 ハッシュコードが変わり、エントリが見つからなくなります。
ContainsKeyの後にインデクサー。 2回の検索になります。TryGetValueなら1回です。
よくある質問
C#のDictionary.Addとインデクサーの違いは何ですか?
dict.Add(key, value) は新しいエントリを挿入し、キーがすでにあれば ArgumentException を投げます。dict[key] = value はキーが新しければ挿入し、あれば値を上書きし、重複で例外を投げることはありません。TryAdd(key, value) はキーが新しいときだけ挿入し、そうでなければ false を返します。
C#のTryGetValueはどう動きますか?
dict.TryGetValue(key, out var value) は、キーがあれば true を返して value を設定し、なければ false を返して value をその型の既定値にします。ContainsKey の後に dict[key] を読むと2回の検索になるところを1回で済ませ、KeyNotFoundException を投げることもありません。
C#でDictionaryをループするには?
foreach (KeyValuePair<string, int> pair in dict) で、各エントリを pair.Key と pair.Value で取得できます。キーだけ、または値だけをループするには dict.Keys か dict.Values を使います。そのループの中で辞書にキーを追加してはいけません。InvalidOperationException が投げられます。
C#のDictionaryは順序付きですか?
順序は保証されていません。エントリを追加しただけの辞書はたいてい挿入順に列挙されますが、Remove の後は新しいエントリが空いた枠に入ることがあり、順序が変わります。順序が必要なら並べ替えます:dict.OrderBy(p => p.Key)。あるいは、常にキー順に列挙される SortedDictionary<TKey, TValue> を使います。
Dictionaryのキーで大文字と小文字を区別しないようにするには?
コンストラクターに比較子を渡します:new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)。すると検索、Add、ContainsKey で "Apple" と "apple" が同じキーになります。比較子は辞書の作成時に固定されます。
C#のKeyValuePairとは何ですか?
KeyValuePair<TKey, TValue> は、辞書を列挙するときに各エントリとして渡される構造体です。読み取り専用の Key と Value プロパティを持つので、それを通してエントリを変更することはできません。代わりに(ループの後か、キーのコピーに対して)dict[pair.Key] = newValue と書きます。