Haskellの型クラスは、うまく使えば高いパフォーマンスと抽象度を両立できる、優れた仕組みである。その使い方のコツは、決して理解の難しいものではない。
小さな性質、大きな恩恵
プログラマは大きなものを小さく見せがちだ。オブジェクト指向プログラミングに慣れている人がやりがちなアンチパターンとして、欲しい機能と、それを分割する基準が現実に寄りすぎていて、一つ一つが巨大というものがある。
普通のプログラミングではありえない例かもしれないが、たとえば家を作りたいことを考える。「ベッド」「箪笥」「台所」「冷蔵庫」「トイレ」「風呂」のように設備ごとに分けた抽象化をしたいと考えるだろう。確かにこれは理に適っているように見える。だが、これらの設備を型クラスでまとめるのは悪手だ。
風呂やトイレには水を利用できるという性質が、冷蔵庫には電気が必要だ。部屋と部屋は壁で仕切られ、場合によっては扉があるかもしれない。水を伝えるにはパイプで繋がなければいけない。電気を取り出すにはコンセントで繋ぎ、扉を取り付けるには蝶番がなければ。繋ぐと一言で言っても、その方法は様々である。「繋がれたものは安定して機能を果たせる」という論理的な共通点から、様々な操作を「繋ぐ」という言葉で抽象化している。このレベルの話でやっと型クラスが有用になってくる。
コンパイル時の定め
Semigroup
という型クラスがsemigroups
パッケージで定義されている。これは半群という代数的構造を表すクラスで、二項演算(<>)
を持つ。
class Semigroup a where (<>) :: a -> a -> a
ただし、正当なSemigroupになるには条件があり、どんなa, b, cに対してもa <> (b <> c) == (a <> b) <> c
が成り立たななければいけない。
Semigroupの例として、リストとその結合、数値の足し算や掛け算などがある。()
の場合は何もしない。
instance Semigroup () where () <> () = () instance Semigroup [] where (<>) = (++) instance Num a => Semigroup (Sum a) where Sum a <> Sum b = Sum (a + b) instance Num a => Semigroup (Product a) where Product a <> Product b = Product (a * b)
型さえはっきりしていれば、コンパイル時にインスタンスが選択され、(<>)
はふさわしい実装で置き換えられる。これに関してパフォーマンスで心配する必要はないし、繰り返し使うのにも適している。一方、仮に風呂が型クラスのメソッドだったとしても、風呂をたくさん置く家はあまりないだろう。
実行の前奏曲
GHC 7.10以前は、Preludeのfoldr
などの関数はリスト専用だった。7.10以降は、Foldable
のメソッドとして一般化されている。これによって今までのコードが遅くなる心配をする必要はない。実際にリストに対するfoldr
を含むプログラムを-ddump-rule-rewrites
オプションをつけてコンパイルすると、リスト専用のfoldr
に置換されるのを確認できる。
Rule fired Rule: Class op foldr Before: Data.Foldable.foldr TyArg [] ValArg Data.Foldable.$fFoldable[] TyArg GHC.Types.Int TyArg GHC.Types.Int ValArg GHC.Num.$fNumInt_$c+ ValArg GHC.Types.I# 0 ValArg GHC.Enum.$fEnumInt_$cenumFromTo (GHC.Types.I# 0) i_aqJ After: GHC.Base.foldr Cont: Stop[BoringCtxt] GHC.Types.Int
コンパイル時の置換をより積極的に利用した例としてlens
パッケージも挙げられる。Lensはゲッターとセッターの対から構築されるアクセサである。
lens :: Functor f => (s -> a) -> (s -> b -> t) -> (a -> f b) -> s -> f t lens getter setter f s = fmap (setter s) (f (getter s))
view
やset
は、Lensが使うfmap
をConst
やIdentity
のために特殊化することでゲッター、セッターとしての機能を取り出している。
view :: ((a -> Const a a) -> s -> Const a s) -> s -> a view l = getConst . l Const set :: ((a -> Identity b) -> s -> Identity t) -> b -> s -> t set l b = runIdentity . l (Identity . const b)
実際に式を変形するとその挙動は明らかだ。lens
パッケージはcoerce
なども併用することで、以下の変形をコストなしで実現している。なんたる業前か!
view (lens getter setter) s = getConst $ fmap (setter s) (Const (getter s)) = getConst $ Const (getter s) -- fmap _ (Const x) = Const x = getter s set (lens getter setter) b s = runIdentity $ fmap (setter s) (Identity (const b (getter s))) = runIdentity $ fmap (setter s) (Identity b) = runIdentity $ Identity (setter s b) -- fmap f (Identity a) = Identity (f a) = setter s b
こうしてみると、型クラスは「複雑な処理をまとめる」というよりは「特定の性質を持つ処理を最適な形で具体化する」ものと見ることができる。たいていの型クラスは半群や関手のような代数的な構造であり、ライブラリとして既に定義されている場合がほとんどである。その観点では、ユーザーである私たちは、あえて新しいクラスを定義する必要は基本的にないのだ。私はHaswerkというMinecraftクローンを開発しているが、今のところ独自のクラスは宣言しておらず、これから作る予定もない。
まずは、Haskellのエコシステムに鎮座する型クラスの数々を最大限に活用しよう。型クラスは抽象化のポータル(玄関口)である。クラスのメソッドを使うことで、あるいはインスタンスを定義することで、互いに再利用できるコードが生まれる――インスタンスが満たす「性質」を頼りにして。