この記事では、Haskellの型クラスの互換性にまつわる問題と、それに対する緩和策を考える。
ネタとしては11月に行われた Haskell Day 2019 のLTで筆者が話したものである。その時使ったスライドは
で読める。
問題
Haskell標準の型クラス階層には時折、破壊的変更が加えられてきた。代表的なものをいくつか挙げる:
- Applicative-Monad Proposal: ApplicativeクラスをMonadのスーパークラスにする
- Semigroup-Monoid Proposal: SemigroupクラスをMonoidクラスのスーパークラスにする
- MonadFail Proposal: MonadクラスのfailメソッドをMonadFailクラスに分離する
型クラスへのスーパークラスの追加や、メソッドの型の変更、メソッドの削除は、一般には互換性のない変更であり、エコシステムに痛みが伴う。
(一方、メソッドの追加やスーパークラスの削除であれば、互換性は壊れないか軽微であることが多い。Num
のスーパークラスから Eq
と Show
を外す変更は影響がそこまで大きくはなかったと思う。)
こういう時に互換性がなくて特に困るのは、その型クラスのインスタンスを提供する側である。インスタンスを定義せずに、型クラス制約をもつ関数を使うだけのコードはそこまで影響を受けないことが多い。以下、この記事では、インスタンスを定義する側の痛みをなるべく軽減する方法を考える。
インスタンスを定義する側にとって特に問題になるのは、メソッドの削除だ。ライブラリーのあるバージョンで型クラスのメソッドが削除された場合、そのライブラリーの前後のバージョンに対応したインスタンスを書くには、最悪の場合、CPP(Cプリプロセッサ)を使う必要がある。
CPPはなるべく避けたいGHC拡張ナンバーワン(筆者の脳内調べ)である12。型クラスの定義を変更した際にインスタンス提供者がCPPを使わなくて済む方法があればそれに越したことはない。この記事ではそういう方法を模索してみる。
例:Monadの場合
ベタな例だが、適当なモナドをでっち上げる。
newtype Identity a = Identity
instance Monad Identity where
return = Identity
Identity x >>= f = f x
fail = error
Monadクラスの定義が
class Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b
(>>) :: m a -> m b -> m b
(>>) = ...
fail :: String -> m a
fail = ...
だった頃はそれで良かった。
しかし、ある日モナドクラスの定義が置き換えられて、スーパークラスに Applicative
が追加されてしまった。こうなると、先ほどの Identity
モナドの定義はそのままではコンパイルが通らない3。
さらに、 Monad
クラスの定義は将来こんな風に変わるかもしれない:
class Applicative m => Monad m where
(>>=) :: m a -> (a -> m b) -> m b
-- fail に関しては後述
return :: Applicative f => a -> f a
return = pure
(>>) :: Applicative f => f a -> f b -> f b
(>>) = (*>)
スーパークラスが増えただけだったら、 Applicative Identity
のインスタンスを別途定義してやれば良かった。しかし、メソッドが削除されるとなると、 Monad
のインスタンスの定義に手を加える必要がある。
(Monadクラスの変更については Haskell: Monadクラスのこれまでとこれから を参照)
まあ、Applicative-Monadの場合は、Applicativeがスーパークラスになってから return
や (>>)
がクラスから取り除かれるまで猶予がある(というか2019年12月現在では return
や (>>)
を取り除く具体的なスケジュールは出ていなさそうだ)。なので、よっぽど広い範囲のGHCをサポートしたいというのでもなければ、CPPを使うような羽目にはならないだろう。
例:MonadFailの場合
適当なモナドトランスフォーマーを考えよう。ここで、 fail
は、下層のモナド m
の fail
に丸投げすることにする。
-- IdentityT / MonadFail以前
newtype IdentityT m a = IdentityT (m a)
instance Functor m => Functor (IdentityT m) where ...
instance Applicative m => Applicative (IdentityT m) where ...
instance Monad m => Monad (IdentityT m) where
IdentityT a >>= f = a >>= f
fail = IdentityT . fail
さて、MonadFail Proposalにより、GHC 8.0で Control.Monad.Fail
モジュールに MonadFail
クラスが追加され、GHC 8.8で Monad
クラスから fail
メソッドが取り除かれた。というわけで上記のコードはGHC 8.8以降では動かない。
IdentityT
をGHC 8.8に対応させるには、以下のように書き換える必要がある:
-- IdentityT / GHC 8.8対応版
import qualified Control.Monad.Fail as Fail
newtype IdentityT m a = IdentityT (m a)
instance Functor m => Functor (IdentityT m) where ...
instance Applicative m => Applicative (IdentityT m) where ...
instance Monad m => Monad (IdentityT m) where
IdentityT a >>= f = a >>= f
-- fail = IdentityT . fail
instance Fail.MonadFail m => Fail.MonadFail (IdentityT m) where
fail = IdentityT . Fail.fail
GHC 8.8以降ではこのコードで問題ないのだが、GHC 8.6以前では問題が起こる。というのは、 Monad
クラスの fail
の実装を省略してしまうとデフォルト実装である fail = error
が使用され、「下層のモナドの fail
を使う」という意図に沿わなくなるからだ。
というわけで、モナドトランスフォーマーの fail
をGHC 8.8以前と以後の両方に対応させようとすると、CPPが必要となる。
(fail
に関してはもう少し緩やかな移行パスが存在したと個人的には思うのだが、今更言っても遅いしこの記事の主題ではないので省略する)
抽象型クラス
抽象データ型
データ型の詳細(コンストラクター)を隠蔽したものは抽象データ型 (abstract data type) と呼ばれる。抽象データ型の操作は、データ型と一緒にエクスポートされる関数を通してのみ行うことができる。
Haskell標準にある抽象データ型の例として、 Ratio
型を挙げておく。
module Data.Ratio (Ratio, Rational, ...) where
data Ratio a = !a :% !a -- コンストラクター (:%) は公開されない!
Ratio
型の利用者は、コンストラクターである (:%)
にはアクセスできず、 Data.Ratio
モジュールが提供する (%)
や numerator
, denominator
などの関数を通してのみ Ratio
型の値を操作できる。
抽象データ型は、型の不変条件(Ratio
の場合は「分子と分母が互いに素」)を守りやすくなるほか、実装の変更(コンストラクターやフィールドの追加・削除)に強いというメリットを持つ。
抽象型クラス
抽象データ型の型クラス版を考える。つまり、クラスの構成要素であるメソッドが非公開であるような型クラスを抽象型クラスと呼ぶことにする。
型クラスの構成要素であるメソッドが非公開であれば、型クラスの定義を変更しても非互換を最小限に抑えられるのではないだろうか。
例として、GHC組み込みの型レベル自然数から値を取り出す KnownNat
クラスを考える。Haddockを見ると、このクラスは natSing
という唯一のメソッドを持っているようだが、モジュールの外には公開されていない。型すらもわからない。KnownNat
クラスは我々がさっき定義した「抽象型クラス」の例となっている。
しかし、 KnownNat
のメソッドが非公開であっても我々は特に困っていない。GHC.TypeLits
や GHC.TypeNats
モジュールが提供する natVal
という関数を使えば、 KnownNat
制約を利用して実行時に使える値を取り出すことができる。
module GHC.TypeNats (KnownNat, natVal, ...) where
class KnownNat (n :: Nat) where
natSing :: ... -- 非公開
-- 公開
natVal :: KnownNat n => proxy n -> Integer
natVal = ...
実は KnownNat
クラスは GHC 8.2 で実装に変化があった。それまでは Integer
型を内部表現としていたが、 Natural
型を内部表現とするように変わったのだ。
だが、 KnownNat
の内部表現が変わったところで誰も困らない4。一般のHaskellユーザーは KnownNat
クラスのインスタンスを手動で作ることはできないし、唯一のアクセス手段である natVal
関数の型は変わっていないのだから影響の受けようがない。
抽象型クラスは、型クラスを提供する側が実装の詳細を変化させても利用者側の互換性を壊すことがない。これは KnownNat
に限らない。
(もちろん、公開インターフェースの互換性は維持することが大前提となる。KnownNat
の場合であれば natVal
の型を変えないことが互換性を保つために重要だった。)
別の例としては、現在の Typeable
クラスは抽象型クラスになっている。かつては typeOf :: a -> TypeRep
だったり typeRep# :: Proxy# a -> TypeRep
を唯一のメソッドとして持っていたようだが、現在は非公開の typeRep :: TypeRep a
が唯一のメソッドとなっている。KnownNat
と同様、 Typeable
もGHCがインスタンスを用意するため、GHCの都合に合わせて内部表現(メソッドの名前や型)を変えることができる。
抽象型クラスのインスタンスを定義する
抽象型クラスには一つ問題がある。型クラスのメソッドを非公開にしてしまうと、利用者がインスタンスを定義できなくなってしまう5。
KnownNat
や Typeable
の場合はユーザーに手書きのインスタンスを定義させる必要がないのでそれでも良かったが、そうじゃないクラスでメソッドを非公開にすると、インスタンスを作る手段が限定されてしまう。
メソッドが非公開なクラスで独自のインスタンスを作るために利用できる方法といえば、デフォルト実装(instance Foo A where {}
)か、GeneralizedNewtypeDerivingだけ……だった。
そう、DerivingViaの登場までは。
DerivingViaを使うと、「インスタンス提供者がメソッドを実装するクラス」と「クラスの利用者が型制約として利用するクラス」を分離することができる。すると、インスタンス提供者やクラスの利用者の互換性をなるべく損なわずに、クラスの詳細を変えることができる。
具体例をいくつか見てみよう。
例:Monadクラスを抽象型クラスにする
再び、Monadクラスっぽいやつを考える。
class MyMonad m where
(>>=) :: m a -> (a -> m b) -> m b
return :: a -> m a
fail :: String -> m a
この定義を抽象型クラスとして書き直す6と、次のようになる:
module MyMonad (MyMonad, (>>=), return, fail) where
class MyMonad m where
bind_ :: m a -> (a -> m b) -> m b
return_ :: a -> m a
fail_ :: String -> m a
(>>=) :: MyMonad m => m a -> (a -> m b) -> m b
(>>=) = bind_
return :: MyMonad m => a -> m a
return = return_
fail :: MyMonad m => String -> m a
fail = fail_
クラスの利用者が使う (>>=)
, return
, fail
は、 MyMonad
クラスのメソッドではなく、 MyMonad
制約を持つただの関数としてexportする7。
もちろん、これだけでは他の人が MyMonad
のインスタンスを実装できない。そこで、このモジュールでは次のような補助的なクラス MyMonadV1
と newtype を提供する。
-- MyMonadモジュール 続き
-- export listには MyMonadV1(..), MyMonadViaV1(..) を追加
class MyMonadV1 m where
bindV1 :: m a -> (a -> m b) -> m b
returnV1 :: a -> m a
failV1 :: String -> m a
newtype MyMonadViaV1 m a = MyMonadViaV1 (m a)
instance MyMonadV1 m => MyMonad (MyMonadViaV1 m) where
-- InstanceSigs や ScopedTypeVariables が必要
bind_ :: forall a b. MyMonadViaV1 m a -> (a -> MyMonadViaV1 m b) -> MyMonadViaV1 m b
bind_ = coerce (bindV1 @m @a @b)
return_ :: forall a. a -> MyMonadViaV1 m a
return_ = coerce (returnV1 @m @a)
fail_ :: forall a. String -> MyMonadViaV1 m a
fail_ = coerce (failV1 @m @a)
(図の中では MyMonad
ではなく Monad
としてしまったが、適当に読み替えてほしい)
さて、 MyMonad
クラスの構造を変えたくなったとしよう。具体的には、 Applicative
クラスをスーパークラスとして追加し、 fail
を MonadFail
クラスに分離したくなったとしよう。
module MyMonad (MyMonad, (>>=), return, MyMonadFail, fail) where
return :: Applicative m => a -> m a
return = pure
class Applicative m => MyMonad m where
(>>=) :: m a -> (a -> m b) -> m b
class MyMonad m => MyMonadFail m where
fail :: String -> m a
クラスの定義を変えた結果、既存のインスタンス定義が壊れると大変である。だが、 MyMonadViaV1
型の MyMonad
インスタンスも一緒に変えれば、少なくとも「メソッドが存在しない」という類のエラーは回避できる。
-- MyMonadモジュール 続き
-- export listには MyMonadV1(..), MyMonadViaV1(..) を追加
class MyMonadV1 m where
-- ここは変えない
bindV1 :: m a -> (a -> m b) -> m b
returnV1 :: a -> m a
failV1 :: String -> m a
newtype MyMonadViaV1 m a = MyMonadViaV1 (m a)
instance MyMonadV1 m => Functor (MyMonadViaV1 m) where
fmap = coerce (liftM @m) -- InstanceSigs や ScopedTypeVariables を使うのは面倒なので以下擬似コードです
instance MyMonadV1 m => Applicative (MyMonadViaV1 m) where
(<*>) = coerce (liftM2 @m)
pure = coerce (returnV1 @m)
instance MyMonadV1 m => MyMonad (MyMonadViaV1 m) where
bind = coerce (bindV1 @m)
instance MyMonadV1 m => MyMonadFail (MyMonadViaV1 m) where
fail = coerce (failV1 @m)
この MyMonad
クラスの変更に際して、 MyMonad
のインスタンス(Identity
)を提供する側は既存のコードをほぼそのまま使いまわせる。もちろん Applicative
のインスタンスも用意する必要はあるが、おそらくCPPを使わなくても対応可能だろう。
さて、新しく MyMonad
クラスを実装したいパッケージがあったとして、そこでもこれまでと同様に return
や fail
を含む MyMonadV1
を実装させるのは無駄である。そこで、新時代の MyMonad
クラスを実装するにふさわしい「インスタンス提供者用のクラス・newtype」も用意する。
-- MyMonadモジュール 続き
-- export listには MyMonadV2(..), MyMonadFailV2(..), MyMonadViaV2(..) を追加
class Applicative m => MyMonadV2 m where
bindV2 :: m a -> (a -> m b) -> m b
class MyMonadV2 m => MyMonadFailV2 m where
failV2 :: String -> m a
newtype MyMonadViaV2 m a = MyMonadViaV2 (m a)
instance MyMonadV2 m => MyMonad (MyMonadViaV2 m) where
bind = coerce (bindV2 @m)
instance MyMonadFailV2 m => MyMonadFail (MyMonadViaV2 m) where
fail = coerce (failV2 @m)
活用例?
拙作 unboxing-vector パッケージでは「抽象型クラス」に近いテクニックを使っている。(というかunboxing-vectorパッケージで使ったテクニックを一般化できないかと思ってこの記事のネタを思いついた)
解説記事:unboxing-vectorの紹介:newtypeフレンドリーなunboxed vector
Unboxable
クラスはメソッドを公開しておらず(associated type familyは公開している)、インスタンスを手書きしようとしてもデフォルト実装にしかならない。
デフォルト実装以外を使いたい場合は、DerivingViaとそれ用のnewtypeを使って、 Generic
や Enum
等のインスタンスから Unboxable
を導出する形になる。
class Unboxable a where
type Rep a
-- 非公開のメソッドがいくつか
newtype Enum a = Enum a
instance (Prelude.Enum a) => Unboxable (Enum a) where ...
newtype Generics a = Generics a
instance (GHC.Generics.Generic a, ...) => Unboxable (Generics a) where ...
この記事で解説した抽象型クラス+DerivingViaのパターンでは「インスタンス提供者がメソッドを実装するクラス」と「クラス利用者が型制約として使用するクラス」を分けることがポイントだった。Unboxable
クラスの場合は、後者が Unboxable
であり、前者は Prelude.Enum
や GHC.Generics.Generic
に相当する。
弱点
この記事で解説した方法はメリットもあるが、弱点もある。
まず、すでに定義済みで普及しているクラスには使えない。互換性を維持するための技法を適用するために互換性を壊したら本末転倒である。
それから、この手法はスーパークラスを追加する類の変更に対しての緩和策にはならない。MyMonad
の例で言えば OVERLAPPABLE 等を使って無理やり
instance {-# OVERLAPPABLE #-} (MyMonadV1 m, MyMonad m) => Functor m where
fmap f u = u >>= \x -> pure (f x)
instance {-# OVERLAPPABLE #-} (MyMonadV1 m, MyMonad m) => Applicative m where
pure = returnV1
u <*> v = u >>= \f -> (v >>= \x -> pure (f x))
とできなくもないが、これはあまりエレガントではないし、OVERLAPPABLE の使用は目新しい話でもない。
第三に、この手法はDerivingViaを使用するため、DerivingViaやGNDの制限や注意事項が当てはまる。例えば、 MyMonad
クラスに
class MyMonad m where
...
join :: m (m a) -> m a
というメソッドを追加してしまうと、 MyMonad
クラスのインスタンスを作るのに DerivingVia は使えなくなる8。joinメソッドの m (m a)
という型がsafe coercionと相性が悪いからである。
また、細かいことを言うとassociated type familyでGND/DerivingViaをやるにはUndecidableInstances拡張が必須となる。
最後に、この技法はボイラープレートがやや多い。クラスメソッドに bind_
を定義して、メソッドじゃない関数を (>>=)
と定義してインスタンス提供者用のクラスに bindV1
と定義するのは冗長である。言語側でのサポートが欲しいところである。
-
だって字句解析の規則が一部Haskellじゃなくなるんだぜ?複数行文字列リテラルが意図通りに動かないとか言って泣いても知らんよ? ↩
-
筆者の中でNo. 2はTemplateHaskellです(小声) ↩
-
先に断っておくが、この記事ではこの種の問題に対するちゃんとした解決策は提示できない。 ↩
-
unsafeCoerce
を使って無理やりKnownNat
クラスの辞書を実行時にでっち上げるようなことをしない限りは、誰も。 ↩ -
メソッドが
undefined
(もしくは、デフォルト実装が存在すればデフォルト実装)で埋められたインスタンスならもちろん定義できるが、そういうのじゃなくて。 ↩ -
もちろん、すでに定義されている
MyMonad
クラスを後から抽象クラスに変えると互換性がなくなってしまう。ここではMyMonad
というクラスを新たに定義しようとしている状況だと思ってほしい。 ↩ -
(>>=)
などをメソッドとして定義すると、たとえエクスポートリストで(MyMonad, (>>=), ...)
という風にクラスの外に書いてもメソッドとしてエクスポートされてしまう。レコードのフィールドと同様だ。 ↩ -
StandaloneDeriving+QualifiedConstraintsでroleに関する制約を記述してやればDerivingViaできるかもしれない。 ↩