LoginSignup
36
16

【Allって】LINQ、この場合どうなる?【空配列は?】

Last updated at Posted at 2023-11-30

この投稿はC#アドベントカレンダー2023の1日目の記事です。

C# アドベントカレンダー 2023 2日目の明日は @advanceboy さんの「VSCode で C# のブロック {} 前後の改行の設定を変更する 2023」です。


2023年の5月末頃、

配列において、全ての要素が条件を満たすならtrueを返すメソッドを定義します。
このメソッドは、空の配列を渡したらfalseを返しますか?trueを返しますか?

という感じの話題が、SNSで盛り上がっていましたね。

C#のLINQに、「IEnumerable<T>の全ての要素が条件を満たすならtrueを返す」というAllメソッドが存在します。Allメソッドは、IEnumerable<T>が空の場合、trueを返すでしょうか?それともfalseを返すでしょうか?

空のIEnumerable<T>でAllメソッドを呼び出すと、trueを返します。もしかしたら「あれ、この場合ってどうなるんだっけ?」と疑問に思った方もいるのではないでしょうか?

All以外にも、LINQにはさまざまなメソッドがあり「LINQのこのメソッド、この場合ってどうなるんだっけ?」という疑問を持つ方もいるかもしれません。この投稿では、そのような「LINQのこのメソッド、この場合どうなるっけ?」を筆者の独断でピックアップして紹介します。

なお、本投稿は.NET 8時点の仕様・挙動・ドキュメントに基づいています。

空のIEnumerable<T>でAll

空のIEnumerable<T>でAllメソッドを呼び出すと、trueを返します。

[Test]
public void TestAll()
{
    IEnumerable<int> numbers = [];
    bool isAllEven = numbers.All(num => num % 2 == 0);
    Assert.True(isAllEven);
}

公式ドキュメントには、次のような記載があります。

Returns
Boolean
true if every element of the source sequence passes the test in the specified predicate, or if the sequence is empty; otherwise, false.

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.all?view=net-8.0

空のIEnumerable<T>でAny

空のIEnumerable<T>でAnyメソッドを呼び出すと、falseを返します。

[Test]
public void TestAny()
{
    IEnumerable<int> numbers = [];
    bool isAnyEven = numbers.Any(num => num % 2 == 0);
    Assert.False(isAnyEven);
}

公式ドキュメントには、次のような記載があります。

Returns
Boolean
true if the source sequence contains any elements; otherwise, false.

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.any?view=net-8.0

余りがあるChunk

要素数 6 のIEnumerable<int>に対して、引数 sizeを4でChunkメソッドを呼び出します。

IEnumerable<int> numbers = [3, 1, 4, 1, 5, 9];

// chunkedNumbersは、[[3, 1, 4, 1], [5, 9]] みたいな感じ?
// もしくは
// chunkedNumbersは、[[3, 1, 4, 1]] みたいな感じ?
IEnumerable<int[]> chunkedNumbers = numbers.Chunk(4);

要素数6に対して、引数 sizeが4だとぴったり割り切れず、余りが出ます。

[[3, 1, 4, 1], [5, 9]]みたいになるでしょうか?
それとも[[3, 1, 4, 1]]みたいになるでしょうか?

[Test]
public void TestChunkSize0()
{
    IEnumerable<int> numbers = [3, 1, 4, 1, 5, 9];
    IEnumerable<int[]> chunkedNumbers = numbers.Chunk(4);
    IEnumerable<int[]> expected = [[3, 1, 4, 1, ], [5, 9]];

    Assert.That(chunkedNumbers, Is.EqualTo(expected));
}

[[3, 1, 4, 1], [5, 9]]みたいになります。最後の要素は「size」未満になる場合があることに注意してください。

公式ドキュメントには、次のように記載があります。

Each chunk except the last one will be of size size. The last chunk will contain the remaining elements and may be of a smaller size.

なお、IEnumerable<int>の要素数よりも大きいsizeでChunkメソッドを呼び出した場合、次のようになります。

