DEV Community

e_ntyo
e_ntyo

Posted on

fp-ts ユーザが Scala with Cats を読み終えたので、fp-ts と Cats の違いをまとめてみた

tl; dr

Scala の fp ライブラリ Cats と、TypeScript の fp ライブラリ fp-ts を比較してみました。主に言語仕様の違い(評価戦略の違いや型コンストラクタの有無など)によって、高カインド型や型クラスの実装方法に違いがあることや、Cats でのみ提供されている型クラスがあること等がわかりました。

この記事を書くことにした経緯

この記事を書くことにしたきっかけは、勤務先の 株式会社 HERP で、Cats の教材 "Scala with Cats"の輪読会を開催したことです1。弊社では Cats ではなく fp-ts を Web フロントエンド・サーバサイドで全面的に使用しているのですが2、 fp-ts は比較的まだ若いライブラリであり3、Learning Resource もさほど充実していないという課題意識がありました。そこで、「fp-ts は Cats から影響を受けている4し、Cats の Learning Resource に良いものがあればそれを勉強してもよいのでは?」と考えた次第です。

輪読会を終えて、「普段 fp-ts を使っている人から見た Cats あるいは Scala」という観点で記事を書いたら面白いのではないか、と考えこの記事を書き始めるに至りました。

対象読者

まず前提として、この記事は「明日から使えるプログラミングテクニック」について書かれたものではありません。この記事はいわば「読み物」ですので、暇なときに読んでいただき「へ〜」と思っていただけたら良いなと思って書いております。

そしてこの「読み物」の対象読者ですが、以下のうち、一つ以上に当てはまる方を想定しております:

  • fp-ts を使ったことがある
  • Cats を使ったことがある
  • (プログラミング言語を問わず)Functional Programming を実践している

Scala with Cats

Scala with Cats は Web 上で無料で公開されており、随時内容が更新されています。英語のリソースですが、全体的に平易な英語で書かれている印象でした。この記事の執筆時点(2021 年 12 月)では、全七章5で以下の内容が扱われています。

  • The Type Class
  • Monoids
  • Semigroups
  • Functors
  • Monads
  • Monad Transformers
  • Semigroup and Applicative
  • Foldable and Traverse

そもそも私は Scala を書いたことがほぼなかったのですが、Scala with Cats では逐次 Scala の言語仕様そのものについても解説されているため、無理なく読み進めることができました。

私が Scala with Cats を読んで良かったことは、まず 「fp-ts にもあることは知っているけれど、よく理解していなかった型クラス」について知ることができたことです。上に挙げたとおり、Scal with Cats では主要な型クラスについての解説が一通り用意されています。

また、それらの型クラス同士の関係性(e.g. Functor と Applicative と Monad の関係性、Semigroup と Applicative の関係性)についても説明されており助かりました。偏見ですが、「Functor と Applicative と Monad の関係性」なんかは、圏論の本を読んだりしないとなかなか知る機会がないのではないかと思っています。

さらに、普段筆者は業務で fp-ts を使っているため、「fp-ts ではこういう面倒くさいことをしなければならないが、Cats (Scala) では同じようなことがより簡潔に書けるのか」といった気づきがありました。以降のセクションには、そうした「気付き」をそれぞれできるだけ具体的にまとめました。

fp-ts と Cats の比較

本題の比較に入ります。なお、fp-ts (TypeScript) および Cats (Scala) のヴァージョンは以下を想定しています。

  • fp-ts: 2.11.8 (TypeScript: 4.5.5)
    • 執筆時点での最新のヴァージョンを想定しています
  • Cats: 2.1.0 (Scala: 2.13.1)
    • 執筆時点での Scala with Cats の最新版に対応したヴァージョンを想定しています

高カインド型と型コンストラクタ

Cats では、Functor や Monad の定義において、Type Constructor (型コンストラクタ)という言語機能が用いられています。以下は Cats における Functor の定義です(一部省略しています):

package cats

import simulacrum.{noop, typeclass}

/**
 * Functor.
 *
 * The name is short for "covariant functor".
 *
 * Must obey the laws defined in cats.laws.FunctorLaws.
 */
@typeclass trait Functor[F[_]] extends Invariant[F] { self =>
  def map[A, B](fa: F[A])(f: A => B): F[B]
  ...
}
Enter fullscreen mode Exit fullscreen mode

