Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Catsを使ってReader関手・Reader Applicative関手を勉強したよ

先日、Haskellで書かれたおもしろFizzBuzzの事を思い出した。読んだときはよく分からなかったけれど、型クラスへの理解が進んで、結構意味が分かるようになりつつある。そこで、それにまつわる要素をちゃんと勉強することにした。勉強といってもCatsでの使い方なので、数学的な背景などは飛ばすことにする。

itchyny.hatenablog.com

分からない要素は大きく2つあった。AlternativeApplicative (->) rだ。Alternativeについては以下の記事で勉強した。

blog.3qe.us

このエントリでは、(->) rのアプリカティブについて学ぶことにする。しかし、今はほとんど事前知識が無いので、いきなりこんなことを言われてもわからない:

  • (->) rは、Applicativeのインスタンスである。

?????

というわけで、なるだけ簡単な要素から勉強していくことにした。

そもそも(->) rってなんだよ

いきなり(->) rとか書かれてもぜんぜん分からんので、より簡単な所から考えていこう。->というのは、Haskellの記法であるから、ちょっとだけHaskellの話をする。

Haskellでr -> aとは、rを受け取ってaを返す1引数関数の型のことである。中置記法ではr -> aと書くが、前置記法では(->) r aとも書ける。

ちなみに(->)単体では、「型を2つ取る型コンストラクタ」である。型コンストラクタとは、型を受け取って初めて実際の型になるもののことで、例えば[]は型コンストラクタである(([]) aのように型を与えることで、実際に型となる)。

そして型コンストラクタも、関数と同様、型を部分適用できる。

  • (->) 型を2つ受け取る型コンストラクタ
  • (->) r 型を1つ受け取る型コンストラクタ
  • (->) r a === r -> a

どうして(->)でもr -> aでもなく(->) rなのかは、いずれ分かる。

とりあえずここからは、(->) rが関手になること、そしてそのうちそれがモナドになっていくこと、それが多分便利らしいことを確認していこう。

Reader関手

まず最初に、(->) rが関手であることについて考えていく。関手とは型クラスの1つで、関数fmapに加えて、いくつか守るべき規則を実装すれば、関手を名乗ることができる(型クラスの説明は、ここでは割愛する)。

ちなみに型クラスの中でも有名なのがモナドだが、より制約が弱い(実装しやすい)ものとしてアプリカティブや関手ファンクタがある。

  • モナド (表現力が高い) (守るべき制約が強い)
    • ↑ 高級
  • アプリカティブ
  • 関手 (お手軽)
    • アプリカティブな関手を、アプリカティブ関手と呼ぶ
    • ↓ ドンキで売ってる

イメージとしてはこういう感じである。

とりあえず定義が小さなものから順に考えて、最終的にアプリカティブ関手に辿り着きたい。そこで、まずは(->) rが関手であることについて考えていく。

関手fが唯一実装すべきfmapの定義は fmap :: (a -> b) -> f a -> f b である。(->) rが関手になることについて考えているので、fに実際の型(->) rを埋めてみると、以下のように変形できる。

  • fmap :: (a -> b) -> f a -> f b
  • (->) rが関手になることについて考えているので、fには(->) rが入る
  • (->) rr -> ...なので・・・
  • fmap :: (a -> b) -> (r -> a) -> (r -> b)

なんだか見覚えのある型だ。a -> br -> aを渡すと、r -> bが得られる計算とは何だろう?

どうして (->) rなのか

閑話休題。ちなみにこのあたりで、(->) rである必要が分かってくる。ただ1つの引数を持つ型コンストラクタでなければ、関手(そしてアプリカティブ、モナド)になれないのだ。

ScalaのライブラリであるCatsのドキュメント Functor を見たほうが、そのことがより際立って見えるはずだ(fmapmapという名称になっていることに注意)。

trait Functor[F[_]] {
  def map[A, B](fa: F[A])(f: A => B): F[B]
}

Functorは明らかに、1引数を取る型コンストラクタF[_]を要求している。この型上の制約に合致させるために、部分適用した(->) r(CatsだとFunction1[In, ?]という表記になっている。平たく言うと「rをもらう関数」)である必要があるのだ。

関手の続き

さて(->) rを関手にするfmapは、a -> br -> aを受け取りr -> bを返すような関数だとわかった。そしてよく見ると、これは関数合成そのものではないか。

  • fmap :: (a -> b) -> (r -> a) -> (r -> b)
  • a -> b があり、 r -> a があるとき、 r -> b が得られる

というわけで、fmapの実装はこうなる(満たすべきファンクタ則は割愛)。

fmap = (.)

シンプル!

こうして得られた(->) rの関手は、Reader関手と呼ばれるらしい。何が便利かというと、関数合成ができる。

import cats._
import cats.implicits._

// (->) r is a functor
val f = (n: Int) => n * 2
val g = (n: Int) => n + 1

// functor can fmap
val fg = f map g // f andThen g
fg(10) // 21

CatsだとScalaの色々な都合で、fしてからgという順序で合成される。Haskellではgしてからfの順になる。

これだけだとあまりお徳な感じがしないというか、だから何という感じなので、このままアプリカティブを実装しに行こう。

