2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【C#】雰囲気で使い分けていた Dictionary の操作を整理した(TryGetValue / TryAdd / foreach など)

2
Posted at

はじめに

C# で Dictionary<TKey, TValue> を使っていると、値の取り出し方や追加の仕方がいくつもあって、なんとなく雰囲気で使い分けていました。TryGetValueContainsKeyAdd とインデクサ、foreach の書き方など、その場その場で書いているうちに、自分の中で基準が曖昧なままだと気づきました。

一度ちゃんと整理しておきたかったので、よく使うところをまとめます。

環境

  • .NET 10.0
  • C# 14.0
  • VSCode

基本

キーと値のペアを保持するコレクションです。キーのハッシュ値から格納場所を決める仕組みなので、要素が増えても検索は平均で一定時間(O(1))になります。キーの重複は許されません。

var dict = new Dictionary<string, int>
{
    ["apple"] = 100,
    ["banana"] = 200,
};

値の取り出し方

取り出し方が複数あって、ここが一番あやふやだったので整理しました。

インデクサはキーが無いと KeyNotFoundException が飛びます。

var price = dict["apple"];   // キーが無いと例外

ContainsKey で存在を確認してからインデクサで取り出す書き方だと、存在確認とその後の取得でハッシュ探索が2回走ることになります。

if (dict.ContainsKey("apple"))
{
    var price = dict["apple"];   // 探索が2回
}

TryGetValue は、存在確認と取得を1回の探索でまとめてやってくれます。値も欲しいならこれが素直だと思いました。

if (dict.TryGetValue("apple", out var price))
{
    Console.WriteLine(price);
}

無いときに既定値が返ればよいだけなら GetValueOrDefault が使えます。

var price = dict.GetValueOrDefault("grape", 0);   // 無ければ 0

GetValueOrDefaultCollectionExtensions の拡張メソッドとして用意されています。

値も使いたいなら TryGetValue、存在するかどうかだけ知りたいなら ContainsKey、という切り分けにしています。

追加の仕方

追加にも3通りあって、それぞれ「重複したときの振る舞い」が違います。

Add は同じキーが既にあると ArgumentException を投げます。重複に気づきたい場面向けです。

dict.Add("apple", 100);   // 既にキーがあると例外

インデクサは、あれば上書き・なければ追加です。重複を気にせず入れたい場面向けです。

dict["apple"] = 100;      // あれば更新、なければ追加

TryAdd は、既にあれば追加せず false を返すだけで、例外にはなりません。

if (!dict.TryAdd("apple", 100))
{
    // 既に apple があった
}

Dictionary<TKey, TValue>TryAdd はインスタンスメソッドで、上のように Dictionary 型の変数から呼ぶとこちらが使われます。なお、IDictionary<TKey, TValue> に対しては同名の CollectionExtensions.TryAdd 拡張メソッドも用意されています。

foreach で回すときの分解代入

foreach で回すと、1要素ずつ KeyValuePair<TKey, TValue> が取り出されます。素直に書くとこうです。

foreach (var pair in dict)
{
    Console.WriteLine($"{pair.Key}: {pair.Value}");
}

これは分解代入で (key, value) の形にして受け取ることもできます。

foreach (var (key, value) in dict)
{
    Console.WriteLine($"{key}: {value}");
}

分解できるのは、KeyValuePair<TKey, TValue>Deconstruct メソッドが定義されているからです。C# の分解代入は、対象の型に Deconstructout 引数を持つもの)がインスタンスまたは拡張メソッドとしてあれば使える仕組みで、タプル専用ではありません。

標準ライブラリの KeyValuePair は .NET Framework では Deconstruct を持たないため、Framework 向けではそのままだとコンパイルエラーになります。

両方を使うなら分解が読みやすいと感じますが、片方しか使わないなら Keys / Values を直接回した方が、何を使いたいのかが伝わると思いました。

foreach (var key in dict.Keys) { }
foreach (var value in dict.Values) { }

列挙順は保証されない

Dictionary の列挙順序は保証されていません(公式ドキュメントにも順序は指定されないと書かれています)。現在の実装では挿入順に見えることもありますが、仕様上は保証されず、特に削除してから別の要素を追加すると、挿入順とは違う順序になり得ます。

必要な順序によって選ぶものが変わります。キー順に並べたいなら SortedDictionary / SortedList、その場かぎりで任意の条件で並べたいなら OrderBy、挿入順を保持したいなら .NET 9 で追加された OrderedDictionary<TKey, TValue> です。

キーの比較方法をカスタマイズできる

文字列キーで大文字小文字を無視したい、といった場合はコンストラクタに IEqualityComparer<T> を渡します。

var dict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
dict["Apple"] = 100;
Console.WriteLine(dict["APPLE"]);   // 100

自作クラスをキーにする場合、同じインスタンスだけを同じキーとみなす(参照の等価性でよい)なら、特に実装は要りません。プロパティの値が同じなら同じキーとして扱いたい、といった値の等価性を使いたいときに、EqualsGetHashCode を整合するように実装するか、IEqualityComparer<T> を渡します。この点、record や値タプルは値の等価性が用意されているので楽だと感じました。

また、キーとして追加した後に、ハッシュ値や等価性に影響する状態を変更すると引けなくなるので、その点は注意します。

スレッドセーフではない

Dictionary は複数スレッドからの同時書き込みに対応していません。書き込みが並行するなら ConcurrentDictionary<TKey, TValue> を使うか、通常の Dictionary へのアクセスをロックで同期します。読み取り専用で共有するだけなら Dictionary のままでも問題ありません。

生成後に変更しない前提で読み取りを最適化した FrozenDictionary もあり、.NET 8 で追加されました。作成コストは高めなので、生成頻度が低く参照頻度が高い用途に向いています。

容量を指定できる

入れる要素数がある程度分かっているなら、初期容量を渡すと内部配列の再確保を減らせます。

var dict = new Dictionary<string, int>(capacity: 10000);

まとめ

  • 値の取り出しは、値も使うなら TryGetValue、存在確認だけなら ContainsKey
  • 追加は、重複に気づきたいなら Add、上書きしたいならインデクサ、例外にしたくないなら TryAddDictionary のは インスタンスメソッド)
  • foreachKeyValuePair が取り出され、Deconstruct があるので (key, value) に分解できる
  • 列挙順は保証されない。キー順なら SortedDictionary、挿入順なら OrderedDictionary(.NET 9〜)、一時的な並べ替えなら OrderBy
  • キーの比較は IEqualityComparer でカスタマイズできる。自作クラスキーで値の等価性を使うなら EqualsGetHashCode を整合させる(参照の等価性でよければ不要)
  • 同時書き込みするなら ConcurrentDictionary かロック、変更しない読み取り用途なら FrozenDictionary
  • 要素数が読めるなら初期容量を指定する

雰囲気で使い分けていた部分が、「重複したときどうなるか」「探索が何回走るか」で整理すると、それぞれの使いどころがはっきりしました。

参考になったら いいねストック をお願いします!
同じような使い分けをしている方のコメントもお待ちしています。

参考

関連リンク

技術ブログでも学びや検証内容をまとめています。

nakamuuublog

2
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?