🎄

私がStateモナドを理解するまでに躓いた各ステップへの振り返り

2023/12/04に公開

本記事に興味を持ってくださりありがとうございます。
弊社でもアドベントカレンダーが始まりまして、じきにクリスマスですね。非日常ですね。

だからというわけではないのですが、今回は、日々の業務とは関係ないけれど 「ちょっと気になってた」 あの技術について、書いてみようと思います。

本日のお題: Stateモナド

こちら。
気になってたんです、ここ数年来。

何度かアタックしつつ、毎度ツレない結果に終わっていたのですが、この秋はようやく少し分かってきました。

そこで、私の中で理解が難しかった箇所について書いてみます。ひょんな拍子に皆さまの疑問解消の一助になれば嬉しく思います。

今年はみんなでStateモナドちゃんと楽しいクリスマスを過ごしましょう!

なお、私は主に Scala で学習してきたので、本記事では Scala Cats を参照して書いていることにご留意ください。

参考情報

  1. Cats
  2. 書籍
    • 「Scala関数型デザイン&プログラミング ―Scalazコントリビューターによる関数型徹底ガイド」
    • 「すごいHaskellたのしく学ぼう! 」

1. 登場人物

振り返ってみると、Stateモナドには登場人物が多く、誰が何であるかを整理して咀嚼するのが難しかったです。

結論、
下記3つの登場人物がいるのだと理解しました。

  1. 状態
  2. 状態アクション : 状態を受け取り、それを使って値を生成する関数群
    • S => (S, A), S => (S, B), S => (S, C), ...
  3. Stateモナド : 状態アクションを保持し、他の状態アクションと連結して実行できるコンテナ

これら3つを区別して認識できるとようやく、徐々にその先の理解が進み始めました。

2. 状態と状態アクション

この両者の関係性をしっかりと理解する必要がありました。

結論、
状態アクションが状態を使用し、それを変化させる。という関係性でした。

  • 状態 S : これを使う処理の前後でここに保持されている値が変化しうるもの
  • 状態アクション S => (S, A): ある時点の状態Sを引数に取り、処理を実行し、変化した状態Sと、状態を変化させる過程で得られた副産物Aを生成する関数

ということです。
状態アクションの両辺の2つの状態Sは、型はSで同じなのですが、処理実行の過程で値が変わっている(ことがある。変わらないことも時にはあるかもだけど。)ことにご留意ください。

そして、これを踏まえてもうひとつ、ここで理解するべきポイントがあります。
状態アクションがひとつ実行されるに伴い状態が変化するということは、2つの状態アクション

  • action_1: S => (S, A)
  • action_2: S => (S, B)

を順番に実行したい時には、状態Sを1から2に引き継ぐ、つまり、action_1の実行を経て変化した右辺のSをaction_2の左辺の引数Sに渡す必要がある。
ということです。

なお、Scala Cats 公式ドキュメント(参考情報 1-1.)での、状態と状態アクションそれぞれの具体例は下記になります。

// 状態
final case class Seed(long: Long) {

  // 自身のthis.longを変化させるのではなく、
  // 変化後を表す新たな状態インスタンスを生成して返却している点、
  // 副作用がない関数型スタイルであることに留意
  def next = Seed(long * 6364136223846793005L + 1442695040888963407L)
}

// 状態アクション
// 下記2つのどちらにおいても、戻り値の状態(Seed)が保持する値は、
// 引数で受け取った時とは変化していることに留意

//状態アクション_1
def nextBoolean(seed: Seed): (Seed, Boolean) =
  (seed.next, seed.long >= 0L)

//状態アクション_2
def nextLong(seed: Seed): (Seed, Long) =
  (seed.next, seed.long)

3. Stateモナドに保持されているもの

これもイメージが付きにくく、混乱していました。

  • 状態が保持されているのかな?
  • それともなにかの値なのかな?

結論、
保持されているのは「状態アクション」でした。つまり、 S => (S, A) という関数でした。

OptionとかFutureですと、「1」という数字だったり「"D2C 太郎"」という文字列だったりと、関数ではない値を入れて使ってきたため、関数が保持されているのだということを把握するのに難しさがありました。

4. Stateモナドが果たす役割

これは、上述の点をまず先に理解しないと全然良く分かりませんでした。

