C# LINQ Whereでインデックス付きpredicateが使える理由|Funcとオーバーロード解決を徹底解説

LINQのWhereで、要素だけでなく「要素+インデックス」を受け取るpredicateが使えるのに、呼び出し側はインデックスを渡していない――この違和感は、Whereのオーバーロードと遅延実行を理解するとスッと解消できます。

目次

結論:Whereには「要素だけ版」と「要素+インデックス版」の2種類がある

まず結論から言うと、LINQのWhereは1種類ではありません。C#では同じ名前で引数が違うメソッドを複数用意でき、これをオーバーロードと呼びます。

そしてWhereには代表的に次の2パターンがあります(IEnumerable向け・概念図)。

呼び出しpredicateの型predicateが受け取るものインデックスの起点用途の例
Where(predicate)Func<TSource, bool>要素のみなし条件に合う要素の抽出
Where(predicate)Func<TSource, int, bool>要素+インデックス(0始まり)列挙ごとに0から「n番目だけ」「行番号で判定」など

見ての通り、呼び出しの形はどちらもWhere(predicate)で同じです。違うのは「渡しているpredicateの型」です。

なぜインデックスを渡していないのに動くのか:C#のオーバーロード解決が働く

質問のポイントはここです。

たとえば次のように、2引数のデリゲートを用意しておいて、Whereにはそれを渡すだけのコードがあるとします。

string[] words = { "orange", "apple", "Article", "elephant", "star", "and" };

Func<string, int, bool> predicate = (str, index) => str.Length == index;

IEnumerable<string> query = words.Where(predicate);

foreach (var w in query)
{
    Console.WriteLine(w);
}

呼び出し側は確かにwords.Where(predicate)としか書いていません。ですが、ここで渡しているpredicateの型がFunc<string, int, bool>です。

C#コンパイラは、Whereの候補(複数のオーバーロード)から「引数の型に合うメソッド」を選びます。今回はFunc<TSource, int, bool>を受け取るWhereがピッタリ一致するため、そちらが選択されます。

ポイント:渡しているのは「インデックス」ではなく「インデックスを受け取れるpredicate」

インデックス自体を呼び出し側が渡しているわけではなく、呼び出し側が渡しているのは「(要素, インデックス)を受け取れる形の関数」です。

そして、実際のインデックス値(0,1,2…)はWhereの内部で生成され、列挙中にpredicateへ渡されます。

インデックスはどこから来るのか:Whereの内部が列挙しながら作る

LINQ to Objects(System.Linq.Enumerable)のWhereは、結果をすぐに全部作って返すのではなく、必要になったタイミングで1件ずつ評価する「遅延実行(deferred execution)」が基本です。

そのため、内部は「列挙しながらindexを増やす」形になります。イメージとしては、次のような実装です(説明用の擬似コード)。

public static IEnumerable<TSource> Where<TSource>(
    this IEnumerable<TSource> source,
    Func<TSource, int, bool> predicate)
{
    if (source == null) throw new ArgumentNullException(nameof(source));
    if (predicate == null) throw new ArgumentNullException(nameof(predicate));

    return WhereIterator(source, predicate);
}

private static IEnumerable<TSource> WhereIterator<TSource>(
    IEnumerable<TSource> source,
    Func<TSource, int, bool> predicate)
{
    int index = -1;

    foreach (var item in source)
    {
        checked { index++; }   // 0,1,2,... と増える(オーバーフロー検出のためcheckedが使われることが多い)
        if (predicate(item, index))
        {
            yield return item;
        }
    }
}

この擬似コードの通り、indexはforeachで列挙している最中に自前で増やしています。つまり、インデックスは「外部から渡される値」ではなく「Whereが列挙のたびに付与する連番」です。

サンプルの動きを手で追う:なぜ star だけ出力されるのか

先ほどの配列は次の順序でした。

"orange","apple","Article","elephant","star","and"

列挙の順序とインデックス、文字列長を対応させるとこうなります。

index要素LengthLength == index
0orange6false
1apple5false
2Article7false
3elephant8false
4star4true
5and3false

一致するのはindex == 4かつLength == 4のstarだけなので、出力がstarになるわけです。

実務で重要:このindexは「元コレクションの添字」とは限らない

インデックス付きWhereを使うとき、理解が浅いとハマりやすい点があります。それは、indexが表すのは「その時点で列挙されているシーケンスにおける何番目か」だということです。