[Test]
public void TestChunkSize1()
{
    IEnumerable<int> numbers = [3, 1, 4, 1, 5, 9];
    IEnumerable<int[]> chunkedNumbers = numbers.Chunk(10);
    IEnumerable<int[]> expected = [[3, 1, 4, 1, 5, 9]];

    Assert.That(chunkedNumbers, Is.EqualTo(expected));
}

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.chunk?view=net-8.0

1未満のsizeでChunk

引数sizeに1未満を渡してChunkメソッドを呼び出した場合、即座にArgumentOutOfRangeExceptionがスローされます。

[Test]
public void TestChunkArgumentOutOfRangeE()
{
    IEnumerable<int> numbers = [3, 1, 4, 1, 5, 9];
    Assert.Catch<ArgumentOutOfRangeException>(() => { numbers.Chunk(0); });
}

公式ドキュメントには、次のように記載があります。

ArgumentOutOfRangeException
size is below 1.

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.chunk?view=net-8.0

要素数が違うZip

要素数が異なるIEnumerable<TFirst>IEnumerable<TSecond>でZipメソッドを呼び出した場合、短い方のシーケンスと同じ要素数のIEnumerable<(TFirst, TSecond)>が生成されます。

[Test]
public void TestZip()
{
    IEnumerable<int> numbers = [3, 1, 4, 1, 5, 9];
    IEnumerable<string> names = ["Taro", "Jiro", "Saburo"];
    IEnumerable<(int, string)> zipped = numbers.Zip(names);

    IEnumerable<(int, string)> expected = [(3, "Taro"), (1, "Jiro"), (4, "Saburo")];

    Assert.That(zipped, Is.EqualTo(expected));
}

公式ドキュメントには、次のような記載があります。

If the sequences do not have the same number of elements, the method merges sequences until it reaches the end of one of them.

これは、引数にresultSelectorを取るオーバーロードでも同様です。

record Person(string Name, string Language);

[Test]
public void TestZipResultSelector()
{
    IEnumerable<string> names = ["Taro", "Jiro", "Saburo"];
    IEnumerable<string> languages = ["C#"];
    var zipped = names.Zip(languages, (name, language) => new Person(name, language));

    var expected = new List<Person> {new("Taro", "C#")};

    Assert.That(zipped, Is.EqualTo(expected));
}

2個以上のIEnumerable<T>をZipするオーバーロードの場合、最も要素数の小さいシーケンスと同じ要素数を持つ、シーケンスを生成します。

[Test]
public void TestZipTriple()
{
    IEnumerable<int> numbers = [3, 1, 4, 1, 5, 9];
    IEnumerable<string> names = ["Taro", "Jiro", "Saburo"];
    IEnumerable<string> languages = ["C#"]; // 一番小さい要素数 1
    IEnumerable<(int, string, string)> zipped = numbers.Zip(names, languages);

    IEnumerable<(int, string, string)> expected = [(3, "Taro", "C#")]; // 要素数 1

    Assert.That(zipped, Is.EqualTo(expected));
}

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.zip?view=net-8.0

同点が複数いるMaxBy

MaxByメソッドは引数で渡したデリゲートで「最大の評価値」となる要素を返すメソッドです。では、最大の評価値となる要素が複数あって、同点となった場合はどうなるでしょう?考えられるのは、

  • 先勝ちで、最初に「最大の評価値」となった要素を返す
  • 後勝ちで、最後に「最大の評価値」となった要素を返す
  • 例外を投げる
  • 実装依存

ドキュメントには同点だった場合の記載がありません。テストには「キーが全てnullだった場合、最初の要素となる」というテストが存在しますが、厳密に「同点だった場合」のテストはありません。

私は「実装依存」だと思います。(.NET 8の時点で)

ちなみに、.NET 8の現状の実装だと、MaxByメソッドで同点が複数いた場合、最初に「最大の評価値」となった要素を返します。

record Player(string Name, int Level);

[Test]
public void TestMaxByTies()
{
    IEnumerable<Player> players = [
        new Player(Name: "Taro", Level: 36),
        new Player(Name: "Jiro", Level: 30),
        new Player(Name: "Saburo", Level: 30),
        new Player(Name: "Hanako", Level: 36),
        ];

    Player? maxLevelPlayer = players.MaxBy(p => p.Level);
    Assert.That(maxLevelPlayer, Is.EqualTo(new Player(Name: "Taro", Level: 36)));
}

