C#を利用した開発において、データの集合を扱う際に「特定の識別子(キー)に関連付けてデータを保持したい」という場面は頻繁に発生します。

多くのプログラミング言語ではこれを「Map」や「連想配列」と呼びますが、C#においては Dictionary<TKey, TValue> クラス がその役割を担っています。

Dictionaryは、膨大なデータの中から目的の値を瞬時に取り出すことができる非常に強力なコレクションです。

そのパフォーマンスの高さから、キャッシュ処理、設定情報の管理、データ集計など、実務のあらゆるシーンで欠かせない存在となっています。

本記事では、C#におけるMap(Dictionary)の基礎から、パフォーマンスを最大限に引き出す高度な最適化手法、そしてスレッドセーフな実装に至るまで、プロフェッショナルが知っておくべき知識を網羅的に解説します。

C#におけるMapの正体:Dictionaryとは

C#でMapを実現するための主要な手段は、System.Collections.Generic 名前空間に定義されている Dictionary<TKey, TValue> です。

このクラスは「ハッシュテーブル」というアルゴリズムに基づいて実装されており、キーと値のペアを効率的に管理します。

Dictionaryの主な特徴

Dictionaryを使用する上でもっとも重要な特徴は、その検索速度にあります。

  1. 高速なアクセス: キーを基にした値の検索、追加、削除の計算量は平均して O(1) (定数時間)です。
  2. 型安全性: ジェネリクスを使用しているため、キーと値の型を厳密に指定でき、コンパイル時に型チェックが行われます。
  3. キーの重複禁止: 同一のキーを複数登録することはできません。同じキーで追加しようとすると例外が発生するか、値が上書きされます。
  4. 順序の非保証: Dictionaryは要素の追加順序を保持することを保証しません(C#のバージョンや実装によりますが、順序に依存した設計は避けるべきです)。

Dictionaryの基本的な操作方法

まずは、実務で頻繁に使用する基本的な操作方法を整理しましょう。

初期化から要素の操作まで、C#の最新の構文を交えて紹介します。

Dictionaryの宣言と初期化

C# 12以降では、コレクション式を使用することでより簡潔に初期化が可能になりました。

もちろん、従来のコレクション初期化子も引き続き利用可能です。

C#
using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        // 1. 従来の初期化方法(コレクション初期化子)
        var userRoles = new Dictionary<int, string>
        {
            { 1, "Admin" },
            { 2, "Editor" },
            { 3, "Viewer" }
        };

        // 2. C# 12以降のコレクション式を使用した初期化
        Dictionary<string, string> config = new()
        {
            ["Theme"] = "Dark",
            ["Language"] = "Japanese"
        };

        // 内容の表示
        Console.WriteLine($"User ID 1 Role: {userRoles[1]}");
        Console.WriteLine($"Current Theme: {config["Theme"]}");
    }
}
実行結果
User ID 1 Role: Admin
Current Theme: Dark

要素の追加と更新

要素を追加するには、Add メソッドを使用する方法と、インデクサ([])を使用する方法があります。

  • Addメソッド: 既に同じキーが存在する場合、ArgumentException をスローします。
  • インデクサ: キーが存在しない場合は新規追加、存在する場合は 既存の値を上書き します。
C#
var inventory = new Dictionary<string, int>();

// Addメソッドでの追加
inventory.Add("Apple", 100);

// インデクサでの追加・更新
inventory["Banana"] = 50; // 新規追加
inventory["Apple"] = 120; // 上書き更新

// TryAddメソッド(C# 6.0以降)
// キーが存在しない場合のみ追加し、成功したかどうかをboolで返す
bool isAdded = inventory.TryAdd("Apple", 200); 
Console.WriteLine($"Apple added again? {isAdded}");
実行結果
Apple added again? False

要素の取得と安全なアクセス

Dictionaryから値を取得する際、存在しないキーを指定すると KeyNotFoundException が発生します。

これを防ぐためには、TryGetValue メソッドの使用が推奨されます。

C#
var prices = new Dictionary<string, int> { ["Coffee"] = 450 };

