C#メタプログラミング概略 in 2021
本稿はC# Tokyo オンライン LT 大会 2021/01にて発表した「C#メタプログラミング概略 in 2021」の発表内容を文書化したものです。
発表時のスライドは以下に公開しています。
またサンプルコードは以下にすべて公開しています。
Introduction
さてC#9.0のリリースとあわせて、Source Generatorがリリースされましたね。昨今、一部でメタプログラミングが活性化しているように、個人的には感じています。
そこで本項では、C#で利用可能な代表的なメタプログラミング手法について
- どういったものが存在し
- どういうときに何をつかえばいいのか?
その大枠を整理してみました。
Attention!
本稿は、可能な限り調べた上で記載したつもりですが、抜け・漏れ・誤りが含まれるかもしれませんし、たぶんに主観が含まれています。
お気づきの件がございましたら、お気軽に@nuits_jpまでご連絡ください。
What is metaprogramming?
さて、まずは「メタプログラミングとはなにか?」を簡単におさらいしてみたいと思います。
Wikipediaには次のように記載されています。
メタプログラミング (metaprogramming) とはプログラミング技法の一種で、ロジックを直接コーディングするのではなく、あるパターンをもったロジックを生成する高位ロジックによってプログラミングを行う方法、またその高位ロジックを定義する方法のこと。
たとえば、データベース構造からクラスを生成したり、クラスを構造解析してJSONへシリアライズしたり・・・
メタデータをもとに、プログラムを生成するプログラミングのことをメタプログラミングと呼びます。
C#で利用できる代表的なメタプログラミング技術には次のようなものがあります。
- CodeDOM
- Roslyn(ここではNuGet上のライブラリ利用のこと)
- Reflection
- Reflection.Emit
- Expression Tree
- T4 Text Templates
- Source Generator ← New!!
- Fody / Mono.Cecil
今回はこれらのうち、つぎの5つを取り上げます。
- Reflection
- Expression Tree
- T4 Text Templates
- Source Generator ← New!!
- Fody / Mono.Cecil
What should be used and when?
さて、複数のメタプログラミング技術をあげましたが、いつ、なにを使うべきなのでしょうか?
それを考える時、つぎのラインで区切って考えると分かりやすいです。
かみ砕いて説明しましょう。
動的メタプログラミング
項目 | 内容 |
---|---|
実現技術 | Reflection Expression Tree |
具体例 | Dapper JSON⇔オブジェクト変換ライブラリなど |
利用タイミング | 実行時 |
たとえばDapperなどが実行時に、SQLの実行結果をオブジェクトにつめたり、その逆を実現するために動的に構造解析して、メンバーを取得・設定したりするのによく利用される技術です。
実行時のパラメーターによって、メタプログラミングの挙動を変化させたい場合には、こちらで実装する必要があります。
たとえばOR Mapperで、検索画面の入力値に応じた動的なSQLを生成したいといった場合は、こちらを実行時に解決する必要があるでしょう。
静的メタプログラミング
項目 | 内容 |
---|---|
実現技術 | T4 Template Source Generator Fody / Mono.Cecil |
具体例 | PropertyChanged.Fody UnitGenerator |
利用タイミング | 実行時 |
こちらは、前者のように限定されず、使用用途が非常に広いです。
現時点では、定型的実装つまりボイラーコードの自動生成に利用されているのをよく見かけます。
たとえば、WPF向けにINotifyPropertyChangedの実装を自動的に生成したり、DDDするためのバリューオブジェクトの典型的な実装を提供したりです。
実行前に解決されるため、動作が非常に軽快です。
これまで、これ系のメタプログラミングは、ちょっと癖が強かったのですが、Source Generatorはその辺りがだいぶ解消されています。そのため、今後は、Dapperみたいなライブラリが、自分で利用するコードのためにSource Generatorを利用するといったケースも増えてくるかもしれません。
Implementation example
それでは実際のサンプルコードを見ていきましょう。
お題
今回はEqualsのオーバーライドをサポートするライブラリをお題とします。
Identifier属性を宣言されたプロパティで同値比較します。
つぎのようなコードです。
public class Customer
{
[Identifier]
public int Code { get; set; }
public override bool Equals(object other)
{
if (other is Customer customer)
{
return Code.Equals(customer.Code);
}
return false;
}
}
Equalsに必要なコードをすべて実装すると結構なボリュームになってしまうので、つぎのような制約を前提とします。
- Identifier属性は1クラス1プロパティにしかない前提とする
- 対象はint型のみサポート
- 非Genericなクラスにのみ対応すればよい(Genericなクラスや構造体は非対応)
- GetHashCodeの実装は割愛する
詳細はGitHubに公開しているので、良かったらご覧ください。
ベンチマーク
また先に各実装コードのベンチマーク値を掲載しておきます。
BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19041.746 (2004/?/20H1)
Intel Core i7-7700T CPU 2.90GHz (Kaby Lake), 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=5.0.102
[Host] : .NET Core 3.1.11 (CoreCLR 4.700.20.56602, CoreFX 4.700.20.56604), X64 RyuJIT
DefaultJob : .NET Core 3.1.11 (CoreCLR 4.700.20.56602, CoreFX 4.700.20.56604), X64 RyuJIT
Method | Mean | Error | StdDev |
---|---|---|---|
Not metaprogramming | 2.798 ns | 0.0174 ns | 0.0154 ns |
Reflection | 2,891.742 ns | 15.2036 ns | 12.6957 ns |
Reflection with Cache | 214.812 ns | 0.3982 ns | 0.3109 ns |
ExpressionTree | 85,835.928 ns | 897.0680 ns | 839.1179 ns |
ExpressionTree with Cache | 7.914 ns | 0.0504 ns | 0.0393 ns |
T4Template | 2.251 ns | 0.0125 ns | 0.0104 ns |
SourceGenerator | 2.259 ns | 0.0185 ns | 0.0173 ns |
Fody | 2.584 ns | 0.0101 ns | 0.0079 ns |
Reflection
てことで、まずはReflectionからいきましょう!
ソリューションを開くと、こんな感じで実装技術ごとにフォルダー分けされています。
一番上は、Notメタプログラミングなコードです。Reflectionは2番目ですね。
開くと、3つプロジェクトがあります。だいたい、どの実装技術も同じ構成です。
- Reflection
- Reflection.Metaprogramming
- Reflection.Test
まず実装技術名のプロジェクトがあり、こちらに構文解析される側のコードが入っています。CustomerとEmployeeがありますね。
つぎがCustomerのコードです。
using Commons;
using Reflection.Metaprogramming;
namespace Reflection
{
public class Customer
{
[Identifier]
public int Code { get; set; }
public override bool Equals(object obj)
=> Equals<Customer>.Invoke(this, obj);
}
}
Equalsをオーバーライドして、内部からEqualsクラスのInvokeに処理を委譲しています。この実装は、Metaprogrammingとついているプロジェクトに含まれています。
using System.Linq;
using System.Reflection;
using Commons;
namespace Reflection.Metaprogramming
{
public static class Equals<T>
{
public static bool Invoke(T self, object other)
{
if (other is T t)
{
var identifyProperty = typeof(T)
.GetProperties()
.Single(x => x.GetCustomAttribute<IdentifierAttribute>() is not null);
return Equals(identifyProperty.GetValue(self), identifyProperty.GetValue(t));
}
return false;
}
}
}
TypeからIdentifier属性が付いているプロパティのPropertyInfoを取得しているのが見て取れます。そしてPropertyInfoを使って、値比較するプロパティの値を取得しています。
さてご覧いただいたように、Reflectionは実装が簡単ですが、半面、動作速度が遅いです。そのため、一度だけ実行すればよい自動化ツールの作成なんかで使い勝手が良いです。
ちなみにどれくらい遅いかというと、前述の表にあるように、非メタプログラミングで2.798 nsのところ、Reflectionでは2,891.742 nsと約1000倍です。
とは言え、単位なナノセコンドなので、使えない遅さという話ではないです。
ただこれは改善の余地があって、構造解析のコードをキャッシングすることで性能を改善できます。
実際にコードを見てみましょう。
using System.Linq;
using System.Reflection;
using Commons;
namespace ReflectionWithCache.Metaprogramming
{
public class Equals<T>
{
public static Equals<T> Instance = new();
private readonly PropertyInfo _propertyInfo;
private Equals()
{
_propertyInfo = typeof(T)
.GetProperties()
.Single(x => x.GetCustomAttribute<IdentifierAttribute>() is not null);
}
public static bool Invoke(T self, object other) => Instance.InvokeInner(self, other);
private bool InvokeInner(T self, object other)
{
if (other is T t)
{
return Equals(_propertyInfo.GetValue(self), _propertyInfo.GetValue(t));
}
return false;
}
}
}
EqualsのInvokeメソッドでは、内部でキャッシュされたInstanceオブジェクトのInvokeInnerメソッドを呼び出しています。その中には、先ほどのPropertyInfoを取得するコードが含まれていません。
Instanceオブジェクトを1度だけインスタンス化する際に、PropertyInfoをキャッシュしています。こんな感じでキャッシュを使いまわします。
これでおよそ10分の1まで短縮できます。これは繰り返し実行すればするほど、差が大きくなります。
なおGenericな実装でキャッシングを実行する場合、Static Type Cachingパターンが非常にはまりやすいのでぜひ活用を検討してください。
Expression Tree
続いてExpression Treeです。
実際に動的メタプログラミングではこれが利用されているケースが非常に多いです。
CustomerコードはReflectionとまったく同一で、Equals<T>の実装が異なります。
public static bool Invoke(T self, object other)
{
if (other is T t)
{
var getIdentifyMethod = typeof(T)
.GetProperties()
.Single(x => x.GetCustomAttribute<IdentifierAttribute>() is not null)
.GetGetMethod();
var entity = Expression.Parameter(typeof(T));
var getterCall = Expression.Call(entity, getIdentifyMethod);
var lambda = Expression.Lambda(getterCall, entity);
var getIdentify = (Func<T, int>)lambda.Compile();
return getIdentify(self).Equals(getIdentify(t));
}
return false;
}
なにやらExpressionというのを組み立てて、最後にCompileしてラムダを生成し、このラムダを値の取得に利用します。
さて、この実行速度なのですが、なんと85,835.928 nsもかかります。コンパイルは重たいという事です。
これはReflectionと同じように、コンパイル結果をキャッシュすることで改善できます。
キャッシュ後はなんと7.914 nsになります。非メタプログラミングコードが2.798 nsで、単位がナノセコンドですから、同等レベルといえるのではないでしょうか?
こういった特徴があるので、型変換を伴うORMや通信系のライブラリと相性が非常によく、実際によく利用されています。
T4 Template
続いてT4テンプレートです。
T4だけ、Metaprogrammingとつくプロジェクトがありません。
これはCustomerのコードと同じプロジェクト内に、ttファイルを配置する必要があるからです。
中身を見てみましょう。
ASP.NETのRazerにもにた、普通のテンプレート構文なファイルですね。
詳細は割愛しますが、コード中にEnvDTE.DTEというクラスが見て取れるかと思います。これはVisual Studioを表すオブジェクトで、そこから含まれるコードのメタ情報を取得します。
ttファイルの下に、Equalsのコードが生成されます。
これを実行すると2.251 nsという結果になりました。
非メタな実装と完全に同等です。
ただ、T4にはいろいろ癖がありまして・・・
まず、コード生成する対象コードが増減しても、自動的に追従してくれない問題もあります。
T4から生成されるコードは、T4ファイルの保存時か、メニューから明示的に実行されたときです。このためたとえば、新しくItemクラスを作っても、明示的に生成しない限りコード生成がされません。
また削除の際には、一度csファイルの中を空にしてから実行しないとエラーになります。
この問題は、再配布して利用する場合に、利用者がその特徴を理解して使わないといけなかったりするのが困りごとです。
またIDE依存なのでCI/CD時に生成ということが難しいです。
でも、複雑なコード生成も、ものすごく簡単にできちゃうというのがT4の強みです。
Source Generatorと比較したとき、それぞれに向き不向きがありますので、Source Generatorを紹介したのちにもう少し補足します。
Source Generator
つぎはいよいよソースジェネレーターです
ソースの生成は、メタプロ側のプロジェクトの中のSourceGenerator.csでソース生成してます。
実際にコード生成をしているコードがこちらです。
private string GenerateSource(EqualsTemplate equalsTemplate)
{
var stringBuilder = new StringBuilder();
stringBuilder.Append(@"namespace ");
stringBuilder.Append(equalsTemplate.Namespace);
stringBuilder.Append(@"
{
public partial class ");
stringBuilder.Append(equalsTemplate.TypeName);
stringBuilder.Append(@"
{
public override bool Equals(object other)
{
if(other is ");
stringBuilder.Append(equalsTemplate.TypeName);
stringBuilder.Append(" ");
stringBuilder.Append(equalsTemplate.TypeName.ToLower());
stringBuilder.Append(@")
{
return ");
stringBuilder.Append(equalsTemplate.PropertyName);
stringBuilder.Append(".Equals(");
stringBuilder.Append(equalsTemplate.TypeName.ToLower());
stringBuilder.Append(".");
stringBuilder.Append(equalsTemplate.PropertyName);
stringBuilder.Append(@");
}
return false;
}
}
}
");
return stringBuilder.ToString();
}
StringBuilderにAppend、Appendしてます。
このコードは実装時に頻繁に呼び出されるため、ストリングインターポレーション($"public partial class {equalsTemplate.Namespace}"みたいな書き方)だとパフォーマンスに問題があるせいです。
でも、これ面倒ですよね?ってことで、T4テンプレートの出番です。
先の例ではファイルのプロパティの「カスタムツール」欄が「TextTemplatingFileGenerator」になっていたと思いますが、これを「TextTemplatingFilePreprocessor」に変更します。
T4テンプレートを利用すると、つぎのように記述できます。
直感的に見やすく、書きやすそうですよね。
ここからつぎのようにソースを生成するコードが自動生成できます。
public partial class EqualsTemplate : EqualsTemplateBase
{
/// <summary>
/// Create the template output
/// </summary>
public virtual string TransformText()
{
this.Write("\n");
this.Write("\n");
this.Write("\n");
this.Write("\n");
this.Write("\n");
this.Write("\n\nnamespace ");
this.Write(this.ToStringHelper.ToStringWithCulture(Namespace));
this.Write("\n{\n public partial class ");
this.Write(this.ToStringHelper.ToStringWithCulture(TypeName));
this.Write("\n {\n public override bool Equals(object other)\n {\n if" +
"(other is ");
this.Write(this.ToStringHelper.ToStringWithCulture(TypeName));
this.Write(" ");
this.Write(this.ToStringHelper.ToStringWithCulture(TypeName.ToLower()));
this.Write(")\n {\n return ");
this.Write(this.ToStringHelper.ToStringWithCulture(PropertyName));
this.Write(".Equals(");
this.Write(this.ToStringHelper.ToStringWithCulture(TypeName.ToLower()));
this.Write(".");
this.Write(this.ToStringHelper.ToStringWithCulture(PropertyName));
this.Write(");\n }\n\n return false;\n }\n }\n}\n");
return this.GenerationEnvironment.ToString();
}
このT4ファイルは、あくまでソース生成の際に利用され、ユーザー側には配布されないためデメリットが非常に小さいです。
さて、Source Generatorのすごいところは
「コードはメモリ上で生成され、ファイルは生成されないのに、生成コードを参照できるしデバックもできる」
ことです。やばい。
また実行速度も2.259 nsと当然ながら、当然非メタプログラミング版と同等です。
このように、Source Generatorは利用者に特別な理解を求める必要がなく、再配布しやすいというのがT4比較時の大きなメリットです。
そのため、今後はDapperのようなライブラリでも型変換コードの生成を動的に実行するのではなく、事前にソース生成しておき、利用する形がはやるかもしれません。早いですからね。
また、Analyzerとの相性が良いので、ユーザーフレンドリーなライブラリを作りやすいのも特徴です。
たとえば、Identifier属性は1クラス1つと制限しました。しかしユーザーがあやまって2つにすると、ちゃんとエラーを出してあげることが簡単にできます。
利用者からすると、使いやすいですよね。
Analyzerのコードはこちらをご覧ください。
またIDE非依存なのでCI/CDもやりやすいという特徴があります。
新しいだけあって、色々よいです。
Fody
最後にFodyです。これは曲者です。
FodyはC#コードを生成するのではなく、ビルドされたあとにDLLを直接改変します。つまりILをいじります。
// Foo foo = other as Foo;
processor.Append(Instruction.Create(OpCodes.Ldarg_1));
processor.Append(Instruction.Create(OpCodes.Isinst, definitionType));
processor.Append(Instruction.Create(OpCodes.Stloc_0));
// if (foo != null)
processor.Append(Instruction.Create(OpCodes.Ldloc_0));
processor.Append(Instruction.Create(OpCodes.Ldnull));
processor.Append(Instruction.Create(OpCodes.Cgt_Un));
processor.Append(Instruction.Create(OpCodes.Stloc_1));
processor.Append(Instruction.Create(OpCodes.Ldloc_1));
processor.Append(Instruction.Create(OpCodes.Brfalse_S, labelReturnFalse));
コメントのC#コードと、その下のIL生成コードが同じものです。C#erでもIL知らない限り読めないですよね・・・
でも実行速度は2.584 nsと非常に高速です。非メタプログラミングとの差は、計測誤差です。
Fodyの最大の強みは、他と違い既存コードの改変が行えるというところにあります。
そのため、AOPしたい場合なんかで、Source Generatorなんかじゃ実現できなかったことができちゃいます。たとえば、既存コードにトラッキングコードやリトライ実装を埋め込んだりなんかです。
しかし、ほかの手段で代替できるものは、他の物を利用したほうが良いです。
まずILを覚えなきゃってことで、類似性のない新言語を1つ覚える必要がありますし、同じ実装するにしても、何倍もコードを書かないといけません。必要なテストコードも膨れ上がります。
これは、ソース生成であれば1パターンでいいのが、IL生成だと複数パターン実装しないといけないのがその理由です。
たとえば、メンバーの値を取得するにしても、メンバーを保持しているのが、クラスなのかストラクトなのか?メンバーがフィールドなのかプロパティなのか?それによって全部分岐が入るので指数関数的にやることが増えていきます。
ほかでできない事ができるけど、ほかよりたいへん。それがFodyです。
T4 Template vs Source Generator
静的メタプログラミングを利用したいとなった場合、新しい手法であるSource Generatorを選べば良いかというと、一概にそうとも言えません。
Source Generatorは後だしなだけあり洗練されていますし、Code AnalyzerやCode Fix Providerなどと連携することで利用者にとって使い勝手の良いものを提供しやすいです。
ではあえてT4 Templateを使う方が良い場所はどこにあるかというと、主につぎの2つのケースにあるとわたしは考えています。
- Source Generatorでソース生成する箇所
- 生成されたソースをあえて構成管理したい場合
前者については、さきにSource Generatorの説明の際に触れましたね。
ここでは後者について説明したいと思います。
T4 TemplateとSource Generatorを比較したときに、大きな相違のひとつに生成されたコードがファイルとして出力され管理されるかどうか?という違いがあります。
ソースがファイルとして出力され、構成管理しないといけないというのは、基本的に大きなコストがかかります。たとえばプルリクが送られてきたときに、それが適切に生成されたコードなのか判断するのは非常にナンセンスです。
そもそもメタプログラミングを行うのは、よくあるパターンの実装を自動的に解決することで多くの面倒から解放されたいからです。そしてその面倒のひとつに、間違いなく「コードを正しく管理すること」が含まれています。
T4 Templateを利用すると、この面倒から完全に開放されることができません。この点をみるとSource Generatorが有意に見えます。
しかしよく考えると「あえて生成コードを明示的に管理したい」ケースはたしかに存在します。
それはメタモデルがソースコード外にある場合です。
もっとも分かりやすいのは、データベースのメタモデルからマッピングクラスを自動生成するような場合でしょう。
データベースからマッピングクラスを自動生成してライブラリ化して利用していたとしましょう。
さて、そのライブラリは生成されたときのデータベースのバージョンはいつのものですか?
Source Generatorを利用した場合、これが分かりにくい状態となります。
T4 Templateであれば生成コードが構成管理されるため、これはコードにより自明な状態を保てます。
メタモデルを解析する対象がソースのようなプロジェクト内に含まれるリソースか、それとも外にあるのか?を考えてみるのはT4 Template vs Source Generatorを選択するための1つの指針になると思います。
まとめ
ということで、まとめましょう。
いま実はIComparableのボイラーコードを生成するSource Generatorのライブラリを作ってます。
できたら、良かったら使ってください!おしまい
・・・ではなくて。
.NETにはいろんなメタプログラミングの技法があります。
- CodeDOM
- Roslyn(ここではNuGet上のライブラリ利用のこと)
- Reflection
- Reflection.Emit
- Expression Tree
- T4 Text Templates
- Source Generator ← New!!
- Fody / Mono.Cecil
どれもそれぞれに強みがあって適材適所です。当たり前ですが銀の弾丸はありません。
向いているものを向いている場所で使いましょう
とくにILをいじるのは最終手段ですよ!
おしまい。
Discussion