Lambdaカクテル

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

Invite link for Scalaわいわいランド

ScalaのCatsにおけるTuple上でのmapやproductの振舞いが面白いので紹介する

ScalaでプログラミングをしているとTuple2にお世話になることが多い。例えばSeq[(K, V)]からMap[K, V]を得ることができるし、何かと応用範囲が広い。catsでも、Tuple2をうまく操作するための数々のインスタンスが用意されている。

そんな中、Tuple2のインスタンスを眺めていると面白い記述があった。なんとTuple2でもmapできるのだ。

github.com

override def map[A, B](fa: (X, A))(f: A => B): (X, B) = (fa._1, f(fa._2))
/* 略 */
override def productR[A, B](a: (X, A))(b: (X, B)): (X, B) =
    (s.combine(a._1, b._1), b._2)
/* 略 */
override def productL[A, B](a: (X, A))(b: (X, B)): (X, A) =
    (s.combine(a._1, b._1), a._2)
/* 略 */
def flatMap[A, B](fa: (X, A))(f: A => (X, B)): (X, B) = {
    val xb = f(fa._2)
    val x = s.combine(fa._1, xb._1)
    (x, xb._2)
  }

なんだか独特な記述になっているのがわかる。というのも、mapは関数を右側にしか適用しないし、productL/productRは逆に左側の要素をcombineしている。flatMapも同様だ。どうしてこのような定義になっているのだろう?

Eitherとのアナロジー

そもそもTuple2は2つの型引数を取る型で、おおまかにいえばEither[L, R]の親戚だといえる。そう考えると、Eithermapがright projectionであるように、Tuple2もright projectionである、つまり右側の値に関数が適用される、と考えるのは自然に思える。左側の値に関数を適用する必要があればswapすればよいのだ。 State[S, A]Reader[E, A]も、「mapされうるもの」は右側に置かれている。そういえば、Map[K, V]も右側だけmapされるんだった!これは盲点だった。

import cats.implicits._
import cats._

val l: Either[Int, String] = Left(42)
val r: Either[Int, String] = Right("foo")

l.map(_ * 2) // => Left(42)
r.map(_ * 2) // => Right("foofoo")

val t: (Int, String) = (42, "foo")
t.map(_ * 2) // => (42, "foofoo")

Listと違って、EitherTuple2では左右の値の型が等しい保証は無いので、どちらかにしかmapを定めることはできない。

ちなみにEitherにもTuple2にもbimapのインスタンスが存在するため、このようなことが可能:

val f = (x: Int) => x + 1
val g = (s: String) => s * 2

l.bimap(f, g) // => Left(43)
r.bimap(f, g) // => Right("foofoo")
t.bimap(f, g) // => (43, "foofoo")

productL/ProductR

2つある型引数の両方がSemigroupであるとき、Tuple2同士のSemigroupが導かれる。Catsはそのようなインスタンスを自動的に用意してくれる:

val u: (Int, String) = (10, "bar")
t |+| u // => (52, "foobar")

これと似ているがどちらかといえばApplicativeの系譜の操作として、productLproductRとが用意されており、それぞれ<**> というエイリアスが存在する*1:

t <* u // (52, "foo")
t *> u // (52, "bar")

やや独特な挙動になった。productL(l._1 |+| r._1, l._2)を、productR(l._1 |+| r._1, r._2)を返す。こちらは、Tuple2の第1要素がcombineで合成されて、第2要素はcombineされずに左右のどちらかを選ぶことになる。

productLのscaladocには以下のように記されている:

Compose two actions, discarding any value produced by the second. https://typelevel.org/cats/api/cats/Apply.html#productL%5BA,B%5D(fa:F%5BA%5D)(fb:F%5BB%5D):F%5BA%5D

ふーむ。Applicativeの文脈においては、第1要素こそが一級市民で、第2要素のことはどうでもいいようだ。mapのときとは逆だ。

ちなみに単体のproductも用意されている:

val fm = implicitly[FlatMap[(Int, *)]]
fm.product(t, u) // => (52, ("foo", "bar"))

Tuple3以降

ちなみにTuple3以降では、Tuple2のときのものを引き継いだような挙動になる。

val triple = (42, "foo", true)
val triple2 = (10, "bar", false)

triple.map(_.toString) // => (42, "foo", "true")
triple <* triple2 // => (52, "foobar", true)
triple *> triple2 // => (52, "foobar", false)

特にproductLproductRが面白くて、最後の要素以外はcombineするような挙動になった。 見方を変えると、最後の要素こそがproductL/productRの「選択する」という最も重要な作用を受ける一級市民であり、それ以外は単に結合されるのだという見方をすれば、mapが最後の要素にのみ適用されることと整合性が取れる。productL/productRの主な役割は、combineすることではなく選択することだったのだ。

どうしてこのような振舞いが必要かというと、ApplyFunctorの型に適合させるためには型引数を1つに絞る必要があるため、他の型を固定する必要があるからだと思われる。Tupleを「nつの組」として見るのではなく、「ある型Aに追加で補助的な型がいくつか生えたもの」として見ると理解しやすいと思う。

まとめると

catsのTupleにおけるmapなどのメソッドの振舞いは以下のように整理できる。

  • map
    • 役割: 関数によって値を写す
    • Tuple上での振舞い: 最後の要素(n-Tupleであればn番目の要素)を関数によって写す。それ以外の要素はなにもしない
  • product
    • 役割: 2つのものを合成し、組を作る
    • Tuple上での振舞い: 最後の要素を組にする。それ以外の要素は、Semigroup上のcombineで結合する
  • productL / productR
    • 役割: 2つのものを合成しつつ、左右のうちどちらかを選択する
    • Tuple上での振舞い: 最後の要素のうち左右のいずれかを選択する。それ以外の要素は、product同様

productLとかは、それなりに使い所があるかもしれない。

普段からcatsのインスタンスを眺めていると、面白かったり便利そうなものが生えていたりするので、おすすめだ。

*1:Tuple2はFlatMapのインスタンスであり、そしてFlatMapはApplyを継承している

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