T4による不変オブジェクト生成のためのテンプレート
- C# - 10.08/30
不変欲しい!const欲しい!readonlyをローカル変数にもつけたい!という要望をたまに見かけるこの頃。もし、そういった再代入不可というマークがローカル変数に導入されるとしたら、readonlyの使い回しだけは勘弁です。何故って、ローカル変数なんて大抵は再代入しないので、readonly推奨ということになるでしょう、そのうちreadonly付けろreadonly付けろというreadonly厨が出てくるのは目に見えています。
良いことなら付ければいいじゃない、というのはもっともですが、Uglyですよ、視覚的に。readonly var hoge = 3 だなんて、見たくはない。頻繁に使うほうがオプションで醜く面倒くさいってのは、良くないことです。let hoge = 3 といったように、let、もしくはその他のキーワード(valとか?)を導入するならば、いいかな、とは思いますが。
それに、ただ単にマークしただけじゃあ不変を保証するわけでもない……。例えばListなんてClearしてAddRangeしたのと再代入とは、どう違うの?的な。難しいねえ。そんなimmutableの分類に関してはufcppさんのimmutableという記事が、コメント欄含め参考になりました。
そうはいっても、そんなにガチに捉えなくても、不変にしたいシチュエーションはいっぱいあります。実はオブジェクト指向ってしっくりきすぎるんです! 不変オブジェクトのすゝめ。 - Bug Catharsis。おお、すすめられたい。ところでしかし、こういう時にいつも疑問に思っているのは、生成どうすればいいのだろう、ということ。今のところ現実解としてあるのはreadonly、つまり、コンストラクタに渡すしかないのですが……
public Hoge(int a, int b, int c, string d, string e, DateTime f, .....
破綻してる。こんなクソ長いコンストラクタ見かけたら殺していいと思う。全くもって酷い。さて、どうしましょう。こういう場合はビルダーを使いましょう、とはEffective Javaが言ってますので(私、この本あんま好きじゃないんだよねー、とかはどうでもいいんですがー)とりあえずストレートに従ってみます。
// あまり行数使うのもアレなので短くしますが、実際は10行ぐらいあると思ってください Hoge hoge = new HogeBuilder() .Age(10) .Name("hogehoge") .Build();
まあ、悪くない、ですって?いえいえ、これはBuilder作るの面倒くさいし、第一Java臭い。メソッドチェーンだからモダンで素敵、と脳が直結してる人は考えが一歩足らない。むしろ古臭い。最近は流れるようなインターフェイスとかも割と懐疑的で、私は。頂くのはアイディアだけであって、書き方に関しては、各言語にきっちり馴染ませるべき。先頭の大文字小文字だけ整えて移植だとか、愚かな話。というわけで、C#ならオブジェクト初期化子を使おう。
var hoge = new HogeBuilder { Age = 10, Name = "hogehoge" }.Build(); // 暗黙的な型変換を使えばBuildメソッドも不要になる(私はvarのほうが好みですが) Hoge hoge = new HogeBuilder { Age = 10, Name = "hogehoge" };
ええ、これなら悪くない。オブジェクト初期化子は大変素晴らしい(本当にそろそろModern C# Designをですね……)。ビルダーを作る手間もJava方式に比べ大幅に軽減されます(set専用の自動プロパティを用意するだけ)。それにIntelliSenseのサポートも効きます。
未代入のもののみリストアップしてくれる(Ctrl+Space押しだと全部出てきたりする、バグですかね、困った困った)。そういえばで、これは、不変である匿名型の記法とも似ています。余分なのは.Build()だけで、書く手間的にはそんな変わらない。
前説が長ったらしくなりました。本題は「匿名型のような楽な記法で不変型を生成したい」が目標です。C#の現在の記法では、それは無い。欲しいなあ。名前付き引数使えば似たような雰囲気になると言えばなるんですが、アレ使うと「省略可」な雰囲気が出てダメ。ビルダーで作りたいのは、原則「省略不可」なので。
なければ作ればいいじゃない、オブジェクト初期化子を使って.Buildで生成させるビルダーを作れば似たような感じになる。あとは、手動でそれ定義するの非常に面倒なので、そう、T4で自動生成しちゃえばいいぢゃない。
以下コード。例によってパブリックドメインで。別にブログにベタ貼りなコードは自明でいいんじゃないかって気もするんですが、宣言は一応しておいたほうがいいのかなー、と。
<#@ assembly Name="System.Core.dll" #> <#@ import namespace="System.Linq" #> <#@ import namespace="System.Collections.Generic" #> <#@ import namespace="System.Text.RegularExpressions" #> <#@ output extension="Generated.cs" #> <# // 設定:クラス名はそのまま文字列で入力 // クラスの持つ変数は、コンストラクタに書くみたいにdeffinesに // "string hoge","int huga" といった形で並べてください // usingとnamespaceは、直下の出力部を直に弄ってください // partial classなので、これをベースにメソッドを足す場合は別ファイルにpartialで定義することを推奨します // Code Contractsに関わる部分は(ContractVerification属性とContract.EndContractBlock())は、 // 対象がWindows Phone 7などContractが入っていない環境下では削除してください(通常の.NET 4環境では放置で大丈夫) var className = "Person"; var deffines = new DeffineList { "string name", "DateTime birth", "string address" }; #> using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.Contracts; namespace Neue.Test { [DebuggerDisplay(@"<#= deffines.DebuggerDisplay #>", Type = "<#= className #>")] public partial class <#= className #> : IEquatable<<#= className #>> { <# foreach(var x in deffines) {#> [DebuggerBrowsable(DebuggerBrowsableState.Never)] private readonly <#= x.TypeName #> <#= x.FieldName #>; public <#= x.TypeName #> <#= x.PropName #> { get { return <#= x.FieldName #>; } } <# } #> private <#= className #>(<#= deffines.Constructor #>) { <# foreach(var x in deffines) {#> this.<#= x.FieldName #> = <#= x.FieldName #>; <# } #> } [ContractVerification(false)] public static implicit operator Person(Builder builder) { return builder.Build(); } public bool Equals(<#= className #> other) { if (other == null || GetType() != other.GetType()) return false; if (ReferenceEquals(this, other)) return true; return EqualityComparer<<#= deffines.First().TypeName #>>.Default.Equals(<#= deffines.First().FieldName #>, other.<#= deffines.First().FieldName #>) <# foreach(var x in deffines.Skip(1)) {#> && EqualityComparer<<#= x.TypeName #>>.Default.Equals(<#= x.FieldName #>, other.<#= x.FieldName #>) <# } #> ; } public override bool Equals(object obj) { var other = obj as <#= className #>; return (other != null) ? Equals(other) : false; } public override int GetHashCode() { var hash = 0xf937b6f; <# foreach(var x in deffines) {#> hash = (-1521134295 * hash) + EqualityComparer<<#= x.TypeName #>>.Default.GetHashCode(<#= x.FieldName #>); <# } #> return hash; } public override string ToString() { return "{ " + "<#= deffines.First().PropName #> = " + <#= deffines.First().FieldName #> + <# foreach(var x in deffines.Skip(1)) {#> ", <#= x.PropName #> = " + <#= x.FieldName #> + <# } #> " }"; } public class Builder { <# foreach(var x in deffines) {#> public <#= x.TypeName #> <#= x.PropName #> { private get; set; } <# } #> public <#= className #> Build() { <# foreach(var x in deffines) {#> if ((object)<#= x.PropName #> == null) throw new ArgumentNullException("<#= x.PropName #>"); <# } #> Contract.EndContractBlock(); return new <#= className #>(<#= string.Join(", ", deffines.Select(d => d.PropName)) #>); } } } } <#+ class Deffine { public string TypeName, FieldName, PropName; public Deffine(string constructorParam) { var split = constructorParam.Split(' '); this.TypeName = split.First(); this.FieldName = Regex.Replace(split.Last(), "^(.)", m => m.Groups[1].Value.ToLower()); this.PropName = Regex.Replace(FieldName, "^(.)", m => m.Groups[1].Value.ToUpper()); } } class DeffineList : IEnumerable<Deffine> { private List<Deffine> list = new List<Deffine>(); public void Add(string constructorParam) { list.Add(new Deffine(constructorParam)); } public string DebuggerDisplay { get { return "\\{ " + string.Join(", ", list.Select(d => string.Format("{0} = {{{1}}}", d.PropName, d.FieldName))) + " }"; } } public string Constructor { get { return string.Join(", ", list.Select(d => d.TypeName + " " + d.FieldName)); } } public IEnumerator<Deffine> GetEnumerator() { return list.GetEnumerator(); } System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { return GetEnumerator(); } } #>
以下のようなのが出力されます(長いねー)
using System; using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.Contracts; namespace Neue.Test { [DebuggerDisplay(@"\{ Name = {name}, Birth = {birth}, Address = {address} }", Type = "Person")] public partial class Person : IEquatable<Person> { [DebuggerBrowsable(DebuggerBrowsableState.Never)] private readonly string name; public string Name { get { return name; } } [DebuggerBrowsable(DebuggerBrowsableState.Never)] private readonly DateTime birth; public DateTime Birth { get { return birth; } } [DebuggerBrowsable(DebuggerBrowsableState.Never)] private readonly string address; public string Address { get { return address; } } private Person(string name, DateTime birth, string address) { this.name = name; this.birth = birth; this.address = address; } [ContractVerification(false)] public static implicit operator Person(Builder builder) { return builder.Build(); } public bool Equals(Person other) { if (other == null || GetType() != other.GetType()) return false; if (ReferenceEquals(this, other)) return true; return EqualityComparer<string>.Default.Equals(name, other.name) && EqualityComparer<DateTime>.Default.Equals(birth, other.birth) && EqualityComparer<string>.Default.Equals(address, other.address) ; } public override bool Equals(object obj) { var other = obj as Person; return (other != null) ? Equals(other) : false; } public override int GetHashCode() { var hash = 0xf937b6f; hash = (-1521134295 * hash) + EqualityComparer<string>.Default.GetHashCode(name); hash = (-1521134295 * hash) + EqualityComparer<DateTime>.Default.GetHashCode(birth); hash = (-1521134295 * hash) + EqualityComparer<string>.Default.GetHashCode(address); return hash; } public override string ToString() { return "{ " + "Name = " + name + ", Birth = " + birth + ", Address = " + address + " }"; } public class Builder { public string Name { private get; set; } public DateTime Birth { private get; set; } public string Address { private get; set; } public Person Build() { if ((object)Name == null) throw new ArgumentNullException("Name"); if ((object)Birth == null) throw new ArgumentNullException("Birth"); if ((object)Address == null) throw new ArgumentNullException("Address"); Contract.EndContractBlock(); return new Person(Name, Birth, Address); } } } }
これで、どれだけ引数の多いクラスであろうとも、簡単な記述でイミュータブルオブジェクトを生成させることが出来ます。しかも、普通にクラス作るよりも楽なぐらいです、.ttをコピペって、先頭の方に、コンストラクタに並べる型を書くだけ。後は全部自動生成任せ。もし積極的に使うなら、Generated.csのほうを消して.ttのみにした状態で、ファイル→テンプレートのエクスポートで項目のエクスポートをすると使い回しやすくて素敵と思われます、項目名はImmutableObjectとかで。
// 書くときはこんな風にやります var person1 = new Person.Builder { Name = "hoge", Birth = new DateTime(1999, 12, 12), Address = "Tokyo" }.Build(); // 暗黙的な型変換も実装されているので、.Buildメソッドの省略も可 Person person2 = new Person.Builder { Name = "hoge", Birth = new DateTime(1999, 12, 12), Address = "Tokyo" }; // 参照ではなく、全てのフィールドの値の同値性で比較される Console.WriteLine(person1.Equals(person2)); // true
匿名型の再現なので、EqualsやGetHashCodeもオーバーライドされて、フィールドの値で比較を行うようになっています。この辺はもう手動だと書いてられないですよね。ReSharperなどを入れて生成をお任せする、という手はありますが。
==はオーバーライドされていません。これもまた匿名型の再現なので……。Tupleもされてないですしね。これは、フィールドをreadonlyで統一しようと「変更可能」な可能性が含まれるので==は不適切、というガイドライン的なもの(と解釈しました)に従った結果です。変更可能云々は、下の方で解説します。
Code Contracts
更に、Code Contractsを入れれば、値の未代入に対するコンパイラからの静的チェックまで得られます!下記画像のは、Addressが未入力で、通常は実行時に例外が飛ぶことで検出するしかないですが、Code Contractsが静的チェックで実行前にnullだと警告してくれています。
ビルダーの欠点は未代入の検出が実行時まで出来なかったりすること。インターフェイスで細工することで、順番を規定したり、必ず代入しなければならないものを定義し終えるまでは.BuildメソッドがIntelliSenseに出てこないようにする。などが出来ますが、手間がかかりすぎて理想論に留まっている気がします。
簡単であることってのはとても大事で、過剰な手間暇や複雑な設計だったりってのは、必ず無理が生じます。手間がかかること、複雑であることは、それ自体が良くない性質の一つであり、メリットがよほど上回らない限りは机上の空論にすぎない。
今回、Code Contractsのパワーにより、シンプルなオブジェクト初期化子を使ったビルダーでも未代入の静的チェックをかませる、という素敵機構が実現しました。残念ながらCode Contractsは要求環境が厳しいです。アドインを入れてない/入れられない(Express)場合はどうなるのか、というと、.NET 4にクラス群は入っているので、コンパイル通らないということはありません。普通にArgumentNullExceptionがthrowされるという形になります。
私が考えるに、.NET 4でクラスが入ったのって、Code Contractsのインストールの有無に関係なくコードが共有出来るように、という配慮でしかない予感。ExpressでContractクラスを使う意味は、あまりなさそうですね。Windows Phone 7環境など、Contractクラスそのものがないような場合では、T4のBuilderクラスBuildメソッドのContract.EndContractBlock();の一行とimplict operatorのContractVerification属性を削除してください。自分で好きに簡単に書き換えられるのもT4の良さです。
今回はnullチェックしかしていないので、つまり値型の未代入には無効です。何とかして入れたいとは思ったんですが、例えば対策として値型をNullableにするにせよType情報が必要で、そのためにはAssembly参照が必要で、と設定への手間が増えてしまうので今回は止めました(このT4はただ文字列を展開しているだけで、完全にリフレクション未使用)
Code Contractsに関しては、事前条件のnullチェックにしか使っていなくて真価の1%も発揮されていないので、詳しくはとある契約の備忘目録。契約による設計(Design by Contract)で信頼性の高いソフトウェアを構築しよう。 - Bug Catharsisなどなどを。不変オブジェクトに関してもそうだけれど、zeclさんの記事は素晴らしいです。
Code Contracts自体は、メソッド本体の上の方で、コントラクトの記述が膨れ上がるのは好きでないかも。従来型の、if-then-throwでの引数チェックも、5行を超えるぐらいになるとウンザリしますね。ご丁寧に{で改行して、if-then-throwの一個のチェックに4行も使って、それが5個ぐらい連なって20行も使いやがったりするコードを見ると発狂します。そういう場合に限ってメソッド本体は一行で他のメソッド呼んでるだけで、更にその、他のメソッドの行頭にも大量の引数チェックがあったりすると、死ねと言いたくなる。コードは視覚的に、横領域の節約も少しは大事だけど、縦も大事なんだよ、分かってよね……。メソッド本体が1000行とか書く人じゃなく、100行超えたら罰金(キリッ とか言ってる人だけど、それならガード句が10行超えたら罰金だよこっちとしては。
話が脱線した。つまるところ、コントラクトはライブラリレベルで頑張るよりも、言語側でのサポートが必要な概念ですね、ということで。実際rewriterとか、ライブラリレベル超えて無茶しやがって、の領域に踏み込んでいますし<Code Contracts。
プラスアルファ
partial classで生成されるので(デフォルトではクラス名.Generated.cs)、別ファイルにクラスを作ることで、フィールドの増減などでT4を後で修正しても、影響を受けることなくメソッドを追加することができます。
// Person.csという形で別ファイルで追加 using System; namespace Neue.Test { public partial class Person { public int GetAge(DateTime target) { return (target.Year - birth.Year); } } }
それと、nullチェックだけじゃなくきっちりBuildに前提条件入れたい(もしくはnullを許容したい)場合は、T4のBuildメソッドの部分に直に条件を書いてしまうか、それも何だか不自然に感じる場合は生成後のファイルをT4と切り離してしまうのも良いかもですね。自由なので好きにどうぞですます。
で、本当に不変なの?
何をもってどこまでを不変というのかはむつかしいところですが、Equalsが、GetHashCodeが変化するなら、可変かしら? 単純に全ての含まれる型のゲッターが常に同一の値を返さなければ不変ではない、でも良いですが。冒頭でも言いましたが、そう見るとreadonlyだけでは不変を厳密には保証しきれていません。匿名型で例を出すと
class MyClass { public int i; public override int GetHashCode() { return i; } } static void Main(string[] args) { var anon = new { MC = new MyClass { i = 100 } }; var hashCode1 = anon.GetHashCode(); anon.MC.i = 1000; // 変更 var hashCode2 = anon.GetHashCode(); Console.WriteLine(hashCode1 == hashCode2); // false Console.WriteLine(hashCode1); Console.WriteLine(hashCode2); }
参照しているMyClassのインスタンスの中身が変化可能で、それが変化してしまえば、違う値になってしまいます。厳密に不変であるためには、中のクラス全てが不変でなければなりません。これは今の言語仕様的には制限かけるのは無理かなー、といったところ。T4なのでリフレクションで全部バラして、参照している型が本当の意味で不変なのかどうか検証して、可変の型を含む場合はジェネレートしない、という形でチェックかけるのは原理的には可能かもしれません、が、やはり色々無理があるかなあ。
まとめ
プログラミングの楽しさの源は、書きやすく見た目が美しいことです。私はLinq to Objects/Linq to Xmlでプログラミングを学んだようなものなので、Linqの成し遂げたこと(究極のIntelliSenseフレンドリーなモデル・使いづらいDOMの大破壊)というのが、設計の理想と思っているところが相当あります。C#は言語そのものが素晴らしいお手本。匿名型素晴らしいよ(一年ぐらい前は匿名型も可変ならいいのに、とか口走っていた時期があった気がしますが忘れた、いやまあ、可変だと楽なシチュエーションってのもそれなりにいっぱいあるんですよね)。
T4の標準搭載はC#にとって非常に大きい。T4標準搭載によって、出来る事の幅がもう一段階広がった気がします。partial class素晴らしい。自動生成って素敵。T4はただのテキストテンプレートじゃなくて「VSと密接に結びついていて」「なおかつ標準搭載」「もはやC#の一部といってもいい」ことが、全く違った価値をもたらしていると思います。自動生成前提のパターンを作っても/使ってもいいんだよ、と。言語的に足らない部分の迂回策が、また一つ加わった。
見た目上若干Uglyになっても自動生成でなんとかする、というのはJava + Eclipseもそうですが、それと違うのはpartialでUglyな部分を隔離出来る(隔離によって自動生成の修正が容易になることも見逃せない)ことと、自動生成部分をユーザーが簡単に書けること、ですね。Eclipseの自動生成のプラグインを書くのは敷居が高すぎですが、T4を書く、書くまではしなくても修正する、というのは相当容易でしょう。
最近本当にT4好きですねー。色々と弄ってしまいます。こーどじぇねれーと素晴らしい。あとは、T4自体のUglyさが少し軽減されればな、といったところでしょうか。テンプレートエンジンとしてRazorに切り替えられたりを望みたいなあ。