つまり「元の配列やListの添字」と一致するとは限りません。途中に他のLINQ演算子が挟まると、インデックスの意味は変わります。

SkipやOrderByが挟まるとどうなる?

たとえば次の例は、見た目は似ていますがindexの意味が異なります。

var a = words.Where((w, i) => i < 3);          // 先頭3件
var b = words.Skip(2).Where((w, i) => i < 3);  // Skip後の先頭3件

この2つの違いを表にすると、理解が早いです。

クエリindexがカウントする対象結果のイメージ注意点
words.Where((w,i) => i < 3)words全体(orange=0から開始)orange, apple, Article元シーケンス先頭基準
words.Skip(2).Where((w,i) => i < 3)Skip(2)後のシーケンス(Article=0から開始)Article, elephant, starindexはSkip後にリセットされる
words.OrderBy(w => w).Where((w,i) => i == 0)並び替え後の先頭(ソート結果の0番目)and(例)「元の順番の0番目」ではない

現場でよくあるのは「Skipしたのに、indexを元配列の添字だと思って条件を書いてしまいズレる」ケースです。インデックス付きpredicateを使うときは、“いま見ているシーケンスの並び”を常に意識すると事故が減ります。

再列挙でインデックスは振り直される:遅延実行の本質

Whereは多くの場合、遅延実行です。つまり、次のように同じクエリを複数回列挙すると、indexは毎回0から始まります。

var q = words.Where((w, i) => i % 2 == 0);

foreach (var x in q) { /* ここで列挙:indexは0,1,2... */ }
foreach (var x in q) { /* もう一度列挙:indexはまた0,1,2... */ }

「一度列挙したから次は続きの番号になる」ということはありません。番号は「クエリの状態」ではなく「列挙の実行中にWhereが作る一時的な値」です。

よくある誤解:自前カウンタで代用するとバグりやすい

インデックスが必要になったとき、次のように外側でカウンタ変数を増やすコードを見かけることがあります。

int i = 0;
var q = words.Where(w =>
{
    bool ok = (w.Length == i);
    i++;
    return ok;
});

一見それっぽく動きますが、実務では避けた方が安全です。

  • 再列挙で結果が変わる(2回目以降、iが前回の値から始まってしまう)
  • 途中で列挙が中断されたときの整合性が崩れる(Takeなど)
  • 並列処理や非同期列挙に弱い(共有状態になる)
  • 意図が読み取りづらい(副作用があるpredicateになる)

この手のバグを避けるためにも、インデックスが必要なら素直にインデックス付きオーバーロードを使うのが基本です。

オーバーロードが選ばれる条件をもっと具体的に理解する

「型で決まる」と言われても、実感が湧かないことがあります。そこで、コンパイル時に何が起きているかを、よくあるパターン別に整理します。

パターンA:ラムダを直接渡す(引数の数で決まりやすい)

words.Where(w => w.Length > 3);          // 1引数ラムダ → Func<string,bool> → 要素だけ版
words.Where((w, i) => w.Length == i);    // 2引数ラムダ → Func<string,int,bool> → インデックス版

ラムダの引数の数が違うため、ほぼ迷いなく該当オーバーロードが選ばれます。

パターンB:predicate変数を作って渡す(変数の静的型が決定打)

Func<string, bool> p1 = w => w.Length > 3;
Func<string, int, bool> p2 = (w, i) => w.Length == i;

words.Where(p1); // 要素だけ版
words.Where(p2); // インデックス版

この場合、Whereがどちらになるかは「変数の型」で決まります。つまり「インデックスは渡していない」のではなく、「インデックスを受け取れる形の関数を渡している」ためです。

パターンC:メソッドグループ(メソッド名だけ)を渡す

次のように、メソッドをそのままpredicateとして渡すこともあります。

static bool FilterByIndex(string s, int i) => s.Length == i;

var q = words.Where(FilterByIndex);

この場合も、FilterByIndexが(string, int) -> boolなので、インデックス版のWhereが選ばれます。

IEnumerableとIQueryableで注意点が変わる

LINQには大きく分けて2つの世界があります。

  • LINQ to Objects:IEnumerable<T>(メモリ上の配列・List・コレクションなど)
  • LINQ to Providers:IQueryable<T>(DBや外部データ、ORMなど)

