読者です 読者をやめる 読者になる 読者になる

.NETの標準ライブラリと仲良くする話

F# Advent Calendar 2013の9日目の記事です。

昨日の記事は、id:nenono さんの「F# でリフレクション/式木に触れてみる」でした。 リフレクション、扱いにくいですよねぇ・・・ リフレクションといえば、LangExtシリーズの一つとしてReflectionExtなんてのを作っているんですが、 時間がないうえにいろいろ問題もあって滞ってます・・・

さて、今回は.NETの標準ライブラリと仲良くする話(もしくはBasis.Coreの紹介)です。

はじめに

F#は.NET Frameworkの資産が使えるため、標準状態で色々なことができます。 これはF#の利点の一つですが、.NET Framework関数型言語のために作られたわけではありません。 そのため、F#から.NET Frameworkの標準ライブラリを使うと、F#の標準ライブラリとは違った使い心地を体験することになります。

型推論との相性が悪い

例えば、System.String型のToUpperメソッドを呼び出すとしましょう。

let f (str: string) = str.ToUpper()

このように、メソッド(や、プロパティ)しか呼び出さない場合、型推論が働いてくれないため、strの型を明示してあげる必要があります。 C#などと違い、引数の位置以外でも型の指定は出来ますが、どこかで一回は明示する必要があります。

let f str = (str: string).ToUpper()

カリー化されていない

当然、.NET Frameworkのメソッドはカリー化されていません。 そのため、メソッドに対して部分適用できません。

(* let f (str: string) = str.Substring(1, _) *)
(* 上のように書けないので、引数を追加する必要がある *)
let f (str: string) n = str.Substring(1, n)

さらに、カリー化されていないので、パイプライン演算子との相性も良くないです。

str
|> fun s -> s.ToUpper()
|> fun s -> s.Substring(1)

こう書くくらいなら、メソッドチェインしますよね。

str.ToUpper().Substring(1)

高階関数との相性が悪い

一番厄介なのは、高階関数との相性が悪いことです。 例えば、文字列のリストすべてを大文字にした新たなリストを作りたいとします。 こういう場合にList.mapという高階関数を使うのですが・・・

["hoge"; "piyo"; "foo"; "bar"]
|> List.map (fun s -> s.ToUpper())

このように、ラムダ式を書く必要があります。 レシーバは、先に固定しておきたいことよりも、最後に決めたいことの方が圧倒的に多いため、 インスタンスメソッドの場合はほぼ常にラムダ式を使うことになります。

もっと.NET Frameworkの関数と仲良くしたい!

いくら.NET Frameworkで用意されたものが使えるといっても、 このままではつらい場合が多いのも事実なのです。 特に、仕事で文字列操作やXMLの操作を行うことが多いので、 これらが楽に行えると(個人的に)とても嬉しいのです。

そのために、.NETの標準ライブラリとF#の橋渡しをするライブラリを作っています。 それが、Basis.Coreです。 「Basis.Core」というidでnuget.orgにも登録してありますので、簡単に使えます。

Basis.Coreとは何であって何でないか

