Scala with Cats 輪読会 第一回ノート
tl;dr
e_ntyo.icon が書きました。
このページは、Scala with Cats 輪読会 の第一回でカバーされる箇所をe_ntyoが読み、重要な箇所をノートとしてまとめたものです。次回以降の担当者はこれにならって(あるいは反面教師として)ノートを事前に作成するようにしてください。 0. 事前準備
多分実際に教材のコードを動かしたくなると思うので、各自 Scala(sbt) のインストールと sandbox プロジェクトの作成を済ませておいてください。Template projects のセクションで紹介されている通り、sbt new scalawithcats/cats-seed.g8 でプロジェクトテンプレートが生成されます。
1 Introduction
基本的には「型クラス(Type Class)」についての章です
Cats では様々な 型クラスが提供されている
Option
List
Either
etc.
この章では、Eq と Show を例に Scala (Cats)における型クラスのつくりかた・つかいかたを見ていく
Scala (Cats)における型クラスのつくりかた・つかいかたを見ていく
ざっくりこう:
trait: type classes - 型クラスそのものを表現する、e_ntyo.iconは TypeScript の interface だと思っている
implicit value: type class instance - trait を実装したもの。TypeScript だったら、interface に生やしたプロパティに具体的な値を入れるイメージ
code:typescript
interface Dog {
bark(): void
}
const dog: Dog = {
bark: () => { console.log("bow!") },
}
implicit parameter : type class use
(implicit class: optional utilities that make type classes easier to use) <- これはまああとで
By example でみていきます:
code:json.scala
package Json
// Define a very simple JSON AST
// sealed: 同一ファイル内からのみ継承可能
sealed trait Json
final case class JsObject(get: MapString, 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 きました、これ型クラスです
def write(value: A): Json
}
object JsonWriterInstances {
// implicit value きました、これ型クラスのインスタンス
implicit val stringWriter: JsonWriterString = def write(value: String): Json =
JsString(value)
}
implicit val personWriter: JsonWriterPerson = def write(value: Person): Json =
JsObject(
Map(
"name" -> JsString(value.name),
"email" -> JsString(value.email)
)
)
}
implicit def optionWriterA(implicit new JsonWriter[OptionA] { def write(option: OptionA): Json = option match {
case Some(aValue) => writer.write(aValue)
case None => JsNull
}
}
// etc...
}
object Json {
// implicit parameter きました、型クラスを関数において使う場合このように定義します
def toJsonA(value: A)(implicit w: JsonWriterA): Json = w.write(value)
}
final case class Person(name: String, email: String)
object Main extends App {
import JsonWriterInstances._
// 1. Json.toJson は implicit parameter として JsonWriterA をとる // 2. JsonWriterPerson の実装を、 implicit variable として JsonWriterInstances.personWriter に定義してあるので、コンパイラがそれを見つける println(Json.toJson(Person("Dave", "dave@example.com")))
println(Json.toJson("hello!"))
// コンパイラは以下のフローで対応する実装を探してくれている
// Json.toJson(Option("A string"))(optionWriterString) -> Json.toJson(Option("A string"))(optionWriter(stringWriter)) println(Json.toJson(Option("Hoge")))
}
簡単ですね。この例では JsonWriter という型クラスを定義しています。こいつの役割はその名の通り Scala 上の任意の型 A から Json をつくることです。教材でこの後に出てくる型クラスとして Show (Printable) がありますが、これは A の String 表現を得る役割で、これのJsonバージョンと理解することができます。
こいつの役割はその名の通り Scala 上の任意の型 A から Json をつくることです
コードの上では、これは def write(value: A): Json という関数でもって実現されています。したがって、A ごとにこの write という関数の実装を定義していくことになります。実装を定義するために、 implicit val を使う方法と implicit def を使う方法がありますが、まず implicit val の方を見ていきます。
code:jsoninstances.scala
object JsonWriterInstances {
// implicit value きました、これ型クラスのインスタンス
implicit val stringWriter: JsonWriterString = def write(value: String): Json =
JsString(value)
}
}
ここでは、A = String ということで、 String の Json 表現を得るための式を書いています。具体的には、単に JsStringに渡しているだけです。次に、 implicit def を使う場合を見ていきます。
code:jsoninstances.scala
object JsonWriterInstances {
implicit def optionWriterA(implicit new JsonWriter[OptionA] { def write(option: OptionA): Json = option match {
case Some(aValue) => writer.write(aValue)
case None => JsNull
}
}
}
}
implicit def は、A が高階型である場合に便利です。というのも、例えば A = Option<T> の実装を愚直に用意しようと思うと、
code:foolish.scala
implicit val optionIntWriter: JsonWriter[OptionInt] = ???
implicit val optionPersonWriter: JsonWriter[OptionPerson] = ???
implicit val optionStringWriter: JsonWriter[OptionString] = ???
// and so on...
といった具合でめっちゃ大変なので、「Option<T> をとって、 JsonWriter[Option[T]] にマップする関数を自分で定義します!」というやり方が implicit def を使うアプローチです。上の例では、パターンマッチを利用して Some 出会った場合には T の既存の write 実装を使い、 None であった場合には JsNullという値にマップする、という式を書いています。
ちなみに、↑の関数たちはすべて JsonInstances という object の中に定義されています。これは、Aについての write の実装が2つ以上存在してしまった場合に、コンパイラくんが「わかんないよ〜!」となってしまうために隠蔽しているという背景があります。例えば、Cats のようなライブラリを使うと、ある型クラスについて、プリミティブ型に対する実装が提供されている場合がありますが、この実装が自分たちがすでに定義してあった実装とコンフリクトする、といったケースが考えられます。このとき、型クラスごとに実装(インスタンス)が object 内で閉じていれば、適時使いたいときにだけその object を import して使うようにすることでコンフリクトのリスクを下げることができるはずです。
code:importobject.scala
// object 内のインスタンスを import して、
import JsonWriterInstances._
// 使う
println(Json.toJson(Person("Dave", "dave@example.com")))
そしてここでしれっと 、これまでに定義してきた JsonWriter およびその実装を Json.toJson から使っています。Json.toJSon というのはどういう関数だったかというと、
code:toJson.scala
object Json {
// implicit parameter きました、型クラスを関数において使う場合このように定義します
def toJsonA(value: A)(implicit w: JsonWriterA): Json = w.write(value)
}
という定義で、ここで implicit parameter としてここまで一生懸命定義してきた JsonWriter[A] の出番がやってきます。ここで今更 Scala における implicit というキーワードについて触れておくと、"implicit" (暗黙的, 対: explicit((明示的)))という言葉の通り、「暗黙的に書いておいて、具体的にどうするかはコンパイラに調べさせる」という理解でとりあえずは問題ないと考えています。つまり、implicit parameter の場合には、「JsonWriter[A] っていう型クラスを使うから、あとは引数をもらうときに具体的な型 A がわかるから、対応する実装を使うようにしてね!よろしく!」といった具合になるわけです。
println(Json.toJson(Person("Dave", "dave@example.com")))のようにして使うときには、 A = Person となりますから、コンパイラは JsonWriter[Person] の実装を探しに行きまして、これが implicit val personWriter: JsonWriter[Person] = ...として定義されていますんでそれが使えるね!めでたしめでたし。となります。これが基本的なScalaにおける型クラスのつくりかた・つかいかたです。
1.1 Show Type Class
では、Cats で実際に定義されている型クラスを見ていこうと思います。まずは Show で、これは前に書いたとおり A からその文字列表現を得るための型クラスです。Cats における Show 型クラスの(簡略化された)定義は以下の通りです(本当の型定義を知りたい人はこちら): code:show.scala
package cats
def show(value: A): String
// 以下略
}
object Show extends ScalaVersionSpecificShowInstances with ShowInstances {
def applyA(implicit instance: ShowA): ShowA = instance // 以下略...
}
簡単ですね。 apply という関数は、Catsが提供するすべての型クラスに存在しています。apply[A](implicit instance: Show[A]): Show[A]というシグネチャからわかるとおり、Show.apply[Int]のように呼び出すことで、Show 型クラスの Int の実装を探して値を返してくれる便利関数です。以下のように使います:
code:show.scala
// 1.4.2 Importing Default Instances
import cats.Show
import cats.instances.int._ // for Show
import cats.instances.string._ // for Show
val showInt: ShowInt = Show.applyInt // "1" を出力する
println(showInt.show(1))
// "bluh" を出力する
println(showString.show("bluh"))
import cats.instances.int._ は、JsonWriter の例で JsonWriterInstances を定義したときのように、Cats が提供してくれている Show の Int に対する実装を import しています。(その後に String に対する実装も import している。)
ちなみに、 val showInt: Show[Int] = Show.apply[Int]; println(showInt.show(1)); のように毎度 instance を作成するのは手間なので、Cats では型クラスごとに syntaxというモジュールが用意されており、これを別途 import することで、プリミティブ型についてはより簡単に当該型クラスのインスタンスのメソッドを呼び出すことができるようです。Show の場合は以下のとおりです。
code:show.scala
import cats.syntax.show._ // for show
val shownInt = 123.show
// shownInt: String = "123"
val shownString = "abc".show
// shownString: String = "abc"
Int と String について、Show のインスタンスを作成することなく、それらの型の値にメソッドを生やす形で show 関数を使うことができています。
最後に、プリミティブ型ではなく、独自型(hubの Candidacy みたいな )について、Show 型クラス(に限らずあらゆる型クラス)のインスタンスを実装してみます。ここでは、以下の Cat という型について考えます。
code:cat.scala
final case class Cat(name: String, age: Int, color: String)
Cat 型は case class (だいぶ違いますが、TypeScript でいう Object みたいなものを考えてください) を用いて定義されており、 name: String , age: Int, color: Stringという3つのプロパティを持ちます。したがって、 Cat 型の値の文字列表現を得る場合には、これら3つの情報が含まれていることが望ましいです。次のような実装が考えられると思います。
code:cat.scala
import cats.Show
import cat.Cat
object ShowInstances {
implicit val catShow: ShowCat = def show(cat: Cat): String =
s"${cat.name} is a ${cat.age} years-old ${cat.color} cat."
}
}
簡単ですね。ちなみに、毎度 new Show[A] { def show(a: A): String = ... } と書くのはダルいので、Cats から Show.show という construction method が提供されています。次のように使います:
code:cats.scala
import cats.Show
import cat.Cat
object ShowInstances {
implicit val catShow: ShowCat = Show.show(cat => s"${cat.name} is a ${cat.age} years-old ${cat.color} cat.")
}
1.2 Eq Type Class
次の型クラスは Eq です。これはある型 A について、その値が等しいか比較するための型クラスです。
code:eq.scala
package cats
def eqv(a: A, b: A): Boolean
// other concrete methods based on eqv...
}
Show 型クラスは def show(value: A): String を実装する必要がありましたが、 Eq の場合は eqv(a: A, b: A): Boolean を実装する必要があります。インスタンスは次のように使われます:
code:eqint.scala
import cats.instances.int._ // for Eq
eqInt.eqv(123, 123)
// res1: Boolean = true
eqInt.eqv(123, 234)
// res2: Boolean = false
Show のプリミティブ型のインスタンスについては、1.show() のようにして簡単にメソッドを呼び出す方法が cats.syntax.show から提供されていました。Eq の場合は、 cats.syntax.eq から === と =!= の2つのオペレータが提供されています。次のように使います。
code:eqint-op.scala
import cats.syntax.eq._ // for === and =!=
123 === 123
// res4: Boolean = true
123 =!= 234
// res5: Boolean = true
もちろん、異なる型同士の値を比較しようとした場合には compile error となります:
code:eq.scala
123 === "123"
// error: type mismatch;
// found : String("123")
// required: Int
// 123 === "123"
// ^^^^^
この件について注意したいのが、Option[A] のような高階型同士の値の比較です。
code:eq.scala
import cats.instances.int._ // for Eq
import cats.instances.option._ // for Eq
// compile error!
Some(1) === None
// error: value === is not a member of SomeInt // Some(1) === None
// ^^^^^^^^^^^
// passes compile!
(Some(1) : OptionInt) === (None : OptionInt) // res8: Boolean = false
まあ皆さんは普段 TypeScript (fp-ts) を使っているためあまり違和感がないかと思うのですが、Some(1) === Noneのような比較は、 Some[Int]とOption[Int]の比較としてコンパイラに解釈されてしまい、結果として 「Eq の Some[Int] のインスタンスがないヨ😁」と言われてしまいます ( fp-ts では none とだけ書くと None[unknown] や Option[unknown] となってやはり同じようなことになると思う) 。(Some(1) : Option[Int]) === (None : Option[Int])のようにして、明示的に型を教えてあげるとうまくいきます。
ちなみに、Option[A]にも Eq や Show と同様に cats.syntax.option._ モジュールがあり、スマートコンストラクタなんかが定義されているようです。
code:option.scala
import cats.syntax.option._ // for some and none
// O.none<T>
// res10: Boolean = false
// res11: Boolean = true
最後に、Eq についても Showのときと同様に独自型についてインスタンスを実装してみます。Cat型のインスタンスをつくります。
code:eq.scala
import cats.instances.long._ // for Eq
import cats.instances.string._ // for Eq
import cats.instances.int._ // for Eq
import cats.syntax.eq._ // for === and =!=
import cats.kernel.Eq
import cat.Cat
object EqInstances {
implicit val catEq: EqCat = cat1.name === cat2.name && cat1.age === cat2.age && cat1.color === cat2.color
}
}
---
1.3 (番外編) TypeClass in fp-ts
code:show.ts
/**
* @category type classes
* @since 2.0.0
*/
export interface Show<A> {
readonly show: (a: A) => string
}
/**
* @category instances
* @since 2.0.0
* @deprecated
*/
export const showString: Show<string> = {
show: (a) => JSON.stringify(a)
}
/**
* @category instances
* @since 2.0.0
* @deprecated
*/
export const showNumber: Show<number> = {
show: (a) => JSON.stringify(a)
}
型クラスは interface で定義している
型クラスのインスタンスは、 const typeClassInstanceForA: TypeClass<A> = { ... } みたいにつくる
TypeScript に implicit 相当の機能はないので、型Aの値についてある型クラスのAのインスタンスを使う、ということは自分で明示的にやる必要あり。コンパイラに探させることは(多分、簡単には)できない(し、少なくともfp-tsではサポートしていない)
code:fp-ts-option-eq.ts
/**
* @example
* import { none, some, getEq } from 'fp-ts/Option'
* import * as N from 'fp-ts/number'
*
* const E = getEq(N.Eq)
* assert.strictEqual(E.equals(none, none), true)
* assert.strictEqual(E.equals(none, some(1)), false)
* assert.strictEqual(E.equals(some(1), none), false)
* assert.strictEqual(E.equals(some(1), some(2)), false)
* assert.strictEqual(E.equals(some(1), some(1)), true)
*
* @category instances
* @since 2.0.0
*/
export function getEq<A>(E: Eq<A = Cat>): Eq<Option<A>> {
return {
equals: (x, y) => x === y || (isNone(x) ? isNone(y) : isNone(y) ? false : E.equals(x.value, y.value))
}
}
code:fp-ts-option-eq.ts
import { getEq } from 'fp-ts/Option';
import { fromEquals, eqString, eqNumber } from 'fp-ts/Eq';
interface Cat {
name: string;
color: string;
age: number;
}
const catEq = fromEquals<Cat>(
(a, b) =>
eqString.equals(a.name, b.name) &&
eqString.equals(a.color, b.color) &&
eqNumber.equals(a.age, b.age)
);
const catOptionEq = getEq<Cat>(catEq);
Cats では A = Option<T> のときの Eq インスタンスの実装は、 implicit の強力な力によって「パターンマッチして 両方 Some のときは A のインスタンスの eqv() に任せる」というかなり雑な書き方でOKだったが、繰り返すように TypeScript にそんなものはないので明示的に Eq<A> のインスタンスを渡す必要がある
Cats の cats.syntax.eq._ に置いてある === や =!= は Type-Safe な operator であったが、TypeScript でこういう operator を定義することはできない
showString.show(1)
上の例の eqString.equals(a.name, b.name), eqNumber.equals(a.age, b.age) のように、instance.equals を呼ぶ必要がある