IEnumerableのCastを抹殺するためのT4 Templateの使い方

.NET Framework 1.0の負の遺産、HogeCollection。それらの大半はIEnumerable<T>を実装していない、つまるところ一々Cast<T>しなければLinqが使えない。ほんとどうしょうもない。大抵のHogeCollectionは実質Obsoleteみたいなもので、滅多に使わないのだけれど、ただ一つ、RegexのMatchCollectionだけは頻繁に使うわけで、Castにイラつかされるので殺害したい。RegexにはMatchCollection、GroupCollection、CaptureCollectionという恐怖の連鎖が待っているので余計に殺したい。(ところで全く本題とは関係ないのですが、Captureは今ひとつ使い道がわからな……)

// わざとらしい例ですが
var q = Regex.Matches("aag0 hag5 zag2", @"(.)ag(\d)")
    .Cast<Match>()
    .SelectMany(m => m.Groups.Cast<Group>().Skip(1).Select(g => g.Value))
    .ToArray(); // a0h5z2

おお、何というCast地獄!つーか.NET 4でBCL書き直したとか言うんなら、その辺も少し融通聞かせてIEnumerable<T>にしてくれてもさー。あ、要望出さないのが悪いとかなのでしょうか……。それなら自己責任ですね、ちゃんと出していかないと。なのはともかく、自己責任ならば自己責任なりに、文句だけ言っててもしょうがないので自前で何とかしましょう。

ようするに.Cast<Hoge>()を自動で挟めばいいわけですよね。んー、ぴこーん!T4でジェネレートすればいいんじゃね?というわけで、T4 Templateを使ってみました。実際のところT4試してみたかったんだけどネタがなかったので、ネタが出てきて万歳!が本音だったりはします。

何もないところからテンプレートじゃあ作りようもないので、ひとまず完成系を書いてみる。

public static class MatchCollectionExtensions
{
    public static IEnumerable<TResult> Select<TResult>(this MatchCollection source, Func<Match, TResult> selector)
    {
        return source.Cast<Match>().Select(selector);
    }
    
    // Where, Aggregate, ....
}

こんな形。グッとイメージしやすくなります。型引数のTSourceを消して、Castを挟んで……。やるべき事が大体見えてきました。まずは、Enumerableの拡張メソッドの抽出を。

var extMethods = typeof(Enumerable)
    .GetMethods()
    .Where(mi => Attribute.IsDefined(mi, typeof(ExtensionAttribute)));

特にBindingFlagsは設定しませんが、ExtensionAttributeが指定されているものがあれば拡張メソッド、という判定で問題なく取り出すことが出来ます。続いて戻り値を抽出。

var returnType = extMethods
    .Select(mi => mi.ReturnType)
    .Select(mi => Regex.Replace(mi.Name, "`.*$", "")
        + (mi.IsGenericType ? ("<" + string.Join(", ", mi.GetGenericArguments().Select(t => t.Name)) + ">") : ""));

IEnumerable<T>のNameはIEnumerable1になっているので1を正規表現で削除。そして引数を並べる。ただまあ、これだけだとジェネリック引数がネストしたものに対応出来ていなかったりTSourceが除去できてなかったりダメなのですが、それはそれ(最終的なコードは下記の実例のほうを見てください)。

といったわけで、相変わらずリフレクション+Linqは鉄板ですね。というかLinqなしのリフレクションとかやりたくない……。こんな感じにポチポチと素材集めをしたら、T4化します。

<#@ template language="C#" #>
<#@ output extension="cs" #>
<#@ assembly Name="System.Core.dll" #>
<#@ import namespace="System" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Runtime.CompilerServices" #>
<#@ import namespace="System.Text.RegularExpressions" #>
<#@ import namespace="System.Reflection" #>
<#
    var target = new Dictionary<string, string>
    {
        {"MatchCollection", "Match"},
        {"GroupCollection", "Group"},
        {"CaptureCollection", "Capture"}
    };
#>
<#
    var ignoreMethods = new HashSet<string>
    {
        "Max", "Min", "Average", "Sum", "Zip", "OfType", "Cast",
        "Join", "GroupJoin", "ThenBy", "ThenByDescending", "LongCount"
    };
#>
using System;
using System.Collections.Generic;
using System.Linq;