trait Functor[F[_]] と書くことで、ブロックの内側で F[A]F[B] という型を使うことができています。ここでは、Type Constructor F はカインドが * -> * の型(例えば、Option[A]List[A] )をエミュレートしています。このおかげで、FunctorOption[A] 型や List[A] 型のインスタンス(FunctorForOption[A]FunctorForList[A]) を個別に定義する必要がなくなっています。

TypeScript における高カインド型の実装

一方、TypeScript には Type Constructor (相当の機能)がありません。ではどのように Functor などの型クラスを実装しているのでしょうか6

超シンプルな型: type Identity<A> = A について、 fp-ts の Functor のインスタンスを導出する方法を、例として見ていきます。

// Identity.ts

import { Functor1 } from "fp-ts/lib/Functor";

export const URI = "Identity";

export type URI = typeof URI;

declare module "fp-ts/lib/HKT" {
  interface URItoKind<A> {
    readonly Identity: Identity<A>;
  }
}

export type Identity<A> = A;

// Functor instance
export const identity: Functor1<URI> = {
  URI,
  map: (ma, f) => f(ma),
};
Enter fullscreen mode Exit fullscreen mode

ここで、 fp-ts の Functor1 の定義は以下の通りです:

// fp-ts/lib/Functor.ts

export interface Functor1<F extends URIS> {
  readonly URI: F;
  readonly map: <A, B>(fa: Kind<F, A>, f: (a: A) => B) => Kind<F, B>;
}
Enter fullscreen mode Exit fullscreen mode

では、 URItoKindURISKind はそれぞれ何でしょうか?

URItoKind は type-level map です。型 URI をある特定の型に map します。TypeScript の module augmentation を利用して、それぞれの型を URItoKind に登録しています。

具体的には、HKT.tsURItoKind が定義されていて:

// fp-ts/lib/HKT.ts

export interface URItoKind<A> {}
Enter fullscreen mode Exit fullscreen mode

module augmentation を用いて、 Identity.ts に、以下のように「IdentityURItoKind に登録する」コードを書くことができます:

// Identity.ts

declare module "fp-ts/lib/HKT" {
  interface URItoKind<A> {
    readonly Identity: Identity<A>; // maps the key "Identity" to the type `Identity`
  }
}
Enter fullscreen mode Exit fullscreen mode

URISkeyof URItoKind<any> であり、 Functor1 interface において URItoKind に登録されていない HKT が Functor1 の instance だということにされないよう、型レベルの制約として機能しています(interface Functor1<F extends URIS>)。

Kind<F, A>Kind<URI extends URIS, A> = URI extends URIS ? URItoKind<A>[URI] : any です。例えば URI = 'Identity' のとき、 Kind<URI, number>Identity<number> へと写されます。URIURI extends URIS を満たさない、例えば HOGEHOGE という値だった場合には、 any 型となります。

ここで、 Identity は kind が * -> * (OptionList と同じ) でしたので Kind<F, A> というシグネチャになっていましたが、 kind が * -> * -> * であるような型はどのように表現されるのでしょうか。

そのような型のために URItoKind2, URIS2, Kind2 が定義されています。 Either を例として、その使われ方を見てみます:

// Either.ts

import { Functor2 } from "fp-ts/lib/Functor";

export const URI = "Either";

export type URI = typeof URI;

declare module "fp-ts/lib/HKT" {
  interface URItoKind2<E, A> {
    readonly Either: Either<E, A>;
  }
}

export interface Left<E> {
  readonly _tag: "Left";
  readonly left: E;
}

export interface Right<A> {
  readonly _tag: "Right";
  readonly right: A;
}

export type Either<E, A> = Left<E> | Right<A>;

// Functor instance
export const either: Functor2<URI> = {
  URI,
  map: (ma, f) => (ma._tag === "Left" ? ma : right(f(ma.right))),
};
Enter fullscreen mode Exit fullscreen mode

ここで、 Functor2 の定義は以下の通りです:

// fp-ts/lib/Functor.ts

export interface Functor2<F extends URIS2> {
  readonly URI: F;
  readonly map: <E, A, B>(fa: Kind2<F, E, A>, f: (a: A) => B) => Kind2<F, E, B>;
}
Enter fullscreen mode Exit fullscreen mode

では、Functor のような HKT を扱う関数、すなわち Cats では Type Constructor を使って定義されるような関数を、fp-ts を使う場合ではどう定義するのでしょうか? 以下の lift 関数を例に見ていきます:

import { HKT } from "fp-ts/lib/HKT";

export function lift<F>(
  F: Functor<F>
): <A, B>(f: (a: A) => B) => (fa: HKT<F, A>) => HKT<F, B> {
  return (f) => (fa) => F.map(fa, f);
}
Enter fullscreen mode Exit fullscreen mode