// 安全な値の取得
if (prices.TryGetValue("Coffee", out int price))
{
    Console.WriteLine($"Price of Coffee: {price}");
}
else
{
    Console.WriteLine("Price not found.");
}

// キーの存在確認のみを行う場合
if (prices.ContainsKey("Tea"))
{
    Console.WriteLine("Tea is on the menu.");
}

ループ処理とデータの抽出

Dictionary内のすべての要素を走査したり、特定の条件でデータを抽出したりする方法を解説します。

foreachによる列挙

Dictionaryをループで回す際、各要素は KeyValuePair<TKey, TValue> 型として扱われます。

C# 7.0以降では、タプルによる分解(Deconstruction) を使うことで、コードをより読みやすく記述できます。

C#
var ranking = new Dictionary<string, int>
{
    ["Alice"] = 1,
    ["Bob"] = 2,
    ["Charlie"] = 3
};

// タプル分解を利用したループ
foreach (var (name, rank) in ranking)
{
    Console.WriteLine($"{name} is ranked #{rank}");
}

LINQを利用したフィルタリング

特定の条件に合致する要素だけを抽出したい場合は、LINQ(Language Integrated Query)が非常に便利です。

C#
using System.Linq;

var scores = new Dictionary<string, int>
{
    ["Math"] = 90,
    ["English"] = 75,
    ["Science"] = 85
};

// 80点以上の科目だけを抽出して新しいDictionaryを作る
var highScores = scores
    .Where(kvp => kvp.Value >= 80)
    .ToDictionary(kvp => kvp.Key, kvp.Value);

foreach (var item in highScores)
{
    Console.WriteLine($"High Score: {item.Key} ({item.Value})");
}

実務で役立つ高度な活用法

基本操作を理解したところで、次は実際の開発現場で直面する課題を解決するための高度なテクニックを見ていきましょう。

独自クラスをキーにする際の注意点

Dictionaryのキーに、数値や文字列以外の「自作クラス(オブジェクト)」を使用する場合、単にクラスを定義しただけでは正しく動作しません。

Dictionary内部では、キーの同一性を判定するために GetHashCodeEquals メソッドを使用しているためです。

これらを正しくオーバーライドしないと、同じ内容のオブジェクトであっても「別のキー」として扱われてしまいます。

C#
public class UserKey
{
    public int Id { get; }
    public string Code { get; }

    public UserKey(int id, string code)
    {
        Id = id;
        Code = code;
    }

    // 同一性判定のためにEqualsをオーバーライド
    public override bool Equals(object obj)
    {
        if (obj is UserKey other)
        {
            return Id == other.Id && Code == other.Code;
        }
        return false;
    }

    // 一貫したハッシュ値を返すためにGetHashCodeをオーバーライド
    public override int GetHashCode()
    {
        return HashCode.Combine(Id, Code);
    }
}

このように、HashCode.Combine を利用することで、複数のプロパティに基づいたハッシュ値を安全に生成できます。

大文字・小文字を区別しない検索

文字列をキーにする際、デフォルトでは大文字と小文字は区別されます(”Apple” と “apple” は別物)。

これを区別しないようにするには、コンストラクタで StringComparer を渡します。

C#
// 大文字小文字を無視するDictionary
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

dict["Key"] = "Value";

if (dict.ContainsKey("key")) // Trueになる
{
    Console.WriteLine("Key found (case-insensitive)");
}

パフォーマンスを最適化するテクニック

Dictionaryは高速ですが、使い方を誤ると不必要なメモリ消費や処理遅延を引き起こします。

大規模なデータを扱う際に意識すべきポイントを整理します。

初期容量(Capacity)の指定

Dictionaryに大量の要素を追加することがあらかじめ分かっている場合は、初期化時に 初期容量(Capacity) を指定してください。

Dictionaryは内部のバッファがいっぱいになると、自動的にリサイズ(領域の拡張)を行います。

このリサイズ処理はコストが高いため、あらかじめサイズを指定しておくことでパフォーマンスを劇的に改善できます。

C#
// 10,000個の要素が入ることが分かっている場合
var largeDict = new Dictionary<int, string>(10000);

読み取り専用の制限