そういう実装になっているとはいえ、ドキュメントに記載もないので現時点で、実装に依存した処理は避けるべきでしょう。

またMinByも同じように、.NET 8の実装だと、同点が複数いた場合、最初に「最小の評価値」となった要素を返します。

[Test]
public void TestMinByTies()
{
    IEnumerable<Player> players = [
        new Player(Name: "Taro", Level: 36),
        new Player(Name: "Jiro", Level: 30),
        new Player(Name: "Saburo", Level: 30),
        new Player(Name: "Hanako", Level: 36),
        ];

    Player? minLevelPlayer = players.MinBy(p => p.Level);
    Assert.That(minLevelPlayer, Is.EqualTo(new Player(Name: "Jiro", Level: 30)));
}

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.maxby?view=net-8.0

空のIEnumerable<T>でMaxBy

空のIEnumerable<T>でMaxByメソッドを呼び出した場合、Tのdefaultがnullなのか、そうでないのかで挙動が異なります。

次のようにTのdefaultがnullな場合、空のIEnumerable<T>でMaxByメソッドを呼び出すと、nullを返します。

record Record(int value);

[Test]
public void TestMaxByEmptyReference()
{
    IEnumerable<Record> records = [];

    Record? record = records.MaxBy(p => p.value);
    Assert.Null(record);
}

次のようにTのdefaultがnullではない場合、空のIEnumerable<T>でMaxByメソッドを呼び出すと、例外InvalidOperationExceptionがスローされます。

record struct StructRecord(int value);

[Test]
public void TestMaxByEmptyStruct()
{
    IEnumerable<StructRecord> records = [];

    Assert.Catch<InvalidOperationException>(() =>
    {
        StructRecord minValueRecord = records.MaxBy(p => p.value);
    });
}

公式ドキュメントには、次のような記載があります。

If the source sequence is empty and TSource is a nullable type, this method returns null. If the source sequence is empty and TSource is a non-nullable struct, such as a primitive type, an InvalidOperationException is thrown.

MinByも同様です。

[Test]
public void TestMinByEmptyReference()
{
    IEnumerable<Record> records = [];

    Record? record = records.MinBy(p => p.value);
    Assert.Null(record);
}

[Test]
public void TestMinByEmptyStruct()
{
    IEnumerable<StructRecord> records = [];


    Assert.Catch<InvalidOperationException>(() =>
    {
        StructRecord minValueRecord = records.MinBy(p => p.value);
    });
}

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.maxby?view=net-8.0

int.MaxValue(2147483647)を超えてCount

IEnumerable<T>でCountメソッドを呼び出し、結果がint.MaxValue(2147483647)を超えると、OverflowExceptionがスローされます。

[Test]
public void TestCountException()
{
    // int.MaxValueは、2147483647
    // intの表現範囲を1超える
    static IEnumerable<int> Repeat2147483648()
    {
        for (long i = 0; i < int.MaxValue + 1L; i++)
        {
            yield return 0;
        }
    }

    IEnumerable<int> a = Repeat2147483648();

    Assert.Throws<OverflowException>(() =>
    {
        int count = a.Count(it => it == 0);
    });
}

コンパイルオプションでCheckForOverflowUnderflowをfalseにしても、OverflowExceptionがスローされることに注意してください。

もしCountメソッドの結果が、int.MaxValueを超える可能性がある場合は、LongCountを使いましょう。

なお、LongCountメソッドの結果が、Int64.MaxValue(9223372036854775807)を超えると、同様にOverflowExceptionがスローされることに注意してください。

公式ドキュメントには、次のような記載があります。

OverflowException
The number of elements in source is larger than Int32.MaxValue.

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.count?view=net-8.0

同点がいた場合のOrderByは?

LINQには、次のようなソートを行うメソッドが存在します。

このようなOrderByなどのメソッドにおいて、同点(比較を行う際、要素のキーが同じ)な場合、どのような並び順になるでしょうか?

  • 元の順序で先にあった要素が、先に整列されるでしょうか?(安定ソートでしょうか)
  • 元の順序で後にあった要素が、先に整列されるでしょうか?
  • 特に決まっておらず場合によるでしょうか?

