はじめに
2021 年末に Cats の型クラスについての記事をマイクロアド社のアドベントカレンダーの企画に参加させていただいたり、技術ブログに寄稿させていただいたりしました。
Cats や Cats Effect にはまだまだ型クラスがたくさん定義されているのですが、データ型についても面白いものがたくさんありますので、主にチームメンバーが見てくれることを期待して、紹介していこうと思います。
上記の記事は、それぞれ分量が多すぎて読むのもしんどいレベルになってしまったなと感じているので、本記事ではライトに NonEmptyList についてのみ書いていこうと思います。
NonEmptyList を使う利点と、Semigroup として NonEmptyList(のファーストクラスコレクション)を扱った場合に使える関数について、を中心に書いていきます。
まず公式ページを読む
typelevel の cats の公式ページの NonEmptyList に関するページは以下になります。
めちゃくちゃざっくり要約すると、
- エラーを累積するようなデータ型(Validated, Ior)のときに使われるのがユースケースの一つ。なぜならエラーの場合は必ずエラーが一つ以上存在するから(進次郎構文みがある・・・)
- List 使ってると、例外処理とかそれを避けるときに Option になったりするのめんどくさいよね?的な話
- NonEmptyList のファクトリメソッドの紹介
というような内容だと思います。
NonEmptyList とは
NonEmptyList とはどんな存在かと言うと、もう名前の通りでしかありませんが empty が定義できないようになっている List なんですね。
final case class NonEmptyList[+A](head: A, tail: List[A])
head は必ず存在するので、empty になり得ないようになっているデータ構造になります。
ただそれだけ。
それだけなんですが、「if(isEmpty) みたいな考慮が要らなくなる」というのが結構いいなと思っています。結構いいなとみんな思うので、他の言語でもライブラリとして NonEmptyList があったりするのでしょう。
クラスの不変表明との違いなど
DDD というかオブジェクト指向っぽく設計する場合、ファーストクラスコレクションとして、List などのイテレータブルな基本型をラップすると思います。
全然詳しくないのですが、その際に「empty という状態を持てないようにする」というのを require などを使って表現したりできると思います。
case class Id(toInt: Int) extends AnyVal
case class Ids(toList: List[Id]) {
require(toList.nonEmpty)
}
これに対して、NonEmptyList を使ったファーストクラスコレクションの表現はどういうふうになるかと言うと、
case class Id(toInt: Int) extends AnyVal
case class Ids(toNel: NonEmptyList[Id]) extends AnyVal
こんな感じになると思います。
定義だけの違いで言うと、
- require を使うと、AnyVal にできない。
くらいの違いしかなさそうですね。
クラスの不変表明を利用する場合のコード
今度は、利用する側のコードを書いてみましょう。
case class Id(toInt: Int) extends AnyVal
case class Ids(toList: List[Id]) {
require(toList.nonEmpty)
}
object Test extends App {
val ids: List[Id] = List.empty[Id]
println(Ids(ids))
// Exception in thread "main" java.lang.IllegalArgumentException: requirement failed
}
または、
object Test extends App {
val ids: List[Id] = List.empty[Id]
println(Either.catchNonFatal(Ids(ids)))
// Left(java.lang.IllegalArgumentException: requirement failed)
}
require は Exception を投げるのでエラーハンドリングしたい場合は使う側が catch したりしなくてはいけません。それだとつらすぎると思うので仕組みとして制限したい場合は、コンストラクタを非公開にして、ファクトリメソッドを介してしかインスタンスを生成できないようにするなどの方法がありますでしょうか。
case class Id(toInt: Int) extends AnyVal
class Ids private (toList: List[Id]) {
require(toList.nonEmpty)
}
object Ids {
def of(ids: List[Id]): Either[Throwable, Ids] =
Either.catchNonFatal(new Ids(ids))
}
object Test extends App {
val ids: List[Id] = List.empty[Id]
println(Ids.of(ids))
}
ちょっと仰々しいような気がしないでもないですね。。
NonEmptyList を利用する場合のコード
次に NonEmptyList を使った場合を見てみましょう。
case class Id(toInt: Int) extends AnyVal
case class Ids(toNel: NonEmptyList[Id]) extends AnyVal
object Test extends App {
val ids: List[Id] = List.empty[Id]
val nelIds: Option[NonEmptyList[Id]] = ids.toNel
println(nelIds.toRight(new Error("id is empty")).map(Ids))
}
そもそも、NonEmptyList を List から作るときに、Option[NonEmptyList[_]]
になってしまうので、存在しない場合はエラーにするとか、スルーするとかの取り回しがしやすいですね。
(Option にならない unsafe な手段はあるけど、通常は使わないはずです。)
まとめると、require を使った not empty なファーストクラスコレクションの作り方と比較すると、難しいことを考えなくても実現できるのが良いところかなと思いました。scala でのクラスの不変表明について全然知見がないので間違ったことを言っていたらすみません。
クラス設計を考える
さて、ファーストクラスコレクションについては一旦おいておいて、NonEmptyList を使うとクラス設計がどういうふうに変わるんだろうかという部分を考えていきたいと思います。
クラス設計をするための具体例として、たとえば仲良い人を集めて「俺の要らなくなったシャツあげるよぉ」とお配り会を開催するときの処理を考えてみましょう。
芸人さんがそんな話をしているのを、テレビで見たことがありますよね。
さてこのとき、お配り会を開催する前提条件として
- お気に入りの後輩が一人以上いること
- 要らないシャツが一つ以上あること
というのがあると思います。
この前提条件をどうやって表現するのかなどが、List[_]
を使うのと、Option[NonEmptyList[_]]
を使うかで変わってきたりするのかなと思います。
さて、クラス設計をしてみましょう。
List を使った場合の設計
class Parson
class Shirt
人とシャツをクラスにしましょう。中身は空で良いですかね。
trait MyMensService {
def apply(parson: Parson): List[Parson]
}
trait UnnecessaryShirtsService {
def apply(parson: Parson): List[Shirt]
}
trait DistributeService {
def apply(myMens: Seq[Parson], unnecessaryShirts: Seq[Shirt]): Unit
}
- 仲の良い人を取得するクラスを MyMensService
- 要らないシャツを取得するクラスを UnnecessaryShirtsService
- お配り会を DistributeService
として定義してみました。これを使って前提条件を満たすようにクラスを実装してみましょう。
case class ClothesToYouService(
private val myMensService: MyMensService,
private val unnecessaryShirtsService: UnnecessaryShirtsService,
private val distributeService: DistributeService
) {
def apply(parson: Parson): Unit = {
val myMens: List[Parson] = myMensService(parson)
lazy val unnecessaryShirts: List[Shirt] = unnecessaryShirtsService(parson)
if (myMens.nonEmpty && unnecessaryShirts.nonEmpty) distributeService(myMens, unnecessaryShirts)
}
}
こういう感じになりました。お配り会を開催する前に if で myMens があることと unnecessaryShirts があることを確認しています。
ClothesToYouService 上では確認していますが、DistributeService 自体は、myMens, unnecessaryShirts が空じゃないという知識は持っていないので、防御プログラミングな感じでいうと、DistributeService の中でもう一回判定したりするかもしれないですね。
単に List を使っている場合に、そこらへんの責任がどこにあるべきかがあやふやになるのが良くないポイントかなと思います。
別の手段としては if の判定が終わったタイミングで、require などを使ったファーストクラスコレクションに変換した上で distributeService に渡すこともするかもしれません。
NonEmptyList を使った場合の設計
続いては、NonEmptyList を使った場合のクラス設計を考えてみましょう。NonEmptyList を使う場合は、返り値はList[_]
ではなくOption[NonEmptyList[_]]
になると思います。
trait MyMensService {
def apply(parson: Parson): Option[NonEmptyList[Parson]]
}
trait UnnecessaryShirtsService {
def apply(parson: Parson): Option[NonEmptyList[Shirt]]
}
trait DistributeService {
def apply(myMens: NonEmptyList[Parson], unnecessaryShirts: NonEmptyList[Shirt]): Unit
}
case class ClothesToYouService(
private val myMensService: MyMensService,
private val unnecessaryShirtsService: UnnecessaryShirtsService,
private val distributeService: DistributeService
) {
def apply(parson: Parson): Unit =
for {
myMens <- myMensService(parson)
unnecessaryShirts <- unnecessaryShirtsService(parson)
} yield distributeService(myMens, unnecessaryShirts)
}
Option[NonEmptyList[_]]
を使った場合は、for 式を使って Option をまとめており、DistributeService としては、myMens, unnecessaryShirts が空じゃないという知識を持てていることがわかりますね。
今回は、どちらも一つ以上存在する場合でないとお配り会を開催しないという処理ですが、
- MyMens が存在しない場合はエラー
- unnecessaryShirts が存在しない場合はエラー
のような処理として扱うように変換することも簡単にできます。
さらに NonEmptyList をファーストクラスコレクションにした場合
ファーストクラスコレクションにしておいたほうが処理の取り回しがしやすいかなとは思いますので、個人的な最終形態としては、以下のようになっていると気持ちよさを感じます。
case class MyMens(toNel: NonEmptyList[Parson]) extends AnyVal
case class UnnecessaryShirts(toNel: NonEmptyList[Shirt]) extends AnyVal
MyMens の要素は MyMen で有るべきとかあると思いますが、ラフにはこんな感じでファーストクラスコレクションにするかなと思います。
trait MyMensService {
def apply(parson: Parson): Option[MyMens]
}
trait UnnecessaryShirtsService {
def apply(parson: Parson): Option[UnnecessaryShirts]
}
trait DistributeService {
def apply(myMens: MyMens, unnecessaryShirts: UnnecessaryShirts): Unit
}
case class ClothesToYouService(
private val myMensService: MyMensService,
private val unnecessaryShirtsService: UnnecessaryShirtsService,
private val distributeService: DistributeService
) {
def apply(parson: Parson): Unit =
for {
myMens <- myMensService(parson)
unnecessaryShirts <- unnecessaryShirtsService(parson)
} yield distributeService(myMens, unnecessaryShirts)
}
余計な型情報がなくなったのもあり、文章として捉えて読みやすいものになった気がします。
F(副作用)がつくときに事を考える
蛇足ですが、Cats, Cats Effect を使った場合は副作用をF[_]
としてまとめると思いますので、その際はどんなコードになるんだろうかと言うのを示しておこうと思います。
trait MyMensService[F[_]] {
def apply(parson: Parson): F[Option[MyMens]]
}
trait UnnecessaryShirtsService[F[_]] {
def apply(parson: Parson): F[Option[UnnecessaryShirts]]
}
trait DistributeService[F[_]] {
def apply(myMens: MyMens, unnecessaryShirts: UnnecessaryShirts): F[Unit]
}
case class ClothesToYouService[F[_]: Monad](
private val myMensService: MyMensService[F],
private val unnecessaryShirtsService: UnnecessaryShirtsService[F],
private val distributeService: DistributeService[F]
) {
def apply(parson: Parson): F[Unit] =
(for {
myMens <- OptionT(myMensService(parson))
unnecessaryShirts <- OptionT(unnecessaryShirtsService(parson))
_ <- OptionT.liftF(distributeService(myMens, unnecessaryShirts))
} yield ()).value.void
}
このように、F で包まれた状態になったとしても OptionT(モナドトランスフォーマー)を使ってあまり変わらない書き味でコードを表現することができました。実際に直面する設計はこういう場合が多いと思いますので、F との付き合い方がうまくなっていく必要が出てくると思います。
そんなときは、僕が前に書いた(冒頭で紹介した)「Cats の関数覚書」数記事をみていただけると、F を上手に乗りこなせるきっかけになると思いますのでご一読ください。
ファーストクラスコレクションを Semigroup のインスタンスにする
さて、これまでで作った NonEmptyList のファーストクラスコレクションをより便利に使えるように Semigroup のインスタンスにしてみましょう。
上記の記事に Semigroup のファクトリメソッドなどは詳しく書いたのですが、
case class MyMens(toNel: NonEmptyList[Parson]) extends AnyVal
object MyMens {
implicit val semigroupMyMens: Semigroup[MyMens] =
Semigroup[NonEmptyList[Parson]].imap(MyMens(_))(_.toNel)
}
このように書くと「MyMens を Semigroup のインスタンスにする。」ことができます。
Semigroup だと使える便利な関数
MyMens を Semigroup にすると何が嬉しいんでしょうか?一つの答えとしては、便利な関数を使うことができるようになるという点が挙げられると思います。雑に調べただけで以下のような関数が使える様になるみたいです。
- Semigroup::combine
- Semigroup::maybeCombine
- Align::alignCombine
- Foldable::combineAllOption
- Reducible::reduce
- Reducible::reduceMap
- Reducible::reduceA
- Reducible::reduceMapA
- Reducible::reduceMapM
- Reducible::nonEmptyIntercalate
一つずつみていきましょう。
Semigroup::combine
まずは、Semigroup に定義されている combine です。
def combine(x: A, y: A): A
Semigroup の存在そのもののような関数ですが、足し合わせできる関数になります。
val myMensA: MyMens = ???
val myMensB: MyMens = ???
val combined: MyMens = myMensA |+| myMensB
このような感じで、中のリストが足されるイメージになります。
Semigroup::maybeCombine
続いては、Semigroup に定義されている maybeCombine です。
def maybeCombine[@sp(Int, Long, Float, Double) A](ox: Option[A], y: A)
定義になにやら色々書いていますが、Option[A]
と A を足して A にしてくれる関数ですね。
val myMensA: MyMens = ???
val maybeMyMensB: Option[MyMens] = ???
val combined: MyMens = Semigroup.maybeCombine(myMensA, maybeMyMensB)
このように使う事ができます。
Semigroup::maybeCombine を使わないと・・・
Semigroup::maybeCombine を使わないとどういうコードを書く羽目になるかというと・・・(めんどくさくなりすぎないように combine は使って良いルールにしましょう。)
maybeMyMensB match {
case Some(myMensB) => myMensA |+| myMensB
case None => myMensA
}
こういう様なパターンマッチなどを書く羽目になると思います。めんどくさいですね。
Align::alignCombine
続いては、Align に定義されている alignCombine です。
def alignCombine[A: Semigroup](fa1: F[A], fa2: F[A]): F[A]
こちらは、Option[A]
に限らず、List[A]
などでも使える関数ですが、Semigroup::maybeCombine と比較する意味でOption[A]
を使ってみようと思います。
val maybeMyMensA: Option[MyMens] = ???
val maybeMyMensB: Option[MyMens] = ???
val combined: Option[MyMens] = maybeMyMensA.alignCombine(maybeMyMensB)
このようになりますでしょうか。Semigroup::maybeCombine では片方が Option のときに使うものでしたが、こちらは Option 同士の足し合わせに使うことができました。
Align::alignCombine を使わないと・・・
Align::alignCombine を使わないとどういうコードを書く羽目になるかというと・・・(こちらもめんどくさくなりすぎないように combine は使って良いルールにしましょう。)
(maybeMyMensA, maybeMyMensB) match {
case (Some(myMensA), Some(myMensB)) => Some(myMensA |+| myMensB)
case (Some(_), _) => maybeMyMensA
case (_, Some(_)) => maybeMyMensB
case _ => None
}
なんかみたことあるような気がするコードになりました。。こちらも、めんどくさいですね。
ちょっとスッキリ書こうとすると、
List(maybeMyMensA, maybeMyMensB).flatten match {
case x :: xs => Some(xs.foldLeft(x)(_ |+| _))
case _ => None
}
こんなかんじでどうでしょうか。意図が随分と分かりづらいコードになってしまったように思います。
それをやるくらいなら、
List(maybeMyMensA, maybeMyMensB).combineAll
と書いたりできますが、いずれにしても、alignCombine を使うよりは複雑になってしまっている気がします。
Foldable::combineAllOption
続いては Foldable に定義されている combineAllOption です。
def combineAllOption[A](fa: F[A])(implicit ev: Semigroup[A]): Option[A]
Foldable なF[A]
をOption[A]
としてまとめてくれる関数です。
List が Foldable のわかりやすい例ですので List を使ってコードを書いてみましょう。
val myMensList: List[MyMens] = ???
val combined: Option[MyMens] = myMensList.combineAllOption
このように MyMens リストを Optional な MyMens に変換することができました。
Foldable::combineAllOption を使わないと・・・
Foldable::combineAllOption を使わないとどのようなコードが考えられるでしょうか。
myMensList match {
case x :: xs => Some(xs.foldLeft(x)(_ |+| _))
case _ => None
Align::alignCombine と flatten している以外全く一緒になってしまいましたが、こんな感じのコードになるでしょうか。これも myMensList.combineAllOption
に比べると野暮ったいですね。
Reducible::reduce
続いては Reducible に定義されている reduce です。
def reduce[A](fa: F[A])(implicit A: Semigroup[A]): A
Reducible のインスタンスは、わかりやすい例がちょうど、NonEmptyList ですので、今回でいうと
NonEmptyList[MyMens]
というのが登場したときに reduce を使うことができます。
val myMensList: NonEmptyList[MyMens] = ???
val reduced: MyMens = myMensList.reduce
Reducible::reduce を使わないと・・・
Reducible::reduce を使わないとどういうコードを書く羽目になるかというと・・・(こちらもめんどくさくなりすぎないように combine は使って良いルールにしましょう。)
val reduced: MyMens = myMensList.tail.fold(myMensList.head)(_ |+| _)
わざわざ tail と head をとって combine で畳み込むような処理を書くことになると思います。reduce を使ったほうがシンプルですね。
Reducible::reduceMap
続いては Reducible に定義されている reduceMap です。
def reduceMap[A, B](fa: F[A])(f: A => B)(implicit B: Semigroup[B]): B
定義をみると map + reduce のような事ができる関数の様ですね。reduce の例と同じ様に NonEmptyList を使って考えてみるとしましょう。
trait MyMensService {
def findAll: MyMens
}
val myMensList: NonEmptyList[MyMens] = ???
val services: NonEmptyList[MyMensService] = ???
MyMensService という、インターフェイスがあって、それが複数個存在する場合、NonEmptyList[MyMensService]
というふうにもつこともあると思います。この複数あるサービスの全てから MyMens を取ってきて、一つの MyMens にまとめたい場合以下のように実装することができます。
val reduced: MyMens = services.reduceMap(_.findAll)
便利ですね。
Reducible::reduceMap を使わないと・・・
Reducible::reduceMap を使わないとどういうコードを書く羽目になるでしょうか。map して reduce することになると思います。
val reduced: MyMens = services.map(_.findAll).reduce
reduce も使わない場合は、
val reduced: MyMens = services.map(_.findAll).pipe { myMensList =>
myMensList.tail.fold(myMensList.head)(_ |+| _)
}
こんな感じになるでしょうか。辛いですね。
Reducible::reduceA
続いては Reducible に定義されている reduceA です。
@noop def reduceA[G[_], A](fga: F[G[A]])(implicit G: Apply[G], A: Semigroup[A]): G[A]
reduce との違いは、F[A]
の A がさらに G という Apply に包まれている点になります。
例えば、NonEmptyList[Option[MyMesn]]
に対してこの関数を使うことができます。
val myMensList: NonEmptyList[Option[MyMens]] = ???
val reduced: Option[MyMens] = myMensList.reduceA
NonEmptyList[Option[MyMens]]
をOption[MyMens]
にまとめることができました。
この例だと、Option[_]
は Monoid ですので例えばval reduced: Option[MyMens] = myMensList.combineAll
でも同じ処理になのですが、より弱い制約で実装できるので reduceA を使うといいかなと思います。
Reducible::reduceA を使わないと・・・
Reducible::reduceA を使わないとどういうコードを書く羽目になるでしょうか。
val reduced: Option[MyMens] = myMensList.tail.foldLeft(myMensList.head) {
case (Some(acc), Some(myMens)) => Some(acc |+| myMens)
case (None, myMens: Some[MyMens]) => myMens
case (acc: Some[MyMens], None) => acc
case _ => None
}
Option[MyMens]
をパターンマッチによって畳み込むような実装をするはめになると思います。これは辛いですね。
Reducible::reduceMapA
続いては Reducible に定義されている reduceMapA です。
def reduceMapA[G[_], A, B](fa: F[A])(f: A => G[B])(implicit G: Apply[G], B: Semigroup[B]): G[B]
インターフェイスを見ると map + reduceA という感じのことが実現できそうです。
trait MyMensService {
def findAll: Option[MyMens]
}
val myMensServices: NonEmptyList[MyMensService] = ???
reduceMap のときに使ったインターフェイスが、実は Option で返ってくる関数を持っている場合で考えてみましょう。NonEmpytList[MyMensService]
として持っているサービスの全部から MyMens を取り出してすべてまとめてあげたい場合、以下のように実装することができます。
val reduced: Option[MyMens] = myMensServices.reduceMapA(_.findAll)
Reducible::reduceMapA を使わないと・・・
Reducible::reduceMapA を使わないとどういうコードを書く羽目になるでしょうか。
まず考えられるのは、map + reduceA をつかった実装です。
val reduced: Option[MyMens] = myMensServices.map(_.findAll).reduceA
これらを使わない場合は、foldLeft を使うと以下のような実装になるでしょうか。
val reduced: Option[MyMens] =
myMensServices.tail.foldLeft(myMensServices.head.findAll) {
case (acc @ Some(myMens), service) =>
service.findAll.map(myMens |+| _).orElse(acc)
case (None, service) => service.findAll
}
辛いですね。ここまでとは言わずとも、似たような辛さになりがちだと思います。
Reducible::reduceMapM
続いては Reducible に定義されている reduceMapM です。
こちらは、reduceMapA との違いが、G の制約のみなので省略します。
def reduceMapM[G[_], A, B](fa: F[A])(f: A => G[B])(implicit G: FlatMap[G], B: Semigroup[B]): G[B]
Reducible::nonEmptyIntercalate
続いては Reducible に定義されている nonEmptyIntercalate です。
def nonEmptyIntercalate[A](fa: F[A], a: A)(implicit A: Semigroup[A]): A
こちらは intercalate という mkString を抽象化したような関数の nonEmpty ver になります。
個人的には mkString 的な使い方以外の用途が今の所浮かんでいないのですが、MyMens を使った場合どういう処理になるのか確かめて見ようと思います。
case class Parson(name: String)
case class MyMens(toNel: NonEmptyList[Parson]) extends AnyVal
挙動の確認のために Parson が name 要素を持つようにしてみます。
val myMens: MyMens = MyMens(NonEmptyList.one(Parson("アムパムマム")))
val myMensList: NonEmptyList[MyMens] = NonEmptyList.of(
MyMens(NonEmptyList.of(Parson("ジャムじい"), Parson("バターちゃん"), Parson("チズー"))),
MyMens(NonEmptyList.of(Parson("ばい菌"), Parson("土管ちゃん"), Parson("ガリガリマム"))),
MyMens(NonEmptyList.of(Parson("加齢パムマム"), Parson("職パムマム"))),
MyMens(NonEmptyList.of(Parson("メメ先生"), Parson("バカ夫くん")))
)
println(myMensList.nonEmptyIntercalate(myMens))
みんなの大好きなヒーローを intercalate してみましょう。
MyMens(
NonEmptyList(
Parson(ジャムじい),
Parson(バターちゃん),
Parson(チズー),
Parson(アムパムマム), // intercalate される要素
Parson(ばい菌),
Parson(土管ちゃん),
Parson(ガリガリマム),
Parson(アムパムマム), // intercalate される要素
Parson(加齢パムマム),
Parson(職パムマム),
Parson(アムパムマム), // intercalate される要素
Parson(メメ先生),
Parson(バカ夫くん)))
それぞれのグループの間にヒーローが挟まっているかんじで MyMens を形成しているのがわかりますね。
実務上ではどういうときに使うかまだ想像できていませんが、挙動さえわかっていればジョシュア・ツリーよろしく使う場面が出てくるものかなと思っています。
まとめ
- NonEmptyList を使った場合の設計の比較
- NonEmptyList のファーストクラスコレクションを Semigroup のインスタンスにした場合に使える関数について
をつらつらと書いて来ました。NonEmptyList 便利だなぁ、使ってみようかなぁと思っていただけると幸いです。Cats のデータ型はまだまだありますので次の機会に他のデータ型についても書いていけたらと思っています。