MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

Cats の関数覚え書き

はじめに

前回 Scala With Cats を読む前に知っておきたかったこと というタイトルで、Scala With Cats の輪読会に参加した際の感想などを書きました。

developers.microad.co.jp

その中で "今後やろうと思っていること" として挙げた「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 には本当にたくさんの型クラスやデータ型が定義されているのですが

ListOptionEitherなどは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です。MonoidKApplicativeを組み合わせた定義になっていますね。

この型クラスのインスタンスとしてはListOptionなどが定義されています。

unite

def unite[G[_], A](fga: F[G[A]])(implicit FM: Monad[F], G: Foldable[G]): F[A]

Alternativeに定義された関数のなかで、まず紹介するのはuniteです。

こちらはFAlternativeかつMonadで更にGFoldableである場合にF[G[A]]F[A]にしてくれる関数ということですね。

なんのこっちゃ・・・という感じなので具体例として、どちらの条件も満たしているListOptionの組み合わせでいろいろ挙動を見てみましょう。

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標準のflattenOption[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]) 

こちらの関数はFAlternativeかつMonadである場合でGBifoldableの時F[G[A, B]](F[A], F[B])に出来る関数です。

よくわからないと思うのでFListGEitherとして考えてみましょう。

その場合は、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
}

ちょっと仰々しいですがこの様な感じになるでしょうか。

foldLeftTuple2Listに対して追加する感じで処理を書いていくと思います。

もしくは今回はEitherですのでpartitionMapidentityを使えばシンプルに実装出来るでしょうか。

def apply: (List[String], List[Int]) = strOrInts.partitionMap(identity)

separate を使うと・・・

def apply: (List[String], List[Int]) = strOrInts.separate

最高ですね!Catsありがとう!!と言いたくなるくらいシンプルに記述することができました。

partitionMapを一般化したような存在がseparateであるということが出来るかもしれません。

separateFoldableというseparateFoldable版といった感じの関数も用意されています。

guard

def guard(condition: Boolean): F[Unit]

こちらの関数は、なかなか特殊な感じがしますがBooleanに対して使う事ができる関数のようです。

falseの場合はemptyが返りtrueの場合はunit(pure + void)が返るという挙動になります。

false.guard[Option]
// None
true.guard[Option]
// Some(())

Optionを指定した時はfalseの場合Optionemptyである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と同じようで少し違うuniteunzip,partitionMap(identity)を幅広く使えるようにしたようなseparateなど面白い関数が発見できたと思います。

Align

つづいてはAlignです。こちらは docs を読むと「zippingをしてIor型にできるようなクラスだよ」みたいなことが書いてあります。

IorというのはLeftRightBothという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引数を必要としない(Semigroupcombineを利用して足し合わせる)タイプの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]

alignTuple2[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とは逆の事ができる型クラスということなんでしょう。

型クラスのインスタンスとしてはListOptionなどが挙げられます。どんな関数があるんでしょうか。

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)

ListcoflatMapの定義をみてみると、

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のような関数が存在する
  • その関数を使ってコンテキストに包まれたままの状態で関数を実行したい

という時に使うケースが出てくるかなと思います。

OptioncoflatMapの実装は以下の様になっています。

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を使用した広告配信システムの開発にチャレンジしたいという仲間を募集しています。

また、サーバサイドエンジニアだけでなく機械学習エンジニア、フロントエンジニア、インフラエンジニアなど幅広く募集しています。気になった方は以下からご応募いただければと思います。

recruit.microad.co.jp