OrderBy、OrderByDescending、ThenBy、ThenByDescendingは「安定ソート」です。

次のコードのように、同点(2つの要素のキーが等しい)な場合、要素の順序は保持され、元の順序で先にあった要素が先に整列されます。

[Test]
public void TestOrderByStableSort()
{
    IEnumerable<Player> players = [
        new Player(Name: "Taro", Level: 36),
        new Player(Name: "Jiro", Level: 30),
        new Player(Name: "Saburo", Level: 30),
        new Player(Name: "Hanako", Level: 36),
        ];

    var orderedByLevel = players.OrderBy(it => it.Level);

    IEnumerable<Player> expected = [
        new Player(Name: "Jiro", Level: 30),
        new Player(Name: "Saburo", Level: 30),
        new Player(Name: "Taro", Level: 36),
        new Player(Name: "Hanako", Level: 36),
    ];
    Assert.That(orderedByLevel, Is.EqualTo(expected));
}

公式ドキュメントには、次のような記載があります。

This method performs a stable sort; that is, if the keys of two elements are equal, the order of the elements is preserved.

キーが重複してしまったToDictionaryは?

ToDictionaryメソッドを呼び出し、キーが重複してしまうと、ArgumentExceptionがスローされます。

[Test]
public void TestToDictionary()
{
    IEnumerable<Player> players = [
        new Player(Name: "Taro", Level: 36),
        new Player(Name: "Jiro", Level: 30),
        new Player(Name: "Saburo", Level: 30),
        new Player(Name: "Hanako", Level: 36),
        ];

    Assert.Throws<ArgumentException>(() =>
    {
        var levelDictionary = players.ToDictionary(it => it.Level);
    });
}

公式ドキュメントの記載は、次のとおりです。

ArgumentException
source contains one or more duplicate keys.

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.todictionary?view=net-8.0

もしキーが重複することがありえる場合やDictionary<TKey, List<TElement>>のような結果が欲しいのであれば、ToDictonaryメソッドではなく、ToLookupメソッドを使うことを検討しましょう。

[Test]
public void TestToLookup()
{
    IEnumerable<Player> players = [
        new Player(Name: "Taro", Level: 36),
        new Player(Name: "Jiro", Level: 30),
        new Player(Name: "Saburo", Level: 30),
        new Player(Name: "Hanako", Level: 36),
        ];

    var levelLookup = players.ToLookup(it => it.Level);
    Assert.That(levelLookup.Count, Is.EqualTo(2));
    Assert.That(levelLookup[30], Is.EqualTo(new List<Player>
        {
            new Player(Name: "Jiro", Level: 30),
            new Player(Name: "Saburo", Level: 30)
        }
    ));
    Assert.That(levelLookup[36], Is.EqualTo(new List<Player>
        {
            new Player(Name: "Taro", Level: 36),
            new Player(Name: "Hanako", Level: 36)
        }
    ));
}

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.tolookup?view=net-8.0

IEnumerable<T>の要素数より大きい数でSkip

IEnumerable<T>の要素数より大きい数でSkipメソッドを呼び出した場合、空のIEnumerable<T>を返します。

例外はスローされないことに注意してください。

[Test]
public void TestSkipOverLength()
{
    IEnumerable<int> numbers = [3, 1, 4, 1, 5, 9];
    IEnumerable<int> actual = numbers.Skip(100);
    IEnumerable<int> expected = Enumerable.Empty<int>();
    Assert.That(actual, Is.EqualTo(expected));
}

公式ドキュメントには、次のような記載があります。

If source contains fewer than count elements, an empty IEnumerable is returned.

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.skip?view=net-8.0

0以下の数でSkip

0以下の数でSkipメソッドを呼び出した場合、元の要素を全て持ったIEnumerable<T>を返します。

例外はスローされないことに注意してください。

[Test]
public void TestSkipUnder0()
{
    IEnumerable<int> numbers = [3, 1, 4, 1, 5, 9];
    IEnumerable<int> actual = numbers.Skip(-1);
    IEnumerable<int> expected = [3, 1, 4, 1, 5, 9];
    Assert.That(actual, Is.EqualTo(expected));
}

