例外処理

【C#】Dictionaryで発生するKeyNotFoundExceptionの原因とTryGetValue・ContainsKeyによる安全な回避方法

C#のKeyNotFoundExceptionはDictionaryなどで登録されていないキーにアクセスすると発生するランタイム例外です。

防ぐには値取得前にTryGetValueで存在確認を行うかContainsKeyでチェックするのが安全です。

捕捉してログ出力すれば原因特定が早まり、アプリの信頼性向上につながります。

KeyNotFoundExceptionとは

C#のプログラミングにおいて、KeyNotFoundExceptionは非常に重要な例外の一つです。

これは主にDictionarySortedListなどのキーと値のペアを管理するコレクションで、存在しないキーにアクセスしようとしたときに発生します。

プログラムの動作を安定させるためには、この例外の発生条件や特徴を理解し、適切に対処することが必要です。

発生タイミング

KeyNotFoundExceptionは、コレクションに存在しないキーを使って値を取得しようとした場合に発生します。

ここでは代表的なケースを紹介します。

Dictionaryのインデクサアクセス

Dictionary<TKey, TValue>は、キーと値のペアを高速に検索できるコレクションです。

キーを指定して値を取得する際に、インデクサ[]を使うことが多いです。

しかし、指定したキーが存在しない場合、KeyNotFoundExceptionがスローされます。

以下のサンプルコードをご覧ください。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        var dict = new Dictionary<string, string>();
        dict.Add("100", "Apple");
        dict.Add("200", "Banana");
        // 存在しないキー"300"にアクセスしようとする
        var value = dict["300"]; // ここでKeyNotFoundExceptionが発生
        Console.WriteLine(value);
    }
}
Unhandled exception. System.Collections.Generic.KeyNotFoundException: The given key '300' was not present in the dictionary.
   at System.Collections.Generic.Dictionary`2.get_Item(TKey key)
   at Program.Main() in Console.cs:line 11

この例では、"300"というキーはdictに存在しないため、dict["300"]のアクセス時に例外が発生します。

インデクサはキーが存在しない場合に例外をスローする仕様であるため、事前にキーの存在を確認しないとプログラムがクラッシュする原因になります。

SortedListやConcurrentDictionaryでのアクセス

SortedList<TKey, TValue>ConcurrentDictionary<TKey, TValue>もキーと値のペアを管理するコレクションですが、Dictionaryと同様に存在しないキーに対してインデクサでアクセスするとKeyNotFoundExceptionが発生します。

例えば、SortedListの場合は以下のようになります。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        var sortedList = new SortedList<int, string>();
        sortedList.Add(1, "One");
        sortedList.Add(2, "Two");
        // 存在しないキー3にアクセス
        var value = sortedList[3]; // KeyNotFoundExceptionが発生
        Console.WriteLine(value);
    }
}

ConcurrentDictionaryも同様に、インデクサで存在しないキーにアクセスすると例外が発生します。

ただし、ConcurrentDictionaryはスレッドセーフなコレクションであり、マルチスレッド環境での安全なアクセスが可能です。

これらのコレクションでも、例外を防ぐためにはTryGetValueContainsKeyを使った事前チェックが推奨されます。

例外メッセージとスタックトレース

KeyNotFoundExceptionが発生すると、例外メッセージには「The given key ‘キー名’ was not present in the dictionary.」という内容が表示されます。

これは指定したキーが辞書に存在しなかったことを示しています。

例外のスタックトレースは、どのコード行で例外が発生したかを特定するのに役立ちます。

例えば、先ほどのDictionaryの例では以下のようなスタックトレースが出力されます。

System.Collections.Generic.KeyNotFoundException: The given key '300' was not present in the dictionary.
   at System.Collections.Generic.Dictionary`2.get_Item(TKey key)
   at Program.Main()

このスタックトレースから、Dictionaryget_Itemメソッド(インデクサ)が例外をスローし、Program.Mainメソッド内で発生していることがわかります。

例外メッセージとスタックトレースは、デバッグ時に問題の原因を特定するために非常に重要です。

例外が発生した場合は、これらの情報を活用してどのキーが存在しなかったのか、どのコード行で問題が起きているのかを確認しましょう。

よくある原因

キーの誤字・大文字小文字の不一致

Dictionaryのキーはデフォルトで大文字小文字を区別します。

そのため、キーの文字列に誤字があったり、大文字と小文字が異なっていると、存在しないキーとして扱われてしまい、KeyNotFoundExceptionが発生します。

