口がすべって

プログラミング, 開発組織, Web技術....

【型クラス】Scala 3.0でHaskellerも嬉しい記法がいっぱい来たので遊んでみた。【アプリカティブスタイル】【オブジェクト指向】

こんにちは、入田 関太郎(らむだ ふぁんたろう) こと坂田です。

今年の目標は、

  • 新規性が一切なくても
  • 嬉しさが主観でただのお気持ち記事でも

記事を書くことです。

はじめに

Scala3(Dotty)で型クラスの定義やメソッドの拡張が読みやすく書きやすくなったらしいのでいろいろ実験してみました。全体的に気持ち良くなれました。

ちなみに筆者はScalaJavaも一切書いたことがなく、もっと良い書き方や間違っている箇所がありましたらコメントで教えていただけたら幸いです。

結論

  • 型クラスを利用してflatMap(function, fa)という風に使える高階でGenericな関数をわかりやすく書くことができて気持ちいい(主観)
  • 上記の関数を定義すると、ちょっとしたコードの追加で全てのFunctorなデータ型に、fa.flatMap(function) というオブジェクト指向なメソッドをはやすことができて気持ちいい(主観)
  • Scalaでは上記のメソッドは中置記法ができるため、 fa >>= functionfunction2 <*> fa1 <*> fa2 のようなアプリカティブスタイルも簡単に定義できて気持ちがいい(主観)

Scala2.xでも型クラスの定義やメソッドの拡張はできたが、Dottyだとわかりやすい記法でできるので、それらが全体的に嬉しかったです(小学生並みの感想)

やっていくこと

  • 型クラスの定義(Functor, Applicative, Monad)
  • Identityデータ型の定義
  • Identityデータ型をFunctor, Applicative, Monadインスタンスにする
  • 関数を利用してモナディックな計算ができることを示す
  • 拡張メソッド、中置演算などを利用してモナディックな計算をアプリカティブスタイルで書いて気持ちよくなれることを示す
  • for-yieldで書いて手続チックに書いて気持ち良くなれることを示す

やったことのソースコードはこちらに載せてます。

github.com

型クラスの定義

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の定義

haskell

class (Functor m, Pointed m) => Monad m where
  (>>=) :: m a -> (a -> m b) -> m b

scala

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[_]な型のmaMonad[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のインスタンス定義

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技術, アーキテクチャ, アジャイル開発, 関数型言語とかその辺の自分の興味関心について書くとするか。