公式ドキュメントの記載は、次のとおりです。

If count is less than or equal to zero, all elements of source are yielded.

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.skip?view=net-8.0

IEnumerable<T>の要素数より大きい数でTake

IEnumerable<T>の要素数より大きい数でTakeメソッドを呼び出した場合、元の要素を全て持ったIEnumerable<T>を返します。

例外はスローされないことに注意してください。

[Test]
public void TestTakeOverLength()
{
    IEnumerable<int> numbers = [3, 1, 4, 1, 5, 9];
    IEnumerable<int> actual = numbers.Take(100);
    IEnumerable<int> expected = [3, 1, 4, 1, 5, 9];
    Assert.That(actual, Is.EqualTo(expected));
}

公式ドキュメントには、次のような記載があります。

If count exceeds the number of elements in source, all elements of source are returned.

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.take?view=net-8.0

0以下の数でTake

0以下の数でTakeメソッドを呼び出した場合、空のIEnumerable<T>を返します。

例外はスローされないことに注意してください。

[Test]
public void TestTakeUnder0()
{
    IEnumerable<int> numbers = [3, 1, 4, 1, 5, 9];
    IEnumerable<int> actual = numbers.Take(-1);
    IEnumerable<int> expected = Enumerable.Empty<int>();
    Assert.That(actual, Is.EqualTo(expected));
}

公式ドキュメントの記載は、次のとおりです。

If count is less than or equal to zero, source is not enumerated and an empty IEnumerable is returned.

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.take?view=net-8.0

Rangeが要素数を超えたTake

TakeにはRangeを引数にとるオーバーロードも存在します。

次のコードのようにIEnumerable<T>の要素を超えるRangeを引数に渡しても、例外はスローされません。

[Test]
public void TestTakeRangeO()
{
    int[] numbers = [3, 1, 4, 1, 5, 9];
    Range range = ..100;
    IEnumerable<int> expected = [3, 1, 4, 1, 5, 9];

    Assert.That(numbers.Take(range), Is.EqualTo(expected));
}

[Test]
public void TestTakeRange1()
{
    int[] numbers = [3, 1, 4, 1, 5, 9];
    Range range = ^100..;
    IEnumerable<int> expected = [3, 1, 4, 1, 5, 9];

    Assert.That(numbers.Take(range), Is.EqualTo(expected));
}

[Test]
public void TestTakeRange2()
{
    int[] numbers = [3, 1, 4, 1, 5, 9];
    Range range = 100..;
    IEnumerable<int> expected = [];

    Assert.That(numbers.Take(range), Is.EqualTo(expected));
}

[Test]
public void TestTakeRange3()
{
    int[] numbers = [3, 1, 4, 1, 5, 9];
    Range range = ..^100;
    IEnumerable<int> expected = [];

    Assert.That(numbers.Take(range), Is.EqualTo(expected));
}

[Test]
public void TestTakeRange4()
{
    int[] numbers = [3, 1, 4, 1, 5, 9];
    Range range = ^100..100;
    IEnumerable<int> expected = [3, 1, 4, 1, 5, 9];

    Assert.That(numbers.Take(range), Is.EqualTo(expected));
}

[Test]
public void TestTakeRange5()
{
    int[] numbers = [3, 1, 4, 1, 5, 9];
    Range range = 100..^100;
    IEnumerable<int> expected = [];

    Assert.That(numbers.Take(range), Is.EqualTo(expected));
}

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.take?view=net-8.0

空またはnullしか持たないIEnumerable<T>のSum

空のIEnumerable<T>でSumメソッドを呼び出した場合、0になります。

[Test]
public void TestSumEmptyNullable()
{
    IEnumerable<int?> nullableInts = [];
    int? sum = nullableInts.Sum();
    Assert.That(sum, Is.EqualTo(0));
}

[Test]
public void TestSumEmptyNonNull()
{
    IEnumerable<int> ints = [];
    int sum = ints.Sum(it => 0);
    Assert.That(sum, Is.EqualTo(0));
}