実はここまでに触れられてこなかった HKT という型がようやく登場しました。定義は以下の通りです。

// fp-ts/lib/HKT.ts

export interface HKT<URI, A> {
  readonly _URI: URI;
  readonly _A: A;
}
Enter fullscreen mode Exit fullscreen mode

HKT 型は、kind が * -> * であるような型のための Type Constructor を表現しています。もうお気づきの方もいらっしゃるかもしれませんが、Functor<n>URITtKind<n> と同様に、 HKT<n>fp-ts/lib/HKT.ts に定義されています。ただし、fp-ts では HKT4 (kind が * -> * -> * -> * -> * の型)にまで対応しています。

さて、 HKT 型を定義したので、これでようやく lift のような関数の型定義を書くことができる…と言いたいところですが、実はもうひと工夫必要です。

const double = (n: number): number => n * 2;

//                             v-- the Functor instance of Identity
const doubleIdentity = lift(identity)(double);
Enter fullscreen mode Exit fullscreen mode

以上のコードは、以下のように compile error となります:

Argument of type 'Functor1<"Identity">' is not assignable to parameter of type 'Functor<"Identity">'
Enter fullscreen mode Exit fullscreen mode

Functor1<"Identity">Functor<"Identity"> はあくまで別の型なので、lift の型定義を書く上でオーバーロードを定義する必要があります:

export function lift<F extends URIS2>(
  F: Functor2<F>
): <A, B>(f: (a: A) => B) => <E>(fa: Kind2<F, E, A>) => Kind2<F, E, B>;
export function lift<F extends URIS>(
  F: Functor1<F>
): <A, B>(f: (a: A) => B) => (fa: Kind<F, A>) => Kind<F, B>;
export function lift<F>(
  F: Functor<F>
): <A, B>(f: (a: A) => B) => (fa: HKT<F, A>) => HKT<F, B>;
export function lift<F>(
  F: Functor<F>
): <A, B>(f: (a: A) => B) => (fa: HKT<F, A>) => HKT<F, B> {
  return (f) => (fa) => F.map(fa, f);
}
Enter fullscreen mode Exit fullscreen mode

liftFunctor, Functor1, Functor2 に対応できるようオーバーロードを定義したので、以下のように liftIdentity のインスタンスも Either のインスタンスも引数に取ることができます:

// `doubleIdentity` has type `(fa: Identity<number>) => Identity<number>`
const doubleIdentity = lift(identity)(double);

// `doubleEither` has type `<E>(fa: Either<E, number>) => Either<E, number>`
const doubleEither = lift(either)(double);
Enter fullscreen mode Exit fullscreen mode

だいぶ長くなりましたが以上です。HKT を実装することができありがたいのですが、この手法には二点弱点があります。

fp-ts の HKT 実装の弱点その 1 「型レベルで部分適用できない」

1つめの弱点としては、「HKT2<"URI", A, B> を「型レベルで部分適用」して、HKT1 をつくる」ということができません。

「型レベルで部分適用」というのは、例えば Either 型は kind が * -> * -> * ですが、proper な型(例えば、String) を渡すことで、kind が * -> * の型となります。Haskell の REPL には:kというコマンドがあり7、型の kind を調べることができます。以下は Either を「型レベルで部分適用」していく例です。

Prelude> :k Either
Either :: * -> * -> *
Prelude> :k Either String
Either String :: * -> *
Prelude> :k Either String Int
Either String Int :: *
Enter fullscreen mode Exit fullscreen mode

他方、TypeScript では「型レベルで部分適用」ができません。

kind が * -> * -> * の型 HKT2<"either", A, B> について、A = string として type EitherAsHKT1<B> = HKT2<"either", string, B> という型を定義したとします。

EitherAsHKT1<B> は型パラメータを 1 つだけ取るため、一見 kind が * -> * のようですが、実際のところ HKT2<"either", string, B> のエイリアスですので、kind は * -> * -> * という扱いになってしまうのです。

ちなみに、Scala の REPL にも :K が実装されており8、使ってみると以下のようになります:

scala> :k Int
Int's kind is A

scala> :k Either
Either's kind is F[+A1,+A2]

scala> :k Option
Option's kind is F[+A]

scala> type IntOrA[A] = Either[Int, A]
defined type alias IntOrA

scala> :k IntOrA
IntOrA's kind is F[A]
Enter fullscreen mode Exit fullscreen mode

