C# から使いやすい F# コードの書き方
さて始まりました、F# Advent Calendar 2012 です。
今年は、「実用」がテーマと言うことで、F# で書いたコードを C# から使いたくなった時に気を付けるべきポイントなどをまとめました。
F# と C# で異なる名前を付ける
F# では、module に定義する関数や変数の名前は、lowerCamel で付けるのが一般的です (List.map など)。
しかし .NET の世界では、これらの名前は基本的に PascalCase で付けることになっています。
CompiledName 属性を使うことで、この差を埋め、F# からは lowerCamel に、C# からは PascalCase に見える名前を付けることができるようになります。
(* F# *) module Util = [<CompiledName "ToStr">] let toStr x = sprintf "%A" x
これで、F# からは Util.toStr という名前で、C# からは Util.ToStr という名前でアクセスできる関数が定義できます。
CompiledName 属性は、関数や変数だけでなく、型に付けることも可能です。
(* F# *) [<CompiledName "MyUtil">] module Util = [<CompiledName "ToStr">] let toStr x = sprintf "%A" x
こうすることで、Util ではなく、MyUtil というモジュールとして公開されることになります。
C# とは関係ないですが、型引数を持たない型名とモジュール名がかぶってしまうとコンパイルが出来ません。
(* F# *) type User = { Name: string; Age: int } (* Userが重複しているのでコンパイルエラー *) module User = let name user = user.Name
この場合、CompilationRepresentation 属性を使うことで回避できます。
(* F# *) type User = { Name: string; Age: int } [<CompilationRepresentation(CompilationRepresentationFlags.ModuleSuffix)>] module User = let name user = user.Name
こうすると、C# からは User モジュールは UserModule という名前で見えるようになります。
C# からはオーバーロードされて見えるメソッド
CompiledName 属性について、少し前にこんなやり取りがありました。
F# と C# を併用して組んでると、関数名を camelCase にするか CamelCase にするかすごく迷う・・・。どうしたらいいのん? .
@u_1roh CompiledName属性を使うといいですよ
2012-10-25 15:53:14 via Tween to @u_1roh
@bleis おお!おおーっ!もしかして関数のオーバーロードもこれで勝つる!?
2012-10-25 16:06:30 via Tween to @bleis
@u_1roh 関数のオーバーロードは、クラスを使うとできます
2012-10-25 16:07:31 via Tween to @u_1roh
@bleis あ、それは分かってます。言葉足らずですいません。F# 側では違う名前でも(オーバーロードしていなくても)、CompiledName 属性で同じ名前を与えれば C# 側からはオーバーロードに見えるのかな?と思いまして・・・。
2012-10-25 16:09:34 via Tween to @bleis
@u_1roh あ、なるほど。F#上は関数fと関数gだけど、C#上からはどちらもFとして見せたい、ということですね。その発想はなかった!
2012-10-25 16:22:56 via Tween to @u_1roh
@bleis そーです、そーです!
2012-10-25 16:23:48 via Tween to @bleis
つまりこういうことができるわけです。
(* F# *) module StreamUtil = [<CompiledName "Write">] let writeByte (b: byte, stream: Stream) = ... [<CompiledName "Write">] let writeInt (i: int, stream: Stream) = ...
これで、C# 側からは Stream という名前でオーバーロードされて見え、F# 側からは別々の関数として見えるようになります。
ちなみに、関数名以外全く同一のシグネチャを持つ 2 つの関数に、CompiledName で同じ名前を指定すると、dll の書き込みに失敗するという、あまり見ないコンパイルエラーになります。
戻り値のみが異なる場合は後勝ちになるようです。
C# に暗黙の型変換を提供する
F# には暗黙の型変換は存在しませんが、C# 用に提供したい場合があります。
これは、op_Implicit を実装することで実現できます。
(* F# *) type Str = { Value: string } with static member op_Implicit(str) = str.Value
こうすることで、C# 上で以下のような記述が可能になります。
// C# var s = new Str("hoge"); string str = s;
しかし、この方法は型引数を持つ型に対してはなぜか使えません。
(* F# *) type Wrapper<'a> = { Value: 'a } with static member op_Implicit(wrapper) = wrapper.Value
// C# var w = new Wrapper<string>("hoge"); // 型 'Wrapper<string>' を型 'string' に暗黙的に変換できません。 string str = s;
これが出来たら、素敵なことができるんですが・・・
どうにかできる人がいたら連絡を!
拡張メソッド
F# には型拡張*1という仕組みがあり、これを用いることで型に関数などを後付けできるのですが、この方法で実装した拡張は C# 側からアクセスすることができません*2。
C# 側からもアクセスできる拡張メソッドを定義したい場合は、Extension 属性を使用します。
(* F# *) open System.Runtime.CompilerServices [<Extension>] module HogeExtensions = [<Extension>] [<CompiledName "Hoge">] let hoge(x) = ...
こっちでもいいです。
(* F# *) open System.Runtime.CompilerServices [<Extension>] type HogeExtensions = [<Extension>] static member Hoge(x) = ...
使い分けとしては、F# 側からはモジュールとして使いたいけど、C# 側からは拡張メソッドとして使いたい、という場合に前者を、どちらからも拡張メソッドとして使いたい(もしくは、F# 側からは使わない)、という場合は後者を使うといいでしょう。
前者の場合、モジュール名はもうちょっと考えたいところですが。
判別共用体を C# 側から使いたい
インターフェイスやクラスなど、C# 側にも対応する機能が存在するものは F# で定義しても C# 側からは簡単に使えます。
また、モジュールとレコードも、C# 側から自然に扱えます。
レコードにメソッドを定義できるのも、結構便利ですよね。
(* F# *) type User = { Name: string; Age: int } with override this.ToString() = sprintf "%A" this
しかし、判別共用体だけは簡単に C# から使うことができません*3。
例えば、成功と失敗を表す以下の判別共用体を定義したとします。
(* F# *) type Result<'TSuccess, 'TFailure> = Success of 'TSuccess | Failure of 'TFailure
これを C# 側から使おうとする場合・・・
// C# // 生成 var res = Result<int, string>.NewSuccess(42); // 分岐 switch (res.Tag) { case Result<int, string>.Tags.Success: int s = ((Result<int, string>)res).Item; // Successの時の処理 break; case Result<int, string>.Tags.Failure: string f = ((Result<int, string>)res).Item; // Failureの時の処理 break; }
これはひどい。
これでは使えたものではないので、これを操作するユーティリティを提供するといいでしょう。
F# からは関数をパイプライン演算子で繋いで使いたいので、モジュールを使うことにします。
しかし、C# 側からはそれでは面倒なので、拡張メソッドを使うことにします。
その前に、何にでも使える便利なメソッドを定義しておきましょう。
(* F# *) open System type Result<'TSuccess, 'TFailure> = Success of 'TSuccess | Failure of 'TFailure with member this.Match(ifSuccess: Func<_, _>, ifFailure: Func<_, _>) = match this with | Success x -> ifSuccess.Invoke(x) | Failure x -> ifFailure.Invoke(x) member this.Action(ifSuccess: Action<_>, ifFailure: Action<_>) = match this with | Success x -> ifSuccess.Invoke(x) | Failure x -> ifFailure.Invoke(x)
これだけでも、分岐処理を非常に簡単に記述することができるようになります。
// C# // 生成 var res = Result<int, string>.NewSuccess(42); // 分岐 var x = res.Match<string>( s => (s + 1).ToString(), f => "!" + f); // 分岐(副作用) res.Action( s => /* Successの時の処理 */, f => /* Failureの時の処理 */);
あとは、F# 用にモジュールを、C# 用に拡張メソッドを提供するだけです。
(* F# *) module Result = let fold f seed = function Success x -> f seed x | Failure _ -> seed let bind f = function Success x -> f x | Failure x -> Failure x let map f = function Success x -> Success(f x) | Failure x -> Failure x let count = function Success _ -> 1 | Failure _ -> 0 let exists f = function Success x -> f x | Failure _ -> false let forall f = function Success x -> f x | Failure _ -> true let iter f x = fold (fun _ x -> f x) () x let isFailure = function Success _ -> false | Failure _ -> true let isSuccess = function Success _ -> true | Failure _ -> false open System.Runtime.CompilerServices open System.ComponentModel [<EditorBrowsable(EditorBrowsableState.Never)>] [<Extension>] type public ResultExtensions = [<Extension>] static member Fold(x, seed, f: Func<_, _, _>) = Result.fold (fun a b -> f.Invoke(a, b)) seed x [<Extension>] static member Bind(x, f: Func<_, _>) = Result.bind f.Invoke x [<Extension>] static member Map(x, f: Func<_, _>) = Result.map f.Invoke x [<Extension>] static member Count(x) = Result.count x [<Extension>] static member Exists(x, f: Func<_, _>) = Result.exists f.Invoke x [<Extension>] static member Forall(x, f: Func<_, _>) = Result.forall f.Invoke x [<Extension>] static member Iter(x, f: Func<_, _>) = Result.iter f.Invoke x [<Extension>] static member IsFailure(x) = Result.isFailure x [<Extension>] static member IsSuccess(x) = Result.isSuccess x
EditorBrowsable 属性はなくてもいいんですが、付けておくと VS の IntelliSense を汚さないのでいい感じです。
コンピュテーション式に対応するクエリ式の提供
コンピュテーション式を提供する場合、C# 側にはクエリ式を提供することを考えましょう。
例えば先ほどの Result は、コンピュテーション式を提供すると便利です。
(* F# *) type ResultBuilder () = member this.Bind(x, f) = Result.bind f x member this.Return(x) = Success x member this.ReturnFrom(x: Result<_, _>) = x member this.Zero () = Failure() member this.Combine(e1, e2) = Result.bind (fun () -> e2) e1 let result = ResultBuilder()
result を提供することで、失敗の場合を気にせずに処理を書くことができるようになります。
(* F# *) let res = result { let! x = tryGetX() let! y = tryGetY() let! z = tryGetZ() let adjust = getAdjust() return adjust * double (x + y + z) }
C# 用には、Select と SelectMany を提供し、クエリ式が使えるようにします。
(* F# *) [<EditorBrowsable(EditorBrowsableState.Never)>] [<Extension>] type public ResultExtensions = (* 略 *) [<Extension>] static member Select(x, f: Func<_, _>) = Result.map f.Invoke x [<Extension>] static member SelectMany(x : Result<'a, 'TFailure>, f: Func<'a, Result<'b, 'TFailure>>, selector: Func<'a, 'b, 'c>) : Result<'c, 'TFailure> = x |> Result.bind (fun x -> f.Invoke x |> Result.bind (fun y -> Success (selector.Invoke(x, y))))
コンピュテーション式での let! が from in に、let が let に、return が select になったと思って使えます。
// C# var res = from x in TryGetX() from y in TryGetY() from z in TryGetZ() let adjust = GetAdjust() select adjust * (x + y + z);
ここまで考慮すれば、C# 側からもそれなりに使いやすいライブラリが提供できるようになります。
F# だけで閉じずに生きたいものですよね。
え? VB はどうしたって?
や、VB 分かりませんし・・・
次は・・・
次は、Gab_km さんの NaturalSpec なネタらしいです。楽しみですね!
あ、参加者の皆さんは、ネタかぶりを避けるためにも #FsAdventJP タグをつけて書く予定のネタを Twitter でつぶやいとくと良いかもです。
いげ太さんに reply を飛ばしておくとなおよし。
null の扱い
Maybe つながりで、色んな言語の null の扱いについてちょっとだけまとめてみた。
観点は一つ、「this が null の場合に this の指す先にアクセスしないメソッドが呼び出せるか」です。
C++
#include <iostream> struct hoge { void f() { if (this == 0) std::cout << "this is null." << std::endl; else std::cout << "this is not null." << std::endl; } }; int main() { hoge* h = 0; h->f(); // => this is null. }
できます。
追記:
ideone で試してできただけで、未定義動作でした。(thx: id:uskz)
参考:API Only - Stack Exchange
Java
public class Main { void f() { if (this == null) System.out.println("this is null."); else System.out.println("this is not null."); } public static void main(String[] args) { Main x = null; x.f(); // NullPointerException } }
できません。
C#
class Program { void F() { if (this == null) System.Console.WriteLine("this is null."); else System.Console.WriteLine("this is not null."); } public static void Main() { var x = default(Program); x.F(); // NullReferenceException } }
できません。が、拡張メソッドを使うと見た目上はできるように書けます。
class Program { public static void Main() { var x = default(Program); x.F(); // NullReferenceException } } static class ProgramExt { public static void f(this Program self) { if (self == null) System.Console.WriteLine("this is null."); else System.Console.WriteLine("this is not null."); } }
F#
[<AllowNullLiteral>] type Hoge() = member x.f() = if x = null then printfn "this is null." else printfn "this is not null." let x: Hoge = null x.f() (* => this is null. *)
できます。null リテラルを直接使いたい場合、AllowNullLiteral 属性を付けなければならない点に注意してください。
JSX
class _Main { function f(): void { if (this == null) log "this is null."; else log "this is not null."; } function main(args: string[]): void { var x: _Main = null; x.f(); // Type Error: Cannot call method 'x$' of null } }
できません。
まとめ?
レシーバをメソッドの隠れた引数と考えるのであればできるし、そうじゃないナニモノかであると考えるのであればできない、ということでしょうか。
レシーバが null とはどういうことだ!けしからん!!とかってなって即ぬるぽ。
これができることによる問題って思いつかないんだけど、何かあるんだろうか。
Quick Test Switcher 1.1 をリリースした
テストコードと実装コードの切り替えを支援する Visual Studio 用のアドインを作りました。
コードを開いた状態で Ctrl-0 を押すと、対応するファイルがある場合にそれを開きます。
頻繁にテストコードと実装コードを切り替える TDD のお供にどうぞ。
再帰で考える
再帰は関数型言語を構成する重要な部品の一つです*1。
しかし、手続型言語に慣れたプログラマにとって、再帰で考えるというのは難しいものがあります。
このエントリは、そういうプログラマが再帰で考えることができるようになるために書きました。
言語としては、F# と C# を使っています (推奨は F#。C# の例は実用性が無いに等しい) が、Java プログラマでもある程度読めるでしょう。
前提条件として、これらの言語の文法は知っているものとします。
特に、C# で言う Func デリゲートを多用します。
すごい長いので、時間があるときに一気にどうぞ。
再帰以外の話もちょろちょろと出てきます。
演算子のオーバーロードを乱用してみた
C# は演算子のオーバーロードの規則が厳しくてつらいことがあるんですけど、回避できるかも!
・・・と思いついて、やってみたら出来なかった、という話です。
やりたいことは関数合成です。
F# の関数合成みたいに、>> とか << で合成できたらわかりやすくていいよなぁ、とか思うんですけど、C# で << とか >> は右項が int に限定されています。
なので、関数が int に暗黙変換できれば解決できるかも?と思ってやってみたんですが、非常に残念な感じになりました。
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace OpOverloadHack { class Program { static void Main(string[] args) { F f = new F(arg => ((int)arg) + 10); F g = new F(arg => ((int)arg) * 2); Console.WriteLine((f >> g)[3]); // (3 + 10) * 2 = 26 Console.WriteLine((f << g)[3]); // (3 * 2) + 10 = 16 } } class F { static readonly IDictionary<int, F> instances = new Dictionary<int, F>(); static int nextId = 0; readonly int id; readonly Func<object, object> call; public F(Func<object, object> f) { this.call = f; this.id = nextId++; instances[this.id] = this; } public object this[object arg] { get { return call(arg); } } public static F operator >> (F f, int id) { var g = instances[id]; return new F(arg => g.call(f.call(arg))); } public static F operator <<(F f, int id) { var g = instances[id]; return new F(arg => f.call(g.call(arg))); } public static implicit operator int(F f) { return f.id; } } }
型情報が保てませんでした・・・
まぁ、もしできたとしてもやるべきではないですけどね。
Excel-DNA のセルに対する書き込み性能を計ってみた
ExcelDnaUtil.Application を F# から使ったらえらい遅かったので、色々試してみました。
結論を書くと、キャッシュしてなかったから遅かっただけでした。
ベンチマーク概要
100 * 100 のすべてのセルに 1 を設定していく時間を計測しました。
セルに対する書き込み性能を見るためのベンチマークなので、実際の使用状況では全く別の結果となることに注意してください。
ベンチマークの取り方
- https://github.com/bleis-tift/DNA からコードを取得する
- dna.sln をビルドする
- Excel を起動する
- dna_fsdll/bin/Release/dna_fsdll.xll を起動した Excel に DnD
- 「このアドインをこのセッションに限り有効にする」を選択
- dna_csdll/bin/Release/dna_csdll.xll を起動した Excel に DnD
- 「このアドインをこのセッションに限り有効にする」を選択
- リボンからアドインタブを選択
- 好きなベンチマークを走らせる
- ステータスバーにかかった時間が秒で表示される
ベンチマーク結果
コード | 結果 (秒) |
---|---|
F#(COM/キャッシュなし) | 362 |
F#(COM/キャッシュあり) | 2.65 |
F#(DNA) | 0.56 |
C#(COM) | 1.84 |
C#(DNA) | 0.55 |
VBA | 0.48 |
キャッシュなしの F#(COM) は VBA の 700 倍以上遅いという結果に。
まぁぶっ飛んだ値はとりあえず置いときましょう (なんでこんなに遅くなったのかは後述)。
傾向としては、セルに対する書き込み性能では
ということが言えるでしょう。
結論
セルへの書き込み速度が重要になるようなものの場合、COM ではなく Excel-DNA で用意されたオブジェクトを使うべきです。
最速は VBA ですが、単純にセルへの書き込みのみのマクロは少なく、何らかの計算などが行われるでしょう。
そうなると C# や F# で書いた方が高速になる (要出典) ため、VBA の出番は限られます。
使用したコード
C#
Range メソッドとかタプルの扱いとかがちょっと面倒でした。
ローカル変数以外で var が使えないのも残念です。
COM を使用したコードでは、dynamic が大活躍です。
Excel-DNA のオブジェクトを使用したコードでは、ExcelDnaUtil.Application をいったん dynamic で受ける必要があるのはちょっと面倒です。
static IEnumerable<Tuple<int, int>> range = from r in Enumerable.Range(1, 99) from c in Enumerable.Range(1, 99) select Tuple.Create(r, c); [ExcelCommand(MenuName = "Bench(C#)", MenuText = "COM Object")] public static void ComCs() { var sw = Stopwatch.StartNew(); dynamic excel = ExcelDnaUtil.Application; dynamic sheet = excel.ActiveSheet; foreach (var r_c in range) { dynamic cell = sheet.Cells.Item(r_c.Item1, r_c.Item2); cell.Value2 = 1; } sw.Stop(); excel.StatusBar = sw.Elapsed.TotalSeconds; } [ExcelCommand(MenuName = "Bench(C#)", MenuText = "DNA Object")] public static void DnaCS() { var sw = Stopwatch.StartNew(); foreach (var r_c in range) { var row = r_c.Item1 - 1; var col = r_c.Item2 - 1; var cell = new ExcelReference(row, col); cell.SetValue(1); } sw.Stop(); dynamic excel = ExcelDnaUtil.Application; excel.StatusBar = sw.Elapsed.TotalSeconds; }
F#
C# よりもやりたいことに集中できるのはいいのですが、dynamic に相当する仕組みを自分で作らなければならないのは面倒です。
幸い、gist に上げてくれていた人がいたため、ほぼそのまま使わせてもらいました。
が、これが dynamic に比べて遅い遅い・・・
C# の dynamic は裏でキャッシュされるので、上の仕組みにキャッシュ機能を組み込んだら 100 倍以上速くなりました。
let range = [ for r in [1..100] do for c in [1..100] -> (r, c) ] [<ExcelCommand(MenuName="Bench(F#)", MenuText="COM Object ??? Cache")>] let nonCachedCom () = let sw = Stopwatch.StartNew() let excel = ExcelDnaUtil.Application let sheet = excel?ActiveSheet range |> List.iter (fun (row, col) -> let cell = sheet?Cells?Item(row, col) cell?Value2 <- 1 ) sw.Stop() excel?StatusBar <- sw.Elapsed.TotalSeconds [<ExcelCommand(MenuName="Bench(F#)", MenuText="DNA Object")>] let dna () = let sw = Stopwatch.StartNew() range |> List.iter (fun (row, col) -> let row, col = row - 1, col - 1 let cell = ExcelReference(row, col) cell.SetValue(1) |> ignore ) sw.Stop() ExcelDnaUtil.Application?StatusBar <- sw.Elapsed.TotalSeconds
TDD Boot Camp のお題を C# と Git でやってみた
自分で考えたお題を自分で解くとかそれなんてマッチポンプ・・・
打ち上げ終了後のホテルと、翌日の帰りの新幹線の中で書いたコードを順番に追ってみます。
準備するものは Git で、あるといいものは Visual Studio 2010 と NUnit です。
まぁ、割と小さいコード (テストを含めても 300 行もない) だし C# を知らない人でもそれなりに雰囲気は掴めると思います。
あ、このエントリかなり長いです。
準備
Windows の場合、Git Bash を開いて、適当なフォルダに移動して
git clone git://github.com/bleis-tift/MotsunabeZombieProject.git cd MotsunabeZombieProject
としてください。
MotsunabeZombieProject というフォルダができて、その中に Git のリポジトリとかができます。
Mac とか Linux とかでも基本そんな感じで、まずは clone してきてください。
git clone は当日説明しなかったけど、すでにあるリポジトリを丸ごと取ってくるコマンドです。
git init が空っぽのリポジトリを作るのに対して、git clone は既存のリポジトリのコピーをローカルに作ります。
clone したら、gitk --all & してみてください。
master ブランチの他に、タグがいくつかあるはずです。
これらのタグは、git now --rebase する前に打ったタグなので、どういうタイミングで git now しているかの参考にしてください。
お題
お題を簡単にまとめると、「つぶやきの種別を判定するシステム」です。
チケットとしては、
- 普通のつぶやきを判定
- ハッシュタグを含むつぶやきを判定
- リプライを含むつぶやきを判定
- メンションを含むつぶやきを判定
- 複数の種別を含むつぶやきを判定
- ネットワークからつぶやきを取得して判定
- 非公式 RT を含む判定
- 現在時刻の前後 30 分のつぶやきを最大 20 件判定
- URL を含むつぶやきを判定
- 短縮 URL の展開
の 10 個で、この中の 1 〜 6 までを実装してあります。
タグに含まれる数字と、このリストの数字が対応しています。
まずは最終形を眺める
とりあえずどんなファイルがあるか見てみましょう。
- MotsunabeZombieProject/
- CategorizedResult.cs
- TweetAnalyzer.cs
- TweetCategorizer.cs
- MotsunabeZombieProject.Tests/
- CategorizedResultTest.cs
- TweetAnalyzerTest.cs
- TweetCategorizerTest.cs
重要なファイルはこの 6 つです。
テストを除くと、クラスとしては 3 ファイルに 4 つのクラスが定義されています。
- TweetCategorizer
- つぶやきの種類を判定するためのクラス
- CategorizedResult
- 判定結果などを保持するクラス
- TweetAnalyzer
- 与えられた URL のつぶやきを判定するためのクラス
- TweetProvider
- つぶやきを TweetAnalyzer に与えるためのクラス
特徴的なのは、CategorizedResult を導入したところでしょうか。
このクラスを導入したため、カテゴリに対するテスト (TweetCategorizerTest.cs) が簡潔かつ分かりやすくなりました。
これは、文字列への変換を CategorizedResult 自体に持たせたことによって、TweetCategorizerTest の方では文字列の一点比較を行う必要がなくなったからです。
TweetProvider というクラス名は Java のペアが使っていたものをそのまま使わせてもらいました。
こいつはテスト用のクラスで、外部から文字列配列を渡したものを TweetAnalyzer に設定するとその文字列配列を使ってくれます。
何も設定しなければ、TweetAnalyzer はデフォルトの TweetProvider を使うため、HTTP 通信を行ってつぶやきを取得します。
この小さなクラスに通信を任せてあるため、他の部分ではネットワークを全く意識する必要がありませんし、テストも完全にオフラインで実行できるようになっています。
テストを除く各ファイルの行数を見てみると、どれも 50 行に収まっています。
メソッド内の行数を数えてみても、今のところ最長で 6 行と、非常にコンパクトに収まっています。
テストコードとプロダクションコードの行数を見てみると、テストコードが約 140 行、プロダクションコードが約 130 行と、だいたい半々になりました。
テストの総数は 23 個で、カバレッジは 87.04 % です。
カバーされていないところを見ると、
- 実際にネットワークに接続しに行く部分 (TweetProvider.GetTweetsFromUrl メソッド) 全て
- その呼び出し部分 (TweetProvider.GetTweets メソッド) の一部
- Debug.Assert で前提条件を埋め込んでいる部分
- 正規表現の一部
となっていました。最後が若干気になりますが、他は問題なさそうです。
実装時間は合計 4 時間くらいで、残っているコミット間の時間を調べてみると、
最長 | 最短 | 平均 |
---|---|---|
約20分 | 0.4分 | 約4.8分 |
となりました。
コミット総数は 67 ですが、残していない git-now のコミットもあるため、実際にはもうちょっと増えます。
普通のつぶやきの判定
gitk は立ち上がっているでしょうか?立ち上がっていない場合、
gitk --all &
として立ち上げておいてください。
さて、ではチケット 1 の実装から見ていきましょう。
昔の状態を取ってくるのには、checkout が使えます。
でも、ここではブランチに馴染んでもらうためにコードを追うためのブランチを作って、ブランチを移動させることでコードを追いましょう。
git checkout -b hoge da1939
これでコードを追うためのブランチ hoge を作成し、そのブランチに切り替わりました。
da1939 というのは、ブランチを作成するコミットのハッシュ値の先頭 6 文字です。
Git では Subversion と違い、リビジョン番号ではなくハッシュ値によってコミットを識別します*1。
gitk に移り、F5 キーを押してください。
左上の領域 (コミットグラフ) を一番下までスクロールすると、先ほど作った hoge ブランチが確認できます。
hoge が太字になっていることからもわかるように、現在の作業ディレクトリの中はさっきまでの最新のものではなく、da1939 のものになっています。
ようやくコードです。
TweetCategorizerTest を見ると、テストケースが一つだけ書かれています。
var categorizer = new TweetCategorizer(); var result = categorizer.Categorize("bleis\tほげほげ"); Assert.That(result, Is.EqualTo("Normal\tほげほげ"));
テストコードを書くときの癖なんですが、まず Assert.That(result, Is.EqualTo("Normal\tほげほげ")); までを一気に書き上げてしまいます。
そして、var result = ... と上に上に組み立てていきます*2。
こうすることによって、「結果としてほしいもの」と「どうやってそれを手に入れるべきか」を同時に考える必要がなくなります。
まずは結果として欲しいものを書き下しておいて、それからどうやってそれを手に入れるかの API を考えている、ということです。
そのため、
var categorizer = new TweetCategorizer(); Assert.That(categorizer.Categorize("bleis\tほげほげ"), Is.EqualTo("Normal\tほげほげ"));
とは動作としては同じなのですが、考え方としては全く別 (こちらは API をまず考えて、その結果を後で考えている) と言えます。
もちろん、テストコードのリファクタリングを行って後者の形にすることもあります*3。
次に実装コードを見てみましょう。
TweetCategorizer ですが、まだ普通のつぶやきにしか対応しないため、本文の先頭に "Normal\t" を追加するだけになっています。
特に説明する部分もありませんので、次に行きましょう。
ハッシュタグを含むつぶやきの判定
以下のコマンドを実行してください。
git reset --hard d6950
このコミットは、さっき見たコミットの次のです。
テストケースを追加し、それを満たす実装を行っています。
特に問題はないでしょう。進みます。
git reset --hard e36fb
コミットを一つ飛ばしました。
このコミットの前に、ハッシュタグの判定のケースを増やそうとしてテストメソッドを書き始めたのですが・・・
[Test] public void ハッシュタグ付きのTweetがHashTagに判定される() { var categorizer = new TweetCategorizer(); var result = categorizer.Categorize("bleis\tほげほげ #hash"); Assert.That(result, Is.EqualTo("HashTag\tほげほげ #hash")); } [Test] public void 数字のみの場合はNormalと判定される() { var categorizer = new TweetCategorizer(); var result = categorizer.Categorize("bleis\tほげほげ #1234"); Assert.That(result, Is.EqualTo("Normal\tほげほげ #1234")); }
と、ほとんど同じ内容になりそうだったので、このように書かずに TestCase 属性を使うようにテストをリファクタリングしようと思いなおしました。
このコミットはその第一歩で、まず既存のテストと同じものを TestCase 属性を使って書き直しています*4。
git reset --hard 4a15
さっきのコミットの次のコミットですが、TestCase 属性のテストが通ったので、既存のテストを削除しました。
これ以降、git now を使って TDD のサイクルを回して行っています (work/2 のタグを参照)。
この間の各 git now の間隔は、最長約 5 分となっています。
自分の場合は git now の間隔よりも TDD のサイクルの方が短い *5 ので、TDD のサイクルは 4 分とか 3 分とかで回している感じになります。
git reset --hard 772a
チケット 2 がとりあえず終わった状態です。
テストケースがいくつか追加されており、実装もリファクタリングによってわかりやすくなっています。
特にテストケースは TestCase 属性を導入したことによって、無駄に行数を増やさずにテストケースを追加できています。素敵。
リプライを含むつぶやきの判定
git reset --hard 7d57
この時点のテストを抜粋します。
[TestCase("bleis\tほげほげ #hash", "HashTag\tほげほげ #hash")] [TestCase("bleis\tほげほげ #1234", "Normal\tほげほげ #1234")] [TestCase("bleis\tほげほげa#hash", "Normal\tほげほげa#hash")] [TestCase("bleis\tほげほげ #hash", "HashTag\tほげほげ #hash")] [TestCase("bleis\t#hash", "HashTag\t#hash")] [TestCase("bleis\tほげほげ#hash", "HashTag\tほげほげ#hash")] public void ハッシュタグ付きのTweetがHashTagに判定される(string record, string expected) { var categorizer = new TweetCategorizer(); Assert.That(categorizer.Categorize(record), Is.EqualTo(expected)); } [TestCase("bleis\t@t_wada ほげほげ", "Reply\t@t_wada ほげほげ")] [TestCase("bleis\t@ ほげほげ", "Normal\t@ ほげほげ")] [TestCase("bleis\t.@t_wada ほげほげ", "Normal\t.@t_wada ほげほげ")] public void リプライ付きのTweetがReplyに判定される(string record, string expected) { var categorizer = new TweetCategorizer(); Assert.That(categorizer.Categorize(record), Is.EqualTo(expected)); }
また重複が目につくようになってきましたので、テストコードをリファクタリングすることにしました。
git reset --hard b029
ScreenName は特に使っていませんし、常に固定でも問題ありませんので、ヘルパメソッドを使うようにしました。
string _(string body) { return "bleis\t" + body; }
さらにリファクタリングを続けます。
git reset --hard d6555
さっきのリファクタリングで、テストメソッドの引数は body のみを受け取るようになりました。
ということは、期待する結果はカテゴリと body をタブ文字で連結すればいいことになります。
string _(string category, string body) { return category + "\t" + body; }
さっきのリファクタリングで追加したのと合わせ、2 つのメソッドを導入したことでテストコードはこうなりました。
[TestCase("ほげほげ #hash", "HashTag")] [TestCase("ほげほげ #1234", "Normal")] [TestCase("ほげほげa#hash", "Normal")] [TestCase("ほげほげ #hash", "HashTag")] [TestCase("#hash", "HashTag")] [TestCase("ほげほげ#hash", "HashTag")] public void ハッシュタグ付きのTweetがHashTagに判定される(string body, string expectedCategory) { var categorizer = new TweetCategorizer(); Assert.That(categorizer.Categorize(_(body)), Is.EqualTo(_(expectedCategory, body))); } [TestCase("@t_wada ほげほげ", "Reply")] [TestCase("@ ほげほげ", "Normal")] [TestCase(".@t_wada ほげほげ", "Normal")] public void リプライ付きのTweetがReplyに判定される(string body, string expectedCategory) { var categorizer = new TweetCategorizer(); Assert.That(categorizer.Categorize(_(body)), Is.EqualTo(_(expectedCategory, body))); }
重複が消え去った上、どういったテストケースなのかが非常にわかりやすくなりました。
実装コードのリファクタリングを軽く行って、チケット 3 終了です。
メンションを含むつぶやきの判定
git reset --hard 95774
実装をとりあえず終えた段階ですが、今度はテストメソッドの中の重複が気になってきました。
var categorizer = new TweetCategorizer();
Assert.That(categorizer.Categorize(_(body)), Is.EqualTo(_(expectedCategory, body)));
このコードが 3 個所で使われています。ここもリファクタリングしてしまいましょう。
git reset --hard 9b065
ここでさっきまで使っていたヘルパメソッド 2 つを削除し、新たに AssertCategory メソッドを追加しました。
void AssertCategory(string body, string expectedCategory) { var categorizer = new TweetCategorizer(); Assert.That(categorizer.Categorize("bleis\t" + body), Is.EqualTo(expectedCategory + "\t" + body)); }
これによって、テストメソッドの中はこうなりました。
AssertCategory(body, expectedCategory);
いい感じです。
更に 3 つのメソッド
- ハッシュタグ付きのTweetがHashTagに判定される
- リプライ付きのTweetがReplyに判定される
- メンション付きのTweetがMentionに判定される
を一つのメソッドにまとめることもできるのですが、これらはテストケースとしては別物だと思っているので、まとめていません*6。
複数の種別を含むつぶやきの判定
git reset --hard 6ff2
とりあえず書いてみたものの、テストメソッドが微妙です。
[TestCase("@t_wada ほげほげ#hash", new[] { "Reply,HashTag", "HashTag,Reply" })] public void 複数の種類を含むTweetがカンマ区切りで連結される(string body, string[] expectedCategories) { var categorizer = new TweetCategorizer(); var result = categorizer.Categorize("bleis\t" + body); // 順番はどうでもいい foreach (var exCat in expectedCategories) { if (result.StartsWith(exCat + "\t")) { Assert.Pass(); return; } } Assert.Fail(string.Format("expected starts with [{0}] but [{1}].", string.Join(" or ", expectedCategories), result)); }
パッと見てこれがなんのテストなのか全くわかりません。
これはダメだと思い、判定結果を表すクラスを導入してみることにしました。
git reset --hard 27190
CategorizedResult というクラスを導入しました。
つぶやき本体と判定されたカテゴリを持つだけの単純なクラスです。
git reset --hard 706b
CategorizedResult に ToString を追加しました。
これによって、結果の文字列が等しいことを確認するテストと、判定されたカテゴリが正しいことを確認するテストを分離することができそうです。
git reset --hard f7cc
ここからコードを置き換えていきます。Git があるので、安心して置き換えを試してみることができます。
まずは普通のつぶやきの判定を行うテストを、CategorizedResult を使って書き直してみました。
var categorizer = new TweetCategorizer(); var result = categorizer.Categorize("bleis\tほげほげ"); Assert.That(result.Categories, Is.EqualTo(new[] { "Normal" }));
もちろん、categorizer.Categorize("bleis\tほげほげ") の戻り値の型は string なので、まだコンパイルは通りません。
git reset --hard 43592
Green バーが見たいので、CategorizedResult を返すように Categorize メソッドを書き換えます。
これでコンパイルが通ると思ったんですが、今度は Categorize メソッドの戻り値の型を string として扱っているテストが残っているため、コンパイルが通りませんでした。
git reset --hard 5b5f
面倒なので #if false 〜 #endif で無効化しちゃいました。コンパイルは通りますし、テストも Green です。
あとはテストを書き換えていって、無効化した範囲を狭めていきます。
テストコードのリファクタリングを行って、重複部分をかなり削っていたため、置き換えは非常に簡単に行うことができました。
CategorizedResult 導入のきっかけとなったテストが、CategorizedResult を導入することでどうなったか見てみましょう。
git reset --hard 498a
[TestCase("@t_wada ほげほげ#hash", new[] { "Reply", "HashTag" })] [TestCase("@t_wada ほげほげ#hash", new[] { "HashTag", "Reply" }, Description="順不同")] public void 複数の種類を含むTweetの判定結果に含まれるすべての種類が存在する(string body, string[] expectedCategories) { var categorizer = new TweetCategorizer(); Assert.That(categorizer.Categorize("bleis\t" + body).Categories, Is.EquivalentTo(expectedCategories)); }
非常にすっきりしたテストになりました。
NUnit の EquivalentTo は順番を考慮しない比較を行ってくれます。
置き換え前はひとつのテストケースに結果の組み合わせを人力で記述していましたが、CategorizedResult を導入したことによりその必要がなくなりました。
そのため、テストの追加が非常に簡単にできるようにもなっています。
いくつかテストを追加して、チケット 5 は終了です。
ネットワークからつぶやきを取得して判定
TDD Boot Camp 福岡では、ここで入力形式の変更という仕様変更が入りました。
今までは ScreenName と Body がタブ文字で区切られた形式だったのですが、ここからは yyyy/MM/dd HH:mm:ss 形式の日付が先頭にくっつき、ScreenName との間にタブ文字が置かれます。
つまり、日時と ScreenName と Body がタブ文字で区切られた形式です。
この変更に対応するために、とりあえずテストをひとつ修正してみます。
git reset --hard 4b14
テストを実行してみると、Green です。
・・・あれ?仕様変更の影響を受けていない?と思いつつ、ほかのテストも新形式に修正してみました。
git reset --hard 4efe
落ちました。リプライ判定用の正規表現で、先頭にマッチという条件が含まれていました。
新形式をそのまま既存の Categorize メソッドに渡すと、日付の部分が ScreenName として、ScreenName 以降が Body として認識されてしまうため、Reply を期待しているテストがことごとく落ちました。
git reset --hard ee2e
Body 部分の取得方法を修正し、新形式への対応完了です。
しかし、GetCategory メソッド内部でコレクションをカンマでくっつけて文字列化し、それを呼び出し側で Split するという部分が目についたので、リファクタリングを行うことにしました。
この際、チケットとして分けるのが面倒なので同じチケット内でやってしまうことが多いです。
git reset --hard 04e1
さらに LINQ を使って内部を書き直したところで、チケット 6 本来の作業に入ります。
git reset --hard d4d8
このコミットで TweetAnalyzer を導入していますが、名前の付け方に迷い、20 分くらい使っています。
結局、最終的にはつぶやきの分析を行うことになりそうなストーリーだったので*7、TweetAnalyzer としました。
更に、TweetAnalyzer では Categorize するのが目的ではないという判断から、Categorize メソッドは public ではなく internal にしました。
git reset --hard c0554
ここまでのコミットは、API を決めるために右往左往して、最終的に残ったコミットです。
TweetProvider クラスを導入して、データの取得先をこのクラスに隠ぺいすることにしました。
ここからもあぁでもないこうでもないと試行錯誤しながら、最終的な実装に落ち着きました。
git reset --hard work/6
ここでタイムアップ、新幹線が名古屋に着いたようです。
家で git now --rebase して、ゴミの除去とテストを軽くリファクタリングしたものを GitHub に公開しました。
まとめ
- 最初のテストは Assert First (Assert から書く)
- 結果を決めてから API を設計する (ゴールを見える状態にしておくのは重要)
- 上へ上へ書いていくので、VsVim が便利
- Assert.That 可愛いよ Assert.That
- テストケースのリファクタリング
- ヘルパメソッドの導入
- TestCase 属性
- TestCaseSource 属性
- テストケースを簡潔に書くためにクラスを導入
- git
- git now は TDD との相性がいい
- git now --rebase する前にタグを打つことで、自分の傾向を知ることができる
- git のブランチはとても軽いので気軽に使える*8
*1:なんでハッシュ値なんてわけわからないものを使ってるの?という疑問を持った方は、入門 Git (大文字の方) か実用 Git をどうぞ。[http://d.hatena.ne.jp/bleis-tift/20100922/1285140344#_commitid:title=リビジョン番号がない話]あたりもどうぞ
*2:VsVim を使っている場合、
*3:なので、テスト対象のメソッド呼び出しの後からメソッドチェインでつなげていくのは TDD とは相性が悪いです。もちろん、既存のコードに対してテストを追加する場合はそういうテスティングフレームワークは強力でしょうが、NUnit の Assert は TDD と相性がいいのです。一部 C#er からは Assert.That ないわー、とか言われちゃってますけど、俺はアリ派です。Assert.That 万歳!
*4:この際 result 変数を除去していますが、ある程度 API が固まったと感じたために assert first を一旦やめました。API が固まっているのに assert first するのにあまりメリットを感じないからです。この辺りは人によって考え方が違いそう。
*5:VS 上で git の操作ができないというのが git now の間隔と TDD のサイクルが一致しない主な原因だと思います。テストを実行する手間は TestDriven.NET によってほとんどないですが、git のコマンドを実行するためにはウィンドウを切り替える必要がある
*6:ただ、メンションのテストとリプライのテストに完全に同じものがあるので、これは消した方がいいのかも。
*7:後で何らかの形でお題が公開されると思いますので、参加者以外の方はそれまでよくわからないかもしれませんが、そんな感じだったのです。
*8:今回ブランチを使って過去のコードを取り出していましたが、どうでしょう?全然遅くなかったのではないでしょうか?