結論、
複数の状態アクション群 action_1, action_2, ..., を順々に実行していく際に、1アクション実行ごとに変化していく状態を適切に次のアクションに引き継ぎながら実行してくれる。
というものでした。

  • 状態アクションを連結できる場
  • 連結された状態アクション群全体を、その過程で起こる一連の状態変化を踏まえて実行できる環境

を果たしてくれるということです。

5. Stateモナドにおける状態アクションの連結と実行

  • 状態アクションを連結できる場
  • 連結された状態アクション群全体を、その過程で起こる一連の状態変化を踏まえて実行できる環境

について、理解を深めていきましょう。

まず、状態アクションを「連結」できる、ということについてです。

Stateモナド、つまりモナドというものにおいての処理の連結は、 mapflatMap で実現されます。

この中でも特に、flatMapによって、モナドを伴う処理同士の連結ができます。

ただ、ここでも、Stateモナドにおいてのこれらの演算のイメージ把握が難しかったです。
Stateモナドに保持されているものが状態アクション、つまり関数であることをまず踏まえる必要がありました。

状態アクション S => (S, A) を保持しているStateモナドに

  • f: A => B
  • f: A => F[B]

という関数を渡すということの意味するところはつまり、保持されている状態アクション関数とこれらの関数が関数合成され、新しい状態アクション関数が生み出される。ということでした。

なお、ここでfの左辺Aに渡されるのは、保持されている状態アクション S => (S, A)の右辺のAです。
そのように両者を関数合成することで、 S => (S, B) という新たな状態アクションを生み出すということです。

詳細としては、

になります。
(素朴なStateモナドとしてではなく、抽象的なモナドトランスフォーマーの具象として実装されているため複雑ですが汗)

  • extends Functor[IndexedStateT[F, SA, SB, *]]
  • with Monad[IndexedStateT[F, S, S, *]]

という型クラスインスタンス宣言において、状態アクション S => (S, A)のAにあたる部分が * で宣言されています。
そしてこの * 、つまり、状態アクションを実行した結果の副産物に対して、

  • def map[A, B](fa: IndexedStateT[F, SA, SB, A])(f: A => B): IndexedStateT[F, SA, SB, B]
  • def flatMap[A, B](fa: IndexedStateT[F, S, S, A])(f: A => IndexedStateT[F, S, S, B])

map, flatMapにて引数で受け取る関数が適用される定義となっています。

次に、連結された状態アクション群全体を、その過程で起こる一連の状態変化を踏まえて実行できる、についてです。

  • action_1: S => (S, A)
  • action_2: S => (S, B)
    を順番に実行したい時には、状態Sを1から2に引き継ぐ、つまり、action_1の実行を経て変化した右辺のSをaction_2の左辺の引数Sに渡す必要がある。

の部分です。
これはつまり、モナドを伴う処理同士の連結を担うflatMapでの関数合成において、

  • flatMapが呼び出される側のStateモナドAが保持する状態アクションA
  • flatMapの引数に渡される関数が生み出すStateモナドBが保持する状態アクションB

の2つの状態アクションAからBへと、状態の引き継ぎが行われる必要があるということです。

上記2点を踏まえると、mapやflatMapの実装イメージは下記のようになります。

// 状態アクションを保持するコンテナ
class State[S, A](val run: S => (S, A)) {

  def map[B](f: A => B): State[S, B] =
    new State((state: S) => // ある状態をもらって
      val (newState, a) = this.run(state)
      val b = f(a)
      return (newState, b) // 新しい状態と、副産物に演算を適用した結果を返す
    )

  def flatMap[B](f: A => State[S, B]): State[S, B] =
    new State((state: S) => // ある状態をもらって
      val (newState_1, a) = this.run(state)
      val stateB: State = f(a)

      // 1つめの状態アクションthis.runの実施を経て変化した状態 newState_1 をここで、
      // Bが保持している2つめの状態アクションに引き渡す
      val (newState_2, b) = stateB.run(newState_1)
      
      return (newState_2, b) // 新しい状態と副産物を返す
    )
}

// コンテナにFunctorおよびMonadの機能を持たせる型クラスインスタンス
class StateMonad[S] extends Functor[State[S, *]] with Monad[State[S, *]] {
  def map[A, B](fa: State[S, A])(f: A => B): State[S, B] =
    fa.map(f)
  def flatMap[A, B](fa: State[S, A])(f: A => State[S, B]): State[S, B] =
    fa.flatMap(f)
  // ...
}