nullしか要素に持たないIEnumerable<T>でSumメソッドを呼び出した場合、0になります。nullでないことに注意して下しさい。

[Test]
public void TestSumNullNullable()
{
    IEnumerable<int?> nullableInts = [null];
    int? sum = nullableInts.Sum();
    Assert.That(sum, Is.EqualTo(0));
}

公式ドキュメントには、次のような記載があります。

This method returns zero if source contains no elements or all elements are null.

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.sum?view=net-8.0

計算結果がint.MaxValueやdouble.MaxValueを超えた場合のSum

Sumメソッドを呼び出して、計算結果がint.MaxValueやdouble.MaxValueなどそれぞれの型の最大値を超えてしまった場合、OverflowExceptionがスローされます。また、int.MinValueやdouble.MinVaueなどの最小値を下回ってしまった場合も同様です。

[Test]
public void TestSumOverflow()
{
    IEnumerable<int> ints = [int.MaxValue, 1];
    Assert.Throws<OverflowException>(() =>
    {
        int sum = ints.Sum();
    });
}

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.sum?view=net-8.0

空またはnullしか持たないIEnumerable<T>のAverage、Max、Min

空のIEnumerable<int>IEnumerabel<int?>でAverageやMax、Minを呼び出したらどうなるでしょうか?

これはオーバーロードによって結果が異なります。つまりIEnumerable<T>のTの型(数値型)によって結果が異なります。

※ Average、Max、Minは、IEnumerable<int>IEnumerable<int?>IEnumerable<double>IEnumerable<double?>などなど、数値型ごとに異なるオーバーロードで実装されています。

まずIEnumerable<int>IEnumerable<double>など、Null許容値型ではない普通の数値型のオーバーロードでは、空でAverageメソッドなどを呼び出すと、InvalidOperationExceptionがスローされます。

[Test]
public void TestAverageThrowException()
{
    IEnumerable<int> ints = [];
    Assert.Throws<InvalidOperationException>(() =>
    {
        double average = ints.Average();
    });
}

公式ドキュメントには、次のような記載があります。

InvalidOperationException
source contains no elements.

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.average?view=net-8.0#system-linq-enumerable-average(system-collections-generic-ienumerable((system-int32)))

一方でIEnumerable<int?>IEnumerable<double?>など、Null許容値型のオーバーロードでは、空でAverageメソッドなどを呼び出すと、nullを返します。

[Test]
public void TestAverageNull()
{
    IEnumerable<int?> ints = [];
    double? average = ints.Average();
    Assert.Null(average);
}

またIEnumerable<int?>IEnumerable<double?>など、Nullableな型のオーバーロードで、nullしか要素に持たない場合でAverageメソッドなどを呼び出しても、nullを返します。

[Test]
public void TestAverageOnlyNull()
{
    IEnumerable<int?> ints = [null];
    double? average = ints.Average();
    Assert.Null(average);
}

公式ドキュメントの記載は次の通りです。

The average of the sequence of values, or null if the source sequence is empty or contains only values that are null.

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.average?view=net-8.0#system-linq-enumerable-average(system-collections-generic-ienumerable((system-nullable((system-int32)))))

MinやMaxも同様です。

平均計算途中で最大値を超えたAverage

long.MaxValueは「9223372036854775807」です。
long.MinValueは「-9223372036854775808」です。

では、「long.MaxValueとlong.MinValueと1」の3個の平均はいくつでしょうか?そう0.0ですね。

IEnumerableのAverageメソッドでも「次のコード」では、次の示すように、平均は0.0になります。

[Test]
public void TestAverageNotOverflow()
{
    IEnumerable<long> longs = [long.MaxValue, long.MinValue, 1];
    double average = longs.Average();
    Assert.That(average, Is.EqualTo(0.0));
}

少し順番を変えてみましょう。似たようなコードですが、「次のコード」では、OverflowExceptionが発生します。

[Test]
public void TestAverageOverflow()
{
    IEnumerable<long> longs = [1, long.MaxValue, long.MinValue];
    Assert.Throws<OverflowException>(() =>
    {
        double average = longs.Average();
    });
}

実は、平均計算途中でlongやdecimalの表現範囲を超えた場合、OverflowExceptionが発生します。