Basis.Coreは、.NET(と、F#)の標準ライブラリで扱える範囲のものをより上手くF#で扱えるようにすることを最大の目標にしています。 そのため、ライブラリとしてはあまり面白味のないものになっています。

Basis.Coreは、以下のものではありません。

  • 様々な型を提供するライブラリではありません
  • 型プロバイダーを提供するライブラリではありません
  • 型クラスを提供するライブラリではありません
  • .NETの標準ライブラリの範囲を大きく超える機能を提供するライブラリではありません

これらのものではないため、このライブラリはあれもこれもと詰め込みすぎるようなことにはならないはずです。 まぁ、.NETの標準ライブラリが大きいので、それに合わせて肥大化してしまうことは考えられますが・・・

Basis.Coreを使うと何ができるか

型推論

文字列を大文字化するために、型の明示が必要でした。

let f (str: string) = str.ToUpper()

Basis.CoreのStrモジュールを使うと、型推論が効くようになります。

let f str = str |> Str.toUpper

基本的にはインスタンスメソッドの名前をlowerCamelにした名前になっています*1

部分適用

Basis.Coreの関数はカリー化されているため、部分適用可能です。

let f n (str: string) = str.Substring(1, n)

こう書いていたコードは、部分適用を使って書き直せます。

let f = Str.sub 1

パイプライン演算子

カリー化されており、レシーバに相当する引数が最後になっているので、 パイプライン演算子と組み合わせることができます。

str
|> fun s -> s.ToUpper()
|> fun s -> s.Substring(1)

こう書く必要があり、パイプライン演算子で書く意味が見いだせなかったコードも、

str
|> Str.toUpper
|> Str.subFrom 1

このとおりです。

高階関数に渡す

カリー化されており、レシーバに相当する引数が最後になっているので、 高階関数に渡す際もラムダ式を書く必要がありません。

["hoge"; "piyo"; "foo"; "bar"]
|> List.map (fun s -> s.ToUpper())

こう書く必要があったコードは、

["hoge"; "piyo"; "foo"; "bar"]
|> List.map Str.toUpper

こうです。

Basis.Coreで(まだ)できないこと

F#はメソッド以外ではオーバーロードが使えませんが、Basis.Coreはメソッドではなくモジュール関数で構築しています。 そのため、標準ライブラリで用意されているオーバーロードには、それぞれ別名を付ける必要があります。

例えば、System.StringのTrimには、char配列を取るバージョンがありますが、そちらにはtrimCharsという別名を付けています。

現状では対応していないメソッドもあります。 例えば、ToUpperはCultureInfoを取るバージョンがありますが、対応していません。 CultureInfoやIFormatProviderなどを取るメソッドは、 個人的には使用頻度も高くないため、正直何か別名を与えて補完の候補を増やすのは避けたいところです。 そのため、これらのメソッドには別名を与えるのではなく、別モジュールで定義するようにしようと考えています。

F#では同名のモジュールの同名の関数を後から定義したほうで「隠す」ことができるので、 例えばBasis.Core.Culture名前空間Strモジュールを置き、それをopenすることでCultureInfoを取る版のtrim等を使えるようにするのです。

open Basis.Core
open Basis.Core.Culture

(* ここではStr.trimは、Basis.Core.Culture.Str.trim *)
let f culture str = str |> Str.trim culture

open Basis.Core (* もしくは、open Basis.Core.NonCultureとか *)

(* ここではStr.trimは、Basis.Core.Str.trim *)
let g str = str |> Str.trim

(* 両方使いたい場合は、フル修飾するかモジュールに別名を付ける *)
module CultureStr = Basis.Core.Culture.Str
let h culture str =
  (str |> Str.trim) = (str |> CultureStr.trim culture)

このような感じで、Basis.Coreを発展させていこうと考えています。

その他雑多なこと

Nullable

Nullableって言うと、.NET的にはSystem.Nullable構造体のことを指すと思います。 でも、これってより正しくはNullableValueとかNullableValueTypeとかすべきだったものですよね。 だって、参照型はNullableなんてものに頼らずにnullが突っ込めるわけですし。

Nullableモジュールを提供する場合にこれがすごく嫌な感じだったんです。 Nullableって言ってるのに、参照型のnullは扱えないの?的な。 これを解決するために、Basis.CoreではNullable.ValueTypeとNullable.ReferenceTypeという2つのモジュールを用意しました。 これにより、どちらも同じように扱えますし、RequireQualifiedAccess属性を付けているので、 nullを扱いにくくしつつサポートする、ということを実現しています。

アクティブパターンも用意していますが、これも値型用のアクティブパターン(NullValとNonNullVal)と参照型用のアクティブパターン(NullRefとNonNullRef)の2つを提供しています。

Result型

最初の方で、

Basis.Coreは、.NET(と、F#)の標準ライブラリで扱える範囲のものをより上手くF#で扱えるようにすることを最大の目標にしています。

と書きましたが、.NETの標準ライブラリにもF#の標準ライブラリにもない型を提供している、例外t的なものがあります。 それがResult型です。

Result型は、成功するかもしれないし失敗するかもしれない処理の結果として使える型です。 こう書くと、Optionと同じようなものに感じますが、実際やりたいことは同じことです。 しかし、Optionの場合、失敗側に原因を持たせることができません。 これを持たせるようにできるようにしたのがResult型です。

実は、F#にはChoiceという型があり、これに対するアクティブパターンや型の別名を与えることでResult型の代わりにすることもできます。 実際に、他のF#のライブラリではそうしていることがほとんどのようです。 ですが、Choiceではどの選択肢の重みも同じイメージを受けるのです。 それに対して、Resultという名前は何らかの結果、それも成功側に重点を置くという主張を型名によって行うことができます。 このメリットが大きいと感じるため、Choiceを使わずにResultという型を提供しています。

getOr/getOrElse/getOr'

OptionとResultに対して、getOr/getOrElse/getOr'という3つのよく似た関数を提供しています。 これらに与えられたCompiledNameはGetOr/GetOrElse/GetOrElseとなっており、 F#から見た場合はgetOr'はgetOrに似た感じを受けますが、 他の言語から見た場合はGetOrElseになります。

getOrは、値がなかった場合にデフォルト値を返しますが、 F#は正格評価言語のため、デフォルト値は常に計算されてしまいます。

let str = opt |> Option.getOr "empty"

これでは駄目な場合があるので、getOrElseというバリエーションがあります。 こちらは、デフォルト値そのものを受け取るのではなく、それを関数で包んで受け取ります。 そのため、値の計算を遅らせることができます。

let str = opt |> Option.getOrElse (fun () -> loadFromDb conStr)

VBのOrが短絡評価されず、OrElseが短絡評価されることを参考に語彙を選んでいます。

更にバリエーションとして、関数ではなくLazy<'T>を受け取るバージョンを用意しています。 これは、挙動としてはgetOrElseと同じであるため、getOrElse'にしようとおもったのですが、 F#ではLazyはlazyキーワードで作ることができます。 そして、getOr'に渡す場合はほぼその場で値を作るだろう、との判断から、getOr'という名前にしました。

let str = opt |> Option.getOr' (lazy "empty")

しかし、F#以外の.NET言語がlazyのようなものを持っているとは限らないため、 CompiledNameの方はGetOrElseとしました。

正直、この選択が正しかったのかという自信はありません。 そもそも、Lazy版を提供しない、という選択肢もあったわけですし・・・

コンピュテーション式

みんな大好きコンピュテーション式ですが、Basis.CoreでもOptionとResultに対するコンピュテーション式を提供しています。 独特なところを上げると、

  • returnreturn!で必ずコンピュテーション式から抜け出せる
  • Failure側用のコンピュテーション式も用意している
  • "ゼロ値"を与えることで、通常はResultで用意できないwhileforに対応している

ところです。

returnreturn!でコンピュテーション式から抜けるのを実現しているのは、 Combineです。 こんな感じのCombineの定義は他のライブラリでは見かけないですが、 こうしないとreturnreturn!してもコンピュテーション式から抜け出さないという、 なんだかなぁ、なものになってしまいます。

実は今日までバグがあって、NoneやFailureをreturn!してもコンピュテーション式から抜け出せませんでした。 これを解決するためにCombineやReturnFromなどのシグネチャをいじくって色々試していたんですが、 残念ながら時間切れでした。 現在の解決策は、コンピュテーション式のビルダーが状態を持つという非常に残念なことになっていますので、 誰か改善を・・・! 改善されました。ビルダーが状態を持つのではなく、状態を引数で引き回すことで解決しています。

あとの2つはあまり面白味はないですね。

次は・・・

ほぼF# Advent Calendar専用のブログと化している2つのアンコールを持つ、 @htid46 さんのアクティブパターンに関する記事のようです。

楽しみですね!

*1:一部例外アリ