typelevel/cats を 2.10 から 2.11にアップデートする際の注意点 (traverse編)

cats を最新にアップデートする際にハマった点を書きます*1

何が変わったのか

https://github.com/typelevel/cats/pull/4498 により List(1,2,3).traverse(i => IO(i)) の実装が List(1,2,3).map(i => IO(i)).sequence と同じ評価順になりました。(ちょっと雑な解説なので詳しくはPRを参照)

どう違うのか

  • before: 先頭要素のIOを作成 => 評価 => エラーじゃなければ次要素のIOを作成 =>評価
  • after: 各要素のIOを作成 => 先頭を評価 => エラーじゃなければ次要素のIOを評価

何が違うのか

IOインスタンスを作成する際に副作用が入っていると挙動が微妙に変わります。

副作用がなければ全く同じ挙動。IO作成時に副作用起こす人なんて居ないですよね!

困る例1: mockのcall countは副作用だよ編

以下はmockitoを使っている場合の例です

// test
when(barMock.run(any)).thenReturn(IO.raiseError(...)) // mockでエラーを返す異常系のテスト

// main
fooList.traverse  { foo =>
  for {
     result <- barMock.run(foo) <----- ここは今まで1回しか呼ばれなかったけどN回呼ばれる。(コールをカウントするのは副作用)
                                       なお1回目のrunの返り値はIO(エラー) なので、mock的にはN回呼ばれてIOインスタンスがN個作成されるものの2番目以降のIOインスタンスは実際には評価されない(中のIOが実行されることはない)
     ...
  } yield result
}

ちなみに今回の事象は traverse内の先頭のIO作成が副作用をもつ場合(今回はmockによるコールカウント)でのみ起こります。(for式の先頭IOしか作成されないので、mockがfor式の2番目とかであればflatMapにつつまれてIOインスタンスの作成が先頭IOの評価後に延期されるため挙動は今までと変わらない)

回避例

fooList.traverse  { foo =>
  for {
     _      <- IO.unit          <-- このインスタンスがN個作られるだけでその次の行は呼ばれない
     result <- barMock.run(foo) <----- List1要素目のIOチェーンを評価したタイミングでエラーになるため、2要素目のIOチェーンはインスタンスは作成されるものの評価されることはない = barMock.runがN回呼ばれることはない
     ...
  } yield result
}

無駄に IO.unit を入れるのは微妙なのでログを追加するのもいいでしょう。(なおそのログを消すと何故かテストが落ちる爆弾になるのでコメント必須) 現実的にはMockの検証を緩めて上げるのもありなのではと思います。

困る例2: 連番idのgenerateは副作用だよ編

今度はこちら側の書き方が悪かった例です。

// test
val ids = (1 to 100)
when(mockBarIdGenerator.generate()).thenReturn(ids.head, ids.tail: _*) // 呼ばれるたびに連番を返すようmock設定

// main
fooList.traverse { foo =>
  for {
    bar <- createBarIO(barIdGenerator.generate())          // A  <-- IO[Bar]のインスタンスがN回呼ばれるようになった。当然引数が評価されるので `barIdGenerator.generate()` がN回呼ばれる 。
    ...
    barId <- barIdGenerator.generate()                     // B <- 昔は A => B => A => B... と呼ばれていた。update後は A => A => ... => B => B => ... となる
    ...
  } yield bar

回避例

そもそも状態持つなら barIdGeneartor.generate の返り値の型を BarId ではなく IO[BarId] にすべきで、それをしていないのが敗因(例外が出ないとサボりがち...)

fooList.traverse { foo
  for {
    barId <- IO.delay(barIdGenerator.generate())       <-- IO.delayは `=> A` を受け取るので引数が評価されるのはIO作成時ではなく評価時。 
    bar    <- createBarIO(barId)                       <-- これで挙動は今までと同じになる
    ...
  } yield bar

困る例というか直しているときにハマった例

困る例1を直しているときに

fooList.traverse  { foo =>
     IO.unit *> barMock.run(foo)
}

のように修正したのですが、def *>[B](another: IO[B]): IO[B] なので何の意味もなかったです。(IOインスタンスが作成されないようにしたいのに引数評価時に作成されてしまう) 普通はIOインスタンスが作成されたところで副作用なんてないに決まっているのだからそこまで気にしなくて良いところではあります。 IO.defer(barMock.run(foo)) とかしてあげても良いと思いますが、やはりtestのためにmain側に手を加える(しかも一件するとdeferする意味がわかりにくい)のが微妙なようなありなような、、というところです

終わりに

いまさらMay 28, 2024のバージョン(Release v2.11.0 · typelevel/cats · GitHub) に上げとるんかいなというツッコミどころはありますが誰かの役に立てば幸いです。

*1:ハマったのは2.10から2.11の変更点なので執筆時点の最新2.13の問題ではありません