First メソッドの実装をざっくり読む

Deep Dive into LINQ #3

Updated on 2022-03-07
target: .NET 6

Deep Dive into LINQ シリーズ記事一覧

Part 1 - First メソッドの使い方

Part 2 - First メソッドを作ってみる

Part 3 - First メソッドの実装をざっくり読む

Part 4 - First メソッドのパフォーマンス特性

はじめに

この記事は Deep Dive into LINQ シリーズの第3回目の記事です。 Deep Dive into LINQ シリーズでは、 LINQ の内部実装やパフォーマンス特性について基本的なことから調べていきます。

前回に引き続き LINQ の First メソッドについて研究していきます。

今回は実際にコードを眺めて処理の流れを追っていきます。

ざっくり実装を眺めてみる

それでは、 FirstFirstOrDefault メソッドの実装を眺めていきましょう。 コード全文は こちら から読めます。

First メソッドにもいろいろなバリエーションがあるので、まずはすべてのバリエーションをざっと眺めます。

いずれのメソッドでも、肝となるのは「最初の要素を探してくる」という処理ですが、この処理は TryGetFirst というメソッドに委譲されています。 この TryGetFirst ついてはこの記事の後半で詳しく見ていきます。

条件なし First メソッド

まずは条件なしの(追加で引数をとらない) First メソッドを読んでみます。 この First メソッドでは、 TryGetFirst メソッドを使って最初の要素の取得を試みます。 要素が見つからなければ例外を発生させ、要素が見つかればその要素を返します。

public static TSource First<TSource>(this IEnumerable<TSource> source)
{
    TSource? first = source.TryGetFirst(out bool found);
    if (!found)
    {
        ThrowHelper.ThrowNoElementsException();
    }

    return first!;
}

条件なし FirstOrDefault メソッド

次に条件なしの FirstOrDefault メソッドを読んでみます。 これも First メソッドとほとんど同じですが、例外を発生させない点が違います。

public static TSource? FirstOrDefault<TSource>(this IEnumerable<TSource> source) =>
    source.TryGetFirst(out _);

TryGetFirst メソッドは通常の Try パターンとは異なり、戻り値に処理の結果を返し、 out 引数に処理の成否を返しています。 この形だと FirstOrDefault の実装がシンプルになるメリットがありますね。

また、要素が見つからなかった場合の既定値を指定できるオーバーロードも存在します。

public static TSource FirstOrDefault<TSource>(this IEnumerable<TSource> source, TSource defaultValue)
{
    TSource? first = source.TryGetFirst(out bool found);
    return found ? first! : defaultValue;
}

このメソッドでは、 found (要素が見つかったかどうかのフラグ)を捨てずに取っておいて、 true なら TryGetFirst の結果を、そうでなければ指定された既定値を返します。

条件付き First メソッド

次に条件付きの First メソッドを見てみます。 これは各要素が条件を満たすかどうかを判定する predicate 関数を引数にとり、最初に条件を満たした要素を返します。

このメソッドの構造は、TryGetFirstpredicate 関数を渡している以外、条件なしの First メソッドとほぼ同じです。

public static TSource First<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
{
    TSource? first = source.TryGetFirst(predicate, out bool found);
    if (!found)
    {
        ThrowHelper.ThrowNoMatchException();
    }

    return first!;
}

条件付き FirstOrDefault メソッド

FirstOrDefault メソッドにも条件付きのものがあります。基本的には First メソッドと同じで、 TryGetFirst に処理のほとんどが委譲されています。

public static TSource? FirstOrDefault<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate) =>
    source.TryGetFirst(predicate, out _);

条件なしの First メソッドの場合と同じように、既定値を指定するオーバーロードも存在します。

public static TSource FirstOrDefault<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate, TSource defaultValue)
{
    TSource? first = source.TryGetFirst(predicate, out bool found);
    return found ? first! : defaultValue;
}

TryGetFirst メソッドを読む

以上でみてきたコードでは、「最初の要素を探してくる」という処理は TryGetFirst メソッドに隠されていました。 次に、 TryGetFirst メソッドを読んで、どのような処理になっているのか確認してみましょう。

条件付き TryGetFirst

まず、「条件付きの」TryGetFirst から読んでいきます。 これは要素が満たすべき条件を Func<TSource, bool> で受け取り、条件を満たす最初の要素を返すものです。

private static TSource? TryGetFirst<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate, out bool found)
{
    if (source == null)
    {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
    }

    if (predicate == null)
    {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.predicate);
    }

    foreach (TSource element in source)
    {
        if (predicate(element))
        {
            found = true;
            return element;
        }
    }

    found = false;
    return default;
}

まず foreach で各要素に対してループを回します。 条件(predicate 関数) を満たす要素が見つかり次第、その要素を返します。 このとき、 out 引数である found フラグが true に設定されます。 このフラグは「要素が見つかった」ことを呼び出し元に伝える役割を果たします。

もし foreach が終わってしまった場合、 すなわち、ひとつも predicate を満たす要素が存在しなかった場合は、 found フラグを false に設定し、型の既定値を返します。

