純粋関数型 React Hooks


この記事は React.js Advent Calendar 2018 の7日目だよ

書いた

経緯

TwitterでReact Hooksが話題になっていた。これはReact 17からの新機能で、以下のようなもの。

import { useState } from 'react'

function StateComponent() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  )
}

これでcountというstateを持ったReactコンポーネントを表現できる。StateComponentの初回の呼び出しでは、countに0が入る。StateComponentの二度目以降の呼び出しでは、以下がcountに入る。

これには違和感があった。プログラミング一般の話として、関数は極力、「同じ引数を与えれば同じ返り値が返るもの」であるべきという原則が自分の中にあった。処理内容が引数だけから決まる関数は、引数を変えるだけでテストができるので。このStateComponentはその原則に反している。

さらに、React Hooksは実装方法も気持ち悪い。上記の例で処理の流れがどうなっているかというと、概念的には以下のような感じ。

汚い。グローバル変数も例外も使わずにHooksを実装できると綺麗で良さそう。そんな実装をしたのでその話をする。

この記事にはReactの使い方ではなくてReact相当のことをどう実装するかが書いてあるので、Reactアドベントカレンダーに投稿するか迷ったが、まあReactユーザーが読んで面白い話ではあると思う。あと、この記事は、まず動かないコード片を提示してそれを動かすにはどう修正すればいいかを考えるという形で進む。完璧に動くコードは各章の最後に提示するので、そこだけ理解できればOK。

前提知識

前提でない知識

この話で分かる

書き心地を無視する

例として、先程と同じStateComponentを純粋関数として実装することを考える。純粋関数とは、「同じ引数を与えれば同じ返り値が返るもの」のこと。

まず、StateComponentは、VirtualDOMを作るにあたって、現在のcountを知る必要がある。StateComponentが純粋関数であるという性質を損なわずにこれを実装するとすれば、以下のような形が考えられる。

function StateComponent() {
  return (count, setCount) => {
    return (
      <div>
        <p>You clicked {count} times</p>
        <button onClick={() => setCount(count + 1)}>
          Click me
        </button>
      </div>
    )
  }
}

StateComponentは、countをグローバル変数などから取ってくるのではなく、countを受け取ってVirtualDOMを返す関数を返す。そしてReactがその関数に現在のcountを渡して呼び出す。
なお、「Reactが、」と書いてあるところは、StateComponentを純粋関数にするためにReactはそう動くことができるという意味。実際のReactの挙動について書いているわけではない。

だがこれではinitialStateを扱えない。StateComponentの呼び出し元(React)にinitialStateを伝える手段が必要になる。純粋関数が呼び出し元に値を伝える手段は、返り値を返すことのみ。

function StateComponent() {
  return [0, (count, setCount) => {
    return (
      <div>
        <p>You clicked {name} {count} times</p>
        <button onClick={() => setCount(count + 1)}>
          Click me
        </button>
      </div>
    )
  }]
}

これでStateComponentは純粋関数になった。
Reactは、まず一度StateComponentを呼び出し、initialStateを手に入れる。
もしStateComponentの呼び出しが初回なら、initialStateを引数としてStateComponentの2つ目の返り値を呼び出す。
そうでなければ、現在のcountを引数としてStateComponentの2つ目の返り値を呼び出す。

ようになった。

これで良さそうに見える。しかし、もしコンポーネントが複数のフックを含んでいたらどうだろうか。

import { useState, useEffect } from 'react';

function MultiHooksComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

useEffectは、Reactに対して、「このコンポーネントをレンダリングした後この関数を実行してくれ」と頼むフック。これを同じように純粋関数にすると以下のようになる。