公式ドキュメントの記載は次のとおりです。

OverflowException
The sum of the elements in the sequence is larger than Int64.MaxValue.

ドキュメントの記載はちょっと不正確で、次のようにInt64.MinValue(long.MinValue)を下回っても例外が発生します。

[Test]
public void TestAverageOverflow2()
{
    IEnumerable<long> longs = [long.MinValue, long.MinValue];
    Assert.Throws<OverflowException>(() =>
    {
        double average = longs.Average();
    });
}

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.average?view=net-8.0

要素がint.MaxValueを超えるRange

要素がint.MaxValueを超えるような引数startとcountを渡してRangeメソッドを呼び出すと、ArgumentOutOfRangeExceptionがスローされます。

※ 要素がint.MaxValueを超えるのは、start+count-1が、int.MaxValueを超えるような時

[Test]
public void TestRangeOverElementIntMaxValue()
{
    Assert.Throws<ArgumentOutOfRangeException>(() => { 
        Enumerable.Range(start: int.MaxValue, count: 2);
    });

    Assert.Throws<ArgumentOutOfRangeException>(() => { 
        Enumerable.Range(start: 2, count: int.MaxValue);
    });
}

公式ドキュメントには、次のような記載があります。

ArgumentOutOfRangeException

start + count -1 is larger than Int32.MaxValue.

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.range?view=net-8.0

0未満でRange

引数countに0未満の数を渡してRangeメソッドを呼び出すと、ArgumentOutOfRangeExceptionがスローされます。

[Test]
public void TestRangeCountUnder0()
{
    Assert.Throws<ArgumentOutOfRangeException>(() => {
        Enumerable.Range(start: 10, count: -1);
    });
}

公式ドキュメントには、次のような記載があります。

ArgumentOutOfRangeException
count is less than 0.

なお、引数countに0を渡してRangeメソッドを呼び出すと、空のIEnumerable<int>になります。

[Test]
public void TestRangeCount0()
{
    IEnumerable<int> range = Enumerable.Range(start: 10, count: 0);

    Assert.That(range, Is.EqualTo(Enumerable.Empty<int>()));
}

0未満でRepeat

引数countに0未満を渡してRepeatメソッドを呼び出すと、ArgumentOutOfRangeExceptionがスローされます。

[Test]
public void TestRepeatUnder0()
{
    Assert.Throws<ArgumentOutOfRangeException>(() =>
    {
        IEnumerable<string> messages = Enumerable.Repeat("Hello World", -1);
    });
}

公式ドキュメントにも記載があります。

ArgumentOutOfRangeException
count is less than 0.

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.repeat?view=net-8.0

なお、引数countに0を渡してRepeatメソッドを呼び出すと、空のIEnumerable<T>を返します。

[Test]
public void TestRepeat0()
{
    IEnumerable<string> messages = Enumerable.Repeat("Hello World", 0);
    Assert.That(messages, Is.EqualTo(Enumerable.Empty<string>()));
}

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.repeat?view=net-8.0

順番どうなるIntersect

LINQには集合演算を行うメソッドも存在します。Intersectは積集合を生成するメソッドです。

公式ドキュメントには、次のような記載があります。

When the object returned by this method is enumerated, Intersect yields distinct elements occurring in both sequences in the order in which they appear in first.

つまり生成されたIEnumerable<T>の順番は、拡張メソッドの第一引数(次のコードではfirst)において要素が出現する順番になります。

[Test]
public void TestIntersect()
{
    IEnumerable<string> first = ["Taro", "Jiro", "Saburo", "Hanako"];
    IEnumerable<string> second = ["Hanako", "Matsuko", "Takeko", "Umeko", "Taro"];
    IEnumerable<string> expected = ["Taro", "Hanako"];

    Assert.That(first.Intersect(second), Is.EqualTo(expected));
}

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.intersect?view=net-8.0

空のIEnumerable<T>でAggregate

空のIEnumerable<T>で、引数として初期値seedを取らないオーバーロードのAggregateメソッドを呼び出すと、InvalidOperationExceptionをスローします。

