C# Tips | コレクション・LINQ:キャッシュ付き検索

C# C#
スポンサーリンク

はじめに:「キャッシュ付き検索」は“同じものを何度も探さない”ための技

LINQ で検索を書くとき、最初はだいたいこうなります。

var user = users.FirstOrDefault(x => x.Id == id);
C#

これ自体は悪くないのですが、「同じ id を何度も検索する」ような処理になってくると、
毎回コレクションをなめるのはもったいないです。

そこで出てくるのが「キャッシュ付き検索」という発想です。

一度探した結果を覚えておいて、
次に同じ条件で検索されたときは、
もう一度 LINQ で探さずに“キャッシュから返す”

という仕組みを、小さなユーティリティとして持っておくイメージです。

ここでは、初心者向けに

キャッシュ付き検索の基本アイデア
Dictionary を使ったシンプルなキャッシュ
LINQ と組み合わせた「遅延キャッシュ」パターン
業務での具体例(マスタ参照・外部 API 結果のキャッシュ)

を、例題付きでかみ砕いて説明していきます。


基本アイデア:「キー → 結果」を Dictionary に覚えさせる

まずは素朴な「毎回検索」から

例えば、商品コードから商品情報を引く処理を考えます。

public class Item
{
    public string Code { get; set; } = "";
    public string Name { get; set; } = "";
}

var items = new[]
{
    new Item { Code = "A", Name = "Apple" },
    new Item { Code = "B", Name = "Banana" },
    new Item { Code = "C", Name = "Cherry" },
};

Item? FindItem(string code)
{
    return items.FirstOrDefault(x => x.Code == code);
}
C#

FindItem("A") を 1 回だけ呼ぶならこれで十分ですが、
同じコードを何度も引くような処理だと、毎回 FirstOrDefault が走ります。

ここでの重要ポイントは、「“同じ条件で何度も検索する”なら、結果を覚えておいたほうが得」という感覚を持つことです。

Dictionary を使って「一度調べたら覚えておく」

キャッシュ付き検索の一番シンプルな形は、こうです。

public class ItemFinder
{
    private readonly Item[] _items;
    private readonly Dictionary<string, Item?> _cache = new();

    public ItemFinder(Item[] items)
    {
        _items = items;
    }

    public Item? Find(string code)
    {
        if (_cache.TryGetValue(code, out var cached))
        {
            // すでに調べたことがある → キャッシュから返す
            return cached;
        }

        // 初めてのコード → LINQ で検索
        var item = _items.FirstOrDefault(x => x.Code == code);

        // 結果をキャッシュに保存(見つからなかった null も含めて)
        _cache[code] = item;

        return item;
    }
}
C#

使い方はこうなります。

var finder = new ItemFinder(items);

var a1 = finder.Find("A"); // LINQ で検索してキャッシュ
var a2 = finder.Find("A"); // キャッシュから即返る
C#

ここでの重要ポイントは、「FirstOrDefault 自体をやめるのではなく、“一度だけやって、あとは結果を再利用する”」ということです。
LINQ は「初回の検索」にだけ使い、2 回目以降は Dictionary の TryGetValue で高速に返します。


LINQ と組み合わせた「遅延キャッシュ」パターン

「必要になったものだけキャッシュする」という考え方

さきほどの ItemFinder は、呼ばれたコードだけをキャッシュしていました。
これをもう少し汎用的にして、「任意のキーでキャッシュ付き検索できる」ユーティリティにしてみます。

public class CachedLookup<TSource, TKey, TValue>
    where TKey : notnull
{
    private readonly IEnumerable<TSource> _source;
    private readonly Func<TSource, TKey> _keySelector;
    private readonly Func<TSource, TValue> _valueSelector;
    private readonly Dictionary<TKey, TValue?> _cache = new();

    public CachedLookup(
        IEnumerable<TSource> source,
        Func<TSource, TKey> keySelector,
        Func<TSource, TValue> valueSelector)
    {
        _source = source;
        _keySelector = keySelector;
        _valueSelector = valueSelector;
    }

    public TValue? Find(TKey key)
    {
        if (_cache.TryGetValue(key, out var cached))
        {
            return cached;
        }

        var found = _source.FirstOrDefault(x => _keySelector(x)!.Equals(key));

        if (found is null)
        {
            _cache[key] = default;
            return default;
        }

        var value = _valueSelector(found);
        _cache[key] = value;
        return value;
    }
}
C#

使う側はこう書けます。

var itemNameLookup = new CachedLookup<Item, string, string>(
    items,
    x => x.Code,
    x => x.Name);

var name1 = itemNameLookup.Find("A"); // LINQ で検索してキャッシュ
var name2 = itemNameLookup.Find("A"); // キャッシュから返る
C#

ここでの重要ポイントは、「キャッシュ付き検索の“型”を 1 回作ってしまえば、LINQ の FirstOrDefault を中に閉じ込められる」ということです。
呼び出し側は「Find を呼ぶだけ」で、キャッシュか LINQ かを意識しなくて済みます。


「最初から全部 Dictionary にしてしまう」との違い

ToDictionary との比較

高速検索の話で出てきたように、最初からこうしてしまう手もあります。

var itemByCode = items.ToDictionary(x => x.Code);
C#

これは「全件を一度に Dictionary に変換する」やり方です。
一方、キャッシュ付き検索は「必要になったキーだけ、順次キャッシュしていく」やり方です。

