てくメモ

trivial な notes

【C#】LINQ to Objects は難しそうで難しくない、けれどちょっと難しい、と思う

ポエム寄り。

LINQLINQ to Objects)は、宣言的な記述によりリーダビリティが高くて堅牢、書き味がよく、覚えればシーケンスとみなせるなら広く適用しうる守備範囲を持っている。
コツを押さえれば難しくない、大切な概念はコレ、といった記事もたくさんあり、実際特別難しすぎるといったこともなく素晴らしい点を享受できると思う。


ただ、「けれどちょっと難しい」といった感じの一面があると個人的には思う点がある。

要旨は以下のふたつ。

  1. 適用範囲が広いため、どこでも使いそうになる
  2. きちんと書くなら(抽象化されているのに)シーケンスの実体を意識しないといけない

① どこでも使いそうになる

文字列操作について考える。
イーハトーヴォを句点を除いて反転するとする。

private string text = "あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。";
// → 波の草るかひらぎらぎの外郊、市オーリモたれら飾で森いしくつう、らそい青つもをさた冷に底もで夏、風たっおときすのォヴートハーイのあ。


文字列はcharのシーケンスなのでLINQが適用できる。

public string ByLinq()
{
    return new(text
        .SkipLast(1) // 句点を除いて、
        .Reverse() // 反転
        .Append('。') // 句点はそのまま
        .ToArray());
}


一方で、単純にString.Create

public string ByStringCreate()
{
    return string.Create(text.Length, text, static (buf, str) =>
    {
        str.AsSpan()[..^1].CopyTo(buf); // 句点以外をコピー
        buf[..^1].Reverse(); // 反転
        buf[^1] = '。'; // 最後は句点
    });
}
         Method |        Mean |     Error |   StdDev |   Gen0 | Allocated |
--------------- |------------:|----------:|---------:|-------:|----------:|
         ByLinq | 2,172.17 ns | 150.86 ns | 8.269 ns | 0.4578 |    1440 B |
 ByStringCreate |    37.70 ns |  32.09 ns | 1.759 ns | 0.0485 |     152 B |

2桁遅いのは流石に厳しい。
出力がほしいだけの書き捨てやパフォーマンス考慮が一切不要といった場合を除けば、LINQを適用するべきではなかった、と言えそう。


こんな感じで、適用できる対象が広いがゆえ、他に適した手段がある場所でバンバン使えてしまう。
広く使える一方で、適切に使うならこの範囲、な判断を求められる。
ということで、LINQ to Objects 難しい。

② (抽象化されているのに)シーケンスの実体を意識しないといけない

LINQAverageは滅茶苦茶速いらしい、確かめてみよう!

IEnumerable<int> GetNums1()
{
    for (int i = 0; i < 128; ++i) yield return i;
}

public double ByIteration() => GetNums1().Average();

結論から言うと、これは速いといったことはない。

LINQAverageが速いらしいぞ、というのは以下のような場合。

int[] array = Enumerable.Range(0, 128).ToArray();
IEnumerable<int> GetNums2() => array;

public double ByArray() => GetNums2().Average();
      Method |      Mean |     Error |   StdDev |   Gen0 | Allocated |
------------ |----------:|----------:|---------:|-------:|----------:|
 ByIteration | 837.21 ns | 20.834 ns | 1.142 ns | 0.0095 |      32 B |
     ByArray |  54.12 ns |  0.418 ns | 0.023 ns |      - |         - |


ちなみにこれは単にループするよりも速い。

public double ByNotLINQ()
{
    // 連番なので本当はループする必要はないけれど比較のためにループ
    double sum = 0;
    for (int i = 0; i < 128; i++)
    {
        sum += i;
    }
    return sum / 128;
}
      Method |      Mean |     Error |   StdDev |   Gen0 | Allocated |
------------ |----------:|----------:|---------:|-------:|----------:|
   ByNotLINQ |  96.98 ns |  2.949 ns | 0.162 ns |      - |         - |


なぜそういったことが起きるのかというと、Average()が内部的に中身のSpanを抜き出せそうな場合は抜き出し、特殊な最適化のかかった処理に投げているから。

同じIEnumerable<T>の見た目なのに、扱われ方がまったく違ったものになっている。

性質の違うシーケンスを高い抽象度で統一的な扱いにしているというのは色んなところに影響している。
標準ライブラリに投げるにしても自分で扱うにしても、使い手側でIEnumerable<T>という抽象の向こう側を意識しないと正確に扱えない。


目の前のIEnumerable<T>の中身に思いを馳せずにはいられない。
ということで、LINQ to Objects 難しい。


きちんと使おうとすると色々認識しなければいけないことがあって難しい、という感じで〆。