flatMapにおいて、Stateモナドに保持された2つの状態アクション間で状態の引き継ぎが行われています。
flatMapを使って状態アクションを連結させることで、Stateモナドの中で自動的に状態を引き継いで一連の処理を実行してくれるということです。

Scala Cats 公式ドキュメント(参考情報 1-1.)での具体例は下記部分です。

val createRobot: State[Seed, Robot] =
  for {
    id <- nextLong
    sentient <- nextBoolean
    isCatherine <- nextBoolean
    name = if (isCatherine) "Catherine" else "Carlos"
    isReplicant <- nextBoolean
    model = if (isReplicant) "replicant" else "borg"
  } yield Robot(id, sentient, name, model)

このfor式は実際には、flatMapとmapによる処理の連結に展開されます。

val createRobot: State[Seed, Robot] =
  nextLong.flatMap { id =>
    nextBoolean.flatMap { sentient =>
      nextBoolean.flatMap { isCatherine =>
        val name = if (isCatherine) "Catherine" else "Carlos"
        nextBoolean.map { isReplicant =>
          val model = if (isReplicant) "replicant" else "borg"
          Robot(id, sentient, name, model)
        }
      }
    }
  }

ここでの3回にわたるflatMapにおいて、3つの状態アクションが連結され、それらの間で自動的に、変化していく状態が適切に引き渡されていっている。
引き渡しをコードの書き手が行わなくて済んでいる、ということです。

ここに、状態アクションをStateモナドに包んで扱うことの価値がありました。

6. runして値を取り出す

これも、Stateモナドに保持されているのが状態アクション関数であるということが分からないと理解できませんでした。
分かってしまえば簡単ですね。

保持されている状態アクション S = (S, A) にSを渡して実際に実行し、処理結果である(S, A)を生成するということでした。

Scala Cats 公式ドキュメント(参考情報 1-1.)での具体例は下記です。

// .value はStateモナドに関係するものでなく、
// runA()で得られるEvalモナドに保持されている値を取り出していることに注意。
// CatsではStateモナドは高度な抽象の具象として定義がされており、その実装の都合で、
// 状態アクションの実行結果が Eval[(S, A)]で取得されるために、さらにEval.valueを呼び出して
// Aを取得する必要があるため。
// https://github.com/typelevel/cats/blob/main/core/src/main/scala/cats/data/package.scala#L79C40-L79C40
// type State[S, A] = StateT[Eval, S, A]
// 状態アクションの実行結果自体は State.runA()の時点で取得できている。
val robot = createRobot.runA(initialSeed).value
// =>
// robot: Robot = Robot(
//   id = 13L,
//   sentient = false,
//   name = "Catherine",
//   model = "replicant"
// )

結び

私の場合、上記のように、Stateモナドちゃんとお友達になるまでにたくさんの躓きがあり難儀しましたが、年に1回くらいのペースでのんびりやっていたら、ふと理解の時がやってきました。

OptionやFutureに比べると難しい概念だと思うので、もし初めてのチャレンジで躓いてもあまり気にせず、忘れた頃にまたアタックしてればそのうち分かるさ。
そんなふうに構えるくらいでちょうど良いのかもしれません。

最後まで読んでくださりありがとうございました。
Stateモナドや関数型のアプローチを面白そう!と感じた方は、サンタさんに関数型関連の書籍をお願いしてワクワク過ごすのも楽しいかもですね。

それでは、良いクリスマス&良いお年を!

付録

当社はアドベントカレンダーの真っ最中であり、新しい記事が続々投稿される予定ですので、
ぜひD2C m-techアカウントのフォローを宜しくお願い致します!

また、D2Cグループではデータサイエンティスト、エンジニアの方々を募集しています。
ブログを読んでD2Cで働いてみたいと思った方、D2Cの業務に興味をもってくださった方がいらっしゃいましたら是非ご応募ください!

▼ D2Cグループ採用サイトはこちら
https://recruit.d2c.co.jp/

▼ D2C問い合わせフォームはこちら
https://www.d2c.co.jp/inquiryform/

D2C m-tech

Discussion