[Test]
public void TestAggregateProduct()
{
    IEnumerable<int> numbers = [];
    Assert.Throws<InvalidOperationException>(() =>
    {
        int product = numbers.Aggregate((lhs, rhs) => lhs * rhs);
    });
}

公式ドキュメントには、次のように記載があります。

 InvalidOperationException
source contains no elements.

一方、空のIEnumerable<T>で、引数として初期値seedをとる2つのオーバーロードのAggregateメソッドを呼び出しても、InvalidOperationExceptionはスローされません。

[Test]
public void TestAggregateProductWithSeed()
{
    IEnumerable<int> numbers = [];
    int product = numbers.Aggregate(1, (lhs, rhs) => lhs * rhs);
    Assert.That(product, Is.EqualTo(1));
}

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.aggregate?view=net-8.0

innerに対応する要素がなかった場合のouterはどうなる、GroupJoin

GroupJoinの公式ドキュメントに次のような記載があります。

Note
If there are no correlated elements in inner for a given element of outer, the sequence of matches for that element will be empty but will still appear in the results.

このNoteの内容を、例を用いて補足します。次のようなレコードがあります。

public record User(string Id, string Name);

public record Order(string OrderId, string UserId, string Name);

public record UserDisplayInfo(string UserName, int OrderCount);

IEnumerable<Users>のusersとIEnumerable<Order>のordersでGroupJoinメソッドを用いて、User型のIdとOrder型のUserIdで突合させて、一致するusers内のUser要素とorders内の複数のOrder要素から、UserDisplayInfoを生成します。

[Test]
public void TestGroupJoin()
{
    IEnumerable<User> users = [
        new User(Name: "Taro", Id: "abc"),
        new User(Name: "Jiro", Id: "def"),
        new User(Name: "Saburo", Id: "ghi"),
        new User(Name: "Hanako", Id: "jkl"),
        ];

    IEnumerable<Order> orders = [
        new Order(Name: "Order0", UserId: "abc", OrderId: "123"),
        new Order(Name: "Order1", UserId: "def", OrderId: "612"),
        new Order(Name: "Order2", UserId: "def", OrderId: "422"),
        new Order(Name: "Order3", UserId: "jkl", OrderId: "532"),
        new Order(Name: "Order4", UserId: "xyz", OrderId: "698"),
        ];

    var userDisplayInfos = users
        .GroupJoin(inner: orders,
            outerKeySelector: u => u.Id,
            innerKeySelector: o => o.UserId,
            resultSelector: (u, orders) =>
                new UserDisplayInfo(UserName: u.Name, OrderCount: orders.Count())
        );

    IEnumerable<UserDisplayInfo> expected = [
        new UserDisplayInfo(UserName: "Taro", OrderCount: 1),
        new UserDisplayInfo(UserName: "Jiro", OrderCount: 2),
        new UserDisplayInfo(UserName: "Saburo", OrderCount: 0),
        new UserDisplayInfo(UserName: "Hanako", OrderCount: 1),
        ];

    Assert.That(userDisplayInfos, Is.EqualTo(expected));
}

ここで、

  • Nameが"Saburo"のusers要素(Idは"ghi")に対応する、Order要素はorders中に存在しないこと
  • UserNameが"Saburo"のUserDisplayInfo要素は、GroupJoinの結果中に存在すること

に注目してください。

公式ドキュメント

Note
If there are no correlated elements in inner for a given element of outer, the sequence of matches for that element will be empty but will still appear in the results.

とあるように、

Nameが"Saburo"のusers(outer)の要素のように、users(outer)の要素に対応するoreders(inner)の要素が存在しなくても、最終結果の要素を(resultSelectorにより)生成する際は、innerのリストは空で提供され、結果の要素(UserNameが"Saburo"のUserDisplayInfo)が生成されます。

see : https://learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.groupjoin?view=net-8.0

まとめ

この投稿で紹介したLINQの挙動、みなさん正確に把握していましたか?

C#のLINQに限らず、さまざまなプログラミング言語の標準コレクションAPIは、使う機会が非常に多いです。だからこそ、その仕様・挙動を正確に理解していないと、思わぬ不具合を発生させてしまいます。正しく仕様・挙動を把握して、やっていきましょう!

36
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
36
16