kind は Type Constructor の F を用いて表現されており、* -> * -> *F[+A1, +A2] で、* -> *F[+A] で、そして *A で表現されています。そして問題の「型レベルで部分適用」ですが、Scala では type IntOrA[A] = Either[Int, A] として定義した型の kind が F[A] となっていることがわかります。

fp-ts の HKT 実装の弱点その 2 「HKT を使う上で必要になるボイラープレートコードが多すぎ」

2 つ目の弱点は、HKT を使ったコードを書くために必要なオーバーロードが多すぎるという点です9。すでに見てきたように、例えば lift 関数は次のようにたくさんのオーバーロードを定義する必要がありました。

// 再掲
export function lift<F extends URIS2>(
  F: Functor2<F>
): <A, B>(f: (a: A) => B) => <E>(fa: Kind2<F, E, A>) => Kind2<F, E, B>;
export function lift<F extends URIS>(
  F: Functor1<F>
): <A, B>(f: (a: A) => B) => (fa: Kind<F, A>) => Kind<F, B>;
export function lift<F>(
  F: Functor<F>
): <A, B>(f: (a: A) => B) => (fa: HKT<F, A>) => HKT<F, B>;
export function lift<F>(
  F: Functor<F>
): <A, B>(f: (a: A) => B) => (fa: HKT<F, A>) => HKT<F, B> {
  return (f) => (fa) => F.map(fa, f);
}
Enter fullscreen mode Exit fullscreen mode

これは大変なことですが、fp-ts とは別の Effect-TS というライブラリでは、この問題を考慮した HKT の実装が採用されているようです。具体的には、* -> *, * -> * -> * などの複数の kind について、fp-ts の実装のように Kind1, Kind2, ... と個別に型を定義するのではなく、共通した 1 つの型を定義しています。例えば、kind を * -> * から * -> * -> * -> * までに限定して対応するとしたら、type Kind<F extends URIS, S, R, E> = URItoKind<S, R, E>[F] です。もう少し詳しく見ていきます:

export interface URItoKind<S, R, E> {
  Effect: Effect<R, E, A>; // * -> * -> * -> * な型の一例
  Either: Either<E, A>; // * -> * -> * な型の一例
  Option: Option<A>; // * -> * な型の一例
}

export type URIS = keyof URItoKind<any, any, any, any>;

export type Kind<F extends URIS, S, R, E, A> = URItoKind<S, R, E, A>[F];

export interface Functor<F extends URIS> {
  URI: F;
  map: <A, A2>(
    f: (a: A) => A2
  ) => <S, R, E>(fa: Kind<F, S, R, E, A>) => Kind<F, S, R, E, A2>;
}

export function lift<F extends URIS>(
  F: Functor<F>
): <S, R, E, A, B>(
  f: (a: A) => B
) => (fa: Kind<F, S, R, E, A>) => Kind<F, S, R, E, A> {
  return (f) => (fa) => F.map(f)(fa);
}
Enter fullscreen mode Exit fullscreen mode

こちらの lift の定義では、Kind1, Kind2 などのために個別にオーバーロードを定義する必要がなくなっています。一方で、個人的には初見で URItoKindKind の定義を見たときの認知的負荷は、扱う型パラメータの数が増えた分こちらのほうが比較的きつい印象があります。ただし、fp-ts での実装を理解してからこちらの実装を見ていく流れであればほとんど気にならないかなとも思います。

implicit キーワード

Scala の個人的にすごいと思う言語機能として、implicit キーワードがあります。Cats のような型クラスを提供する類のライブラリにとっては、implicit キーワードのおかげで、「ある型 A とある型クラス T について、A のための T のインスタンスを(実装されていれば)コンパイラが勝手に見つけてきてくれる」という嬉しさがあります。

Cats が提供するすべて型クラスは apply[A] というメソッドを提供していて、これは A のための T のインスタンスを返してくれるスマートコンストラクタです。例えば Cats の提供する Eq 型クラスについて、自分が定義した Cat 型や Dog 型のインスタンスを手に入れるためには次のようにします:

// EqInstances.scala
object EqInstances {
  // `implicit val` というキーワードで `Eq` のインスタンスを定義する
  implicit val catEq: Eq[Cat] =
    Eq[Cat] { (cat1, cat2) =>
      cat1.name === cat2.name && cat1.age === cat2.age && cat1.color === cat2.color
    }

  implicit val dogEq: Eq[Dog] =
    Eq[Dog] { (dog1, dog2) =>
      dog1.cry === dog2.cry
    }
}

// Main.scala
import EqInstances.EqInstances._
import cats.Eq