メソッドの戻り値などでDictionaryを返す際、外部から勝手に要素を書き換えられたくない場合があります。

そのときは、ReadOnlyDictionary<TKey, TValue> を使用するか、インターフェースとして IReadOnlyDictionary を公開します。

C#
using System.Collections.ObjectModel;

public class ConfigManager
{
    private Dictionary<string, string> _settings = new();

    // 読み取り専用として公開
    public IReadOnlyDictionary<string, string> Settings => _settings;
}

マルチスレッド環境での利用:ConcurrentDictionary

標準の Dictionary はスレッドセーフではありません。

複数のスレッドから同時に読み書きを行うと、内部データが破損したり、無限ループに陥ったりする危険があります。

マルチスレッド環境(例えばASP.NET Coreの共有キャッシュなど)では、System.Collections.Concurrent.ConcurrentDictionary<TKey, TValue> を使用します。

主要なスレッドセーフ・メソッド

ConcurrentDictionaryには、スレッド競合を考慮した便利なメソッドが用意されています。

メソッド名説明
GetOrAddキーが存在すれば取得し、なければ生成して追加する
AddOrUpdateキーが存在すれば値を更新し、なければ追加する
TryUpdate既存の値が期待通りであれば、新しい値に更新する(CAS操作)
C#
using System.Collections.Concurrent;

var concurrentCounter = new ConcurrentDictionary<string, int>();

// スレッドセーフな値の加算
concurrentCounter.AddOrUpdate("VisitorCount", 1, (key, oldValue) => oldValue + 1);

Dictionaryと他のコレクションとの比較

「Map」のような機能を持つクラスは、Dictionary以外にもいくつか存在します。

用途に応じて最適なものを選択しましょう。

クラス名特徴適したシーン
Dictionaryハッシュテーブル。もっとも一般的で高速。通常のキー・値管理
SortedDictionary二分探索木。キーが常にソートされる。常にキー順でデータを取り出したい場合
SortedListソート済み配列。メモリ使用量が少ないが追加が遅い。要素数が少なく、メモリを節約したい場合
ImmutableDictionary不変。一度作成すると変更不可。関数型プログラミング、安全な共有

実践例:データ集計プログラム

最後に、Dictionaryを活用した実践的な例として、リスト内の単語の出現回数をカウントするプログラムを紹介します。

C#
using System;
using System.Collections.Generic;

class WordCounter
{
    static void Main()
    {
        string[] words = { "Apple", "Banana", "Apple", "Orange", "Banana", "Apple" };
        
        // 単語とその出現回数を保持するDictionary
        var counts = new Dictionary<string, int>();

        foreach (var word in words)
        {
            // C# 13以降で利用可能な「CollectionsMarshal」を使う手法もあるが、
            // ここでは一般的で読みやすい手法を紹介
            if (counts.TryGetValue(word, out int count))
            {
                counts[word] = count + 1;
            }
            else
            {
                counts[word] = 1;
            }
        }

        foreach (var (word, count) in counts)
        {
            Console.WriteLine($"{word}: {count}");
        }
    }
}
実行結果
Apple: 3
Banana: 2
Orange: 1

このように、Dictionaryを使うことでデータの分類や集計を非常にシンプルかつ高速に行うことができます。

まとめ

C#におけるMap、すなわち Dictionary<TKey, TValue> は、モダンなアプリケーション開発において極めて重要なデータ構造です。

本記事で解説した重要ポイントを振り返りましょう。

  • 基本的なアクセスには TryGetValue を使用し、例外を回避する。
  • C# 12のコレクション式やタプル分解を活用して、可読性の高いコードを書く。
  • 独自クラスをキーにする場合は、必ず EqualsGetHashCode をセットで実装する。
  • マルチスレッド環境では ConcurrentDictionary を選択する。
  • 大量データを扱う際は初期容量を指定し、不要なリサイズを避ける。

これらの知識を正しく活用することで、保守性が高くパフォーマンスに優れたシステムを構築できるようになります。

Dictionaryは単純な「箱」ではなく、適切にチューニングすることで真価を発揮する強力なツールです。

ぜひ日々のコーディングに取り入れてみてください。