TypeScriptと型クラス
HaskellやScalaなどの関数型言語の特徴として型クラスというものがある。
これは関数型における強力な道具の一つであり、そして何もHaskellやScalaなどの特権機構ではない。
サポートの大小はあれど他の言語でも実現できる。
そして最近ではRustやGoが型クラスに似たような思想のセマンティクスを採用していることからもわかるように非常に便利である。
この記事では型クラスの概念を理解し、それをTypeScriptで(最低限)どの様に実現することができるか説明する。
型クラス(Typeclass)とは
型クラスの目的はJava/C++のOOPスタイルの継承機構と同様にいろんなデータ構造に対して共通の構造を定義することである。
更にその共通部分をしてある種同じ存在だとみなすことでいわゆる多相性を実現する。
また型クラスによって定義された共通部分に対してのみコードを書くことによって、再利用性の高いコードが実現できる。
言葉だけでは何のことかわからないと思うので具体例を通して言葉の定義を見ていこう。
型クラス
まずは型クラスをコードにすると一体何に当たるのかを見たい。
以下は型クラスの一種であるSemigroupと呼ばれる構造の定義である。
interface Semigroup {
concat: (x: A, y: A) => A
}
このようなinterfaceの定義を型クラスという。
型クラスは型パラメタを取り、そのパラメタに対して行える操作を定義する。
例えば上のSemigroupではAに対してある種足し算のような操作を定義している。
型クラスという言葉
「型」や「クラス」といったものがいわゆるOOPのそれを連想させるが、全く別物だと考えてほしい。あくまでも型クラス(Typeclass)はそれだけで一つの言葉であり、上で定義したように何らかの型Aに対して操作を定義した構造であるという以上の意味はない。
型クラスのインスタンス
上で型クラス自体は定義したが、実際にこれを使うときはどうしたら良いだろう?
それはinterface、つまり抽象として存在する型クラスを具象に落とし込めば良いのである。
例えばstringに対してSemigroupの操作を与えよう。
const strSemi: Semigroup = {
concat: (x: string, y: string) => x + y
}
この様にある特定の型(今回はstring)に対して型クラスを具象化したものを型クラスのインスタンスなどという。
型クラスのインスタンスを使用すれば実際に特定型の上で定義した操作を用いて処理を行うことができる。
console.log(strSemi.concat("a", "b")) // => ab
型クラスのインスタンスはstringやnumberのようにプリミティブなものにだけではなく、もっと複雑なものに対してもインスタンス化できる。
type Book = {
name: string;
page: number;
};
const bookSemi: Semigroup = {
concat: (x: Book, y: Book) => ({
name: x.name + "/" + y.name,
page: x.page + y.page,
}),
};
console.log(
bookSemi.concat({ name: "animal", page: 100 }, { name: "dict", page: 200 })
);
// => { name: 'animal/dict', page: 300 }
bookに与えた操作は名前を/で連結してpageを足すような操作である。
この様にどんな型に対しても型クラスのインスタンスを定義できる。
インスタンスという言葉
ここでも注意してほしいのは、「型クラスのインスタンス」というのはいわゆるOOPのインスタンスとは違う言葉である。この文脈でのインスタンスはあくまでも上で定義したような型クラスをある型に対して具象化したものだと思ってほしい。
型クラスと代数系
型クラスを用いて代数系を構成することもできる。
代数系とは簡単にいうと以下の3つから構成されるものである。
- 何らかの集合A
- その集合の上で定まる操作の集合
- 操作が満たすべき法則(law)
この3つを指して代数系などと呼ぶ。
1と2は上で見たように型クラスの定義とフィットする。
例えばSemigroup<A>に関して言えば、インスタンス化の際にAに何らかの型を入れ込めばそれがある意味対象の集合になる。
そしてSemigroup<A>.combineはAの上で定義された演算である。
具体例を見れば、上で見たようなSemigroup<string>はstring集合上で構成されたSemigroup代数系になる。
そして代数系に必要な3つ目の要素がまだ言及されていない。
Semigroupは代数系の一種であるから、もちろん法則が存在する。
それは以下のようなものだ。
concat(x, concat(y, z)) = concat(concat(x, y), z)
これを結合法則(Associativity)などという。
中学校の数学とかでやったあれ。
lawは型では保証できないので多くの場合テストで保証される。
lawがあることによって数学的な対象をコードで表現できるだけではなく、その数学的性質を利用してコードの有用性をレバレッジできる。
例えば上のように結合法則があると、計算の全体のどの部分から計算しても良くなるのでMapReduceなどの並列計算と相性が良かったりする。
型クラスはこの様に代数系を構成できるが、すべての型クラスが必ずしも代数系を構成するとは限らない。
型クラスの有用性
型クラスはどのようなものかがわかったところで、その有用性を見ていきたい。
アドホック多相
プログラミング言語が大きく関心を占めるものに多相性がある。
これはコードをどこまで論理的に同じものだとみなせるかという技術である。
有名な多相性には「パラメータ多相」や「派生型」があり、それと並んで「アドホック多相」が存在する。
パラメータ多相とはいわゆるジェネリクスであり、これを用いることである種様々な型に対して同じようなコードがかける。
そして派生型はsupertypeとその子に当たるsubtypeを関係性として表現することで、子を親として扱うことができるという技術である。
このどちらも広く知れ渡っているので説明は程々にしてアドホック多相とは何か、特に型クラスを使用してアドホック多相をどの様に実現できるかを見たい。
アドホック多相とは簡単に言えば関数のオーバーロードの様に処理の対象の型によって処理を変えるような多相性のことを言う。
具体例を見よう。
例えば以下のようにstringに変換する処理を持つことを保証する型クラスがあるとする。
interface ToString { toString: (a: A) => string; }
更にこの型クラスを使用して多相的に振る舞う関数showを定義する。
const show =
(instance: ToString) => (a: A) => console.log(instance.toString(a));
この関数はある型Aに対してインスタンス化されたToStringを受け取る。
そしてそのインスタンスを利用してA型のオブジェクトをstringにし、consoleに出力する。
ここでDogとPerson型を定義し、それらに対してのToStringインスタンスを定義しよう。
type Dog = {
name: string;
};
type Person = {
firstName: string;
lastName: string;
};
const dogToString: ToString = {
toString: (a) => a.name,
};
const personToString: ToString = {
toString: (a) => a.firstName + " " + a.lastName,
};
型クラスを使用することでshowに対してDogだろうがPersonだろうがshowに突っ込むことができるようになる。
show(dogToString)({ name: "john" }); // => john
show(personToString)({ firstName: "taro", lastName: "tanaka" }); // => taro tanaka
派生型との違い
ここまでのアドホック多相の例を見て、継承などを使用する派生型でも同じような多相性を実現できると思うかもしれない。
確かに上の例は派生型を用いて以下のように書き換えることができる。
interface ToString { toString(): string; } class Dog implements ToString { name: string; constructor(name: string) { this.name = name; } toString(): string { return this.name; } } class Person implements ToString { firstName: string; lastName: string; constructor(firstName: string, lastName: string) { this.firstName = firstName; this.lastName = lastName; } toString(): string { return this.firstName + " " + this.lastName; } } const show =` `(a: A) => console.log(a.toString()); show(new Dog("john")); // => john show(new Person("taro", "tanaka")); // => taro tanaka
しかしshowをnullなどのプリミティブに対して使用する際に困ることになる。
自分が定義に触ることのできないnullなどに対して派生型ではToString制約を設けることができない。
show(null); // => Argument of type 'null' is not assignable to parameter of type 'ToString'.
しかし型クラスならばnullに対してインスタンスを用意するだけで容易に多相性を実現できる。
const nullToString: ToString = {
toString: (a: null) => "null",
};
show(nullToString)(null); // => null
この様に型クラスは派生型よりも柔軟に多相性を実現することができる。
パラメトリック多相の補助
パラメトリック多相はそれだけでも十分強力だが、型クラスの補助があることでより表現力を増す。
例えば以下のようにArray<number>の中身を足す関数sumを考えよう。
const sum = (as: number[]) => as.reduce((a, b) => a + b, 0);
console.log(sum([1, 2, 3])); // => 6
次はこの関数を一般化してArray<A>に対してaddを定義したい。
しかしこれはただでは定義できない。
const sumGeneric =
(as: A[]) => as.reduce((a, b) => a + b, ?) // Operator '+' cannot be applied to types 'A' and 'A'. // Argument expression expected. // A型のa,bに対して+は定まっていない & 初期値が定められない
つまりただジェネリクスを使用するだけではこの多相性は実現できないことになる。
ここで活躍するのが型クラスであり、上の処理で足りない部分を補う型クラスMonoidを定義しよう。
interface Monoid
{ empty: A; concat: (a: A, b: A) => A; }
Monoidに対するインスタンスをいくつか定義してみる。
const numberMonoid: Monoid = {
empty: 0,
concat: (a, b) => a + b,
};
const dogMonoid: Monoid = {
empty: { name: "" },
concat: (a, b) => ({
name: a.name + "/" + b.name,
}),
};
そしてMonoidを使用することによって定義できるgenericなsumを定義して使ってみる。
const sumGeneric =
(mon: Monoid) => (as: A[]) => as.reduce(mon.concat, mon.empty); console.log(sumGeneric(numberMonoid)([1, 2, 3])); // => 6 console.log(sumGeneric(dogMonoid)([{ name: "big" }, { name: "john" }])); // => { name: '/big/john' }
数だろうが犬だろうが(犬を足すというのは意味がわからないが)一応sumすることができた。
この様にアドホック多相はパラメトリック多相に対して補助的に振る舞うことができる。
このパラメトリック多相の問題点はGoのジェネリクス実装にあたって大きく意識されているようで、実際にGoに導入されるジェネリクスには型クラスでの補助機能相当の機能が入っている。
https://medium.com/eureka-engineering/golang-generics-design-draft-linked-list-4d1174e2355d
型クラスの自動導出
この節で書く型クラスのメリットは残念ながらTypeScriptで享受できないので流し見程度に見てほしい。
だが「型クラスの自動導出」とでも表現できる機能は本当に強力である。
パラメトリック多相に絡む問題なのだが、パラメタが具体化したときにその各々の具体化に対して違う実装をしたいことがある。
例えばArray<number>とArray<string>に対して違う実装を施すようなものである。
そしてArray<T>はTに何でも入る可能性があるのが更に問題をより一層複雑にする。
Array<Array<T>>の様にネストすることも可能であり、その各々のバリエーションに対して異なる実装を用意することは現実的ではない。
他の多相性でこのような実装は非常に複雑、または不可能であることは想像に難くないし、たとえ型クラスを使おうとも難しい気がする。
しかしこれを可能にするのが型クラスの自動導出という機能である。
自動導出のために必要なのがコンパイラの補助であり、型を実装のタグとしてコンパイル時に実装を型ごとに確定させる。
またコンパイル時の型クラスによる実装の確定は、この際に別の型クラスが必要な場合はそれを更にさかのぼって確定させていく。
これを型クラスの自動導出とか呼んだりする。
ざっと書いたがもっと詳しく知りたい人は以下の記事たちを参照することをおすすめする。
https://typelevel.org/cats/typeclasses.html
終わり
型クラスについてさらっと書いてみたが、型クラスの有用性や見方はもっとある。
しかし実用上でこれくらい知っておけば型クラスについて理解していると言ってもいいと思う。