// コンパイラは、apply[A] の A から、自動的に対応する instance を見つけてくれる
val eqCat = Eq.apply[Cat]
eqCat.eqv(cat1, cat2)
val eqDog = Eq.apply[Dog]
eqDog.eqv(dog1, dog2)

// `eqv` を使いたいだけであれば、インスタンスを作らなくとも operator として`===` が提供されている
// === の実態は `eqv` なので(後述)、やはり自動的に対応する `Eq` の instance を見つけてくれる
// `cat1 === cat2` と書くと `catEq.eqv` が使われるし、`dog1 === dog2` と書くと `dogEq.eqv` が使われる
cat1 === cat2
dog1 === dog2
Enter fullscreen mode Exit fullscreen mode

TypeScript には implicit val 相当の言語機能は存在しないため、 fp-ts に Eq.apply[T] 相当のスマートコンストラタはありません。fp-ts で Eq のインスタンスを実装し、使うには以下のように書きます:

// eq-instances.ts
export const eqCat: Eq<Cat> = {
  // fp-ts では `eqv` ではなく `equals` という名前のメソッドを定義する
  equals: (cat1, cat2) =>
    cat1.name === cat2.name &&
    cat1.age === cat2.age &&
    cat1.color === cat2.color,
};

export const eqDog: Eq<Dog> = {
  equals: (dog1, dog2) => dog1.cry === dog2.cry,
};
Enter fullscreen mode Exit fullscreen mode
// main.ts
import { eqCat, eqDog } from "eq-instances";

// Eq.apply[Cat] みたいなことはできないので、`eqCat`, `eqDog` をそれぞれ直接 import している
eqCat.equals(cat1, cat2);
eqDog.equals(dog1, dog2);
Enter fullscreen mode Exit fullscreen mode

また、implicit キーワードはメソッド定義の際にも使うことができ、「A のための T のインスタンスを使うメソッド」を簡潔に定義することができます。以下のコードは Scala with Cats からの引用です:

package Json
// Define a very simple JSON AST
// sealed: 同一ファイル内からのみ継承可能
sealed trait Json
final case class JsObject(get: Map[String, Json]) extends Json
final case class JsString(get: String) extends Json
final case class JsNumber(get: Double) extends Json
final case object JsNull extends Json

// オレオレ型クラス
// The "serialize to JSON" behaviour is encoded in this trait
trait JsonWriter[A] {
  def write(value: A): Json
}

// オレオレ型クラス `JsonWriter` のインスタンスを定義する場所
object JsonWriterInstances {
  // `implicit val` を用いて、`A = String`, `A = Person`, ...のインスタンスを実装している
  implicit val stringWriter: JsonWriter[String] =
    new JsonWriter[String] {
      def write(value: String): Json =
        JsString(value)
    }

  implicit val personWriter: JsonWriter[Person] =
    new JsonWriter[Person] {
      def write(value: Person): Json =
        JsObject(
          Map(
            "name" -> JsString(value.name),
            "email" -> JsString(value.email)
          )
        )
    }

  // `implicit def` を用いることで、`optionWriter` の実装において `JsonWriter[A]` を使うことができる
  implicit def optionWriter[A](implicit
      writer: JsonWriter[A]
  ): JsonWriter[Option[A]] =
    new JsonWriter[Option[A]] {
      def write(option: Option[A]): Json =
        option match {
          case Some(aValue) => writer.write(aValue)
          case None         => JsNull
        }
    }
  // etc...
}

object Json {
  // **implicit parameter** は、ここでいう `w`。
  // 型クラスを扱うメソッドをこれだけで定義できる(`JsonWriter` の `A` の実装はコンパイラが勝手に見つけてくれる)
  def toJson[A](value: A)(implicit w: JsonWriter[A]): Json =
    w.write(value)
}

final case class Person(name: String, email: String)

object Main extends App {
  import JsonWriterInstances._
  // 1. Json.toJson は implicit parameter として `w: JsonWriter[A]` をとる
  // 2. `JsonWriter[A = Person]` の実装を、`implicit val` で `JsonWriterInstances.personWriter` に定義済みのため、コンパイラはそれを見つける
  println(Json.toJson(Person("Dave", "dave@example.com")))
  println(Json.toJson("hello!"))
  println(Json.toJson(Option("Hoge")))
}
Enter fullscreen mode Exit fullscreen mode

Scala with Cats では、この implicit キーワードについて、

Working with type classes in Scala means working with implicit values and implicit parameters.

とさえ書かれています。