例えば、以下のコードではキーが"KeyOne"ですが、アクセス時に"keyone"と小文字で指定しているため例外が発生します。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        var dict = new Dictionary<string, string>();
        dict.Add("KeyOne", "Value1");
        // キーの大文字小文字が異なるため例外が発生
        var value = dict["keyone"];
        Console.WriteLine(value);
    }
}
Unhandled exception. System.Collections.Generic.KeyNotFoundException: The given key 'keyone' was not present in the dictionary.
   at System.Collections.Generic.Dictionary`2.get_Item(TKey key)
   at Program.Main()

この問題を防ぐには、キーの入力ミスを避けることはもちろん、DictionaryのコンストラクタでStringComparer.OrdinalIgnoreCaseなどの大文字小文字を無視する比較子を指定する方法があります。

var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
dict.Add("KeyOne", "Value1");
var value = dict["keyone"]; // 例外は発生しない
Console.WriteLine(value);

このように設定すると、大文字小文字の違いを無視してキーを比較できるため、誤って例外が発生するリスクを減らせます。

型のミスマッチ

Dictionaryのキーや値の型が期待と異なる場合もKeyNotFoundExceptionの原因になります。

特に、ジェネリック型の指定ミスや、ボックス化・アンボックス化の際に型が合わないケースです。

例えば、Dictionary<int, string>に対して文字列のキーでアクセスしようとするとコンパイルエラーになりますが、オブジェクト型を使っている場合は実行時に意図しない型のキーを渡してしまい、存在しないキーとして扱われることがあります。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        var dict = new Dictionary<object, string>();
        dict.Add(1, "One");
        // キーの型が異なるため存在しないキーとして扱われる
        var key = "1"; // string型
        if (dict.TryGetValue(key, out var value))
        {
            Console.WriteLine(value);
        }
        else
        {
            Console.WriteLine("キーが存在しません。");
        }
    }
}
キーが存在しません。

この例では、キーの型がintstringで異なるため、TryGetValuefalseを返します。

型の不一致は例外を直接引き起こさない場合もありますが、存在しないキーとして扱われるため、結果的にKeyNotFoundExceptionの原因となることがあります。

コレクションの更新タイミング

マルチスレッドによる競合

複数のスレッドから同時にDictionaryを読み書きすると、状態が不整合になりやすく、存在するはずのキーが見つからないケースが発生します。

Dictionaryはスレッドセーフではないため、並行アクセス時にKeyNotFoundExceptionが起きることがあります。

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
class Program
{
    static Dictionary<int, string> dict = new Dictionary<int, string>();
    static void Main()
    {
        dict.Add(1, "One");
        Parallel.Invoke(
            () => {
                for (int i = 0; i < 1000; i++)
                {
                    dict[1] = "One" + i;
                }
            },
            () => {
                for (int i = 0; i < 1000; i++)
                {
                    try
                    {
                        var value = dict[1];
                    }
                    catch (KeyNotFoundException e)
                    {
                        Console.WriteLine("例外発生: " + e.Message);
                    }
                }
            }
        );
    }
}

このような状況では、ConcurrentDictionaryの利用やロック機構を導入して排他制御を行うことが推奨されます。

参照破棄後のアクセス

Dictionaryの参照が破棄されたり、コレクションがクリアされた後にアクセスしようとすると、当然ながらキーは存在しません。

特に、メソッド間で共有しているDictionaryが別の処理で初期化やクリアされている場合に起こりやすいです。

using System;
using System.Collections.Generic;
class Program
{
    static Dictionary<string, string> dict;
    static void Initialize()
    {
        dict = new Dictionary<string, string>();
        dict.Add("A", "Apple");
    }
    static void Clear()
    {
        dict.Clear();
    }
    static void Main()
    {
        Initialize();
        Clear();
        // クリア後にアクセスすると例外が発生
        var value = dict["A"];
        Console.WriteLine(value);
    }
}
Unhandled exception. System.Collections.Generic.KeyNotFoundException: The given key 'A' was not present in the dictionary.
   at System.Collections.Generic.Dictionary`2.get_Item(TKey key)
   at Program.Main()

このようなケースでは、コレクションの状態管理を明確にし、アクセス前に状態をチェックすることが重要です。

読み込み専用Dictionaryへの書き込み

ReadOnlyDictionary<TKey, TValue>は読み取り専用のラッパーであり、書き込み操作ができません。

