【型クラス】Scala 3.0でHaskellerも嬉しい記法がいっぱい来たので遊んでみた。【アプリカティブスタイル】【オブジェクト指向】
こんにちは、入田 関太郎(らむだ ふぁんたろう) こと坂田です。
今年の目標は、
- 新規性が一切なくても
- 嬉しさが主観でただのお気持ち記事でも
記事を書くことです。
はじめに
Scala3(Dotty)で型クラスの定義やメソッドの拡張が読みやすく書きやすくなったらしいのでいろいろ実験してみました。全体的に気持ち良くなれました。
ちなみに筆者はScalaもJavaも一切書いたことがなく、もっと良い書き方や間違っている箇所がありましたらコメントで教えていただけたら幸いです。
結論
- 型クラスを利用して
flatMap(function, fa)
という風に使える高階でGenericな関数をわかりやすく書くことができて気持ちいい(主観) - 上記の関数を定義すると、ちょっとしたコードの追加で全てのFunctorなデータ型に、
fa.flatMap(function)
というオブジェクト指向なメソッドをはやすことができて気持ちいい(主観) - Scalaでは上記のメソッドは中置記法ができるため、
fa >>= function
やfunction2 <*> fa1 <*> fa2
のようなアプリカティブスタイルも簡単に定義できて気持ちがいい(主観)
Scala2.xでも型クラスの定義やメソッドの拡張はできたが、Dottyだとわかりやすい記法でできるので、それらが全体的に嬉しかったです(小学生並みの感想)
やっていくこと
- 型クラスの定義(Functor, Applicative, Monad)
- Identityデータ型の定義
- Identityデータ型をFunctor, Applicative, Monadのインスタンスにする
- 関数を利用してモナディックな計算ができることを示す
- 拡張メソッド、中置演算などを利用してモナディックな計算をアプリカティブスタイルで書いて気持ちよくなれることを示す
- for-yieldで書いて手続チックに書いて気持ち良くなれることを示す
やったことのソースコードはこちらに載せてます。
型クラスの定義
Functorの定義
Haskellで定義するとこうなる。
ExplicitForAllをつけるとforall a, b
をソースコード内に記述できてScalaと似るので追加した。
class Functor f where map :: (a -> b) -> f a -> f b
Scalaでも書くことができる
package TypeClass trait Functor[F[_]] { def map[A, B](f: A => B)(fa: F[A]): F[B] }
Applicativeの定義
Applicative自体の説明は省略させてもらう。
class (Functor f) => Applicative f where pure :: a -> f a (<*>) :: f (a -> b) -> f a -> f b
Scalaで書くとこうなる
package TypeClass trait Applicative[F[_]] extends Functor[F] { def pure[A](a: A): F[A] def ap[A, B](f: F[A => B])(fa: F[A]): F[B] }
Monadの定義
class (Functor m, Pointed m) => Monad m where (>>=) :: m a -> (a -> m b) -> m b
package TypeClass trait Monad[M[_]] extends Applicative[M] { def flatMap[A, B](f: A => M[B])(fa: M[A]): M[B] }
型クラスの利用
上記の型クラスを利用して、アドホックでジェネリックなそれぞれの関数を定義していく。
// package TypeClass // ... object Functor { def map[A, B, F[_]](f: A => B)(fa: F[A])(using functor: Functor[F]): F[B] = functor.map(f)(fa) }
// package TypeClass // ... object Applicative { def pure[A, F[_]](a: A)(using applicative: Applicative[F]): F[A] = applicative.pure(a) def ap[A, B, F[_]](f: F[A => B])(fa: F[A])(using applicative: Applicative[F]): F[B] = applicative.ap(f)(fa) }
// package TypeClass // ... object Monad { def flatMap[A, B, M[_]](f: A => M[B])(ma: M[A])(using monad: Monad[M]): M[B] = monad.flatMap(f)(ma) }
Identityを型クラスのインスタンスにする
Identity * Functorのインスタンスを作ってみる
ここまでくると、アドホックでジェネリックな関数群が定義されたのでIdentityモナドとインスタンスを作ってテストを書いて使い方を見ていく。
package Data.Identity case class Identity[+A](value: A)
Identityモナドのモデルは中身に値を一つ持って何もしないだけの型である。
まずはこれをFunctorに拡張する。
package Data.Identity.Instances import TypeClass.Functor import Data.Identity._ given Functor[Identity] { def map[A, B](f: A => B)(fa: Identity[A]): Identity[B] = fa match { case Identity(value) => Identity(f(value)) } }
中身の値に関数を適用してIdentityモナドで包んだだけの簡単なものである。
使い方はこうだ
"map関数" should "中身の値に関数を適用できる" in { val identity = Identity(1) def f = (x: Int) => x * 2 assert(map(f)(identity) === Identity(2)) assert(map(f)(map(f)(identity)) === Identity(4)) assert(map(f compose f)(identity) === map(f)(map(f)(identity))) }
成功!
ついでにFunctor則を満たしているかのテストも書いてみる
"map関数" should "Functor即を満たしている" in { val identity1 = Identity(1) def id[T] = (x: T) => x assert(map(id)(identity1) === identity1) }
Applicative, Monadのインスタンス
Applicativeのインスタンス定義
package Data.Identity.Instances import TypeClass.{Applicative, Functor} import Data.Identity._ given (using functor: Functor[Identity]) as Applicative[Identity] { def map[A, B](f: A => B)(fa: Identity[A]): Identity[B] = functor.map(f)(fa) def pure[A](a: A): Identity[A] = Identity(a) def ap[A, B](ff: Identity[A => B])(fa: Identity[A]): Identity[B] = ff match { case Identity(f) => functor.map(f)(fa) } }
Monadのインスタンス定義
given (using applicative: Applicative[Identity]) as Monad[Identity] { def map[A, B](f: A => B)(fa: Identity[A]): Identity[B] = applicative.map(f)(fa) def pure[A](a: A): Identity[A] = applicative.pure(a) def ap[A, B](ff: Identity[A => B])(fa: Identity[A]): Identity[B] = applicative.ap(ff)(fa) def flatMap[A, B](f: A => Identity[B])(fa: Identity[A]): Identity[B] = fa match { case Identity(a) => f(a) } }
上三つの関数定義はApplicativeをそのまま使っているだけなのだが、この辺うまく継承できないのかな?誰か教えてください。
↑がくぞ様からTwitterで教えていただいたので、一番下に追記しています。
使い方
IdentityのApplicative, Monadのインスタンスも同じように使い方とモナド則を満たしているかテストを書いてみよう。
関数記法だとこんな感じ。
{ import Data.Identity.Instances.{given_Functor_Identity, given_Applicative_Identity} import TypeClass.Applicative.{ap, pure} import TypeClass._ "pure関数" should "持ち上げ" in { val identity1 = pure(1) assert(identity1 === Identity(1)) } "ap関数" should "複数の引数を取れる" in { val identityPlus = pure((x: Int) => (y: Int) => x + y) val identity1 = pure(1) val identity2 = pure(2) assert(ap(ap(identityPlus)(identity1))(identity2) === Identity(3)) } }
モナドのテスト
import Data.Identity.Instances.{given_Functor_Identity, given_Applicative_Identity, given_Monad_Identity} import TypeClass.Monad.flatMap import TypeClass.Applicative.pure import TypeClass._ "flatMap関数" should "文脈が持ち上がる計算をflatに保つ" in { val liftIncrement = (x: Int) => pure(x + 1) val identity1: Identity[Int] = pure(1) assert(flatMap(liftIncrement)(identity1) === Identity(2)) }
オブジェクト指向とアプリカティブスタイル
メソッド記法か中置演算子を利用して関数呼び出しのネストを取り除きたい。
extensionを使うことで、任意のオブジェクトにメソッドを後から生やすことができる。 そして、メソッドは中置記法ができる。
気持ち良くなるためにやりたいのは、M[_]
な型のma
がMonad[M]
の特徴を持つなら、ma.flatMap(function)
又は ma >>= function
と書きたい。
まずはFunctor
// package TypeClass // ... extension [A, B, F[_]](fa: F[A])(using functor: Functor[F]) { def map(f: A => B): F[B] = functor.map(f)(fa) }
このように書いておくことによって、型クラスのインスタンスをインポートした際に自動でメソッドが生えてくるようなジェネリックなextensionを書くことができる。
使い方はこんなかんじ。
"mapメソッド" should "中身の値に関数を適用できる" in { val identity = Identity(1) def f = (x: Int) => x * 2 assert(identity.map(f) === Identity(2)) assert(identity.map(f).map(f) === Identity(4)) assert(identity.map(f compose f) === identity.map(f).map(f)) } "mapメソッド" should "Functor即を満たしている" in { val identity1 = Identity(1) def id[T] = (x: T) => x assert(identity1.map(id) === identity1) }
Applicative, Monadのextension
Scalaではメソッドに記号を用いて中置演算子を定義できるので、Haskellと同じ<*>
を拡張しておく
// package TypeClass // ... extension [A, B, F[_]](ff: F[A => B])(using applicative: Applicative[F]) { def ap(fa: F[A]) = applicative.ap(ff)(fa) def <*>(fa: F[A]) = applicative.ap(ff)(fa) }
モナドも同じ様に>>=
を定義する
// package TypeClass // ... extension [A, B, M[_]](fa: M[A])(using monad: Monad[M]) { def flatMap(f: A => M[B]): M[B] = monad.flatMap(f)(fa) def >>=(f: A => M[B]): M[B] = monad.flatMap(f)(fa) }
このような定義を追加することで、アプリカティブスタイルで記述することが可能になって最高に気持ちがいい
"apメソッド" should "複数の引数を取れる" in { val identityPlus = pure((x: Int) => (y: Int) => x + y) val identity1 = pure(1) val identity2 = pure(2) assert(identityPlus <*> identity1 <*> identity2 === Identity(3)) } "flatMapメソッド" should "文脈が持ち上がる計算をflatに保つ" in { val liftIncrement = (x: Int) => pure(x + 1) val identity1: Identity[Int] = pure(1) assert((identity1 >>= liftIncrement >>= liftIncrement) === Identity(3)) }
for yield
Monadのインスタンスを定義すると、for式を書けるようになる。
こちらもテストしてみよう
import Data.Identity.Instances.{given_Functor_Identity, given_Applicative_Identity, given_Monad_Identity} import TypeClass.Applicative.pure import TypeClass._ "Identityモナド" should "for-yeild" in { val result = for { a <- pure(1) b <- pure(2) c = a + b } yield (c + a + b) assert(result === Identity(6)) }
終わりに
今回はDottyを使って型クラスを定義してIdentity型をMonadに昇華する遊びをした。
アプリカティブスタイルや手続き的にモナディックな計算ができたから何?となるが、とりあえずそれだけでも気持ち良い。 しかし、型クラスとしてモナディックなデータ型を定義できるともっと嬉しいことがたくさんある。
実際に嬉しいことは次(いつ書くかわからないが)の投稿で、これらの型クラスを利用してモナディックパーサーコンビネーターでも書いて嬉しさを享受してみようかな〜と思ってる。
追記 20210112
> 上三つの関数定義はApplicativeをそのまま使っているだけなのだが、この辺うまく継承できないのかな?
— がくぞ (@gakuzzzz) 2021年1月12日
の件ですがexportを使う事で楽に書けるかと思います
given (using applicative: Applicative[Identity]) as Monad[Identity] {
export applicative._
def flatMap[A, B](...
Applicativeのインスタンス定義
package Data.Identity.Instances import TypeClass.{Applicative, Functor} import Data.Identity._ given (using functor: Functor[Identity]) as Applicative[Identity] { export functor._ def pure[A](a: A): Identity[A] = Identity(a) def ap[A, B](ff: Identity[A => B])(fa: Identity[A]): Identity[B] = ff match { case Identity(f) => functor.map(f)(fa) } }
Monadのインスタンス定義
given (using applicative: Applicative[Identity]) as Monad[Identity] { export applicative._ def flatMap[A, B](f: A => Identity[B])(fa: Identity[A]): Identity[B] = fa match { case Identity(a) => f(a) } }
重い腰を上げてブログを開設しました
ブログを始めます。よろしくお願いします。
エンジニアやその周辺の職種としての市場価値的観点、アウトプットによるコミュニティへの貢献、採用広報的な観点など、いろいろな点を鑑みてブログを持たなければなあと思ってはいたが、性格上アクティブではなく何をするにもなかなか動き出せない。 そんな中、会社から業務時間中にブログを書いてくれと頼まれたので重い腰をあげる決心がつきとりあえずブログを開設することができた。なお記事を定期的に書けるとはいっていない。
Qiitaなどの技術系と違い、ゆるふわなことに関しても雑に書くことができる点については素晴らしいと思う。 一旦はWeb技術, アーキテクチャ, アジャイル開発, 関数型言語とかその辺の自分の興味関心について書くとするか。