Lambdaカクテル

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

Invite link for Scalaわいわいランド

関数型プログラミング: map結果を引数とのペアにしたいときはStateが便利だったりする(StateTもあるよ)

この記事では、Scala 3と関数型ライブラリであるCatsを時折使いつつ、Stateモナドを利用することでmap結果にうるおいを与えられるという話題を紹介します。

プログラミング言語で最も頻繁に使われるデータ構造といえば、タプル、とりわけ2つの要素を持つタプルであるペアかもしれない。

【タプルとは?】

タプルとは、いくつかの型を並べて組にし、1つの型として扱えるようにしたものです。例えば、StringIntをくっつけた(String, Int)というタプルを考えることができます。 タプルを構成する型の数によって、3-タプル、5-タプルといった呼び方をします。特に2-タプルのことをダブルとかペアと呼びます。

例えば、2次元座標上の一点を表現するために(Double, Double)というペアを使うことができますね。そして、その座標をさらにペアにするとベクトルを作ることができます。

関数型言語の愛好家でなくても、様々な言語でペアを使った処理が行なわれる。

例えば我々が大好きな辞書構造は、辞書のキーと値とのペアが、(内部構造を抜きにして考えれば)リストをなしているもの、として理解することもできる。

Map[K, V] ≒ List[(K, V)]

実際に、このような変換をサポートするプログラミング言語も存在する。例えばScalaでは、ペアのリストをMapに変換できる:

// Scalaで、x -> yは(x, y)のエイリアス
scala> val xs = Seq("foo" -> 42, "bar" -> 100)
val xs: Seq[(String, Int)] = List((foo,42), (bar,100))

scala> xs.toMap
val res0: Map[String, Int] = Map(foo -> 42, bar -> 100)

このように、ペアは2つの型を組にして1つにするという非常にシンプルな構造でありながら、またその柔軟さゆえに、様々な用途で利用されている。

よくある処理: mapしてからペアにする

ペアが登場する非常によくある処理の一例として、あるリストを処理した結果を、処理前のデータから引ける辞書構造にする、というものがある。

例えば、IDをもとにデータベースにアクセスし、その結果を「IDをキーとした辞書構造」にまとめる、という処理は誰もがやったことがあると思う:

// DBからユーザ情報を取得するメソッド
def findUserById(id: Int): User = ???

// ユーザID
val xs: List[Int] = List(1, 2, 3, 4, 5)

// 単純にmapするとUserのリストになるが……
xs.map(findUserById) // => List(User(...), ...)

// idとペアにしながらmapすると便利
xs.map(id => id -> findUserById(id)) // => List(1 -> User(...), 2 -> User(...), ...)
// Mapにもできる
xs.map(id => id -> findUserById(id)).toMap // => Map(...)

辞書にしておくことで後からさらに加工しやすくなって便利だ。

まあまあよくある: メソッドはたまに値返さないことがある

DBが常に値を返すとは限らない。IDに対応するレコードがないかもしれないし、DBの具合が悪いかもしれない。

findUserByIdの返り値の型をOption[User]にして、現実により近づけてみよう:

// DBからユーザ情報を取得するメソッド
def findUserById(id: Int): Option[User] = ???

// ユーザID
val xs: List[Int] = List(1, 2, 3, 4, 5)

xs.map(findUserById) // => List(Some(User(...)), None, ...)

// idとペアにしながらmapすると便利
val fxs = xs.map(id => id -> findUserById(id)) // => List(1 -> Some(User(...)), 2 -> None, ...)

しかしこれではfxsの型がList[(Int, Option[User])]という微妙に使いづらい型になってしまった。なんとかならないだろうか?

アドホックなパッチとして、Option型にはmapメソッドが生えているので、値があるときだけペアになるように修正できる:

val fxs = xs.map(id => findUserById(id).map(id -> _)) // => List(Some(1 -> User(...)), None, ...)

とりあえず動作する形にはできたが、何がどうなってるのか分かりづらくなってしまった。やりたいことは入力と結果をペアにしたいだけなのに……。

まあまあよくある: メソッドが非同期の場合もある

(ここはIO型が分かっている人だけ読めばよいコーナー)

さっきはOption型を使っていたが、そもそもDBアクセスは副作用があって非同期に動作する可能性があるので、Cats EffectのIO型でラップされた結果を返すことがある:

import cats.effect.IO
def findUserById(id: Int): IO[User] = ???

この場合もアドホックな修正は同じで、map中でmapすることでそこそこ扱える形になる:

val fxs = xs.map(id => findUserById(id).map(id -> _)) // => List(IO(1 -> User(...)), IO(2 -> User(...)), ...)

しかしIOのリストは扱いづらいのでsequenceを使ってリストのIOに変換するのが定石だ。

fxs.sequence // => IO(List(...))

ちなみにmapしてsequenceするのはめちゃくちゃよくあるイディオムなので、traverseとだけ書けばよいようになっている:

val fxs = xs.traverse(id => findUserById(id).map(id -> _))

Stateモナド

さて、冒頭で「ペアは様々な用途で利用されている」と書いた。面白いことに、関数型プログラミングでおなじみのモナドにもペアは頻出する。

そのうちStateモナドは面白い特性を持っているのでこれから見ていこう。

Stateモナドは、state => (nextState, result)の形をとる関数、すなわち「状態を受け取り、処理結果と次の状態をペアにして返す」関数を、モナド則を満たすように構成したものである。

モナド則を満たすように構成する、つまりモナドにすると何が嬉しいかというと、逐次合成できるようになって嬉しい。

import cats.implicits._
import cats.data.State

val f = State { (n: Int) => (n + 1, n * 2) } // 関数をStateモナドにする

