Functional Programming w/ C# LINQ

.NET 5.0 および C# 9.0 リリースによって LINQ 周りの機能が飛躍的に向上するかと思いきやそうではなかった。期待していた record の with 構文は式木にならず *1 IQueryable や LINQ to Entities で利用できないため、生産性向上にいっさい寄与しない *2

しかし、改めて LINQ 周りを調べてみると、わずかではあるが前進が見られるようだ。かつては IEnumerable 専用であった拡張メソッド TakeLast, SkipLast , Index 付き Select, Zip が IQueryable 対応となっている。

ちょっと残念なのは Zip が IQueryable * IEnumerable と第2引数に IEnumerable しか取れないことだ。まあ Zip のような演算をテーブル間で行う SQL 文法はなく、やるとしたらテーブルと持ち込んだデータ列との間でユーザが独自定義するものだろうという前提なのだろう。そこで IQueryable * IQueryable で Zip できないか拡張メソッドを定義してみた。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;

public static class ExtentionIQueryable {
  // IQueryable 版 Zip (IQueryable<T1> * IQueryable<T2> => IQueryable<ValueTuple<T1, T2>>)
  public static IQueryable<ValueTuple<T1, T2>> Zip<T1, T2>(this IQueryable<T1> seq1, IQueryable<T2> seq2) =>
    seq1.Select( (x, i) => new { Item = x, Index = i } )                                        // 第1引数シーケンスに Index を割り振る
      .Join(seq2.Select( (x, i) => new { Item = x, Index = i } ),                               // 第2引数シーケンスに Index を割り振る
            l      => l.Index,
            r      => r.Index,
            (l, r) => new { Value = new ValueTuple<T1, T2>(l.Item, r.Item), Index = l.Index } ) // Index をキーに Inner Join して値ペアを作る
        .OrderBy( x => x.Index )                                                                // IQueryable.Join() は順序を担保しないかもしれないため、ソートする
          .Select( x => x.Value );                                                              // Index を捨てる

  // IQueryable 版 Zip (IQueryable<T1> * IQueryable<T2> => IQueryable<U>)
  public static IQueryable<U> Zip<T1, T2, U>(this IQueryable<T1> seq1, IQueryable<T2> seq2, Expression<Func<T1, T2, U>> func) {
    var t   = Expression.Parameter(typeof(ValueTuple<T1, T2>), "t");                            // ValueTuple 型のパラメータを受ける式木
    var ti  = new [] { 1, 2 }.Select( i => Expression.Field(t, $"Item{i}") ).ToArray();         // ValueTuple の各要素を配列へ展開する式木
    var fnc = Expression.Lambda<Func<ValueTuple<T1, T2>, U>>(Expression.Invoke(func, ti), t);   // 引数配列を func に充て Invoke する式木

    return seq1.Zip(seq2).Select(fnc);
  }
}

IEnumerable 型の第2引数を受ける標準 Zip が2つのシーケンスを受けて値を組にして ValueTuple で返すものと、さらに演算の式木を受けて演算結果を返すものがあるため、それらに似せた。苦労したのは、2引数を ValueTuple で受ける関数とそのまま受ける関数との間の変換を式木で実現するところ。式木を解説する情報が少ないため、想像力で試行錯誤すること2時間(ここだけで所要時間の8割)。

コンパイルは通り、LINQ to Object を AsQueryable() したものを与えると正しい結果を IQueryable で返すため、理論上は IQueryable として機能しているはず。ただし、本当に LINQ to Entities として機能するかは、各 DB の .NET 5.0 用 QueryProvider のでき次第 *3。この点は別の機会に試験してみることとする。

【2021/02/24 追記】
構文は正しいようだが、残念ながら式木から SQL への変換で下記のようにランタイムエラーとなることから、QueryProvider が対応していないように見える。最もシンプルにして第2引数に Enumrable.Range() をとってオリジナルの Queryable.Zip(this System.Linq.IQueryable source1, System.Collections.Generic.IEnumerable source2, System.Linq.Expressions.Expression> resultSelector) を試すもダメ。それどころかインデックス付き Select もダメ。結局、以前と変化なし。

System.InvalidOperationException: The LINQ expression 'DbSet<XXXXXX>()
    .Take(__p_0)
    .Zip(
        source2: __p_1, 
        resultSelector: (x, y) => new { 
            Property1 = x.Property1, 
            y = y
         })' could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

*1:with 構文を式木で利用できるようにコンパイラ実装を修正すべきと C# 開発チームに提案したものの却下された。言語仕様上は問題なく、他の類似構文 (オブジェクト初期化子) の実装と明らかに非対称かつ不整合な不備であるため、論理的に問い詰めて食い下がってみた。が、めちゃくちゃ明晰な頭脳の持ち主である先方も最後は美しくない実装不備であることを認めたため、あきらめた。C# 開発チームには人口に膾炙する改善テーマが他にたくさんあり、各種演算子の式木対応は優先度がかなり低いようだ。

*2:with 構文は LINQ to Entities で利用できてこそ生産性を向上させる。LINQ to Object であればプログラマがちょっと気の利いた Clone() メソッドを書くだけで with と同等のことができる。右記参照 Functional Programming w/ C# LINQ - Crayon's Monologue

*3:Index 付き Select も IEnumerable と絡める Zip も SQL 的には難しくないため LINQ to Entities でも機能すると思うが、単純なことも意外とできなかったりするのが QueryProvider のため、過信してはいけない。