ScalaでプログラミングをしているとTuple2
にお世話になることが多い。例えばSeq[(K, V)]
からMap[K, V]
を得ることができるし、何かと応用範囲が広い。catsでも、Tuple2
をうまく操作するための数々のインスタンスが用意されている。
そんな中、Tuple2
のインスタンスを眺めていると面白い記述があった。なんとTuple2
でもmap
できるのだ。
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]
の親戚だといえる。そう考えると、Either
のmap
が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
と違って、Either
やTuple2
では左右の値の型が等しい保証は無いので、どちらかにしか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
の系譜の操作として、productL
とproductR
とが用意されており、それぞれ<*
と *>
というエイリアスが存在する*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)
特にproductL
とproductR
が面白くて、最後の要素以外はcombine
するような挙動になった。
見方を変えると、最後の要素こそがproductL
/productR
の「選択する」という最も重要な作用を受ける一級市民であり、それ以外は単に結合されるのだという見方をすれば、map
が最後の要素にのみ適用されることと整合性が取れる。productL
/productR
の主な役割は、combineすることではなく選択することだったのだ。
どうしてこのような振舞いが必要かというと、Apply
やFunctor
の型に適合させるためには型引数を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を継承している