もし誤って読み込み専用のDictionaryに対して書き込みや更新を試みると、NotSupportedExceptionが発生しますが、内部的に状態が変わらず、後続の読み取りでKeyNotFoundExceptionが発生することもあります。

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
class Program
{
    static void Main()
    {
        var baseDict = new Dictionary<string, string>();
        baseDict.Add("X", "Xylophone");
        var readOnlyDict = new ReadOnlyDictionary<string, string>(baseDict);
        try
        {
            // 読み込み専用のため例外が発生
            readOnlyDict["Y"] = "Yam";
        }
        catch (NotSupportedException e)
        {
            Console.WriteLine("書き込み不可: " + e.Message);
        }
        // 存在しないキーにアクセスするとKeyNotFoundException
        var value = readOnlyDict["Y"];
    }
}
書き込み不可: Collection is read-only.
Unhandled exception. System.Collections.Generic.KeyNotFoundException: The given key 'Y' was not present in the dictionary.
   at System.Collections.Generic.ReadOnlyDictionary`2.get_Item(TKey key)
   at Program.Main()

読み込み専用のコレクションを扱う際は、書き込み操作を行わないように注意し、必要に応じて元のDictionaryを更新してからラップする設計にすることが望ましいです。

TryGetValueによる安全な取得

基本構文と戻り値

TryGetValueメソッドは、Dictionary<TKey, TValue>において指定したキーが存在するかどうかを安全に確認し、存在すれば対応する値を取得できるメソッドです。

キーが存在しない場合でも例外をスローせず、falseを返すため、例外処理の負荷を減らせます。

基本的な構文は以下の通りです。

bool TryGetValue(TKey key, out TValue value);
  • key:検索したいキー
  • value:キーが存在した場合に対応する値が格納される変数(outパラメータ)
  • 戻り値:キーが存在すればtrue、存在しなければfalse

以下のサンプルコードをご覧ください。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        var dict = new Dictionary<string, string>
        {
            { "A", "Apple" },
            { "B", "Banana" }
        };
        if (dict.TryGetValue("A", out var value))
        {
            Console.WriteLine($"キー'A'の値は: {value}");
        }
        else
        {
            Console.WriteLine("キー'A'は存在しません。");
        }
        if (dict.TryGetValue("C", out value))
        {
            Console.WriteLine($"キー'C'の値は: {value}");
        }
        else
        {
            Console.WriteLine("キー'C'は存在しません。");
        }
    }
}
キー'A'の値は: Apple
キー'C'は存在しません。

このように、TryGetValueはキーの存在チェックと値の取得を一度の操作で行い、例外を回避しながら安全にアクセスできます。

デフォルト値の指定

TryGetValueはキーが存在しない場合にfalseを返し、outパラメータの値は型のデフォルト値(例えばstringならnullintなら0)になります。

これを利用して、存在しないキーに対してデフォルト値を返す処理を簡単に実装できます。

以下は、キーが存在しない場合にデフォルト値を返す例です。

using System;
using System.Collections.Generic;
class Program
{
    static string GetValueOrDefault(Dictionary<string, string> dict, string key, string defaultValue)
    {
        return dict.TryGetValue(key, out var value) ? value : defaultValue;
    }
    static void Main()
    {
        var dict = new Dictionary<string, string>
        {
            { "X", "Xylophone" }
        };
        Console.WriteLine(GetValueOrDefault(dict, "X", "デフォルト値")); // Xylophone
        Console.WriteLine(GetValueOrDefault(dict, "Y", "デフォルト値")); // デフォルト値
    }
}
Xylophone
デフォルト値

このように、TryGetValueを使うことで、存在しないキーに対しても安全にデフォルト値を返すことができます。

ログ出力とエラーハンドリング

TryGetValueを使うことで例外を防げますが、キーが存在しない場合のログ出力やエラーハンドリングは別途実装する必要があります。

例えば、キーが見つからなかった場合にログを残すことで、後から問題の原因を追跡しやすくなります。

以下は、ログ出力を行う例です。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        var dict = new Dictionary<string, string>
        {
            { "Key1", "Value1" }
        };
        if (dict.TryGetValue("Key2", out var value))
        {
            Console.WriteLine($"値: {value}");
        }
        else
        {
            Console.WriteLine("キーが存在しません。ログに記録します。");
            // ここでログ出力処理を行う(例: ファイルやコンソール)
            LogMissingKey("Key2");
        }
    }
    static void LogMissingKey(string key)
    {
        Console.WriteLine($"[LOG] キー '{key}' が見つかりませんでした。");
    }
}
キーが存在しません。ログに記録します。
[LOG] キー 'Key2' が見つかりませんでした。

このように、TryGetValueで安全にアクセスしつつ、キーが存在しない場合の対応を柔軟に行えます。

パフォーマンス比較

TryGetValueとContainsKey

ContainsKeyは指定したキーが存在するかどうかを判定するメソッドで、trueまたはfalseを返します。

ContainsKeyで存在を確認した後にインデクサで値を取得するコードはよく見られますが、実はTryGetValueのほうがパフォーマンス面で優れています。

理由は、ContainsKeyとインデクサの両方が内部でキーの検索を行うため、2回の検索が発生するからです。

一方、TryGetValueは1回の検索で存在確認と値の取得を同時に行います。

以下のコードはContainsKeyを使った例です。

if (dict.ContainsKey("A"))
{
    var value = dict["A"];
    Console.WriteLine(value);
}
else
{
    Console.WriteLine("キーが存在しません。");
}

この場合、ContainsKeyで検索し、続けてdict["A"]で再度検索が行われます。

一方、TryGetValueは1回の検索で済みます。

if (dict.TryGetValue("A", out var value))
{
    Console.WriteLine(value);
}
else
{
    Console.WriteLine("キーが存在しません。");
}

パフォーマンスが重要な場面では、TryGetValueの使用が推奨されます。

TryGetValueと例外捕捉

例外処理はコストが高いため、KeyNotFoundExceptionを例外捕捉で回避する方法はパフォーマンスに悪影響を与えます。

例外が発生するとスタックトレースの生成など多くの処理が行われるため、頻繁に例外が発生する可能性がある場合は特に避けるべきです。

以下は例外捕捉でアクセスする例です。

try
{
    var value = dict["A"];
    Console.WriteLine(value);
}
catch (KeyNotFoundException)
{
    Console.WriteLine("キーが存在しません。");
}

この方法はコードがシンプルに見えますが、例外が発生した場合の処理コストが高く、パフォーマンスが低下します。

TryGetValueを使うことで例外を未然に防ぎ、効率的に値を取得できるため、例外捕捉による回避は避けるのがベストプラクティスです。

ContainsKeyでの事前チェック

基本的な使い方

ContainsKeyメソッドは、Dictionary<TKey, TValue>に指定したキーが存在するかどうかを判定するためのメソッドです。

戻り値はbool型で、キーが存在すればtrue、存在しなければfalseを返します。

基本的な使い方は以下の通りです。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        var dict = new Dictionary<string, int>
        {
            { "apple", 100 },
            { "banana", 200 }
        };
        if (dict.ContainsKey("apple"))
        {
            int value = dict["apple"];
            Console.WriteLine($"appleの値は {value} です。");
        }
        else
        {
            Console.WriteLine("appleは存在しません。");
        }
    }
}
appleの値は 100 です。

このように、ContainsKeyで存在を確認してからインデクサで値を取得することで、KeyNotFoundExceptionの発生を防げます。

可読性と意図の伝達

ContainsKeyを使うコードは、キーの存在を明示的にチェックしているため、コードの意図がわかりやすくなります。

特に、値の取得前に存在確認を行うことが明示されているため、読み手にとって安全性を意識した設計であることが伝わります。

例えば、以下のコードは「キーが存在するかどうかをまず確認し、その後値を取得する」という処理の流れが明確です。

if (dict.ContainsKey("key"))
{
    var value = dict["key"];
    // 値を使った処理
}
else
{
    // キーが存在しない場合の処理
}

このように、ContainsKeyはコードの可読性を高め、意図を明確に伝える効果があります。

二重探索問題と負荷

ContainsKeyを使う場合、キーの存在確認と値の取得で2回辞書の検索が行われるため、パフォーマンスに影響を与えることがあります。

特に大量のアクセスや高頻度の呼び出しがある場合は注意が必要です。

高頻度アクセス

高頻度で辞書にアクセスする場合、ContainsKeyとインデクサの組み合わせは2回のハッシュ検索を行うため、無駄な処理が増えます。

これによりCPU負荷が上がり、パフォーマンスが低下する可能性があります。

for (int i = 0; i < 1000000; i++)
{
    if (dict.ContainsKey("key"))
    {
        var value = dict["key"];
        // 処理
    }
}

このようなループでは、TryGetValueを使って1回の検索で済ませるほうが効率的です。

低頻度アクセス

一方で、アクセス頻度が低い場合やコードの可読性を優先したい場合は、ContainsKeyを使うことに大きな問題はありません。

パフォーマンス差はほとんど無視できるレベルです。

if (dict.ContainsKey("key"))
{
    var value = dict["key"];
    Console.WriteLine(value);
}
else
{
    Console.WriteLine("キーが存在しません。");
}

このように、頻度や用途に応じてContainsKeyの使用を検討するとよいでしょう。

例外捕捉のアプローチ

try-catchの配置場所

KeyNotFoundExceptionを例外捕捉で処理する場合、try-catchブロックの配置場所は非常に重要です。

例外が発生しうる最小限の範囲に限定して配置することで、例外処理の影響範囲を狭め、コードの可読性と保守性を高められます。

例えば、辞書のインデクサアクセス部分だけをtryブロックに含める方法です。

try
{
    var value = dict["key"];
    Console.WriteLine(value);
}
catch (KeyNotFoundException ex)
{
    Console.WriteLine($"キーが見つかりませんでした: {ex.Message}");
}

このように、例外が発生する可能性のある箇所だけを囲むことで、他の処理に影響を与えずに例外を捕捉できます。

逆に、try-catchを広範囲に配置すると、どの処理で例外が発生したのか特定しづらくなり、デバッグが困難になるため避けるべきです。

スタックトレースの記録

例外が発生した際には、スタックトレースを記録することがトラブルシューティングに役立ちます。

スタックトレースは例外が発生した呼び出し履歴を示し、どのメソッドや行で問題が起きたかを特定できます。

例外オブジェクトのStackTraceプロパティを利用してログに出力しましょう。

try
{
    var value = dict["key"];
}
catch (KeyNotFoundException ex)
{
    Console.WriteLine($"例外メッセージ: {ex.Message}");
    Console.WriteLine($"スタックトレース: {ex.StackTrace}");
    // ここでファイルやログシステムに記録することも可能
}

ログにスタックトレースを残すことで、後から問題の原因を詳細に分析でき、再発防止策の検討に役立ちます。

ユーザーへのフィードバック

例外が発生した際にユーザーに適切なフィードバックを返すことも重要です。

KeyNotFoundExceptionの場合、単にエラーメッセージを表示するだけでなく、ユーザーが理解しやすい文言や代替案を提示すると良いでしょう。

例えば、以下のようにユーザー向けメッセージを表示します。

try
{
    var value = dict["key"];
    Console.WriteLine($"取得した値: {value}");
}
catch (KeyNotFoundException)
{
    Console.WriteLine("指定されたキーは存在しません。別のキーを入力してください。");
}

また、GUIアプリケーションやWebアプリケーションでは、例外発生時にエラーダイアログや通知を表示し、操作の継続や修正を促すことが望ましいです。

ユーザーにとって分かりやすく、かつ過度に専門的でないメッセージを提供することで、ユーザー体験の向上につながります。

拡張メソッドでの共通化

GetOrDefaultの実装例

Dictionaryのキーが存在しない場合にデフォルト値を返す処理はよく使われますが、毎回同じコードを書くのは冗長です。

そこで拡張メソッドを使って共通化すると便利です。

以下は、キーが存在すれば値を返し、存在しなければ指定したデフォルト値を返すGetOrDefault拡張メソッドの実装例です。

using System.Collections.Generic;
public static class DictionaryExtensions
{
    public static TValue GetOrDefault<TKey, TValue>(
        this IDictionary<TKey, TValue> dict,
        TKey key,
        TValue defaultValue = default)
    {
        return dict.TryGetValue(key, out var value) ? value : defaultValue;
    }
}

この拡張メソッドはIDictionary<TKey, TValue>を対象にしているため、DictionaryだけでなくReadOnlyDictionarySortedListなどでも利用可能です。

使い方は以下の通りです。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        var dict = new Dictionary<string, string>
        {
            { "A", "Apple" }
        };
        Console.WriteLine(dict.GetOrDefault("A", "デフォルト値")); // Apple
        Console.WriteLine(dict.GetOrDefault("B", "デフォルト値")); // デフォルト値
    }
}
Apple
デフォルト値

このように、GetOrDefaultを使うことでコードの簡潔さと再利用性が向上します。

Lazy初期化とAddOrUpdate

辞書の値を取得する際に、キーが存在しなければ新たに値を生成して追加したいケースがあります。

これを効率的に行うために、拡張メソッドで「Lazy初期化」と「AddOrUpdate」機能を実装できます。

以下は、キーが存在しなければファクトリ関数で値を生成し、辞書に追加して返すGetOrAdd拡張メソッドの例です。

using System;
using System.Collections.Generic;
public static class DictionaryExtensions
{
    public static TValue GetOrAdd<TKey, TValue>(
        this IDictionary<TKey, TValue> dict,
        TKey key,
        Func<TValue> valueFactory)
    {
        if (dict.TryGetValue(key, out var value))
        {
            return value;
        }
        else
        {
            value = valueFactory();
            dict.Add(key, value);
            return value;
        }
    }
}

使い方は以下の通りです。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        var dict = new Dictionary<string, int>();
        int value1 = dict.GetOrAdd("count", () => 1);
        Console.WriteLine(value1); // 1
        int value2 = dict.GetOrAdd("count", () => 100);
        Console.WriteLine(value2); // 1(既に存在するためファクトリは呼ばれない)
    }
}
1
1

この方法は、値の生成コストが高い場合や初期化処理を遅延させたい場合に有効です。

ジェネリック制約で型安全性向上

拡張メソッドにジェネリック制約を付けることで、型安全性を高めることができます。

例えば、TKeyIEquatable<TKey>を制約として付けると、キーの比較が効率的に行われることを保証できます。

以下は、GetOrDefaultIEquatable<TKey>制約を付けた例です。

using System.Collections.Generic;
public static class DictionaryExtensions
{
    public static TValue GetOrDefault<TKey, TValue>(
        this IDictionary<TKey, TValue> dict,
        TKey key,
        TValue defaultValue = default)
        where TKey : IEquatable<TKey>
    {
        return dict.TryGetValue(key, out var value) ? value : defaultValue;
    }
}

この制約により、TKeyIEquatable<TKey>を実装していない型の場合はコンパイルエラーとなり、誤った型の使用を防げます。

また、AddOrUpdateGetOrAddの拡張メソッドにも同様の制約を付けることで、より安全に辞書操作を行えます。

このようにジェネリック制約を活用することで、拡張メソッドの利用時に型の不整合を未然に防ぎ、堅牢なコードを書くことが可能です。

シナリオ別実装パターン

設定データ読込み

アプリケーションの設定データをDictionaryで管理する場合、設定ファイルや外部ソースから読み込んだキーと値を格納します。

設定値が存在しないキーにアクセスするとKeyNotFoundExceptionが発生しやすいため、TryGetValueや拡張メソッドを活用して安全に値を取得することが重要です。

以下は、設定データを読み込み、キーが存在しない場合はデフォルト値を返す例です。

using System;
using System.Collections.Generic;
class Program
{
    static Dictionary<string, string> LoadSettings()
    {
        // 例としてハードコードで設定を用意
        return new Dictionary<string, string>
        {
            { "Theme", "Dark" },
            { "Language", "ja-JP" }
        };
    }
    static void Main()
    {
        var settings = LoadSettings();
        if (settings.TryGetValue("Theme", out var theme))
        {
            Console.WriteLine($"テーマ: {theme}");
        }
        else
        {
            Console.WriteLine("テーマ設定が見つかりません。デフォルトを使用します。");
        }
        // 存在しないキーの例
        var fontSize = settings.GetOrDefault("FontSize", "12pt");
        Console.WriteLine($"フォントサイズ: {fontSize}");
    }
}
public static class DictionaryExtensions
{
    public static TValue GetOrDefault<TKey, TValue>(
        this IDictionary<TKey, TValue> dict,
        TKey key,
        TValue defaultValue = default)
    {
        return dict.TryGetValue(key, out var value) ? value : defaultValue;
    }
}
テーマ: Dark
フォントサイズ: 12pt

このように、設定データの読み込み時は存在しないキーに対しても安全に対応できる実装が求められます。

Web APIレスポンスキャッシュ

Web APIのレスポンスをキャッシュする際、レスポンスデータをDictionaryで管理することがあります。

APIのエンドポイントやパラメータをキーにしてレスポンスを保存し、同じリクエストに対してはキャッシュから高速にデータを返します。

キャッシュに存在しないキーでアクセスすると例外が発生するため、TryGetValueを使って存在チェックと取得を同時に行うのが一般的です。

using System;
using System.Collections.Generic;
class ApiCache
{
    private Dictionary<string, string> cache = new Dictionary<string, string>();
    public void AddResponse(string key, string response)
    {
        cache[key] = response;
    }
    public bool TryGetResponse(string key, out string response)
    {
        return cache.TryGetValue(key, out response);
    }
}
class Program
{
    static void Main()
    {
        var apiCache = new ApiCache();
        apiCache.AddResponse("GET:/users/1", "{ 'id': 1, 'name': 'Alice' }");
        if (apiCache.TryGetResponse("GET:/users/1", out var cachedResponse))
        {
            Console.WriteLine("キャッシュから取得: " + cachedResponse);
        }
        else
        {
            Console.WriteLine("キャッシュに存在しません。API呼び出しが必要です。");
        }
        if (apiCache.TryGetResponse("GET:/users/2", out cachedResponse))
        {
            Console.WriteLine("キャッシュから取得: " + cachedResponse);
        }
        else
        {
            Console.WriteLine("キャッシュに存在しません。API呼び出しが必要です。");
        }
    }
}
キャッシュから取得: { 'id': 1, 'name': 'Alice' }
キャッシュに存在しません。API呼び出しが必要です。

このように、APIレスポンスキャッシュではTryGetValueを活用して安全かつ効率的にキャッシュの有無を判定します。

ゲームリソース管理

ゲーム開発では、リソース(テクスチャ、音声、モデルなど)をDictionaryで管理することが多いです。

リソース名やIDをキーにしてロード済みのリソースを保持し、必要に応じて取得します。

リソースが存在しない場合に例外が発生するとゲームの動作に支障をきたすため、TryGetValueGetOrDefaultで安全にアクセスし、存在しなければ代替リソースを返す設計が一般的です。

using System;
using System.Collections.Generic;
class ResourceManager
{
    private Dictionary<string, string> textures = new Dictionary<string, string>();
    public ResourceManager()
    {
        textures["Player"] = "PlayerTexture.png";
        textures["Enemy"] = "EnemyTexture.png";
    }
    public string GetTexture(string name)
    {
        return textures.GetOrDefault(name, "DefaultTexture.png");
    }
}
class Program
{
    static void Main()
    {
        var resourceManager = new ResourceManager();
        Console.WriteLine(resourceManager.GetTexture("Player"));  // PlayerTexture.png
        Console.WriteLine(resourceManager.GetTexture("Boss"));    // DefaultTexture.png
    }
}
public static class DictionaryExtensions
{
    public static TValue GetOrDefault<TKey, TValue>(
        this IDictionary<TKey, TValue> dict,
        TKey key,
        TValue defaultValue = default)
    {
        return dict.TryGetValue(key, out var value) ? value : defaultValue;
    }
}
PlayerTexture.png
DefaultTexture.png

このように、ゲームリソース管理では例外を避けつつ柔軟にリソースを取得できる実装が求められます。

金融コードテーブル参照

金融システムでは、証券コードや通貨コードなどのコードテーブルをDictionaryで管理し、コードから名称や属性を取得することが多いです。

コードが存在しない場合に例外が発生すると業務処理に影響が出るため、事前に存在チェックや安全な取得方法を用います。

以下は、証券コードから銘柄名を取得する例です。

using System;
using System.Collections.Generic;
class SecurityCodeTable
{
    private Dictionary<string, string> codeToName = new Dictionary<string, string>
    {
        { "7203", "トヨタ自動車" },
        { "6758", "ソニー" }
    };
    public string GetName(string code)
    {
        return codeToName.GetOrDefault(code, "不明な銘柄コード");
    }
}
class Program
{
    static void Main()
    {
        var table = new SecurityCodeTable();
        Console.WriteLine(table.GetName("7203")); // トヨタ自動車
        Console.WriteLine(table.GetName("1234")); // 不明な銘柄コード
    }
}
public static class DictionaryExtensions
{
    public static TValue GetOrDefault<TKey, TValue>(
        this IDictionary<TKey, TValue> dict,
        TKey key,
        TValue defaultValue = default)
    {
        return dict.TryGetValue(key, out var value) ? value : defaultValue;
    }
}
トヨタ自動車
不明な銘柄コード

このように、金融コードテーブル参照では安全にコードの存在を確認し、適切なデフォルト値を返すことが重要です。

落とし穴とその回避策

Null許容参照型とDictionary

C# 8.0以降で導入されたNull許容参照型(Nullable Reference Types)を使う場合、Dictionaryのキーや値にnullが入る可能性に注意が必要です。

Dictionary<TKey, TValue>のキーはnullを許容しません。

もしキーにnullを指定するとArgumentNullExceptionが発生します。

一方、値はnullを許容することができますが、TryGetValueで取得した値がnullの場合とキーが存在しない場合の区別がつきにくくなることがあります。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        var dict = new Dictionary<string, string?>();
        dict["key1"] = null;
        if (dict.TryGetValue("key1", out var value))
        {
            Console.WriteLine(value == null ? "値はnullです" : value);
        }
        else
        {
            Console.WriteLine("キーが存在しません");
        }
        // nullキーは例外になる
        try
        {
            dict[null!] = "test"; // ArgumentNullExceptionが発生
        }
        catch (ArgumentNullException ex)
        {
            Console.WriteLine($"例外発生: {ex.Message}");
        }
    }
}
値はnullです
例外発生: キーは null にできません。

回避策としては、キーにnullを絶対に入れない設計にすること、値がnullの場合の意味を明確にし、TryGetValueの戻り値でキーの存在を判定することが重要です。

Custom IEqualityComparerの設定忘れ

Dictionaryはキーの比較にIEqualityComparer<TKey>を使います。

デフォルトではEqualityComparer<TKey>.Defaultが使われますが、キーの比較方法をカスタマイズしたい場合は、コンストラクタでIEqualityComparer<TKey>を指定する必要があります。

これを忘れると、意図した比較が行われず、存在するキーが見つからずにKeyNotFoundExceptionが発生することがあります。

例えば、大文字小文字を区別しない文字列キーの辞書を作りたい場合、StringComparer.OrdinalIgnoreCaseを指定しないと、"Key""key"は別のキーとして扱われます。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        var dict = new Dictionary<string, string>(); // 大文字小文字区別あり
        dict["Key"] = "Value";
        Console.WriteLine(dict.ContainsKey("key")); // false
        var dictIgnoreCase = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        dictIgnoreCase["Key"] = "Value";
        Console.WriteLine(dictIgnoreCase.ContainsKey("key")); // true
    }
}
False
True

カスタム比較子を設定し忘れると、意図しない動作や例外の原因になるため、キーの性質に応じて適切なIEqualityComparerを設定しましょう。

JSONシリアライズによる型ずれ

DictionaryをJSONなどでシリアライズ・デシリアライズする際、キーや値の型が変わってしまうことがあります。

特にキーが数値型の場合、JSONの仕様上文字列として扱われるため、デシリアライズ後に元の型と異なり、KeyNotFoundExceptionが発生することがあります。

例えば、Dictionary<int, string>をJSONにシリアライズし、文字列キーのDictionary<string, string>としてデシリアライズすると、キーの型不一致でアクセスできなくなります。

using System;
using System.Collections.Generic;
using System.Text.Json;
class Program
{
    static void Main()
    {
        var dict = new Dictionary<int, string>
        {
            { 1, "One" },
            { 2, "Two" }
        };
        string json = JsonSerializer.Serialize(dict);
        Console.WriteLine(json); // {"1":"One","2":"Two"}
        // 文字列キーのDictionaryとしてデシリアライズ
        var dictStr = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
        // intキーでアクセスしようとすると例外や失敗の原因に
        if (dictStr != null && dictStr.TryGetValue("1", out var value))
        {
            Console.WriteLine(value); // One
        }
        else
        {
            Console.WriteLine("キーが見つかりません");
        }
    }
}
{"1":"One","2":"Two"}
One

この例では文字列キーでアクセスしていますが、元のintキーでアクセスしようとすると失敗します。

回避策としては、シリアライズ・デシリアライズ時にキーの型を揃えるか、カスタムコンバーターを使うことが挙げられます。

検証データ不足による潜在バグ

Dictionaryを使う際に十分な検証データがないと、存在しないキーにアクセスしてKeyNotFoundExceptionが発生する潜在的なバグが見逃されることがあります。

特に開発初期やテスト段階で、想定外のキーが渡されるケースを網羅できていないと、本番環境で例外が発生するリスクが高まります。

例えば、以下のようにテストケースが不足していると、特定のキーで例外が発生しても気づかないことがあります。

var dict = new Dictionary<string, string>
{
    { "A", "Apple" },
    { "B", "Banana" }
};
// テストでは"A"と"B"のみ検証
Console.WriteLine(dict["A"]);
Console.WriteLine(dict["B"]);
// "C"のケースがテストされていないため例外が見逃される

回避策としては、以下のポイントが重要です。

  • 入力データの網羅的なテストを行う
  • TryGetValueGetOrDefaultを使い例外を防ぐ実装にする
  • ログやモニタリングで例外発生状況を把握し、早期に対応する

これらを徹底することで、潜在的なバグを減らし、安定した動作を実現できます。

パフォーマンス最適化のポイント

DictionaryのCapacity設定

Dictionary<TKey, TValue>は内部でハッシュテーブルを使って高速なキー検索を実現していますが、初期容量Capacityの設定がパフォーマンスに大きく影響します。

容量が不足すると内部で自動的にリサイズ(再ハッシュ)が発生し、その際に処理が重くなるため、可能な限り初期容量を適切に設定することが重要です。

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        // 初期容量を指定しない場合、デフォルト容量で開始し、
        // 要素数が増えるとリサイズが発生する可能性がある
        var dictDefault = new Dictionary<int, string>();
        // 初期容量を1000に設定
        var dictWithCapacity = new Dictionary<int, string>(1000);
        // 大量のデータを追加する場合は初期容量を設定することで
        // リサイズ回数を減らしパフォーマンスを向上できる
        for (int i = 0; i < 1000; i++)
        {
            dictWithCapacity[i] = $"Value{i}";
        }
        Console.WriteLine("データ登録完了");
    }
}
データ登録完了

リサイズは内部配列の再割り当てと全要素の再ハッシュを伴うため、頻繁に発生するとパフォーマンスが低下します。

事前に要素数の見積もりが可能な場合は、Dictionaryのコンストラクタで適切な容量を指定しましょう。

ReadOnlyDictionaryの活用

ReadOnlyDictionary<TKey, TValue>は読み取り専用のラッパーで、元のDictionaryを変更不可にして安全に共有したい場合に使います。

読み取り専用であるため、スレッドセーフ性が向上し、誤って書き換えられるリスクを減らせます。

パフォーマンス面では、ReadOnlyDictionaryは内部的に元のDictionaryを参照しているため、読み取り操作はほぼオーバーヘッドなしで高速に行えます。

ただし、書き込みはできないため、更新が不要なデータに対して使うのが適切です。

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
class Program
{
    static void Main()
    {
        var dict = new Dictionary<string, int>
        {
            { "A", 1 },
            { "B", 2 }
        };
        var readOnlyDict = new ReadOnlyDictionary<string, int>(dict);
        Console.WriteLine(readOnlyDict["A"]); // 1
        // readOnlyDict["C"] = 3; // コンパイルエラー: 書き込み不可
    }
}

読み取り専用にすることで、複数のコンポーネント間で安全に共有でき、意図しない変更によるバグを防止しつつ、パフォーマンスを維持できます。

ConcurrentDictionary選択基準

ConcurrentDictionary<TKey, TValue>はマルチスレッド環境でのスレッドセーフな辞書実装です。

複数スレッドから同時に読み書きが発生する場合に利用しますが、単一スレッドや読み取り専用の用途ではオーバーヘッドが大きくなるため適切な選択が必要です。

選択基準のポイントは以下の通りです。

条件推奨コレクション理由
単一スレッドでの使用Dictionary<TKey, TValue>シンプルで高速
複数スレッドで読み取り中心Dictionary + ロック読み取りは高速、書き込みはロックで制御
複数スレッドで頻繁な読み書きConcurrentDictionary内部で細かいロック制御により高い並行性

ConcurrentDictionaryは内部で分割ロックやロックフリー技術を使い、高いスループットを実現していますが、単純なDictionaryよりもメモリ使用量やCPU負荷が高くなることがあります。

using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
class Program
{
    static ConcurrentDictionary<int, string> concurrentDict = new ConcurrentDictionary<int, string>();
    static void Main()
    {
        Parallel.For(0, 1000, i =>
        {
            concurrentDict.TryAdd(i, $"Value{i}");
        });
        Console.WriteLine($"登録件数: {concurrentDict.Count}");
    }
}

マルチスレッド環境で安全かつ効率的に辞書を扱いたい場合はConcurrentDictionaryを選択し、単純な用途ではDictionaryを使うのがパフォーマンス最適化の基本です。

実装チェックリスト

実装前の確認項目

  • キーの存在確認方法の選定

Dictionaryから値を取得する際に、TryGetValueを使うかContainsKeyで事前チェックするか、または例外捕捉を使うかを明確に決めておく。

パフォーマンスや可読性のバランスを考慮します。

  • キーの型と比較方法の確認

キーの型が適切か、必要に応じてIEqualityComparer<TKey>を設定しているかを確認します。

特に文字列キーの場合は大文字小文字の扱いに注意します。

  • Null許容参照型の扱い

キーにnullが入らない設計か、値がnullの場合の意味を明確にしているかをチェックします。

  • 例外発生リスクの把握

存在しないキーにアクセスした場合の例外発生リスクを理解し、例外処理や安全な取得方法を実装計画に組み込みます。

  • 拡張メソッドの活用検討

GetOrDefaultGetOrAddなどの拡張メソッドを利用してコードの重複を減らし、保守性を高める方針を検討します。

  • スレッドセーフ性の確認

マルチスレッド環境での使用が想定される場合、ConcurrentDictionaryの利用やロック機構の導入を検討します。

  • 初期容量の設定

予想される要素数に応じてDictionaryの初期容量を設定し、リサイズによるパフォーマンス低下を防ぐ。

  • データの更新タイミングと共有範囲の把握

複数箇所でDictionaryを共有・更新する場合のタイミングや責任範囲を明確にし、不整合を防止します。

デプロイ前の自動検証

  • 単体テストの充実

存在するキーと存在しないキーの両方で値の取得が正しく行われるかをテストします。

TryGetValueや拡張メソッドの動作確認も含める。

  • 境界値テストの実施

空のDictionaryや最大容量に近い状態での動作を検証し、例外やパフォーマンス問題がないか確認します。

  • マルチスレッドテスト

複数スレッドからの同時アクセスがある場合は、競合状態や例外発生の有無を検証します。

  • 例外発生時のログ出力確認

KeyNotFoundExceptionが発生した場合に適切にログが記録され、原因特定に役立つ情報が残るかをチェックします。

  • パフォーマンス測定

大量データアクセス時のパフォーマンスを測定し、必要に応じて初期容量設定やConcurrentDictionaryへの切り替えを検討します。

  • コードレビューの実施

キーの存在チェック方法や例外処理の実装が適切か、パフォーマンスや可読性の観点からレビューを行います。

  • 静的解析ツールの活用

Null許容参照型の警告や例外処理の不備を検出するために、静的解析ツールを導入し問題点を洗い出します。

これらのチェック項目をデプロイ前に自動化テストやレビューで確実にクリアすることで、KeyNotFoundExceptionの発生リスクを低減し、安定したシステム運用を実現できます。

まとめ

KeyNotFoundExceptionは、Dictionaryなどで存在しないキーにアクセスした際に発生します。

例外を防ぐには、TryGetValueContainsKeyで事前にキーの存在を確認することが重要です。

拡張メソッドを活用すればコードの共通化や安全な値取得が可能です。

また、マルチスレッド環境ではConcurrentDictionaryの利用や適切なロックが必要です。

パフォーマンス最適化や例外処理の設計、十分なテストを行うことで、堅牢で効率的な辞書操作が実現できます。

関連記事

Back to top button
目次へ