こんにちは。G・B・S 第2システム開発部の小路です。
関数型プログラミングの流行は以前に比べると落ち着いてきたと言えますが、関数型プログラミングのエッセンスは現在トレンドのアプリケーションアーキテクチャでもよく見られので、それだけ浸透してきたってことでしょうか(ReduxのReducerとかStateモナドの操作に似てますよね)。
純粋関数型プログラミングとは言わないまでも、関数型プログラミングの考え方の基礎はアプリケーションエンジニアの必須教養となっていると言っても過言ではないでしょう。
ということで、AndroidプロジェクトにKotlinの関数型プログラミングライブラリで有名な Arrowを入れて動かしてみました。
Arrowの導入
JDK1.8以上が必要ですので、入っていない場合適宜インストールしましょう。
gradleの設定
以下の設定を追加します。
build.gradle(ルートプロジェクト)
allprojects { repositories { jcenter() } }
build.gradle(appモジュール)
def arrow_version = "0.9.0" dependencies { /* Arrowの設定 (今回はOptionクラスの使用だけなのでこれだけ。状況に応じて必要なモジュールを組み込む) */ implementation "io.arrow-kt:arrow-core-data:$arrow_version" }
ArrowのOptionクラスを使用してみる
関数型プログラミングでよく引き合いに出されるOptionクラス(HaskellでいうMaybeに類似)を使用してみましょう。主な用途としては、Kotlinの言語仕様であるNull safety型の代わりに使用することが考えられます。
Optionクラスに対する公式ドキュメントの説明は以下のようになっています。
Arrow models the absence of values through the Option datatype similar to how Scala, Haskell and other FP languages handle optional values.
Option<A> is a container for an optional value of type A. If the value of type A is present, the Option<A> is an instance of Some<A>, containing the present value of type A. If the value is absent, the Option<A> is the object None.
上記の主旨はざっくり、
- Optionクラスは値が存在しないかもしれないということを表現している。
- 値が存在する場合は、Option<A>型のSome<A>のインスタンスとして表現される。Some<A>のインスタンスはA型の値を持つインスタンスである
- もし値が存在しない場合、Option<A>型はNoneオブジェクトとして表現される。
と解釈できます。
以下で、ArrowのOptionクラスと、Kotlinの言語仕様に標準で備わっているNull safety型を使い比べてみましょう。
実験①::Null safetyとOptionクラスをそれぞれ戻り値とする関数の戻り値をログ出力してみる。
MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val a1 = optionFn(0) Log.d("ArrowSample:optionFn1", a1.toString()) val a2 = nullSafeFn(0) Log.d("ArrowSample:nullSafeFn1", a2.toString()) val b1 = optionFn(1) Log.d("ArrowSample:optionFn2", b1.toString()) val b2 = nullSafeFn(1) Log.d("ArrowSample:nullSafeFn2", b2.toString()) } fun optionFn(a: Int): Option<Int> { return when (a) { 0 -> None else -> Some(a) } } fun nullSafeFn(a: Int): Int? { return when (a) { 0 -> null else -> a } }
ログの出力結果は以下のようになります。
ログ出力結果
D/ArrowSample:optionFn1: None D/ArrowSample:nullSafeFn1: null D/ArrowSample:optionFn2: Some(1) D/ArrowSample:nullSafeFn2: 1
まあ、当然の結果ですね。Optionクラスでは値が存在しないという概念はNoneで表現します。
実験②:Null safetyのChainコールと、Optionのmap関数を比較してみる。
KotlinのNull safety型では、?コールの後にlet関数を呼び出すことで、値がnullでない時にだけ実行される処理を記述することができます。
ArrowのOptionクラスでmap関数を使用し、Null safety型と見比べてみましょう。
Optionクラスでのmap関数は以下のシグネチャで実装されています。Option<A>型のインスタンスに対して、関数f: (A) -> Bを適用し、Option<B>型のインスタンスを返します。
inline fun <B> map(f: (A) -> B): Option<B>
Option<A>型のインスタンスであるSome<A>とNoneは、共にOption<A>型ですので、?コールを行わずにそのままmap関数を使用することができます。
MainActivity.kt
nullSafeFn(0)?.let { Log.d("ArrowSample:nullSafe0", it.toString()) } optionFn(0).map { Log.d("ArrowSample:option0", it.toString()) } nullSafeFn(1)?.let { Log.d("ArrowSample:nullSafe1", it.toString()) } optionFn(1).map { Log.d("ArrowSample:option1", it.toString()) }
※ログ出力はIO操作を伴うため、純粋関数型プログラミングの考え方に則ると、これは副作用がある処理と見なされます。また、map関数の引数に渡している関数のシグネチャは(Int) -> Unitで表現されるため、戻り値のSomeの値が変になりますので注意が必要です。ここではOptionクラスの概観を見ることに主眼を置いているため、これらの問題は取り上げません。
※上記で取り上げたmap関数は、Functor(ファンクター)であるために実装が必要となる関数です。Option<A>型がこのFunctorであるためには、map関数を実装し、かつ実装されたmap関数がファンクター則と言う規則を満たす必要があります。Option<A>型のmap関数はファンクター則を満たした実装となっているので、「Option<A>型はFunctorである」と言えます。
Functorが気になる方はhttps://wiki.haskell.org/Functorなどが参考になります。
上記に対するログ出力結果は以下のようになります。
ログ出力結果
D/ArrowSample:nullSafe1: 1 D/ArrowSample:option1: 1
nullSafeFnおよびoptionFnのいずれも、引数が0の場合ログ出力がされていません。
これは以下の仕様によるものです。
- KotlinのNull safety型でのChainコールにおいては、値がnullの時、以降の処理は無視される
- ArrowのOptionクラスのインスタンスの実体が「None」である時、map関数の引数に指定した関数は実行されず、戻り値としてNoneが返却される
Optionクラスの有用性って何?
上記の実験結果だけだと「Null safetyだけで十分では?」ってなりますね。
正直このパターンだと、Null Safety型でもそれほど困らないと思いますが、Optionクラスには以下の特徴があります(そしてArrowが提供するOptionクラスは、Arrowのほんの一部にすぎません)。
Option<A>型にはnullを代入することはできない
Optionクラスは値がないことをNoneで表現しますので、Null safety型を使用する必要がありません。Null safetyを使用しない場合、必然的にnullを代入できないことになります。Option<A>型のインスタンスにnullが入っていないと分かっているだけで、コーディング時に安心感がありますね。
Null safety型も?コールなどの安全な呼び出し方法があるので好みの問題ですが、筆者はこちらの方が好きです。
Optionクラスでは、nullの代わりにNoneを使っている
Noneは「null」ではなく、Option<A>型に属します。そして、Someも同様にOption<A>型に属します。
すなわち、Option<A>型のインスタンスの実体がSomeであれ、Noneであれ、同じOption<A>型として操作できることになります。NoneはOption<A>型の関数を実行できますが、nullはOption型の実装を知りません。
Option<A>型で定義される演算はモナド則を満たしている
Optionクラス内で実装されている関数は関数型プログラミングで言うところの「モナド則」(や「ファンクター則」、「アプリカティブ則」など)を満たすよう実装されています。
モナドとか言うと頭がこんがらがりますが、この記事ではざっくり「Optionクラスで定義されている関数は、ある演算の法則を満たすよう実装されている」くらいの理解で読んでいただければと思います(私自身、うまく説明できないというのもありますが)。
OptionクラスはSomeとNoneで構築されますが、SomeとNoneいずれの場合でも、上記のモナド則やらの規則を満たすよう実装されています。
なので、Option<A>型の実体が「Some」であるか「None」であるかを気にせずに、それらの関数を使用できることになります。
例えば、Optionクラスではこんなことができます(なんか色々できるんだなーっていうのを感じてもらえれば)。
val f: (Int) -> Int = { it + 1 } //Some(0)に対して関数をチェイン呼び出しする val result = Some(0) /* fun <B> ap(ff: OptionOf<(A) -> B>): Option<B> この場合、Some(0)に対してSomeにくるまれた関数fが適用され、結果はSome(1)となる ちなみにNoneにap関数を適用すると、Noneが返却される */ .ap(Some(f)) /* inline fun <B> map(f: (A) -> B): Option<B> この場合、前のap関数の結果生成されたSome(1)の中身の値「1」に対して関数fが適用され、 結果はSome(2)となる ちなみにNoneにmap関数を適用すると、Noneが返却される */ .map(f) /* inline fun <B> flatMap(f: (A) -> OptionOf<B>): Option<B> この場合、前のmap関数の結果生成されたSome(2)の中身の値「2」に対して、 関数optionFnが適用され、結果はSome(2)となる ちなみにNoneにflatMap関数を適用すると、Noneが返却される */ .flatMap { optionFn(it) } /* 前の計算結果から生成されたSome(2)に対して、filter関数を適用する Option型のインスタンスの実体が空でない、かつ引数に指定された述語を満たす場合、 当該Option値を返す。 それ以外はNoneを返す。 */ .filter { it % 2 == 0 } // fun filter(predicate: Predicate<A>): Option<A> Log.d("ArrowSample", result.toString())
上記のコードを走らせた場合、以下のようなログが出力されます。
ログ出力
D/ArrowSample: Some(2)
※モナドについては、7shiさんの記事が分かりやすいです。 「モナド則がちょっと分かった?」
モナドはOptionクラスだけではない
本記事で、Optionクラス内で定義されている関数はモナド則を満たしていると言いましたが、モナド則を満たす演算をもつ型は全てモナドであると言えます。ArrowではOptionクラスの他にも様々なモナドを提供しています。
Optionクラスの使用くらいであればNull safety型のままでコーディングしても良いかもしれませんが、例外処理や状態管理を行うときに使用するクラスもArrowでは提供されています(Either, Try, Stateなど)。これらはいずれもモナド(とかいうややこしい存在)なので、Optionクラスと同様にmapやflatMapといった関数の実装を持っています。
これらを文脈に応じて使い分けてコーディングすることで、型安全なプログラミングが可能となります。
Arrowではコードを安全に組むための様々なAPIが提供されています。今回見たのはOptionクラスですが、他にも様々なAPIが提供されていますので、興味があればArrowの公式ドキュメントを読んでみるのも良いかもしれません。