ライブラリを作ってみて感じたF#の良いところや悩みどころ
これはF# Advent Calendar 2012の5日目の記事です。
昨日の記事は、kos59125さんのF# のための乱数生成フレームワークでした。私にとっては、あまりなじみのない世界の話で大変刺激的です。stateモナドはいつ見ても魔法のようです。自分もそろそろコンピュテーション式を使いこなせるようになりたい。
さて、今日の記事は、と言いますと、ぼやーっとしたタイトルで恐縮ですが、実体験を基に書いてみたいと思います。
はじめに
ちょうど2年前、Entity FrameworkをベースにC#でステートレスなO/Rマッパーを作っていたんですが、F#を触っているうちにF#で書いたほうが効率的に作れそうだと直感して、一から作り直したことがあります(ついでにEntity Frameworkをベースにするのもやめました)。そしてできたのがSomaというライブラリです。仕事でも使っていて自分にとってはとても実用的。Somaは、F#で作りましたが、C#やVB.NETからも使いやすいAPIを備えています。実際、仕事ではC#から呼び出して使っています。対応RDBMSとしては、SQL ServerだけでなくOracleもサポートしているところも実用的と言えるところです。.NETの環境で一番使われているデータベースがSQL Serverで、その次がOracleだと思うので(たぶん)。
F#で作り直したことは、結果的には正解でした。ずいぶん、手早くコンパクトに作れたと思っています。
と、Somaのアピールはここまでにして、今回は、Somaの機能的な側面ではなくて、Somaを作る過程で実感したF#の良いところや悩みどころを紹介します。良いところはちょっと細かい話なのですがC#メインな方に伝わるとうれしいですね。悩みどころについてはF#好きな方に意見を聞いてみたいです。
F#の良いところ
F#の利点は、型推論とか判別共用体とかパターンマッチングとかいろいろありますが、自分が最も気に入っているのは「スコープを狭くできる」という特長です。ちょっと地味かもしれませんが、この良さは外せない。
スコープを狭くできると、一度に考慮しなければいけない事柄を減らせるので、頭の中にあるロジックをコードに落としやすくなります。これは本当にいいものです。本質的なことにだけ集中できて楽しくコードを書けますから。
スコープを狭くする具体的な方法には
- 値を下方に定義する
- 関数をネストさせる
- 値をシャドウィングする
と言ったものがあります。
値を下方に定義する
F#では、ファイルの並び、モジュールや値(関数含む)の定義順序に意味があって、上方参照しかできないように制限されています。最初は、この制限にとまどいましたが、慣れてみれば便利だと気づきました。公開していない値を変更する際の影響範囲は、その下方に絞られるからです。
(* F# *) (* barを参照できるのはfoo, hoge, main *) let bar x = x * 2 (* fooを参照できるのはhoge, main *) let foo x = bar x (* hogeを参照できるのはmain *) let hoge x = foo x [<EntryPoint>] let main argv = printfn "%d" <| hoge 10 0
関数をネストさせる
関数をネストさせると「内側の関数」は「外側の関数」の内部でしか参照できません。ある特定の関数内だけで使いたい関数は内側に定義するのが自然です。関数のネストは、C#でもメソッドの中にラムダ式を書いたりして可能ですが、ラムダ式にはyield returnができないという制限があって、次のようなことはできません。
// C# class Program { static void Main(string[] args) { Func<IDataReader, IEnumerable<object>> getValues = (reader) => { while (reader.Read()) { // ラムダ式の中ではyield returnできない yield return reader.GetValue(0); } }; ... } }
一方、F#は問題なく内側の関数でyieldできます。それから、C#のFunc型に相当する定義を書く必要がないので簡潔ですね。
(* F# *) [<EntryPoint>] let main argv = let getValues (reader:IDataReader) = seq { while reader.Read() do yield reader.GetValue(0) } ...
値をシャドウィングする
シャドウィングはとても好きな機能です。そして、良さを伝えづらい機能の筆頭です(何回か人に説明しましたがどうもうまくいかない...)。
(* F# *) let read path = (* 正規化前のpathをログ出力する関数 *) let logger () = printfn "%s" path (* シャドウィング。これ以降は正規化前のpathを参照する必要なし *) let path = normalize path ... readFile path logger
シャドウィングは、それ以降で参照したくない値を安全に隠します。たとえば、上の例においてlogger関数は正規化前のpathを参照しますが、シャドウィング後の処理ではnormalizeされた新しいpathを参照します(つまり、シャドウィング前後で値を共有しない)。シャドウィングはスコープを狭くできる上に管理すべき名前も増やさないので、プログラマの思考にやさしいのです。
C#で似たようなこと(再代入)をやると、F#と違った挙動になります。関数が再代入後の値を参照するので(つまり、値を共有する)。
以上、F#の良いところでした。
F#の悩みどころ
悩みどころは、ライブラリ設計に関するものを3つに絞って話します。
どの言語を想定してAPIを作るべきか?
考えとしては3つあると思います。簡単な順に並べると
F#専用のAPIは、簡単ですね。F#でライブラリを作っている以上、考慮点は少ないと思います。
標準的な.NETの規約に則ったAPIにするには、次のガイドラインがあるので、それを参照するのがいいです。通常はこの方法をとると思います。日本語訳もあります。
Somaは一部のAPIで3番目の方法とりました。F#から使いやすいAPIとC#(やVB/NET)から使いやすいAPIは別だと思うからです。ポイントは3つ。
- C#では匿名クラスを利用したいが、F#ではDictinaryまたはTupleを使いたい
- C#ではmutableな型を使いたいが、F#ではimmutableな型(F#のレコード型)を使いたい
- C#ではnullを使いたいが、F#ではOption型を使いたい
ただし、「F#専用のAPI」と「標準的な.NETの規約に則ったAPI」の両方に対応するのは本当に大変です。なぜなら、2倍になるから、コードも、そして...、ドキュメントも。
シグネチャファイル(.fsi)を使うべきか?
シグネチャファイル(.fsi)の利用はガイドラインで推奨されているのですが、まじめにメンテナンスするのはちょっとしんどいです。fsiとfsで同じことを重複して書いている気分になってきます。
Somaではシグネチャファイルを書きましたが、シグネチャファイルの代わりにアクセス修飾子(public, internal, private)を使う方法が簡単でいいと思います。まちがいなく、自分が次にF#でライブラリ作るときはアクセス修飾子を使う方法を選びます。
デフォルトがpublicなので、隠したいところだけinternalかprivateをつけていけばいいんじゃないかというのが今の自分の考えです。
(* F# *) module Greeting = let hello name = "hello " + name let internal goodBye name = "good-bye " + name
拡張ポイントのデフォルト実装をF#で書くべきか?
Somaでは、いくつか拡張ポイント(RDBMSに依存するような機能のカスタマイズなど)を設けましたが、C#(やVB.NET)でも拡張できるように、そこはもちろんインタフェースにしています。ただし、インタフェースを実装した抽象クラスやデフォルトのクラスをいくつかF#で書いたところ、結構面倒でした。
理由は2つあります。
- F#のクラスの構文がC#に比べてかなり冗長(virtualなメソッドの定義やインタフェースの明示的実装など)
- Visual StudioやReSharperといったツールによるコード生成の恩恵を得られない
クラスを書くようなところは、別アセンブリにしてC#で書いてもよかったな、と今は思います。InternalsVisibleToAttributeを使えば、別アセンブリのinternalなAPIを呼べますし。そのほうがたぶん楽です。
以上、F#の悩みどころでした。