働くプログラマ
C# を楽しむ
新しい言語を学ぶと、他の言語 (C# 系列の言語) に新しい観点や新しいコーディング方法を導入できます。今回は、現在 F# MVP として活動している私の個人的な好みから、F# を取り上げます。以前のコラム (bit.ly/1lPaLNr) では関数型プログラミングを簡単に取り上げましたが、今回は新しい言語を紹介します。
新しい言語でコーディングしていると、最終的にコードを C# で記述する必要に迫られることがよくあります (C# への変換については次回のコラムで扱います)。しかし、F# でコーディングすることにはメリットがあります。その理由は次の 3 つです。
- F# を使用すると、今回の例のような問題を C# よりも簡単に解決できる場合がある。
- C# でコードを書き直す前に別の言語で問題を検討すると、ソリューションがわかりやすくなることがよくある。
- F# は C# と同様に .NET 言語の 1 つなので、F# で問題を解決してから .NET アセンブリにコンパイルして、C# から呼び出せることがある (計算やアルゴリズムの複雑さによっては、ソリューションの妥当性が向上することもある)。
問題の概要
このようなソリューションに関する簡単な問題について考えてみましょう。Speedy という個人向け資産管理アプリケーションを開発しているとします。アプリケーションの処理の一環として、オンラインで検出した取引とユーザーがアプリケーションに入力した取引を "照合" する必要があります。ここでの目的は、ほぼ同じデータで構成された 2 つのリストを処理して、同一の要素を照合することです。一致しなかった要素の処理方法はまだ指定されていませんが、このような要素をキャプチャする必要があります。
何年も前、私は当時最も人気があった銀行取引管理用 PC アプリケーションを作成していた "直感的な" 会社の仕事に携わりました。これは、その仕事で実際に対処する必要があった問題です。具体的には、銀行側が把握しているユーザーの取引をダウンロードした後の、当座預金口座通帳の画面が問題となりました。オンライン取引をユーザーが既にアプリケーションに入力した取引と照合し、一致しない取引についてユーザーに問い合わせる必要がありました。
各取引は、金額、取引日、および説明用 "コメント" で構成されています。問題は、日付やコメントが必ずしも一致しないことでした。
つまり、比較対象として本当に信頼できるデータは、取引額だけでした。さいわい、同じ月内の 2 つの取引が 1 ペニー単位まで完全に一致することは、きわめてまれでした。そのため、これは "十分な" ソリューションです。当時にさかのぼって、これが実際に合理的な照合方法であることを確認しましょう。ただし、問題を複雑にするために、取得する 2 つのリストは長さが等しいとは限らないものとします。
F# のソリューション
関数型言語には、"関数型の思考" 方法に影響を及ぼしている原則がいくつかあります。今回の場合、主な原則の 1 つは、反復処理よりも再帰を優先することです。つまり、昔ながらのトレーニングを受けた開発者は for ループの入れ子を作成しますが、関数型プログラマは再帰を作成します。
ここで、取引のローカル リストと取引のリモート リストを見てみましょう。各リストの最初の要素を処理します。要素が一致する場合、その 2 つをそれぞれのリストから切り離し、まとめて結果リストに格納してから、ローカル リストとリモート リストの残りの部分をもう一度再帰的に呼び出します。処理対象の型定義は、次のとおりです。
type Transaction =
{
amount : float32;
date : System.DateTime;
comment : string
}
type Register =
| RegEntry of Transaction * Transaction
簡単に言うと、ここでは 2 つの型を定義しています。1 つは、レコード型です。これは、従来のオブジェクト表記の一部を省略したオブジェクトです。もう 1 つは、判別共用体型です。これは、オブジェクト/クラス グラフの変形です。今回のコラムでは、F# 構文について詳しく説明しません。F# 構文については、拙著『Professional F# 2.0』(Wrox、2010 年) など、さまざまなリソースで学習できます。
ここでは、これらの型はそれぞれ入力型と出力型である、と言うにとどめておきます。結果リスト用に判別共用体型を選択した理由は、すぐにおわかりいただけます。これら 2 つの型定義が完成すれば、この関数の目標を示す外枠となるスケルトンを非常に簡単に定義できます。
let reconcile (local : Transaction list) (remote : Transaction list) : Register list =
[]
F# では型記述子を型名の後に記述することに留意してください。つまりここでは、2 つの Transaction リストを受け取って Register 項目のリストを返す関数を宣言しています。コードに記述しているとおり、空のリスト ("[]") を返すようにこの関数をスタブアウトしています。この手法は便利です。なぜなら、いくつかの関数をスタブアウトして、平凡な通常の F# コンソール アプリケーションで (テスト駆動開発 (TDD) 形式の) テストを実行できるようになるためです。
これで単体テスト フレームワーク内に関数を記述できるようになり、記述する必要も生じました。しかし、System.Diagnostics.Debug.Assert を使用し、main 内部のローカルで入れ子になっている関数を使用すれば、本質的に同じ処理を実現できます。また、開発者の好みによっては、Visual Studio またはコマンド ラインで F# REPL を使用してテストしてもよいでしょう (図 1 参照)。
図 1 F# REPL を使用したコンソール アルゴリズムの作成
[<EntryPoint>]
let main argv =
let test1 =
let local = [ { amount = 20.00f;
date = System.DateTime.Now;
comment = "ATM Withdrawal" } ]
let remote = [ { amount = 20.00f;
date = System.DateTime.Now;
comment = "ATM Withdrawal" } ]
let register = reconcile local remote
Debug.Assert(register.Length = 1,
"Matches should have come back with one item")
let test2 =
let local = [ { amount = 20.00f;
date = System.DateTime.Now;
comment = "ATM Withdrawal" };
{ amount = 40.00f;
date = System.DateTime.Now;
comment = "ATM Withdrawal" } ]
let remote = [ { amount = 20.00f;
date = System.DateTime.Now;
comment = "ATM Withdrawal" } ]
let register = reconcile local remote
Debug.Assert(register.Length = 1,
"Register should have come back with one item")
0 // Return an integer exit code
基本的なテスト スキャフォールディングが存在すると考えて、再帰ソリューションに取り組みます (図 2 参照)。
図 2 再帰ソリューションでの F# パターン マッチングの使用
let reconcile (local : Transaction list)
(remote : Transaction list) : Register list =
let rec reconcileInternal outputSoFar local remote =
match (local, remote) with
| [], _ -> outputSoFar
| _, [] -> outputSoFar
| loc :: locTail, rem :: remTail ->
match (loc.amount, rem.amount) with
| (locAmt, remAmt) when locAmt = remAmt ->
reconcileInternal (RegEntry(loc, rem) :: outputSoFar) locTail remTail
| (locAmt, remAmt) when locAmt < remAmt ->
reconcileInternal outputSoFar locTail remTail
| (locAmt, remAmt) when remAmt > locAmt ->
reconcileInternal outputSoFar locTail remTail
| (_, _) ->
failwith("How is this possible?")
reconcileInternal [] local remote
ご覧のとおり、F# パターン マッチングをふんだんに使用しています。これは、概念上は C# の switch ブロックに似ています (子猫とサーベル タイガーが概念上は似ているようなものですが)。まず、ローカル再帰 (rec) 関数を定義します。この関数のシグネチャは外側の関数と基本的に同じです。ただし、それまでに一致した結果を渡す追加のパラメーターがあります。
関数内では、最初の match ブロックでローカル リストとリモート リストの両方を検査します。最初の match 句 ( [],_) は、ローカル リストが空の場合、処理完了になるので、リモート リストの内容は何でもよいことを表します (アンダースコアはワイルドカードです)。そのため、それまでに取得した結果を返します。次の match 句 ( _, []) についても同様です。
コード全体の要は、最後の match 句にあります。この句は、ローカル リストの先頭を抽出して、loc 値にバインドし、リストの残りを locTail に格納します。リモート リストについても rem と remTail に格納したら、ローカルとリモートを再び照合します。今度の match ブロックでは、リストから抽出した 2 つの項目からそれぞれ amount フィールドを抽出し、ローカル変数の locAmt と remAmt にバインドします。
以降の各 match 句では、再帰的に reconcileInternal を呼び出します。大きな違いは、再帰処理前の outputSoFar リストの処理方法です。locAmt と remAmt が等しい場合は一致したことになるので、新しい RegEntry を outputSoFar リストの先頭に追加してから再帰処理を行います。それ以外の場合は、項目を無視して再帰処理に進みます。結果は RegEntry 項目のリストとなり、これを呼び出し元に返します。
アイデアを広げる
一致しなかった項目を無視できないと仮定すると、一致しなかったローカル取引または一致しなかったリモート取引であることを示す結果リストに、項目を格納する必要があります。中核となるアルゴリズムは同じで、新しい項目を Register 判別共用体に追加して各候補を保持し、それらの項目をリストに追加してから再帰処理を実行するだけです (図 3 参照)。
図 3 Register への新しい項目の追加
type Register =
| RegEntry of Transaction * Transaction
| MissingRemote of Transaction
| MissingLocal of Transaction
let reconcile (local : Transaction list)
(remote : Transaction list) : Register list =
let rec reconcileInternal outputSoFar local remote =
match (local, remote) with
| [], _
| _, [] -> outputSoFar
| loc :: locTail, rem :: remTail ->
match (loc.amount, rem.amount) with
| (locAmt, remAmt) when locAmt = remAmt ->
reconcileInternal (RegEntry(loc, rem) :: outputSoFar) locTail remTail
| (locAmt, remAmt) when locAmt < remAmt ->
reconcileInternal (MissingRemote(loc) :: outputSoFar) locTail remote
| (locAmt, remAmt) when locAmt > remAmt ->
reconcileInternal (MissingLocal(rem) :: outputSoFar) local remTail
| _ ->
failwith "How is this possible?"
reconcileInternal [] local remote
これで結果は、対応するペアのない各 Transaction に対して MissingLocal または MissingRemote エントリが含まれた、完全なリストになります。実際には、このとおりではありません。上記の test2 のテスト ケースのように、2 つのリストの長さが異なる場合、残りの項目には "Missing" エントリが指定されません。
C# の代わりに F# を "概念化" 言語として利用し、関数型プログラミングの原則を使用したことで、これは非常に手軽なソリューションになりました。F# では型の推論が幅広く使用されるため、コードに肉付けする際のさまざまな場面で、パラメーターや戻り値の実際の型を事前に決めておく必要がありませんでした。F# の再帰関数には、戻り値の型を定義するために型の注釈が必要なことがよくあります。今回は、外側の関数に設定している戻り値の型から推論できるため、型の注釈は使用しませんでした。
場合によっては、このコードをアセンブリにコンパイルして C# 開発者に渡すだけでも十分です。しかし、多くの組織ではこの手段は成功しません。そのため、次回のコラムでは、これを C# に変換します。このコードを当初は F# コードとして作成していたことに、上司は気付かないでしょう。
コーディングを楽しんでください。
Ted Neward は、コンサルティング サービス会社の iTrellis で CTO を務めています。これまでに 100 本を超える記事を執筆している Ted は、さまざまな書籍を執筆および共同執筆していて、『Professional F# 2.0』(Wrox、2010 年、英語) もその 1 つです。C# MVP であり、世界中で講演を行っています。彼は定期的にコンサルティングを行い、開発者を指導しています。彼の連絡先は ted@tedneward.com (英語のみ) または ted@itrellis.com (英語のみ) です。彼がチームの作業に加わることに興味を持ったり、ブログをご覧になったりする場合は、blogs.tedneward.com (英語) にアクセスしてください。
この記事のレビューに協力してくれたマイクロソフト技術スタッフの Lincoln Atkinson に心より感謝いたします。