前回の記事 で作った自作のものとほとんど変わりない単純な実装になっています。

条件なし TryGetFirst

次に条件なしの TryGetFirst を見ていきます。 条件がないということは、「最初の要素を取り出して返す、空っぽなら既定値を返す」という簡単な処理で済むはずです。 実際、前回の記事 の自作の実装はとてもシンプルな実装になっていました。

しかし、.NET 6 の実装は少し複雑に見えます。

前回の記事の実装と根本的に違うのは、特別な型に対する最適化が行われている点です。 処理対象は原則として IEnumerable<T> なのですが、 実際の型は配列であったり、リストであったり、LINQ のメソッドで生成された結果であったりなどさまざまです。 「最初の要素を取り出す」という処理の最適な実装方法はコレクションの特性に応じてさまざまです。

TryGetFirst メソッドでは、処理するシーケンスの実際の型に応じて、 最適な処理を選択するような実装になっています。

それでは実際にコードを見てみましょう。

private static TSource? TryGetFirst<TSource>(this IEnumerable<TSource> source, out bool found)
{
    if (source == null)
    {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
    }

    if (source is IPartition<TSource> partition)
    {
        return partition.TryGetFirst(out found);
    }

    if (source is IList<TSource> list)
    {
        if (list.Count > 0)
        {
            found = true;
            return list[0];
        }
    }
    else
    {
        using (IEnumerator<TSource> e = source.GetEnumerator())
        {
            if (e.MoveNext())
            {
                found = true;
                return e.Current;
            }
        }
    }

    found = false;
    return default;
}

IPartition<T> の場合

まず、最初の部分を見ていきます。 もし sourceIPartition<T> 型であれば IPartition<T> に実装された TryGetFirst メソッドを呼びます。

if (source is IPartition<TSource> partition)
{
    return partition.TryGetFirst(out found);
}

IPartition<T> というのは、SkipTake などの、「シーケンス中の連続した一部分を取り出す」という操作をした場合に作られるものです。 インターフェースの定義としては以下のようになっています。

internal interface IPartition<TElement> : IIListProvider<TElement>
{
    IPartition<TElement> Skip(int count);

    IPartition<TElement> Take(int count);

    TElement? TryGetElementAt(int index, out bool found);

    TElement? TryGetFirst(out bool found);

    TElement? TryGetLast(out bool found);
}

IPartition<T>TryGetFirst メソッドの実装は状況に応じてさまざまなものが使われますが、 特に配列のようにランダムアクセスをサポートできる型に対して効果を発揮します。

IPartition<T> に関しては、 .NET 6 では多数の実装があるため、別の記事で詳しく扱います。

IList<T> の場合

次に、 IList<T> の場合を見てみます。

if (source is IList<TSource> list)
{
    if (list.Count > 0)
    {
        found = true;
        return list[0];
    }
}

この場合はまずリストの長さをチェックします。list.Count > 0 が成立するということは、少なくとも「0番目の要素」が存在するということです。 「最初の要素」とはすなわち 0 番目の要素のことですから、0 番目の要素を最初の結果として返します。

一般的に IList<T> のインデクサを使ったアクセスは高速ですので、このような実装になっているのだと考えられます。

ここで、 IReadOnlyList<T> ではなく IList<T> に変換しているのには歴史的な理由があるようです。 .NET 5 でかなり改善されたようですが、共変のインターフェースへのキャストは非常に遅い時期があり、そのコストが許容できなかったようです。 IList<T> は共変でも反変でもない interface ですが、 IReadOnlyList<T> では T は戻り値にのみ使用されるため共変な interface になっているため、 IList<T> が使われています。

共変 (Covariant) と反変 (Contravariant) は圏論由来の概念ですが、コレクションを扱う上では大変重要な概念なので、別の記事で扱います。

一般の場合

最後に、一般の IEnumerable<T> の場合について見てみます。 IEnumerable<T> の場合は要素を順番に列挙してみることしかできませんから、 MoveNextCurrent を利用した、ある意味素直な実装になっています。

using (IEnumerator<TSource> e = source.GetEnumerator())
{
    if (e.MoveNext())
    {
        found = true;
        return e.Current;
    }
}

GetEnumerator() は呼び出しごとに新しく IEnumerator<T> のインスタンスを作って返すため、メモリアロケーションが発生します。 MoveNext() をしてみて Current をとるという処理も、リストの 0 番目の要素をダイレクトに取るよりは若干コストが高いです。 そのため IList<T>IPartition<T> では専用の実装が使われるようです。

まとめ

今回は First メソッドの実装をざっくり読んでみました。 前回の記事 での自作のメソッドに比べて、 .NET 6 の実装ではさまざまな最適化が施されていることがわかりました。 IPartition<T> の最適化については深追いできていませんので、他の記事で深く取り扱います。

次回はこの最適化がどのぐらい効いているのか、いろいろな型に対してパフォーマンスを計測して調べていきます。

ライセンス

今回紹介したコードはすべて、 MIT ライセンスで公開されている .NET 6 の実装の引用です。

ライセンス全文はこちらからご確認いただけます。