直感的に、implicit キーワードを用いて、型クラス T の、型 A のインスタンスを、コンパイラに見つけさせようとするとコンパイルスピードの低下を招きそうですが、コンパイラはソースコードを何も考えずに端から端まで見て回るのではなく、実際には import 済みの object 内などの場所を優先的に見て回るため、上記のコード例のようにそういった場所で A のインスタンスを定義していればコンパイルスピードに大きな影響はないのかもしれません。

implicit と 拡張メソッド

Cats における implicit の活用例として、拡張メソッドがあります。例えば cats.syntax.either では、任意の型 A の値から Either[A, B] のインスタンスをつくるための Smart Constructor asLeft[B] を拡張メソッドとして提供しています。

import cats.syntax.either._

"Error".asLeft[Int].orElse(2.asRight[String])
// res12: Either[String, Int] = Right(2)
Enter fullscreen mode Exit fullscreen mode

上記のコードでは、String が本来実装していないはずのメソッド asLeft を呼び出しており、また Int が本来実装していないはずのメソッド asRight を呼び出しています。

Cats はどのようにしてこれを実現しているか、cats/core/src/main/scala/cats/syntax/either.scala のコードを読んで調べてみます(一部を簡略化しました):

// cats/core/src/main/scala/cats/syntax/either.scala
trait EitherSyntax {
  // 2. implicit def を使うことで、`a: A` を `a: EitherIdOps` にキャストしている
  // これをもって `a` から `asLeft` などの `EitherIdOps` のメソッドが呼び出すことができる
  implicit final def catsSyntaxEitherId[A](a: A): EitherIdOps[A] = new EitherIdOps(a)
}

// 1. `AnyVal` を継承したクラス `EitherIdOps` を定義する
// `EitherIdOps` はコンストラクタの引数に型 `A` の値 `obj` を取る
final class EitherIdOps[A](private val obj: A) extends AnyVal {
  /**
   * Wrap a value in `Left`.
   */
  def asLeft[B]: Either[A, B] = Left(obj)

  /**
   * Wrap a value in `Right`.
   */
  def asRight[B]: Either[B, A] = Right(obj)

  // and more!
}
Enter fullscreen mode Exit fullscreen mode

cats/core/src/main/scala/cats/syntax/either.scala では、まず AnyVal を継承したクラス EitherIdOps を定義しています。EitherIdOps はコンストラクタの引数に型 A の値 obj を取りますが、この objasLeft 等のメソッドを生やしていこうという魂胆です。

次に、EitherSyntax という trait を定義し、ここで implicit final def catsSyntaxEitherId[A](a: A): EitherIdOps[A] = new EitherIdOps(a) を定義しています。これは任意の型 A の値 aEitherIdOps 型にキャストしており、したがって cats.syntax.either を import した場合には、そこと同じ/そこより内側のスコープにおいて、任意の型 A の値には asLeft 等の EitherIdOps に定義したメソッドが生えることになります。

個人的には、別に A のメソッドとして定義しなくても right(a)left(a) を使えばよいのでは…?と思ってしまいますが、先の例: "Error".asLeft[Int].orElse(2.asRight[String]) のようなコードを(pipe 演算子なしで)書きたい場合には、括弧のネストが深くならなくて済むという点では良いのかもしれません。もし他のメリットをご存じの方がいらっしゃいましたら教えてください。

また、Scala においてはオペレータはメソッドなので、cats/syntax/XXX.scala ではメソッドと同様にオペレータも提供されています。以下は Eq 型クラスの例で、メソッド eqv を、オペレータ === として提供しています:

// cats/syntax/eq.scala
package cats
package syntax

trait EqSyntax {
  implicit def catsSyntaxEq[A: Eq](a: A): EqOps[A] = new EqOps[A](a)
}

final class EqOps[A: Eq](lhs: A) {
  def ===(rhs: A): Boolean = Eq[A].eqv(lhs, rhs)
  def =!=(rhs: A): Boolean = Eq[A].neqv(lhs, rhs)
  def eqv(rhs: A): Boolean = Eq[A].eqv(lhs, rhs)
  def neqv(rhs: A): Boolean = Eq[A].neqv(lhs, rhs)
}
Enter fullscreen mode Exit fullscreen mode
// 使うとき
import cats.syntax.eq._ // for === and =!=
123 === 123
123 =!= 234
// type mismatch;
// 123 === "123"
Enter fullscreen mode Exit fullscreen mode

この「eqv メソッドを中置演算子 === としても定義し、使うことができる」という Scala の言語仕様は TypeScript にはなく、結構便利そうな印象です。他には cats.syntax.semigroup|+| などがあります。

