はじめに
前回 Scala With Cats を読む前に知っておきたかったこと というタイトルで、Scala With Cats の輪読会に参加した際の感想などを書きました。
その中で "今後やろうと思っていること" として挙げた「Cats で実装されている型クラスについて勉強すること」についてチームメンバーへの共有も兼ねて書いていこうと思います。
現在開催中の MicroAd Advent Calendar 2021 にも参加しており、そこでは以下の型クラスに実装されている関数について紹介しています(する予定のものも含めています)。
- flatMap とそれに連なる型クラス
- Functor
- Bifunctor
- FunctorFilter
- Apply
- Applicative
- FlatMap
- エラー処理に関する型クラス
- ApplicativeError
- MonadError
- cats.kernel 系
- Eq
- Order
- Semigroup
- (SemigroupK)
- Monoid
- (MonoidK)
- traverse とそれに連なる型クラス
- Foldable
- Bifoldable
- Reducible
- Traverse
- Bitraverse
- TraverseFilter
Cats には本当にたくさんの型クラスやデータ型が定義されているのですが
「List
、Option
、Either
などはCats
だとどういう関数が生えてるんだろうなぁ」というのを把握するためには、これ以外では
- Alternative
- Align
- CoflatMap
の3つの型クラスに定義されている関数を知っておけば、おおよそ良さそうに思っています。
本記事では、その3つの型クラスの関数について見ていきたいと思います。
説明について
今回の関数を説明する上での注意点などを挙げていきます。
今回使用する cats, scala のバージョン
- scala 2.13.1
- cats 2.6.1
syntax をなるべく使う
Cats
では関数の実装(定義)とは別に、それをスマートに使うための syntax が用意されています。
例えばFoldable::combineAll
という関数を使う場合 syntax を使わないで書くと、
Foldable[List].combineAll(List(1, 2, 3)) // 6
この様に書く必要がありますが syntax を使って書くと
List(1, 2, 3).combineAll // 6
この様に書くことが出来ます。こちらのほうが自然ですよね。
実際にコーディングをする場合はこちらを使うことになると思いますので、実践的な意味で syntax を使える場合は使ってコード例を示していこうと思います。
Alternative
trait Alternative[F[_]] extends Applicative[F] with MonoidK[F]
今回最初に紹介する型クラスはAlternative
です。MonoidK
とApplicative
を組み合わせた定義になっていますね。
この型クラスのインスタンスとしてはList
、Option
などが定義されています。
unite
def unite[G[_], A](fga: F[G[A]])(implicit FM: Monad[F], G: Foldable[G]): F[A]
Alternative
に定義された関数のなかで、まず紹介するのはunite
です。
こちらはF
がAlternative
かつMonad
で更にG
がFoldable
である場合にF[G[A]]
をF[A]
にしてくれる関数ということですね。
なんのこっちゃ・・・という感じなので具体例として、どちらの条件も満たしているList
、Option
の組み合わせでいろいろ挙動を見てみましょう。
List(List(1, 2), List(2, 3)).unite // List(1, 2, 2, 3) List(1.some, none[Int], 2.some).unite // List(1, 2) 1.some.some.unite // Some(1) List(1, 2).some.unite // Some(1) none[List[Int]].unite // None List.empty[Int].some.unite // None
List[List[A]]
List[Option[A]]
Option[Option[A]]
に対してunite
を使った場合はflatten
を使った時と同じ様な挙動になりました。
興味深いのがOption[List[A]]
に対してunite
を使った場合です。
Scala
標準のflatten
はOption[List[A]]
に対しては使えないので比較は出来ないですがflatMap(_.headOption)
したような結果になっていますね。
FlatMap::flatten と Scala 標準の flatten との比較
ちなみにCats
に定義されたFlatMap::flatten
ではList[List[A]]
、Option[Option[A]]
はフラットに出来るのですが、
List[Option[A]]
やOption[List[A]]
をフラットにすることは出来ません。
FlatMap[List].flatten(List(1.some, none[Int], 2.some)) // コンパイルエラー FlatMap[List].flatten(List(List(1, 2), List(2, 3))) // List(1, 2, 2, 3) FlatMap[Option].flatten(1.some.some) // Some(1) FlatMap[Option].flatten(List(1, 2).some) // コンパイルエラー
flatten
する型は同一である必要があるからですね。
一方Scala
標準のflatten
ではList[List[A]]
、Option[Option[A]]
だけでなくList[Option[A]]
をフラットにすることが出来ます。
List(1.some, none[Int], 2.some).flatten // List(1, 2) List(List(1, 2), List(2, 3)).flatten // List(1, 2, 2, 3) 1.some.some.flatten // Some(1) List(1, 2).some.flatten // コンパイルエラー
この挙動の差は面白いですね。また、今回の例がたまたまOption
を使っているものなので、ついでに比較すると、
List[Option[A]]
の場合の処理はFunctorFilter::flattenOption
を使うと、同じ目的を果たせると思います。
List(1.some, none[Int], 2.some).flattenOption // List(1, 2)
いまのところ個人的には、
F[F[A]]
の場合はflatten
F[G[A]]
の場合はunite
- ただし
F[Option[A]]
の時はflattenOption
- ただし
という感じで使い分けようかなと思いました。
separate
def separate[G[_, _], A, B](fgab: F[G[A, B]])(implicit FM: Monad[F], G: Bifoldable[G]): (F[A], F[B])
こちらの関数はF
がAlternative
かつMonad
である場合でG
がBifoldable
の時F[G[A, B]]
を(F[A], F[B])
に出来る関数です。
よくわからないと思うのでF
をList
、G
をEither
として考えてみましょう。
その場合は、List[Either[A, B]]
を(List[A], List[B])
に出来る関数ということになります。
Either
のリストをLeft
をまとめたものとRight
をまとめたものに分けてくれる関数なんですね。出来ることがわかりやすくなりました。
さて、以下のようなコードがある場合、
val strOrInts: List[Either[String, Int]] = List(1.asRight[String], "test".asLeft[Int], 2.asRight[String], "test2".asLeft[Int]) def apply: (List[String], List[Int]) = ???
関数apply
を実装することを考えてみましょう。
separate を使わないと・・・
まずはseparate
を使わない場合どういう実装になるか考えてみましょう。
def apply: (List[String], List[Int]) = { val (strings, ints) = strOrInts.foldLeft(List.empty[String] -> List.empty[Int]) { case ((strings, ints), strOrInt) => strOrInt match { case Left(str) => (str :: strings) -> ints case Right(i) => strings -> (i :: ints) } } strings.reverse -> ints.reverse }
ちょっと仰々しいですがこの様な感じになるでしょうか。
foldLeft
でTuple2
なList
に対して追加する感じで処理を書いていくと思います。
もしくは今回はEither
ですのでpartitionMap
とidentity
を使えばシンプルに実装出来るでしょうか。
def apply: (List[String], List[Int]) = strOrInts.partitionMap(identity)
separate を使うと・・・
def apply: (List[String], List[Int]) = strOrInts.separate
最高ですね!Cats
ありがとう!!と言いたくなるくらいシンプルに記述することができました。
partitionMap
を一般化したような存在がseparate
であるということが出来るかもしれません。
separateFoldable
というseparate
のFoldable
版といった感じの関数も用意されています。
guard
def guard(condition: Boolean): F[Unit]
こちらの関数は、なかなか特殊な感じがしますがBoolean
に対して使う事ができる関数のようです。
false
の場合はempty
が返りtrue
の場合はunit(pure + void)
が返るという挙動になります。
false.guard[Option] // None true.guard[Option] // Some(())
Option
を指定した時はfalse
の場合Option
のempty
であるNone
が返りtrue
の場合はSome(Unit)
が返ってきていますね。
どういう時に役に立つんでしょうか。guard
という名前から連想すると、for 式の途中でガード(処理を落とす)のが目的にように感じます。
guard を使わないと・・・
まず、for 式の途中でガードをする場合、
def ints: List[Int] = List(1, 2, 3) for { i <- ints if i % 2 == 0 } yield i
この様に if を使ってガードをすると思います。
guard を使うと・・・
def ints: List[Int] = ??? for { i <- ints _ <- (i % 2 == 0).guard[List] } yield i
この様に、同様の処理を書くことが出来ます。if を使った方がシンプルに思えるのですが、そうでない場面があるのでしょうか。
Alternative の関数の感想
Alternative
の関数をいくつか見てきました。flatten
と同じようで少し違うunite
、unzip
,partitionMap(identity)
を幅広く使えるようにしたようなseparate
など面白い関数が発見できたと思います。
Align
つづいてはAlign
です。こちらは docs を読むと「zipping
をしてIor
型にできるようなクラスだよ」みたいなことが書いてあります。
Ior
というのはLeft
、Right
、Both
という3つの状態を持つデータ型ですので、
長さが違うリストを情報を保持しつつzip
できるやつなのかなぁと予想することが出来ますね。
align
def align[A, B](fa: F[A], fb: F[B]): F[Ior[A, B]]
まずは、型クラス名と同名の関数align
です。F[A]
とF[B]
を渡すとF[Ior[A, B]]
になる、という関数のようです。
List
を使った場合の挙動を見ていきましょう。
List(1, 2, 3).align(List("a", "b", "c")) // List(Both(1,a), Both(2,b), Both(3,c)) List(1, 2, 3).align(List("a", "b", "c", "d")) // List(Both(1,a), Both(2,b), Both(3,c), Right(d)) List(1, 2, 3, 4).align(List("a", "b", "c")) // List(Both(1,a), Both(2,b), Both(3,c), Left(4))
長さが同じリストをalign
すると、全部の要素がBoth
としてzipping
されて、そうでない場合は長い分だけLeft
もしくはRight
として(片側だけ)存在する形になっています。
Option
を使った場合の挙動はどのようになるでしょうか。
1.some.align(2.some) // Some(Both(1,2)) 1.some.align(none) // Some(Left(1)) none.align(2.some) // Some(Right(2))
どちらもSome
の場合はBoth
としてzipping
され、片側しかSome
でない場合はSome
の方の側がLeft
もしくはRight
として存在する形になります。
alignWith
def alignWith[A, B, C](fa: F[A], fb: F[B])(f: Ior[A, B] => C): F[C]
続いてはalignWith
です。こちらはalign
で行ったzipping
処理に加え、その結果に対するmapping
も同時に行えるような関数になっています。
具体的な例で考えてみましょう。言葉のリストと、言葉に対する繰り返し数があるとして、対応する数だけ繰り返した文字列としてmap
したいとしましょう。
val words: List[String] = List("apple", "baby", "car") val repeatCounts: List[Int] = List(3, 5)
その時リストの長さがどっちが長いかわからないので、
- 言葉に対する繰り返す数が定義されていない場合(
repeatCounts
のほうが短い場合)は"not found repeat-counts"
を出力する。 - 繰り返し数に対する言葉が定義されていない場合(
words
のほうが短い場合)は"not found word"
を出力する。
ということがしたいとしましょう。
alignWith を使わないと・・・
まずalignWith
を使わない場合を考えてみましょう。
words .zipAll(repeatCounts, "not found word", 0) .map { case (_, 0) => "not found repeat-counts" case ("not found word", _) => "not found word" case (str, repeatCount) => str * repeatCount }
zipAll
したあとでmap
を行う感じで実装をしてみましたがどうでしょうか。
穴埋めする値に要件バグがありそうな感じがビンビンしますが、一応期待通りの挙動になりそうですかね。
alignWith を使うと・・・
次にalignWith
を使う場合を考えてみましょう。
words.alignWith(repeatCounts) { case Ior.Left(_) => "not found repeat-counts" case Ior.Right(_) => "not found word" case Ior.Both(str, repeatCount) => str * repeatCount }
alignWith
を使うと、第2引数でパターンマッチを行うことが出来て、シンプルでわかりやすいコードになったと思います。
alignMergeWith
def alignMergeWith[A](fa1: F[A], fa2: F[A])(f: (A, A) => A): F[A]
続いてはalignMergeWith
です。こちらは 2 つのF[A]
とそれをマージする関数を受け取ってF[A]
にする関数のようです。
List
の挙動を見てみましょう。
List(1, 2, 3).alignMergeWith(List(10, 20, 30))(_ - _) // List(-9, -18, -27) List(1, 2, 3, 4).alignMergeWith(List(10, 20, 30))(_ - _) // List(-9, -18, -27, 4) List(1, 2, 3).alignMergeWith(List(10, 20, 30, 40))(_ - _) // List(-9, -18, -27, 40)
サイズが一致している場合の処理はzip
+ map
と変わらない使い勝手だと思いますが、
サイズが一致していない場合の処理がAlign
ならではなものになっていて、値が計算されずにそのまま挿入されていることがわかると思います。面白いですね。
次はOption
の挙動を見てみましょう。
1.some.alignMergeWith(2.some)(_ - _) // Some(-1) none[Int].alignMergeWith(2.some)(_ - _) // Some(2) 1.some.alignMergeWith(none)(_ - _) // Some(1)
List
の挙動から想像できるような挙動になっていると思います。
なんだかmap2
関数と挙動が似ていますが、片方がNone
だった場合の挙動が違うのでピンポイントで使いたいシーンがでてくるかもしれません。
Align
には、要素がSemigroup
の場合に第2引数を必要としない(Semigroup
のcombine
を利用して足し合わせる)タイプのalignCombine
という関数も存在します。
def alignCombine[A: Semigroup](fa1: F[A], fa2: F[A]): F[A]
面白いですね。
padZip
def padZip[A, B](fa: F[A], fb: F[B]): F[(Option[A], Option[B])]
続いてはpadZip
です。F[A]
とF[B]
をzipping
してTuple2
にするのですが、サイズが違う場合には足りない要素はNone
になる関数のようです。
List
の場合の挙動を見てみましょう。
List(1, 2, 3).padZip(List("a", "b", "c")) // List((Some(1),Some(a)), (Some(2),Some(b)), (Some(3),Some(c))) List(1, 2, 3, 4).padZip(List("a", "b", "c")) // List((Some(1),Some(a)), (Some(2),Some(b)), (Some(3),Some(c)), (Some(4),None)) List(1, 2, 3).padZip(List("a", "b", "c", "d")) // List((Some(1),Some(a)), (Some(2),Some(b)), (Some(3),Some(c)), (None,Some(d)))
align
ではIor
の3つの状態でサイズが違う場合の状態を実現していますがpadZip
ではTuple2[Option[A], Option[B]]
で表現しているといった感じでしょうか。
後続処理のやりやすさでどちらの関数を使うかチョイスをすれば良さそうですね。
padZipWith
def padZipWith[A, B, C](fa: F[A], fb: F[B])(f: (Option[A], Option[B]) => C): F[C]
align
のTuple2[Option[A], Option[B]]
版がpadZip
だとするとalignWith
に対するTuple2[Option[A], Option[B]]
版がpadZipWith
といったところでしょうか。
padZip
で行ったzipping
処理に加え、その結果に対するmapping
も同時に行えるような関数になっています。
先ほどのalignWith
で使った例を今回も使ってみましょう。
val words: List[String] = List("apple", "baby", "car") val repeatCounts: List[Int] = List(3, 5)
こちらを先ほどと同じ様に、リストの長さがどっちが長いかわからないので、
- 言葉に対する繰り返す数が定義されていない場合(
repeatCounts
のほうが短い場合)は"not found repeat-counts"
を出力する。 - 繰り返し数に対する言葉が定義されていない場合(
words
のほうが短い場合)は、"not found word"
を出力する。
ということがしたいとしましょう。alignWith
では以下のように実装しました。
words.alignWith(repeatCounts) { case Ior.Left(_) => "not found repeat-counts" case Ior.Right(_) => "not found word" case Ior.Both(str, repeatCount) => str * repeatCount }
padZipWith
を使うと、どの様になるでしょうか。
words.padZipWith(repeatCounts) { case (Some(str), Some(repeatCount)) => str * repeatCount case (Some(_), None) => "not found repeat-counts" case _ => "not found word" }
実装上(None, None)
にはならないですが、パターンマッチとしては気になるので、実質(None, Some(_))
のケースを表すために_
を使っているのが少し残念な気持ちになりますね。
どういう時にalignWith
ではなくpadZipWith
を使いたい、となるんでしょうか。
Align の関数の感想
Align
に実装されているzipping
が便利になるような関数をいくつか見てきました。
標準のScala
を使っていると、
zip
関数を使ってあまりを切り捨てるzipAll
関数で要素を追加して長さをあわせる
これらの上記2パターンから選択して実装をしていたと思いますが、Align
を使える状況だと、
- 足りない部分は、足りない状態として保持したまま
zipping
する
という選択肢が選びやすくなったのは、良いことなんだろうなと思います。
CoflatMap
flatMap
に「双対」を表す Co が付いているこの型クラスですが、個人的には全く馴染みがありません。FlatMap
とは逆の事ができる型クラスということなんでしょう。
型クラスのインスタンスとしてはList
、Option
などが挙げられます。どんな関数があるんでしょうか。
coflatten
def coflatten[A](fa: F[A]): F[F[A]]
まずはcoflatten
です。
FlatMap
にはflatten
というF[F[A]]
をF[A]
にする関数がありますが、CoflatMap
にはcoflatten
という関数があるんですね。
F[F[A]]
をF[A]
にするflatten
とは逆に、F[A]
をF[F[A]]
にする関数のようです。
どんな挙動になるのか、具体的な型で確認しましょう。
List の場合
List
の場合はList[A]
をList[List[A]]
にできる関数ということになりますね。
List(1, 2, 3).coflatten // List(List(1, 2, 3), List(2, 3), List(3)) List.empty[Int].coflatten // List()
tails
+ init
と同じ様な挙動になりました。
List(1, 2, 3).tails.toList.init // List(List(1, 2, 3), List(2, 3), List(3)) List.empty[Int].tails.toList.init // List()
「tails
したいけど、空のリストいらないんだよなぁ」と思ってinit
をしたことが何度かあるので、そういう時に役に立つかもしれません。
Option の場合
続いてOption
での挙動をみてみましょう。
"test".some.coflatten // Some(Some(test)) none[String].coflatten // None
Some
の時は更にSome
で包まれてNone
の時は何もしない、という挙動になっているんですね。どういう時に使うか想像が出来ません。。
coflatMap
def coflatMap[A, B](fa: F[A])(f: F[A] => B): F[B]
続いてはcoflatMap
です。
型クラスと同名のこの関数ですがFlatMap::flatMap
と比べて受け取る関数のコンテキストの包まれ方が逆になっています。
def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B]
簡単な例で挙動を確認してみましょう。
List の場合
List
の場合は受取る関数の型はList[A] => B
になるのでList::sum
を使っていこうと思います。
List(1, 2, 3).coflatMap(_.sum) // List(6, 5, 3)
この例と同じ様なコードをCats
を使わずに書こうとするとcoflatten
と同じ様にtails
+ init
を使った上でmap
をすると、同じような処理になるでしょうか
List(1, 2, 3).tails.toList.init.map(_.sum) // List(6, 5, 3)
List
のcoflatMap
の定義をみてみると、
def coflatMap[A, B](fa: List[A])(f: List[A] => B): List[B] = { @tailrec def loop(buf: ListBuffer[B], as: List[A]): List[B] = as match { case Nil => buf.toList case _ :: rest => loop(buf += f(as), rest) } loop(ListBuffer.empty[B], fa) }
tails
(ただしNil
はない)を取得しつつ関数を当てていく様な挙動になっていますね。
今回使ったsum
のようにリストの要素を全部なめるような処理を書きたい場合は、他の実現方法に比べて全体の走査回数が多くなりそうな気がするのですが、現実的な実装として使うシーンがあるんでしょうか。
どうなんでしょう。
Option の場合
続いてはOption
の場合の挙動をみていきましょう。
Option
の場合はOption[A] => B
という関数が必要になりますので、以下のような関数を定義します。
def lengthOf(maybeString: Option[String]): Int = maybeString.map(_.length).getOrElse(0)
文字列が存在する場合はその文字数を、存在しない場合は 0 を返却する関数ですね。こちらを使ってcoflatMap
の挙動を確認していこうと思います。
"test".some.coflatMap(lengthOf) // Some(4) none[String].coflatMap(lengthOf) // None
Some
の場合だけ処理された結果がコンテキストに包まれたままになっています。
lengthOf
の様な関数があった場合、素直に考えると
lengthOf("test".some) // 4 lengthOf(none[String]) // 0
という様にlengthOf
を直接使うケースの方が多いと思うのですが・・・。
None
の時は関数を処理したくない場合などに使うのでしょうか。同じ処理を実現するだけなら、
"test".some.map(_.length) // Some(4) none[String].map(_.length) // None
この様に、関数lengthOf
ではなくmap
+ length
で表現できてしまうのですが、
- もともと関数
lengthOf
のような関数が存在する - その関数を使ってコンテキストに包まれたままの状態で関数を実行したい
という時に使うケースが出てくるかなと思います。
Option
のcoflatMap
の実装は以下の様になっています。
def coflatMap[A, B](fa: Option[A])(f: Option[A] => B): Option[B] = if (fa.isDefined) Some(f(fa)) else None
挙動から想像した通りの実装になっていました。
CoflatMap の関数の感想
正直に書くとCoflatMap
(とComonad
)に関することを検索しても、私にとってはディープな記事しかでてこなくて全然理解できていません。
いつか理解できるようになればいいなと思いつつ今回はList
,Option
での挙動を追いかけるのみに留めました。
おわりに
今回は、
- Alternative
- Align
- CoflatMap
という3つの型クラスの関数について見ていきました。
MicroAd Advent Calendar 2021 で紹介する型クラスも含めると、List
,Option
,Either
などの Scala 標準で用意されているデータ型がインスタンスになっている型クラスはだいたい網羅出来たのではないかと思います。
Cats
(やCats Effect
)に定義されている型クラスやデータ型はまだまだありますので、またの機会に他の型クラスやデータ型の記事を公開できると良いなと思っています。
募集
マイクロアドでは Cats
,DDD
を使用した広告配信システムの開発にチャレンジしたいという仲間を募集しています。
また、サーバサイドエンジニアだけでなく機械学習エンジニア、フロントエンジニア、インフラエンジニアなど幅広く募集しています。気になった方は以下からご応募いただければと思います。