namespace System.Text.RegularExpressions
{
<#
foreach (var kvp in target)
{
#>
    public static class <#= kvp.Key.Replace(".","") #>Extensions
    {
<#
foreach (var methodInfo in typeof(Enumerable).GetMethods().Where(mi => Attribute.IsDefined(mi, typeof(ExtensionAttribute))))
{
    if(ignoreMethods.Contains(methodInfo.Name)) continue;
#>
        public static <#= MakeReturnType(methodInfo, kvp.Value) #> <#= methodInfo.Name #><#= MakeGenericArguments(methodInfo) #>(this <#= kvp.Key #> source<#= MakeParameters(methodInfo, kvp.Value) #>)
        {
            return source.Cast<<#= kvp.Value #>>().<#= MakeMethodBody(methodInfo) #>;
        }

<#}#>
    }
<#}#>
}
<#+
    const string TSource = "TSource";

    static string ConstructTypeString(Type type, string castType)
    {
        var result = type.Name.Contains(TSource)
            ? type.Name.Replace(TSource, castType)
            : Regex.Replace(type.Name, "`.*$", "");
        
        if (type.IsGenericType)
        {
            result += string.Format("<{0}>", string.Join(", ", type.GetGenericArguments().Select(t => ConstructTypeString(t, castType))));
        }
        return result;
    }
    
    static string MakeReturnType(MethodInfo info, string castType)
    {
        return ConstructTypeString(info.ReturnType, castType);
    }
    
    static string MakeGenericArguments(MethodInfo info)
    {
        var types = info.GetGenericArguments().Select(t => t.Name).Where(s => s != TSource);
        return types.Any() ? string.Format("<{0}>", string.Join(", ", types)) : "";
    }
    
    static string MakeParameters(MethodInfo info, string castType)
    {
        var param = info.GetParameters()
            .Skip(1)
            .Select(pi => new { pi.Name, ParameterType = ConstructTypeString(pi.ParameterType, castType) });
        
        return param.Any()
            ? ", " + string.Join(", ", param.Select(a => a.ParameterType + " " + a.Name))
            : "";
    }
    
    static string MakeMethodBody(MethodInfo info)
    {
        var args = info.GetParameters().Skip(1).Select(pi => pi.Name);
        return string.Format("{0}({1})", info.Name, args.Any() ? string.Join(", ", args) : "");
    }
#>

上のほうの、ディクショナリ(target)の初期化子を弄ることで対象の型を増減できます。namespaceはテンプレートに埋め込みなので変える場合は適当に変えてください。ハッシュセット(ignoreMethods)はその名の通り、除外したい拡張メソッドを指定します。今回はMax,Minなどと、Zip,Join,GroupJoin(これらは若干弄らないと対応出来ないので見送り)を除外しています。あとLongCountも外してます、理由はRxのSystem.InteractiveがLongCountで競合するから(多分、Rxチームのミスだと思うのでそのうち直ると思います)。

どんなクラスにも対応出来る(はず)ので、もしキャストが必要なウザいHogeCollectionがあったら、このテンプレートを使ってみると良いかもしれません。WinFormsのControl.ControlCollectionとかWPFのUIElementCollectionとか(そういうのは、元よりごった煮で詰め込むの前提なので、UIElementでSelect出来ても嬉しくはないかなー)。ともあれ、利用はご自由にどうぞ。

こんな感じに、MatchCollection, GroupCollection, CaptureCollectionだと合計1100行ぐらいのコードが生成されます。これで、CastいらずにLinqが書けるようになりました。メデタシメデタシ。

T4 Template

T4 Templateはかなり良いですね。VisualStudioと密接に動作して、生成出来ないようならエラーですぐ知らせてくれるのが嬉しい。これ大事。超大事。それがないと書けません。C#もそうだけれど、とりあえず書く→コンパイラエラー→直す、をリアルタイムで繰り返せるのは素晴らしい。現代のプログラミング環境はこうでないと、な良さに溢れてます。アドインを入れれば入力補完やシンタックスハイライトも付いてくるので非常に快適。

T4 Templateは標準搭載の機能だし実に強力なので、積極的に使っていきたいものです。MSDNだとコード生成とテキスト テンプレート辺りかな。例によって、読んでもさっぱり意味がわかりません(笑) 今のところオフィシャルだとこんなドキュメントしかないのかなあ、少し厳しめ。いやまあ、T4自体は構文がシンプルなので、ただ書くだけならサンプル改変で何とかなる、というか、私もサンプル改変以上の機能は知らないのですががが。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2022

Twitter:@neuecc GitHub:neuecc

Archive