評価戦略

Cats と fp-ts の違いというよりは、Scala と JavaScript の言語レベルでの違いの一つとして、評価戦略があります。

Scala の評価戦略と Cats の Eval

Scala には、変数・メソッドを定義するためのキーワードとして val, def, lazy val があり、それぞれ以下のようにして使われます:

val x = {
   println("Computing X")
   math.random
 }
 // Computing X
 // x: Double = 0.15241729989551633

 x // first access
 // res0: Double = 0.15241729989551633
 x // second access
 // res1: Double = 0.15241729989551633 <- 一度目の access での評価の結果が記憶されている(Memoization)

 def y = {
   println("Computing Y")
   math.random
 }

 y // first access
 // Computing Y
 // res2: Double = 0.6963618800921411
 y // second access
 // Computing Y
 // res3: Double = 0.7321640587866993 <- 一度目の access での評価の結果は記憶されていない。

 lazy val z = {
   println("Computing Z")
   math.random
 }

 z // first access
 // Computing Z
 // res4: Double = 0.18457255119783122
 z // second access
 // res5: Double = 0.18457255119783122 <- 一度目の access での評価の結果が記憶されている
Enter fullscreen mode Exit fullscreen mode

このコードからもわかる通り、val, def, lazy val は、

  1. 右辺がいつ評価されるか
  2. 評価結果はメモ化されるかどうか

の二点において異なります。この違いを以下の表に改めてまとめました。

val def lazy val
キーワードの役割 creates an immutable variable (like final in Java) define a method 文字通り lazy な val
評価のタイミング 即時 遅延 遅延
評価結果はメモ化される? yes no yes

Cats では、これらの評価戦略を抽象化する目的で、Eval という型クラスが提供されています。Eval のインスタンスは、Eval.now, Eval.always, Eval.later を用いて生成することができます(いわゆる Smart Constructor):

import cats.Eval

 val x = Eval.now {
   println("Computing X")
   math.random
 }
 // Computing X
 // x: Eval[Double] = Now(0.681816469770503)

 x.value // first access
 // res10: Double = 0.681816469770503
 x.value // second access
 // res11: Double = 0.681816469770503

 val y = Eval.always {
   println("Computing Y")
   math.random
 }
 // y: Eval[Double] = cats.Always@414a351

 y.value // first access
 // Computing Y
 // res12: Double = 0.09982997820703643
 y.value // second access
 // Computing Y
 // res13: Double = 0.34240334819463436

 val z = Eval.later {
   println("Computing Z")
   math.random
 }
 // z: Eval[Double] = cats.Later@b0a344a

 z.value // first access
 // Computing Z
 // res14: Double = 0.3604236919233441
 z.value // second access
 // res15: Double = 0.3604236919233441
Enter fullscreen mode Exit fullscreen mode

コードを実行した際の挙動(コメントアウトされている箇所)からもわかるとおり、三種類のスマートコンストラクタはそれぞれ val, def, lazy val に対応しており、[Eval のインスタンス].value にアクセスした際の挙動が異なっています。

私個人として、Eval を用いて複数の評価戦略を抽象化することのメリットは、以下の二点だと考えています:

  1. Eval のインスタンスが map メソッド及び flatMap メソッドを持つ
  2. Eval のインスタンスに対して for expression (fp-ts や Haskell  の Do-notation 相当のもの)を使うことができる

以下のコードに例を示します:

val ans = for {
  a <- Eval
    .now { println("Calculating A"); 38 }
    .map(a => { println("map a to a'"); a + 2 })
  b <- Eval
    .always { println("Calculating B"); 1 }
    .map(b => { println("map b to b'"); b + 1 })
} yield {
  println("Adding A and B")
  a + b
}

println(ans.value)
// Calculating A
// map a to a'
// Calculating B
// map b to b'
// Adding A and B
// 42
Enter fullscreen mode Exit fullscreen mode

1 については、A => B な関数や A => Eval[B] な関数があったとき、Eval のインスタンスがどの評価戦略を抽象化したものであっても同じように使うことができるという強みがあります。上記のコード例では実行順序をわかりやすくするために、 Eval.now({...}).map() に渡す式と、Eval.always({...}).map() に渡す式を別のものにしましたが、同じ式を渡すことも可能だったということです。

2 についてはまさにこのコードの通りで、Eval[Int] 型の変数 ab  は、その「中身」にかかわらず同じように for expression の内部で bind できる点が嬉しいです。

fp-ts にも Eval はあるの?