// Stateモナドはrunすることで状態遷移させることができる
// fに1を渡すと状態は2になり、結果は2になる
f.run(1).value // => (2, 2)

// fを3つつなげてみる
val fff = for {
  _ <- f
  _ <- f
  fff <- f
} yield fff

// fffに1を渡すと状態は4(初期状態1 + 1 + 1 + 1)になり、結果は6(直前の状態3 * 2)になる
fff.run(1).value // => (4, 6)

通常の関数をStateに持ち上げる

なにか別のものを、より便利な特定の型クラスにすることを持ち上げ(lift)と呼ぶ。Stateには、普通の関数をStateに持ち上げるためのメソッドがいくつか用意されている:

  • pure
    • 状態を無視して固定値を返すようなStateを作れる。
    • val s: State[Int, Int] = 42.pureと書くと、状態を無視してただ42を返す(s.run(1000).value === (1000, 42)になる)。
  • inspect
    • 状態を受け取り、何かを処理するが、状態を変更しないようなStateを作れる。
    • val s: State[Int, Int] = State.inspect(_ * 2)と書くと、s.run(1000).value === (1000, 2000)になる。
  • modify
    • 状態を受け取り、ただ状態を変更する。結果はUnitになる。
    • val s: State[Int, Unit] = State.modify[Int](_ * 2)と書くと、s.run(1000).value === (2000, ())になる。
  • apply
    • 状態を受け取り、次の状態と結果を返す。

ここで注目したいのがinspectで、渡す関数はstate => resultの形になっていればよい。そういえば、前の節でmapに渡していたのもこの形じゃなかったっけ?

Stateでmapする

ご賢察の通り、リストの各要素に対してStateを実行することができる。State自体はただのデータ構造なので、map可能な形にするにはrunを使えばよい。

val xs = List(1,2,3,4,5)
val double = (n: Int) => n * 2
xs.traverse(State.inspect(double).run).value // => List((1, 2), (2, 4), (3, 6), (4, 8), (5, 10))

ここではmapのかわりにtraverseしているが、これはrunがいったんEvalというデータ構造を返すためだ。List[Eval]は使いにくいので、traverseによってEval[List]を取り出し、最後に.valueで中身を取り出している。

State.inspectを使うことで、引数と返り値のペアの形で結果を得ることができた。これは便利だ。

StateT登場!!

といっても、今のところ普通に手でmapしたほうが手っ取り早いのである。

xs.map(x => x -> double(x)) // => List((1, 2), (2, 4), (3, 6), (4, 8), (5, 10))

このままではモナドがオワコンになってしまう。

しかしStateが威力を発揮するのは、他のモナドと組み合わせる必要が出てきたときである。以前の節で例示した、メソッドがOptionを返しうるパターンだとどうか。

// DBからユーザ情報を取得するメソッド
def findUserById(id: Int): Option[User] = ???
val xs = List(1,2,3,4,5)
val fxs = xs.map(id => findUserById(id).map(id -> _)) // => List(Some(1 -> User(...)), None, ...)

この例ではまだコードのサイズが肥大化せずに済んでいるが、産業コードではコードはどんどん大きくなって処理を追い掛けるのは大変になってしまう。

そこで、StateTを利用してみよう。

【モナドトランスフォーマー】

他のモナドと合体させて機能を追加したモナドを作れるような機構がある。これをモナドトランスフォーマーと呼ぶ。

モナドトランスフォーマーは、<元となったモナド>Tという名前をとるのが通例だ。

StateT.inspectFを使って元の関数をラップしよう。inspectFinspectと違って、返り値がなんらかのモナドになっていることを想定しているぞ。

  • State.inspectが取る関数: S => A
  • StateT.inspectFが取る関数: S => F[A]

例えば「偶数のときにだけ値を2倍するmaybeDouble」の場合、コードは以下のようになる:

import cats.data.StateT

val xs = List(1,2,3,4,5)

val maybeDouble = (n: Int) => n match {
  case nn if n % 2 == 0 => Some(n * 2)
  case nn => None
}

xs.map(StateT.inspectF(maybeDouble).run) // => List(None, Some((2,4)), None, Some((4,8)), None)

IOを利用する場合も、StateTはうまくいく:

import cats.effect.IO

val heavyDouble = (n: Int) => IO { n * 2 }

xs.traverse(StateT.inspectF(heavyDouble).run) // => IO(List((1,2), (2,4), (3,6), (4,8), (5,10)))

一般化する

ここまでは手でStateを作っていたが、拡張メソッドを使って綺麗にすることができる。

extension [A, F[_] : cats.Traverse, B](xs: F[A])
  def tapMap(f: A => B): F[(A, B)] = xs.traverse(State.inspect(f).run).value

extension [A, F[_] : cats.Functor, B](xs: F[A])
  def tapMapF[G[_] : cats.Monad](f: A => G[B]): F[G[(A, B)]] = xs.map(StateT.inspectF(f).run)

xs.tapMap(double) // => List((1, 2), (2, 4), (3, 6), (4, 8), (5, 10))
xs.tapMapF(maybeDouble) // => List(None, Some((2,4)), None, Some((4,8)), None)

この一般化の嬉しいところは、Listでなくても動作するところだ:

val ys: Either[String, Int] = Right(42)
ys.tapMapF(n => (n * 2).some) // => Right(Some((42, 84)))

まとめ

  • Stateはペア構造と密接に結び付いていることを確認した。
  • Stateの機能を使うことで、引数を再利用する処理をうまく書けることを確認した。
  • StateTの機能を使うことで、再利用結果がモナドでも柔軟に処理できることを示した。
★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?