Reader Applicative

Reader Applicativeと言うのが一般的なのかは知らないけど、便宜上ここではそう呼ぼう。

関手にApplicativeを注射してApplicative関手にするためには、2つの関数を追加で実装すればよい。pureap(<*>)だ。

型表記はHaskellの方がシンプルなので、そちらで書こう:

pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b

pure を実装する

pureは、何でもないa型の値を、f a型に昇格させるような関数だ。

これも例によって、f(->) rで置換して、型から内容を考えてみよう。

  • pure :: a -> f a
  • (->) rが関手になることについて考えているので、fには(->) rが入る
  • (->) rr -> ...なので・・・
  • pure:: a -> r -> a
  • a型を受け取り、r型を受け取り、a型を返す関数
    • 2つ引数を取るけど、2つめは捨てて1つめの引数だけ返す関数だと言えそう

これはconstだ。constは畳み込みにも出現する面白い概念だ:

blog.3qe.us

というわけでpureの実装は以下の通りになる。

pure = const

シンプル。

<*> / ap を実装する

pureを実装し終わったところで、Applicativeのもう一つの柱である<*>を実装しよう。文脈によってはapと書かれることもあるぞ。

  • (<*>) :: f (a -> b) -> f a -> f b
  • (->) rが関手になることについて考えているので、fには(->) rが入る
  • (->) rr -> ...なので・・・
  • (<*>) :: (r -> a -> b) -> (r -> a) -> (r -> b)

何これ?(ヒント!r ->を取り除くと(a -> b) -> a -> bという素直な関数適用の形をしているぞ。)

(->) rは関数なので、実際に使われるときはf <*> gというふうに書かれるはず。fgそれぞれに型の定義を割り当ててみると・・・

  • f :: (r -> a -> b)
  • g :: (r -> a)
  • f <*> g :: (r -> b)

となる。fmapを実装したときと異なり、fgrを要求している。だから単純な合成では実装できないぞ。

  • f <*> g全体ではrを引数に受けるから、\r -> ...という形式になりそう。
  • fgはそれぞれrを引数に受けるから・・・
    • f ra -> bになる
    • g raになる
  • g rの型がaなので、f rに渡せばbとなり、全体の型r -> bが完成する
    • \r -> f r (g r) と書いて完成

というわけで(天下り的だけど) <*>の定義は以下の通りになった。

f <*> g = \r -> f r (g r)

r型の値を受け取り、frを部分適用して、さらにgrを適用した値を引数に渡している。

ちなみに、f <*> g <*> hのように引数を増やしていくと、次のような形になる:

f <*> g <*> h = \r -> f r (g r) (h r)
f <*> g <*> h <*> k = \r -> f r (g r) (h r) (k r)

どういう働きをするか、おぼろげながら分かってきた。普通の関数適用のf g hという形と見比べてみてほしい。

例によってApplicative則は割愛する。

Reader Applicative 完成

さてApplicativeが完成したのだけれど、これもいまいち便利さがよく分からない。どういう時に使うのだろう。

関数適用の形をしつつ、全てのfgrを渡してくれるというのがミソのようで、依存性注入DI的な使い方ができる。例えば、ログ処理を各関数に注入することができる:

{
  type Logger= String => Unit
  val log: Logger = println
  val f = (l: Logger) => {
    l("executing f")
    42
  }
  val g = (l: Logger) => {
    l("executing g")
    43
  }
  val h = (l: Logger) => {
    l("executing h")
    44
  }
  val F = (l: Logger) => (fx: Int) => (gx: Int) => (hx: Int) => {
    l("combining together")
    fx :: gx :: hx :: Nil
  }
  val Ffgh = F <*> f <*> g <*> h
  Ffgh(log) // 42 :: 43 :: 44 :: Nil
}

これを実行すると、標準出力に次のように表示されつつも、Ffgh(log)42 :: 43 :: 44:: Nilを返す。

executing f
executing g
executing h
combining together

これは、F(f, g, h)という関数適用を一般化しつつ、第一引数としてl: Loggerが添加される、というイメージである。

関数適用の全ての要素に、「第一引数が付け加えられる」

ちなみにFFfghは以下のようにも書くことができる。

val F2 = (fx: Int, gx: Int, hx: Int) => {
  fx :: gx :: hx :: Nil
}
val F2fgh = (f, g, h) mapN F2

ただし、mapNを使って関数を自動的に"持ち上げlift"ると、F2l: Logを受け取る形にできない。したがってF2自体には注入を行えない。

executing f
executing g
executing h

まとめ

  • (->) rは、特に型クラスとして見る立場からはReaderと呼ぶ
  • (->) rは、fmap(.)で定義することで、関手のインスタンスにできる
  • (->) rは、うまく定義することでApplicativeのインスタンスにできる
  • Applicativeは、関数適用の自然な拡張である
  • Applicativeとしての(->) rは、関数適用の各要素にr型の引数を添加するように拡張する

このままReaderモナドに突入してもよかったけれど、もう力尽きたので一旦Applicativeにしたところで満足しておく。

参考文献

blog.kymmt.com

kazu-yamamoto.hatenablog.jp

mizunashi-mana.github.io

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?