fp-ts ユーザが Scala with Cats を読み終えたので、fp-ts と Cats の違いをまとめてみた
e_ntyo
Posted on February 28, 2022
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]
...
}
trait Functor[F[_]]
と書くことで、ブロックの内側で F[A]
や F[B]
という型を使うことができています。ここでは、Type Constructor F
はカインドが * -> *
の型(例えば、Option[A]
や List[A]
)をエミュレートしています。このおかげで、Functor
の Option[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),
};
ここで、 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>;
}
では、 URItoKind
、 URIS
、 Kind
はそれぞれ何でしょうか?
URItoKind
は type-level map です。型 URI
をある特定の型に map します。TypeScript の module augmentation を利用して、それぞれの型を URItoKind
に登録しています。
具体的には、HKT.ts
に URItoKind
が定義されていて:
// fp-ts/lib/HKT.ts
export interface URItoKind<A> {}
module augmentation を用いて、 Identity.ts
に、以下のように「Identity
を URItoKind
に登録する」コードを書くことができます:
// Identity.ts
declare module "fp-ts/lib/HKT" {
interface URItoKind<A> {
readonly Identity: Identity<A>; // maps the key "Identity" to the type `Identity`
}
}
URIS
は keyof 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>
へと写されます。URI
が URI extends URIS
を満たさない、例えば HOGEHOGE
という値だった場合には、 any
型となります。
ここで、 Identity
は kind が * -> *
(Option
や List
と同じ) でしたので 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))),
};
ここで、 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>;
}
では、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);
}
実はここまでに触れられてこなかった HKT
という型がようやく登場しました。定義は以下の通りです。
// fp-ts/lib/HKT.ts
export interface HKT<URI, A> {
readonly _URI: URI;
readonly _A: A;
}
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);
以上のコードは、以下のように compile error となります:
Argument of type 'Functor1<"Identity">' is not assignable to parameter of type 'Functor<"Identity">'
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);
}
lift
が Functor
, Functor1
, Functor2
に対応できるようオーバーロードを定義したので、以下のように lift
は Identity
のインスタンスも 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);
だいぶ長くなりましたが以上です。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 :: *
他方、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]
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);
}
これは大変なことですが、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);
}
こちらの lift
の定義では、Kind1
, Kind2
などのために個別にオーバーロードを定義する必要がなくなっています。一方で、個人的には初見で URItoKind
や Kind
の定義を見たときの認知的負荷は、扱う型パラメータの数が増えた分こちらのほうが比較的きつい印象があります。ただし、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
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,
};
// main.ts
import { eqCat, eqDog } from "eq-instances";
// Eq.apply[Cat] みたいなことはできないので、`eqCat`, `eqDog` をそれぞれ直接 import している
eqCat.equals(cat1, cat2);
eqDog.equals(dog1, dog2);
また、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")))
}
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)
上記のコードでは、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!
}
cats/core/src/main/scala/cats/syntax/either.scala
では、まず AnyVal
を継承したクラス EitherIdOps
を定義しています。EitherIdOps
はコンストラクタの引数に型 A
の値 obj
を取りますが、この obj
に asLeft
等のメソッドを生やしていこうという魂胆です。
次に、EitherSyntax
という trait を定義し、ここで implicit final def catsSyntaxEitherId[A](a: A): EitherIdOps[A] = new EitherIdOps(a)
を定義しています。これは任意の型 A
の値 a
を EitherIdOps
型にキャストしており、したがって 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)
}
// 使うとき
import cats.syntax.eq._ // for === and =!=
123 === 123
123 =!= 234
// type mismatch;
// 123 === "123"
この「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 での評価の結果が記憶されている
このコードからもわかる通り、val
, def
, lazy val
は、
- 右辺がいつ評価されるか
- 評価結果はメモ化されるかどうか
の二点において異なります。この違いを以下の表に改めてまとめました。
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
コードを実行した際の挙動(コメントアウトされている箇所)からもわかるとおり、三種類のスマートコンストラクタはそれぞれ val
, def
, lazy val
に対応しており、[Eval のインスタンス].value
にアクセスした際の挙動が異なっています。
私個人として、Eval
を用いて複数の評価戦略を抽象化することのメリットは、以下の二点だと考えています:
-
Eval
のインスタンスがmap
メソッド及びflatMap
メソッドを持つ -
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
1 については、A => B
な関数や A => Eval[B]
な関数があったとき、Eval
のインスタンスがどの評価戦略を抽象化したものであっても同じように使うことができるという強みがあります。上記のコード例では実行順序をわかりやすくするために、 Eval.now({...}).map()
に渡す式と、Eval.always({...}).map()
に渡す式を別のものにしましたが、同じ式を渡すことも可能だったということです。
2 についてはまさにこのコードの通りで、Eval[Int]
型の変数 a
と b
は、その「中身」にかかわらず同じように 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 の状況が改善次第、ぜひランチなどもいたしましょう。皆様とお話ができることを楽しみにしております。
ここまで記事をお読み頂き本当にありがとうございました!
-
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 ↩
-
"Who's using fp-ts? · Issue #1124 · gcanti/fp-ts" https://github.com/gcanti/fp-ts/issues/1124#issuecomment-583457360 ↩
-
Cats は最初のリリース(the first non-snapshot version of the Cats library)が2015 年 11 月で、fp-ts の最初のリリース(Initial experimental release)は 2017 年 2 月でした。また、Cats には前身のライブラリとして Scalaz があり、こちらの最初のリリースは 2010 年です。 ↩
-
fp-ts のいくつかの実装は、Cats から porting されています。v2.0.0 の時点では、Semigroup と Monoid のコードに "Adapted from https://typelevel.org/cats" とありますし、思想や設計などについても Cats やその他のライブラリ・プログラミング言語から影響を受けていると思われます。 ↩
-
本当は全 11 章で、第 9 章から第 11 章までは Case Study (第 8 章までの内容を踏まえて、Real World な問題・仕様をどう Functional な実装に落とし込むか)的な内容になっており、今回の輪読会では扱いませんでした。 ↩
-
以下の内容を日本語訳し、補足を加えました。 "How to write type class instances for your data type" https://github.com/gcanti/fp-ts/blob/2.11.8/docs/guides/HKT.md ↩
-
"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 ↩
-
"猫番 — 型を司るもの、カインド" https://eed3si9n.com/herding-cats/ja/Kinds.html ↩
-
この指摘は、こちらの記事で書かれていた内容です: "Encoding HKTs in TS4.1 - DEV Community 👩💻👨💻" https://dev.to/matechs/encoding-hkts-in-ts4-1-1fn2 ↩
-
"Add
memoize
· Issue #1138 · gcanti/fp-ts" https://github.com/gcanti/fp-ts/issues/1138 ↩
Posted on February 28, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.