どちらにもWhereはありますが、内部の意味合いが違います。

IEnumerable(LINQ to Objects)は「C#のコードとして実行される」

ここまで説明してきた「Whereが列挙しながらindexを増やす」は、LINQ to Objectsの典型です。つまり.NETランタイム上でそのままループが回るので、インデックスを付けるのも簡単です。

IQueryableは「式(Expression)として解析され、別言語へ変換されることがある」

IQueryableの場合、Whereに渡すのは多くのケースで「式ツリー(Expression Tree)」として扱われ、DBならSQLへ変換される、といった動きになります。

ここで実務上の注意点として、「インデックス付きのpredicateが必ず変換できるとは限らない」ことがあります。

  • プロバイダがインデックスの概念をSQLへ落とし込めない
  • 変換できず例外になる、またはクライアント側評価になる(設定や製品により挙動が異なる)

DB向けのクエリで「行番号」を使いたい場合は、プロバイダが提供する方法(ROW_NUMBER相当、ウィンドウ関数、キー列など)に寄せる方が安全です。インデックス付きWhereは、基本的にLINQ to Objects寄りのテクニックとして使うのが無難です。

インデックス付きWhereが活きる実用パターン

「インデックスで条件分岐」が活きる場面は意外と多いです。よくある実務例をいくつか挙げます。

偶数行・奇数行だけ抽出する

var even = words.Where((w, i) => i % 2 == 0); // 0,2,4...
var odd  = words.Where((w, i) => i % 2 == 1); // 1,3,5...

ログやCSV行の間引き、サンプリングなどに使えます。

先頭N件だけ「条件付き」で拾う(indexで上限を設ける)

int limit = 100;
var first100Long = words.Where((w, i) => i < limit && w.Length > 10);

Take(limit)でも似たことはできますが、条件と上限を一体で読ませたいときに便利です。

ヘッダー行だけ特別扱いする

CSVなどで「0行目はヘッダー、それ以降はデータ」といったケースは典型です。

var dataLines = lines.Where((line, i) => i != 0);

より柔軟に扱いたいなら:Selectでインデックスを持ち回る

インデックスをWhereだけで使うなら、インデックス付きWhereで十分です。

一方で、次のように「後段でもインデックスを使いたい」「ログに行番号を残したい」など、インデックスを複数ステップで使いたいなら、Selectでインデックスを明示的に持ち回る方が読みやすいことがあります。

var q =
    words
        .Select((w, i) => new { Word = w, Index = i })
        .Where(x => x.Word.Length == x.Index)
        .Select(x => x.Word);

foreach (var w in q)
{
    Console.WriteLine(w);
}

この形だと「インデックスがどこで付いたのか」「その後どの処理がインデックス基準なのか」がコード上で明確になります。チーム開発ではこちらの方が誤解が起きにくい場面もあります。

落とし穴と対策をまとめてチェック

最後に、インデックス付きWhereでよくある落とし穴を、対策とセットで整理します。

落とし穴起きがちな症状原因対策
再列挙で結果が変わると思い込む「2回目はindexが続きからになるはず」と誤解遅延実行で列挙のたびにindexが0から「indexは列挙中の一時値」と理解する
indexを元配列の添字だと思うSkip/Where/OrderBy後にズレるindexは“その時点のシーケンス”基準必要なら早い段階でSelect((x,i)=>…)して固定化
外部カウンタで代用してバグる2回目の列挙で不正、並列で壊れる副作用のあるpredicateになるインデックス付きWhereかSelectで明示的に扱う
IQueryableで同じノリで使う例外、または意図しない評価プロバイダが変換できない場合があるDB側の機能・キー列設計に寄せる

まとめ:indexは「Whereが列挙中に生成して渡している」

  • Whereには「要素だけ」と「要素+インデックス」を受け取るオーバーロードがある
  • 呼び出し側が渡すのはインデックスではなく、Func<TSource,int,bool>という形のpredicate
  • インデックス値(0,1,2…)はWhere内部の列挙ループで生成される
  • indexは「元配列の添字」ではなく「その時点の列挙順での位置」。再列挙で0から振り直される

この仕組みを押さえておくと、LINQのサンプルコードの“渡していないのに受け取れる”違和感がなくなり、インデックス付きpredicateを安全に使いこなせるようになります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次