function MultiHooksComponent() {
  return [0, (count, setCount) => {
    return [
      () => {
        document.title = `You clicked ${count} times`
      }, () => {
        return (
          <div>
            <p>You clicked {count} times</p>
            <button onClick={() => setCount(count + 1)}>
              Click me
            </button>
          </div>
        )
      }
    }
  ]
}

呼び出し元に対して、initialStateと、レンダリング後に実行してほしい関数と、stateを使ってVirtualDOMを作る関数を知らせている。見れば分かるが、書きにくすぎる。

書き心地を両立する

書き心地と純粋関数であることを両立したい。JavaScriptではどうすることもできないので、以下ではScalaを使う。Scalaには、

a.flatMap(x => {
  b.flatMap(y => {
    c.map(z => z + 10)
  })
})

for {
  x <- a
  y <- b
  z <- c
} yield z + 10

と略記できる文法がある。useStateやuseEffectの返り値にmapメソッドとflatMapメソッドが生えていると仮定すれば

def MultiHooksComponent() =
  useState(0).flatMap { case (count, setCount) =>
    useEffect {
      document.title = s"You clicked $count times"
    }.map { _ =>
      <p>You clicked {count} times</p>
    }
  }

は、以下のように略記できる。

def MultiHooksComponent() = for {
  (count, setCount) <- useState(0)
  
  _ <- useEffect {
    document.title = s"You clicked $count times"
  }
  
  vdom = <p>You clicked {count} times </p>
} yield vdom

書きやすい。あとは、上記のコードが先のJavaScriptのコードと同じ意味になるようにmapとflatMapを実装すればいい。

map

話を簡単にするため、まずはuseStateのみについて考える。以下のコンポーネントを例に使う。

def MapComponent() = for {
  (count, setCount) <- useState(0)
  
  vdom = <p>current state: {count}</p>
} yield vdom

くどいようだが、↑を脱糖すると↓になる。

def MapComponent() =
  useState(0).map { case (count, setCount) => 
    <p>current state: {count}</p>
  }

また、Reactコンポーネントとして同じようなコードを書くと:

function MapComponent() {
  const (count, setCount) = useState(0)
  
  return <p>current state: {count}</p>
}

↑を純粋関数として書くと

function MapComponent() {
  return [0, (count, setCount) =>
    <p>current state: {count}</p>
  ]
}

JSでの例で

function MapComponent() {
  return [0, (count, setCount) =>

となっていた部分がScalaだと

def MapComponent() =
  useState(0).map { case (count, setCount) =>

になる。この2つを同じ意味にしたい。つまり、mapの返り値は[0, (count, setCount) =>と同じことを表さなければならない。このことから、mapの返り値は以下のような型であるのが自然だと考えられる。

case class StateHook[S](
  initialState: S,
  continuation: ((S, S => Unit)) => VirtualDOM
)

Sはstateの型。今回の場合はInt。呼び出し元に対してこの型の値を返して、countの初期値である0をinitialStateとして伝え、countsetCountからVirtualDOMを計算する関数continuationを伝える。return [0, (count, setCount) =>とやっていることは同じ。

そして、ここからmapの実装も見えてくる。

なので、mapの実装は、自身に与えられた引数とuseStateの引数からStateHookを作るようなものになるはずだ。以下がその実装もどき。

case class UseState[S](initialState: S) {
  def map(f: ((S, S => Unit)) => VirtualDOM): StateHook[S] =
    StateHook(initialState, f)
}

def useState[S](initialState: S) = UseState(initialState)

VirtualDOMは実装したくないので、頭の中でtype VirtualDOM = scala.xml.Elemしてくれ
これで、MapComponentが動くようになるはずだ。だが実際には動かない。mapは、以下の型を持つべきとされている。

この型に従うと後で良いことがあるので、型を合わせる作業をする。今回のmapUseState[Int]に実装され、StateHook[Int]を返しているので、Fの型が合わない。まずはこれを直す。StateHookuseStateの返り値も表現できるように一般化する必要がある。

def useState[S](initialState: S) = StateHook(initialState, (state, setState) => "")

case class StateHook[S](
  initialState: S,
  continuation: ((S, S => Unit)) => VirtualDOM
) {
  def map(f: ((S, S => Unit)) => VirtualDOM): StateHook[S] =
    StateHook(initialState, f)
}

この実装でも変わらずuseStateの返り値はinitialStateを保持する。mapもできる。useStateの返り値のcontinuationは使わず捨てている。無駄に思えるが、後で無駄ではなくなる。 StateHook[Int]に実装されたmapメソッドがStateHook[Int]を返すようになったので、とりあえずFの型は合った。

まだ型が合わない。mapF[A]に実装され、F[B]を返すメソッドだ。StateHookFに当てはめて具体化すると、useState(0)の返り値とuseState(0).mapの返り値でSが変わるということになってしまう。これは筋が通らない。useState(0)の返り値が保持するinitialStateと、useState(0).mapの返り値が保持するinitialStateは、同じ値なのだから同じ型であるはずだ。これを解決するため、新しい型変数を導入する。

case class StateHook[S, R](
  initialState: S,
  continuation: ((S, S => Unit)) => VirtualDOM
) {
  def map[R2](f: ((S, S => Unit)) => VirtualDOM): StateHook[S, R2] =
    StateHook(initialState, f)
}

type IntStateHook[R] = StateHook[Int, R]

mapIntStateHook[R]に実装されIntStateHook[R2]を返すメソッドになった。IntStateHookFに当てはめると、F[A]に実装されたmapF[B]を返すという規則に合致している。新しい型変数Rは使わずに捨てている。次の段落にRの使い道を書く。

次はmapの引数の型を見ていく。くどいが、mapF[A]に実装され、A => Bを引数に取り、F[B]を返すメソッドだ。IntStateHookFに当てはめれば、AuseState(0)の返り値であるIntStateHook[R]Rのことであり、BuseState(0).mapの返り値であるIntStateHook[R2]R2のことである。現状のmap((S, S => Unit)) => VirtualDOMを引数に取っていて、そもそもRR2も関わっていない。型が全く違う。型を揃えると、以下のようになる。

case class StateHook[S, R](
  initialState: S,
  continuation: ((S, S => Unit)) => VirtualDOM
) {
  def map[R2](f: R => R2): StateHook[S, R2] =
    StateHook(initialState, f)
}

これはコンパイルできない。StateHook(initialState, f)の行で、fR => R2型だが、そこには((S, S => Unit)) => VirtualDOM型が入るべきなので、型が合わない。なので、continuationの型を変える必要がある。 以下のようになる。

case class StateHook[S, R](
  initialState: S,
  continuation: ((S, S => Unit)) => R
) {
  def map[R2](f: R => R2): StateHook[S, R2] =
    StateHook(initialState, x => f(continuation(x)))
}

type IntStateHook[R] = StateHook[Int, R]

Rcontinuationの結果を表す型になった。なのでRにはVirtualDOMが代入されることがある。

mapを見てみる。fを直接continuationにすることはできないので、型を合わせるため、((S, S => Unit)) => R型の関数とR => R2型の関数を繋げて((S, S => Unit)) => R2型の関数を作っている。しかし、これではmapの挙動がUseStateの物と違ってしまわないだろうか。結論から言うと、問題ない。新しいuseStateを見ると分かる。

def useState[S](initialState: S): StateHook[S, (S, S => Unit)] =
  StateHook(initialState, y => y)

useStateの返り値のStateHookが保持するcontinuationは、受け取った値をそのまま返す関数だ。mapの定義にあるx => f(continuation(x))continuationy => yになるので、これはx => f(x)と同じ意味になる。x => f(x)fと同義なので、最終的に、UseStatemapと挙動は同じになる。

anchor

useStateの返り値の型の第二型引数が(S, S => Unit)になっている理由は、MapComponentを見れば分かる。MapComponentは、useState(0).mapの引数に((S, S => Unit)) => VirtualDOM型の値を入れている。mapR => なんちゃら型の引数を受け取るStateHookStateHook[かんちゃら, R]型なので、そのRを書いている。

最初に書いたmapの型

を満たすことが出来た。MapComponentが動くようになった。遊んでみる。

MapComponent().initialState
// -> 0

countの初期値を取得できている!

def injectState[S](stateHook: StateHook[S], state: S): VirtualDOM =
  stateHook.continuation((state, newState => ()))

injectState(MapComponent(), 1)
// -> <p>current state: 1</p>
injectState(MapComponent(), 2)
// -> <p>current state: 2</p>

外からcountを注入できている!setCountも同様の方法で注入できるだろう。これはuseStateを使ったReactコンポーネントに対してReactがやっていることと同じだ。しかも今回はグローバル変数を使わずに、純粋関数のみを使って実装できている。
Reactより綺麗な感じがする。やったぜ。

flatMap

次は、複数のuseStateが存在する場合について考える。

def FlatMapComponent() = for {
  (age, setAge) <- useState(20)
  (count, setCount) <- useState(0)
  
  vdom = <p>current state: {age}, {count}</p>
} yield vdom

これは以下のように脱糖される。

def FlatMapComponent() =
  useState(20).flatMap { case (age, setAge) =>
    useState(0).map { case (count, setCount) =>
      <p>current state: {age}, {count}</p>
    }
  }

flatMapメソッドを実装する必要がある。flatMapは以下の型を持つ。

これを満たすような実装を考える。mapと型が似ているので、とりあえずmapから実装をコピペしてくる。

case class StateHook[S, R](
  initialState: S,
  continuation: ((S, S => Unit)) => R
) {
  def flatMap[R2](f: R => StateHook[S, R2]): StateHook[S, R2] =
    StateHook(initialState, x => f(continuation(x)))
}

当然だが型エラーが起こる。((S, S => Unit)) => R型のcontinuationに、((S, S => Unit)) => StateHook[S, R]型の関数x => f(continuation(x))を入れようとしているため。なので、continuationの型を合わせる必要がある。

case class StateHook[S, R](
  initialState: S,
  continuation: ((S, S => Unit)) => StateHook[S, R]
) {
  def flatMap[R2](f: R => StateHook[S, R2]): StateHook[S, R2] =
    StateHook(initialState, x => f(continuation(x)))
}

また型エラーが起こる。Rを引数に取るfに、continuationの返り値であるStateHook[S, R]を適用しているため。Rを引数に取る関数にStateHook[S, R]を適用する方法はないだろうか。それはflatMapそのものだ!

case class StateHook[S, R](
  initialState: S,
  continuation: ((S, S => Unit)) => StateHook[S, R]
) {
  def flatMap[R2](f: R => StateHook[S, R2]): StateHook[S, R2] =
    StateHook(initialState, x => continuation(x).flatMap(f))
}

これで型エラーは起きなくなった。だがプログラムの意味が壊れてしまっている。これまでは、continuationの返り値はRだった。なので、RVirtualDOMを代入することで、stateとsetStateを与えるとVirtualDOMを返すcontinuationを表現することができた。しかし今のcontinuationの返り値はStateHook[S, R]だ。StateHookは呼び出し元にinitialStateを伝えるための値だったはずである。stateとsetStateを与えるとinitialStateが伝わってくるというのは、意味が通らない。これを解決するには、StateHook[S, R]型がR型の値、つまりVirtualDOMを保持できるようにする必要がある。

case class Result[S, R](result: R) extends StateHook[S, R]
case class Intermediate[S, R](
  initialState: S,
  continuation: ((S, S => Unit)) => StateHook[S, R]
) extends StateHook[S, R]
sealed trait StateHook[S, R] {
  def flatMap[R2](f: R => StateHook[S, R2]): StateHook[S, R2] = this match {
    case Result(_) => ???
    case Intermediate(initialState, continuation) =>
      Intermediate(initialState, x => continuation(x).flatMap(f))
  }
}

StateHook[S, R]型は、IntermediateResultのどちらかになる。Intermediateの場合は、これまで通りinitialStatecontinuationを保持する。Resultの場合は、R型の値を保持する。これで、StateHook[S, VirtualDOM]VirtualDOM型の値を保持できるようになった。

Resultに対するflatMapの実装は未定義だが、それで大丈夫だろうか?useStateの実装を考える。

def useState[S](initialState: S): StateHook[S, (S, S => Unit)] =
  Intermediate(initialState, (y: (S, S => Unit)) => y)

これは型エラーを起こす。この時のIntermediateの第二引数の型は((S, S => Unit)) => StateHook[S, (S, S => Unit)]だが、そこに((S, S => Unit)) => (S, S => Unit)を代入しているため。最後の(S, S => Unit)StateHook[S, (S, S => Unit)]に変換する必要がある。一番簡単なのは、Resultを使う方法だ。

def useState[S](initialState: S): StateHook[S, (S, S => Unit)] =
  Intermediate(initialState, (y: (S, S => Unit)) => Result(y))

これで型エラーが起きなくなった。動作を追うと、useState(0).flatMap(f)は、(x: (Int, Int => Unit)) => Result(x).flatMap(f)というcontinuationを作る。Resultに対するflatMapの挙動を定義する必要がある。

case class Result[S, R](result: R) extends StateHook[S, R]
case class Intermediate[S, R](
  initialState: S,
  continuation: ((S, S => Unit)) => StateHook[S, R]
) extends StateHook[S, R]
sealed trait StateHook[S, R] {
  def flatMap[R2](f: R => StateHook[S, R2]): StateHook[S, R2] = this match {
    case Result(result) => f(result)
    case Intermediate(initialState, continuation) =>
      Intermediate(initialState, (x: (S, S => Unit)) => continuation(x).flatMap(f))
  }
}

useState(0).flatMap(f)が作るcontinuationが呼び出された時に与えられるstateとsetStateであるresultfにそのまま渡している。これで実行時エラーも起こらなくなった。以下が、型が合うように修正したmapも含めた完全版StateHookmapの挙動は以前と変わらない。

case class Result[S, R](result: R) extends StateHook[S, R]
case class Intermediate[S, R](
  initialState: S,
  continuation: ((S, S => Unit)) => StateHook[S, R]
) extends StateHook[S, R]
sealed trait StateHook[S, R] {
  def map[R2](f: R => R2): StateHook[S, R2] = flatMap(x => Result(f(x)))
  def flatMap[R2](f: R => StateHook[S, R2]): StateHook[S, R2] = this match {
    case Result(result) => f(result)
    case Intermediate(initialState, continuation) =>
      Intermediate(initialState, x => continuation(x).flatMap(f))
  }
}

これでFlatMapComponentが動くようになった。遊んでみる。

def injectState[S, R](stateHook: StateHook[S, R], state: S): StateHook[S, R] =
  stateHook match {
    case Result(result) => Result(result)
    case Intermediate(_, continuation) => continuation((state, newState => ()))
  }

val a = FlatMapComponent()
// -> Intermediate(20, ...)
val b = injectState(a, 21)
// -> Intermediate(0, ...)
val c = injectState(a, 1)
// -> Result(<p>current state: 21, 1</p>)

複数のstateを外から注入できた!やったぜ。

FlatMapComponentの動作を追ってみる。まず、useState(20)StateHook[Int, (Int, Int => Unit)]型の値である

Intermediate(20, { case (age, setAge) =>
  Result((age, setAge))
})

を作る。なので、useState(20).flatMap { case (age, setAge) => ... }は、Intermediateに対するflatMapとなる。Intermediateに対するflatMapの結果はIntermediateIntermediateは呼び出し元に対してinitialStateを伝えるための値。今ここでやりたいことは、呼び出し元にに20というinitialStateを伝えることなので、筋が通っている。そうしてa

Intermediate(20, { case (age, setAge) =>
  ({ case (age, setAge) => 
    Result((age, setAge))
  })((age, setAge)).flatMap { case (age, setAge) => 
    ...
  }
})

になる。簡約すると、Intermediate(20, { case (age, setAge) => ... })

次にbを考える。bacontinuation(21, newState => ())という引数で呼び出した返り値。acontinuation

({ case (age, setAge) =>
  useState(0).map { case (count, setCount) =>
    <p>current state: {age}, {count}</p>
  }
})

まずuseState(0)が評価される。これは

Intermediate(0, { case (count, setCount) =>
  Result((count, setCount))
})

に評価される。bはそれに対するmapの結果。以下になる。

Intermediate(0, { case (count, setCount) =>
  ({ case (count, setCount) =>
    Result((count, setCount))
  })((count, setCount)).flatMap { case (count, setCount) =>
    Result(
      ({ case (count, setCount) =>
        <p>current state: {age}, {count}</p>
      })((count, setCount))
    )
  }
})

簡約すると、Intermediate(0, { case (count, setCount) => Result(<p>current state: {age}, {count}</p>) })
cbcontinuationを呼んだ結果なので、Result(<p>current state: {age}, {count}</p>)になる。

🎉
というわけで、useStateを使ったReactコンポーネントっぽいものをテストしやすい純粋関数として実装することに成功した。

DRY

さて、useStateが実装できたのでこの調子でuseContextuseEffectを実装していこう!と言いたいところだが、正直言って面倒くさい。こんな頭が爆発しそうになる構造をバカスカ作ってメンテできる気がしない。なので、まずuseなんちゃらに共通する複雑性を探して、そこを切り出して再利用できるようにしたい。共通点は以下だ。

愚直に実装すると、以下のようになる。

case class Result[S, C, R](result: R) extends Pausable[S, C, R]
case class Intermediate[S, C, R](
  result: S,
  continuation: C => Pausable[S, C, R]
) extends Pausable[S, C, R]
sealed trait Pausable[S, C, R] {
  def map[R2](f: R => R2): Pausable[S, C, R2] = flatMap(x => Result(f(x)))
  def flatMap[R2](f: R => Pausable[S, C, R2]): Pausable[S, C, R2] = this match {
    case Result(result) => f(result)
    case Intermediate(result, continuation) =>
      Intermediate(result, x => continuation(x).flatMap(f))
  }
}

StateHookでは再開時に受け取る値の型が(S, S => Unit)で固定されていたが、そこを可変にした。それだけ。SIntC(Int, Int => Unit)にすればこれまで通りuseStateを使った計算を表現できる。SUnitCSomeContextにすればSomeContext型のcontextに依存した計算を表現できる。

type ContextHook[C, R] = Pausable[Unit, C, R]
def useContext[C]: ContextHook[C, C] =
  Intermediate((), c => Result(c))
def injectContext[C, R](contextHook: ContextHook[C, R], context: C)
: ContextHook[C, R] =
  contextHook match {
    case Result(result) => Result(result)
    case Intermediate(_, continuation) => continuation(context)
  }

case class SomeContext(name: String)

def ContextComponent() = for {
  context <- useContext[SomeContext]
  
  vdom = <p>my name is {context.name}</p>
} yield vdom

injectContext(ContextComponent(), SomeContext("hoge"))
// -> Result(<p>my name is hoge</p>) 

useContextという新しいフックを簡潔に定義することが出来た。🎉
useContextの返り値の型の第二型引数がCなのは、これと同じ理由。

Freer

しかし、Pausableが3つも型引数を取るのは少しややこしい感じがする。頑張れば2つに減らすことができる。減らすと後でいいことがある

case class Result[F[_], R](result: R) extends Pausable[F, R]
case class Intermediate[F[_], C, R](
  result: F[C],
  continuation: C => Pausable[F, R]
) extends Pausable[F, R]
sealed trait Pausable[F[_], R] {
  def map[R2](f: R => R2): Pausable[F, R2] = flatMap(x => Result(f(x)))
  def flatMap[R2](f: R => Pausable[F, R2]): Pausable[F, R2] = this match {
    case Result(result) => f(result)
    case Intermediate(result, continuation) =>
      Intermediate(result, x => continuation(x).flatMap(f))
  }
}

中断時に知らせる値を表していたSが、F[C]になっている。そしてIntermediateC、つまり再開時に知らされる値の型の情報は、Pausableにアップキャストされると消失するようになった。抽象的すぎて何を言っているかわからないので、このPausableを使ったStateHookの実装を考える。

case class UseState[S, C](initialState: S)
type StateHook[S, R] = Pausable[UseState[S, ?], R]

UseState[S, ?]は、({type L[A] = UseState[S, A]})#Lの糖衣構文。kind-projectorという非公式の言語拡張を導入すると使えるようになる。いきなり非公式の言語拡張を使うな ← それはそう。余談だが、Scala3にはこの言語拡張相当の機能が公式で入っている。

UseStatePausableの第一型引数として使っている。Pausableの第一型引数は中断時に知らせる値の型を表現するのに使われる。UseStateinitialStateを保持する。中断時にinitialStateを知らせるというのは、筋が通っている。

UseStateCは、使わずに捨てている。それで大丈夫だろうか?このStateHookに対するinjectStateの実装を考える。

def injectState[S, R](stateHook: StateHook[S, R], state: S): StateHook[S, R] =
  stateHook match {
    case Result(result) => Result(result)
    case Intermediate(_, continuation) => continuation((state, newState => ()))
  }

これはコンパイルを通らない。continuationC型の値を受け取るが、そこに(S, S => Unit)型の値を入れている。型が合わない。型を合わせるために、Fが型引数としてCを受け取ることを活用する。つまりcontinuationの引数の型が、UseStateCになる。

case class UseState[S, C](initialState: S, continueWith: ((S, S => Unit)) => C)

UseStateは、(S, S => Unit)continuationの引数の型に変換する関数を持つ。これを使って、injectStateは以下のように実装できる。

def injectState[S, R](stateHook: StateHook[S, R], state: S): StateHook[S, R] =
  stateHook match {
    case Result(result) => Result(result)
    case Intermediate(UseState(_, continueWith), continuation) =>
      continuation(continueWith((state, newState => ())))
  }

これで型エラーは起きなくなった。しかし、(S, S => Unit)Cに変換する関数とは、具体的にどんなものだろうか。useStateの実装を見てみる。

def useState[S](initialState: S): StateHook[S, (S, S => Unit)] =
  Intermediate(
    UseState(initialState, x => x),
    x => Result(x)
  )

x => xが、(S, S => Unit)Cに変換する関数だ。どういうことだろうか。順を追って見ていく。まず、x => Result(x)continuationだ。引数と返り値で同じxを使っている。なので、continuationは、なんちゃら => StateHook[かんちゃら, なんちゃら]型を持つと推論される。useStateの返り値の型に、そのなんちゃら(S, S => Unit)だと書いてある。なので、このcontinuation(S, S => Unit) => StateHook[かんちゃら, (S, S => Unit)]型に推論される。continuationの引数の型、つまりC(S, S => Unit)に推論された。なので(S, S => Unit)Cに変換する関数は、ただの恒等関数でいい。

型引数が2つのPausableを作ることができた。実はこのPausableは、よく使う(?)ので、関数型界隈ではFreerという名前で親しまれているらしい。ResultPureと呼ばれ、IntermediateImpureと呼ばれる。Freerと、Freerを使ったStateHookContextHookの実装を置いておく。

useStateuseContextを簡潔に定義することができた。

flatMapが抽象メソッドになっているのは、(x: C)の型指定をするため。Scala2の型推論が貧弱なことへのワークアラウンド。プログラムの意味は変わらない。余談だが、Scala3ならこのxの型もちゃんと推論してくれるのでこんなことをする必要はない。

BEGIN 余談
Scala2では、continuationの引数がどんな型だとしても、ImpureFreerにアップキャストされた瞬間continuationの引数の型はAnyとして扱われることがある。なので実は、間にcontinueWithを噛まさずに直接continuationに値を渡してもコンパイルは通る。というかどんな型の値を渡してもコンパイルが通ってしまう。ヤバイ。そして実行時に型エラーが出る。。。

これはScala2の型システムの不健全性が引き起こす問題。Scala3では修正されている。
END 余談

ところで、React 17には、フックを組み合わせてオリジナルのフックを作れる機能がある。同じことをやってみよう。

import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global

def useTimer(initialCount: Int, interval: Int) = for {
  (count, setCount) <- useState(initialCount)

  _ = Future {
    Thread.sleep(interval)
    setCount(count + 1)
  }
} yield count

def TimerComponent() = for {
  count <- useTimer(0, 1000)

  vdom = <p>
    The count is now {count}.
    It changes every second.
  </p>
} yield vdom

一秒毎に変わる数字を使うためのuseTimerフックと、一秒毎に変わる数字を表示するTimerComponentを実装した。TimerComponentのレンダリングを雑に実装すると、以下のようになる。

def render(): Unit = render(None)
def render(state: Int): Unit = render(Some(state))
def render(state: Option[Int]): Unit = {
  val pure = (TimerComponent(): @unchecked) match {
    case Impure(UseState(initialState, continueWith), continuation) =>
      continuation(continueWith((state.getOrElse(initialState), render)))
  }
  println(pure)
}

render()
// Pure(<p>The number is now 0. This changes every second.</p>)
// Pure(<p>The number is now 1. This changes every second.</p>)
// Pure(<p>The number is now 2. This changes every second.</p>)
// つづく...

setCountrenderになるので、一秒毎にTimerComponentが再レンダリングされる。どう動いているかを頭で追うと楽しいので追ってみると良いと思う。書くのが面倒臭いだけ

見た目を考える。useStateinitialStateuseTimerTimerComponentをすっ飛ばしてrenderに渡っているように見える。renderTimerComponentをすっ飛ばしてuseTimercountsetCountを注入しているように見える。要は、コールスタックの奥深くっぽく見える場所と直接やりとりができている。しかも、それにグローバル変数も例外も用いていない。これって実はすごいことじゃないだろうか。Freerすごい!

Reactコミッターの方がこんなことを言っていた。

Hooks could be implemented in a pure way using algebraic effects (if JavaScript supported them).

Freerは、Algebraic Effectsの限定的な形態として見ることができる。はず。Algebraic Effectsは継続を取ってこれる例外らしい。先のコードの振る舞いは、まさしく継続を取ってこれる例外っぽいと思う。useStateinitialStateuseTimerTimerComponentをすっ飛ばしてrenderに渡っているのはめっちゃ例外っぽい。その例えで言う例外ハンドラであるところのrenderは、例外initialStateが投げられた場所以降の処理を表す関数continuationを取得できている。継続っぽい。なるほど確かに継続を取ってこれる例外だ。

TimerComponentを少し簡約すると以下になる。

def TimerComponent() =
  useState(0)
  .map { case (count, setCount) =>
    Future {
      Thread.sleep(1000)
      setCount(count + 1)
    }
    number
  }.map { count =>
    <p>
      The number is now {count}.
      This changes every second.
    </p>
  }

この記事の最初では、useState(0)useState(0).map(x => x)は型が違った。そしてそれらの型を同じにする作業をした。その作業は不毛に思えたかもしれない。しかしその作業によって、useState(0).map(f).map(g).map(h).....ができるようになった。そしてそれが、コールスタックの奥深くっぽい場所にあるuseStateに対して直接stateとsetStateを注入できる能力に繋がった。型パズルは無駄じゃなかった。

複数のフックを同時に使う

Algebraic EffectsにできてFreerにできないこととして、以下のようなものがある。

def StCtxComponent() = for {
  (count, setCount) <- useState(0)
  context <- useContext[SomeContext]
  
  vdom = <p>I'm {context.name}. I'm clicked {count} times.</p> 
} yield vdom

これは以下のように脱糖される。

def StCtxComponent() =
  useState(0).flatMap { case (count, setCount) =>
    useContext[SomeContext].map { context =>
      <p>I'm {context.name}. I'm clicked {count} times.</p>
    }
  }

これは型検査を通らない。StateHookflatMapStateHookを返す関数を引数に取るが、そこにContextHookを返す関数を入れている。また、プログラムとしての意味も通らない。各フックは別々の方法でハンドリングされるべきだが、今の所その方法がない。これを解決するため、Effという物を導入する。このEffは、ScalaでAlgebraic Effectsをやるための構造だ。

type Eff[U[_], R] = Freer[U, R]

なんとFreerの名前が変わっただけ。あと、FUに改名した。Fに特殊なユニオン型を入れて使うFreerを、Effと呼ぶ。実用しようとすると最適化とかがありもっと違う形になるようだが、概念的には間違ってないはず。

…という話を書こうと思ったのだが、書く気力が無くなってきたので、一旦切る。Effについては、このスライドがよかった。

ScalaMatsuriで聴いた時はついていけなかったが、スライドをじっくり読むとめっちゃおもしろかった。

注意

この記事内のコードは、以下のようなbuild.sbtでコンパイルするものとする。

scalaVersion := "2.12.7"
scalacOptions += "-Ypartial-unification"
addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.2.4")
addCompilerPlugin("org.spire-math" %% "kind-projector" % "0.9.4")
libraryDependencies += "org.scala-lang.modules" %% "scala-xml" % "1.1.1"

誰か教えて