どちらを選ぶかは、だいたい次のような感覚で決めます。

全件をほぼ確実に使う → 最初から ToDictionary でいい
使うキーは一部だけかもしれない → キャッシュ付き検索が向いている

ここでの重要ポイントは、「キャッシュ付き検索は“部分的にしか使わないかもしれないデータ”に対して有効」ということです。
全部を Dictionary にするコストを払うほどではないけれど、同じ検索を何度もやるのはもったいない、という中間地帯で効きます。


業務での具体例 1:マスタ参照のキャッシュ付き検索

商品マスタを何度も引く処理

例えば、注文明細を処理しながら、商品名を表示するケース。

public class OrderLine
{
    public string ItemCode { get; set; } = "";
    public int Quantity { get; set; }
}

var lines = new[]
{
    new OrderLine { ItemCode = "A", Quantity = 1 },
    new OrderLine { ItemCode = "B", Quantity = 2 },
    new OrderLine { ItemCode = "A", Quantity = 3 },
};
C#

素朴に書くとこうなります。

foreach (var line in lines)
{
    var item = items.FirstOrDefault(x => x.Code == line.ItemCode);
    Console.WriteLine($"{line.ItemCode} {item?.Name} x {line.Quantity}");
}
C#

ここで「A」が何度も出てくると、そのたびに FirstOrDefault が走ります。

キャッシュ付き検索を使うと、こう書き換えられます。

var finder = new ItemFinder(items);

foreach (var line in lines)
{
    var item = finder.Find(line.ItemCode);
    Console.WriteLine($"{line.ItemCode} {item?.Name} x {line.Quantity}");
}
C#

ここでの重要ポイントは、「同じ商品コードが何度出てきても、実際に LINQ で検索するのは最初の 1 回だけ」ということです。
以降は Dictionary のキャッシュから即座に返ります。


業務での具体例 2:外部 API の結果をキャッシュする

「同じキーで何度も API を叩きたくない」

例えば、「郵便番号から住所を引く外部 API」があるとします。

string CallAddressApi(string zip)
{
    // 実際には HTTP で外部サービスを呼ぶ
    Console.WriteLine($"API 呼び出し: {zip}");
    return "ダミー住所";
}
C#

素朴に書くと、こうなります。

var zips = new[] { "1000001", "1000002", "1000001", "1000003" };

foreach (var zip in zips)
{
    var address = CallAddressApi(zip);
    Console.WriteLine($"{zip}: {address}");
}
C#

"1000001" が 2 回出てくるので、API も 2 回呼ばれます。

キャッシュ付き検索の発想を使うと、こうできます。

public class AddressClient
{
    private readonly Dictionary<string, string> _cache = new();

    public string GetAddress(string zip)
    {
        if (_cache.TryGetValue(zip, out var cached))
        {
            return cached;
        }

        var address = CallAddressApi(zip);
        _cache[zip] = address;
        return address;
    }

    private string CallAddressApi(string zip)
    {
        Console.WriteLine($"API 呼び出し: {zip}");
        return "ダミー住所";
    }
}
C#

使い方はこうです。

var client = new AddressClient();

foreach (var zip in zips)
{
    var address = client.GetAddress(zip);
    Console.WriteLine($"{zip}: {address}");
}
C#

"1000001" に対して API が呼ばれるのは最初の 1 回だけになります。

ここでの重要ポイントは、「キャッシュ付き検索は“重い処理(外部 API・DB)”と相性が抜群」ということです。
LINQ というよりは「検索の考え方」ですが、同じパターンで組み込めます。


どこまでキャッシュするか、どう無効化するか

キャッシュは“永遠に正しい”とは限らない

キャッシュ付き検索は便利ですが、「データが変わる」世界では注意が必要です。

マスタが更新される
外部 API の結果が変わる
一定時間経ったら取り直したい

といった要件がある場合は、

一定時間でキャッシュをクリアする
明示的に Clear() するメソッドを用意する
キーごとに再取得する手段を用意する

など、「キャッシュの寿命」を設計する必要があります。

初心者のうちは、まずは「バッチ処理の中だけで使い捨てるキャッシュ」から始めるのがおすすめです。
プロセス内で完結し、処理が終わったら捨ててしまうので、整合性の問題をあまり気にせずに済みます。

ここでの重要ポイントは、「キャッシュ付き検索は“速くなる代わりに、古くなる可能性がある”」というトレードオフを意識することです。


まとめ:「キャッシュ付き検索ユーティリティ」は“同じ検索を二度しないための道具”

キャッシュ付き検索の本質は、

同じ条件で何度も検索するくらいなら、
一度だけ LINQ(や API)で探して、
結果を Dictionary に覚えさせておく

という発想です。

押さえておきたいポイントを整理すると、

FirstOrDefault を何度も呼ぶくらいなら、「一度だけ呼んで結果をキャッシュする」ほうが得
Dictionary を使えば、「キー → 結果」を高速に引ける
汎用的な CachedLookup のような型を作ると、LINQ 検索を中に閉じ込められる
マスタ参照や外部 API の結果など、「重い or 繰り返し使う検索」と相性が良い
キャッシュは“速さと引き換えに古くなる可能性がある”ので、寿命やクリア方法も設計の一部

ここまで腹落ちしていれば、
「なんとなく毎回 LINQ で検索する」段階から一歩進んで、
“検索の仕方そのものを設計できる C# エンジニア”に近づけます。

タイトルとURLをコピーしました