fp-ts に Eval はありません。そもそも TypeScript には lazy val 相当のキーワードが存在しないため、仮に fp-ts で Eval を提供するのであれば、lazy val 相当の機構を実装する必要があります。

過去に fp-ts の GitHub リポジトリに 「Memoize をサポートしませんか?」という旨の issue が立ったことがあるようですが10、「caching はそれ単体で困難な課題であり、fp-ts が扱う範疇を超えている」という理由から close されています。

まとめ

Scala の言語仕様(評価戦略、高カインド型、implicit キーワード、etc.)に着目して、Cats と fp-ts の主な違いを確認していきました。まとめというよりは私の感想になりますが、やはり言語仕様のレベルでやろうとしていないことをライブラリで頑張るのは大変なのだなと思いました。今回扱ったトピックの中では、特に高カインド型のセクションをお読みいただくとその大変さの一端が伝わるのではないかと思います。ライブラリの開発・メンテナンスをしてくださっている人々には頭が上がらないぜという気持ちです。

広告

この記事の一部は、私が所属している株式会社 HERP の業務時間中に執筆されました。株式会社 HERP では、就業時間の一部をテックブログのエントリ執筆に当てることができます。

株式会社 HERP では fp-ts をヘビーユースしており、また Haskell などの他の関数型プログラミング言語の採用実績もあります。Haskeller も開発チームに多く在籍しています(私は Haskell をほとんど使えませんが…🥺)。弊社の技術スタックやエンジニア組織に興味がある方は、Twitter DM や、マシュマロ(匿名で私に質問を投稿できるサービス)を通してご連絡ください。

Twitter: https://twitter.com/e_ntyo

マシュマロ: https://marshmallow-qa.com/e_ntyo

株式会社 HERP エンジニア向け採用資料: https://github.com/herp-inc/engineering-careers

また、テキストでのやり取りは面倒なのでオンラインでさくっとお話したい、という方は Meety をご利用ください。

Meety: https://meety.net/matches/obYSNNmaztSy

COVID-19 Pandemic の状況が改善次第、ぜひランチなどもいたしましょう。皆様とお話ができることを楽しみにしております。

ここまで記事をお読み頂き本当にありがとうございました!


  1. iintyo on Twitter: "社内で "Scala with Cats" の輪読会を始めました。輪読会の第一回で使用したノートを HERP TechNote で公開しております📓 https://scrapbox.io/herp-technote/Scala_with_Cats_%E8%BC%AA%E8%AA%AD%E4%BC%9A" https://twitter.com/e_ntyo/status/1395296546346868737 

  2. "Who's using fp-ts? · Issue #1124 · gcanti/fp-ts" https://github.com/gcanti/fp-ts/issues/1124#issuecomment-583457360 

  3. Cats は最初のリリース(the first non-snapshot version of the Cats library)が2015 年 11 月で、fp-ts の最初のリリース(Initial experimental release)は 2017 年 2 月でした。また、Cats には前身のライブラリとして Scalaz があり、こちらの最初のリリースは 2010 年です。 

  4. fp-ts のいくつかの実装は、Cats から porting されています。v2.0.0 の時点では、Semigroup と Monoid のコードに "Adapted from https://typelevel.org/cats" とありますし、思想や設計などについても Cats やその他のライブラリ・プログラミング言語から影響を受けていると思われます。 

  5. 本当は全 11 章で、第 9 章から第 11 章までは Case Study (第 8 章までの内容を踏まえて、Real World な問題・仕様をどう Functional な実装に落とし込むか)的な内容になっており、今回の輪読会では扱いませんでした。 

  6. 以下の内容を日本語訳し、補足を加えました。 "How to write type class instances for your data type" https://github.com/gcanti/fp-ts/blob/2.11.8/docs/guides/HKT.md 

  7. "3. Using GHCi — Glasgow Haskell Compiler 9.0.1 User's Guide" https://downloads.haskell.org/~ghc/9.0.1/docs/html/users_guide/ghci.html#ghci-cmd-:kind 

  8. "猫番 — 型を司るもの、カインド" https://eed3si9n.com/herding-cats/ja/Kinds.html 

  9. この指摘は、こちらの記事で書かれていた内容です: "Encoding HKTs in TS4.1 - DEV Community 👩‍💻👨‍💻" https://dev.to/matechs/encoding-hkts-in-ts4-1-1fn2 

  10. "Add memoize · Issue #1138 · gcanti/fp-ts" https://github.com/gcanti/fp-ts/issues/1138 

Discussion (0)