抽象型クラスで型クラスの変更の非互換性を緩和する

この記事では、Haskellの型クラスの互換性にまつわる問題と、それに対する緩和策を考える。

ネタとしては11月に行われた [Haskell Day 2019](https://techplay.jp/event/727059) のLTで筆者が話したものである。その時使ったスライドは

* <https://drive.google.com/file/d/1YGKjl8S-LlfuB8yrHnKSK5G5MGsP9xd3/view?usp=sharing>

で読める。

# 問題

Haskell標準の型クラス階層には時折、破壊的変更が加えられてきた。代表的なものをいくつか挙げる：

* Applicative-Monad Proposal: ApplicativeクラスをMonadのスーパークラスにする
* Semigroup-Monoid Proposal: SemigroupクラスをMonoidクラスのスーパークラスにする
* MonadFail Proposal: MonadクラスのfailメソッドをMonadFailクラスに分離する

型クラスへのスーパークラスの追加や、メソッドの型の変更、メソッドの削除は、一般には互換性のない変更であり、エコシステムに痛みが伴う。

（一方、メソッドの追加やスーパークラスの削除であれば、互換性は壊れないか軽微であることが多い。`Num` のスーパークラスから `Eq` と `Show` を外す変更は影響がそこまで大きくはなかったと思う。）

こういう時に互換性がなくて特に困るのは、その型クラスのインスタンスを提供する側である。インスタンスを定義せずに、型クラス制約をもつ関数を使うだけのコードはそこまで影響を受けないことが多い。以下、この記事では、インスタンスを定義する側の痛みをなるべく軽減する方法を考える。

インスタンスを定義する側にとって特に問題になるのは、メソッドの削除だ。ライブラリーのあるバージョンで型クラスのメソッドが削除された場合、そのライブラリーの前後のバージョンに対応したインスタンスを書くには、最悪の場合、**CPP**（Cプリプロセッサ）を使う必要がある。

CPPは**なるべく避けたいGHC拡張ナンバーワン**（筆者の脳内調べ）である[^CPP][^next-to-CPP]。型クラスの定義を変更した際にインスタンス提供者がCPPを使わなくて済む方法があればそれに越したことはない。この記事ではそういう方法を模索してみる。

[^CPP]: だって字句解析の規則が一部Haskellじゃなくなるんだぜ？複数行文字列リテラルが意図通りに動かないとか言って泣いても知らんよ？

[^next-to-CPP]: 筆者の中でNo. 2はTemplateHaskellです（小声）

## 例：Monadの場合

ベタな例だが、適当なモナドをでっち上げる。

```haskell:Monadインスタンスの例
newtype Identity a = Identity

instance Monad Identity where
  return = Identity
  Identity x >>= f = f x
  fail = error
```

Monadクラスの定義が

```haskell
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 = ...
```

だった頃はそれで良かった。

![class-instance-user.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185919/71be694b-3042-44f7-d0a5-ff4f8b276047.png)

しかし、ある日モナドクラスの定義が置き換えられて、スーパークラスに `Applicative` が追加されてしまった。こうなると、先ほどの `Identity` モナドの定義はそのままではコンパイルが通らない[^1]。

[^1]: 先に断っておくが、この記事ではこの種の問題に対するちゃんとした解決策は提示できない。

さらに、 `Monad` クラスの定義は将来こんな風に変わるかもしれない：

```haskell
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` のインスタンスの定義に手を加える必要がある。

![class-instance-user-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185919/7740cb23-89b4-32ca-6599-1e348bb265a3.png)

（Monadクラスの変更については [Haskell: Monadクラスのこれまでとこれから](https://qiita.com/mod_poppo/items/8c483951703b5ea0f74e) を参照）

まあ、Applicative-Monadの場合は、Applicativeがスーパークラスになってから `return` や `(>>)` がクラスから取り除かれるまで猶予がある（というか2019年12月現在では `return` や `(>>)` を取り除く具体的なスケジュールは出ていなさそうだ）。なので、よっぽど広い範囲のGHCをサポートしたいというのでもなければ、CPPを使うような羽目にはならないだろう。

## 例：MonadFailの場合

適当なモナドトランスフォーマーを考えよう。ここで、 `fail` は、下層のモナド `m` の `fail` に丸投げすることにする。

```haskell
-- 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に対応させるには、以下のように書き換える必要がある：

```haskell
-- 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` 型を挙げておく。

```haskell
module Data.Ratio (Ratio, Rational, ...) where

data Ratio a = !a :% !a -- コンストラクター (:%) は公開されない！
```

`Ratio` 型の利用者は、コンストラクターである `(:%)` にはアクセスできず、 `Data.Ratio` モジュールが提供する `(%)` や `numerator`, `denominator` などの関数を通してのみ `Ratio` 型の値を操作できる。

抽象データ型は、型の不変条件（`Ratio` の場合は「分子と分母が互いに素」）を守りやすくなるほか、実装の変更（コンストラクターやフィールドの追加・削除）に強いというメリットを持つ。

## 抽象型クラス

抽象データ型の型クラス版を考える。つまり、クラスの構成要素であるメソッドが非公開であるような型クラスを**抽象型クラス**と呼ぶことにする。

型クラスの構成要素であるメソッドが非公開であれば、型クラスの定義を変更しても非互換を最小限に抑えられるのではないだろうか。

例として、GHC組み込みの型レベル自然数から値を取り出す `KnownNat` クラスを考える。[Haddock](https://hackage.haskell.org/package/base-4.12.0.0/docs/GHC-TypeNats.html#t:KnownNat)を見ると、このクラスは `natSing` という唯一のメソッドを持っているようだが、モジュールの外には公開されていない。型すらもわからない。`KnownNat` クラスは我々がさっき定義した「抽象型クラス」の例となっている。

しかし、 `KnownNat` のメソッドが非公開であっても我々は特に困っていない。`GHC.TypeLits` や `GHC.TypeNats` モジュールが提供する `natVal` という関数を使えば、 `KnownNat` 制約を利用して実行時に使える値を取り出すことができる。

```haskell
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` の内部表現が変わったところで誰も困らない[^KnownNat-rep]。一般のHaskellユーザーは `KnownNat` クラスのインスタンスを手動で作ることはできないし、唯一のアクセス手段である `natVal` 関数の型は変わっていないのだから影響の受けようがない。

[^KnownNat-rep]: [`unsafeCoerce` を使って無理やり `KnownNat` クラスの辞書を実行時にでっち上げるようなこと](https://github.com/ekmett/reflection/issues/41)をしない限りは、誰も。

抽象型クラスは、型クラスを提供する側が実装の詳細を変化させても利用者側の互換性を壊すことがない。これは `KnownNat` に限らない。

（もちろん、公開インターフェースの互換性は維持することが大前提となる。`KnownNat` の場合であれば `natVal` の型を変えないことが互換性を保つために重要だった。）

別の例としては、現在の `Typeable` クラスは抽象型クラスになっている。かつては `typeOf :: a -> TypeRep` だったり `typeRep# :: Proxy# a -> TypeRep` を唯一のメソッドとして持っていたようだが、現在は非公開の `typeRep :: TypeRep a` が唯一のメソッドとなっている。`KnownNat` と同様、 `Typeable` もGHCがインスタンスを用意するため、GHCの都合に合わせて内部表現（メソッドの名前や型）を変えることができる。

## 抽象型クラスのインスタンスを定義する

抽象型クラスには一つ問題がある。型クラスのメソッドを非公開にしてしまうと、利用者がインスタンスを定義できなくなってしまう[^undefined-method]。

[^undefined-method]: メソッドが `undefined` （もしくは、デフォルト実装が存在すればデフォルト実装）で埋められたインスタンスならもちろん定義できるが、そういうのじゃなくて。

`KnownNat` や `Typeable` の場合はユーザーに手書きのインスタンスを定義させる必要がないのでそれでも良かったが、そうじゃないクラスでメソッドを非公開にすると、インスタンスを作る手段が限定されてしまう。

メソッドが非公開なクラスで独自のインスタンスを作るために利用できる方法といえば、デフォルト実装（`instance Foo A where {}`）か、GeneralizedNewtypeDerivingだけ……だった。

そう、DerivingViaの登場までは。

DerivingViaを使うと、「インスタンス提供者がメソッドを実装するクラス」と「クラスの利用者が型制約として利用するクラス」を分離することができる。すると、インスタンス提供者やクラスの利用者の互換性をなるべく損なわずに、クラスの詳細を変えることができる。

具体例をいくつか見てみよう。

## 例：Monadクラスを抽象型クラスにする

再び、Monadクラスっぽいやつを考える。

```haskell
class MyMonad m where
  (>>=) :: m a -> (a -> m b) -> m b
  return :: a -> m a
  fail :: String -> m a
```

この定義を抽象型クラスとして書き直す[^changing-class]と、次のようになる：

[^changing-class]: もちろん、すでに定義されている `MyMonad` クラスを後から抽象クラスに変えると互換性がなくなってしまう。ここでは `MyMonad` というクラスを新たに定義しようとしている状況だと思ってほしい。

```haskell:MyMonad.hs(v1)
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する[^method-export]。

[^method-export]: `(>>=)` などをメソッドとして定義すると、たとえエクスポートリストで `(MyMonad, (>>=), ...)` という風にクラスの外に書いてもメソッドとしてエクスポートされてしまう。レコードのフィールドと同様だ。

もちろん、これだけでは他の人が `MyMonad` のインスタンスを実装できない。そこで、このモジュールでは次のような補助的なクラス `MyMonadV1` と newtype を提供する。

```haskell:MyMonad.hs(v1)
-- 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)
```

![class-instance-user-3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185919/44b7b525-47c2-e34d-48e4-db7432f57c44.png)

（図の中では `MyMonad` ではなく `Monad` としてしまったが、適当に読み替えてほしい）

さて、 `MyMonad` クラスの構造を変えたくなったとしよう。具体的には、 `Applicative` クラスをスーパークラスとして追加し、 `fail` を `MonadFail` クラスに分離したくなったとしよう。

```haskell:MyMonad.hs(v2)
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` インスタンスも一緒に変えれば、少なくとも「メソッドが存在しない」という類のエラーは回避できる。

```haskell
-- 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)
```

![class-instance-user-4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/185919/34e253a7-12c8-903f-c221-e3d12f81b186.png)

この `MyMonad` クラスの変更に際して、 `MyMonad` のインスタンス（`Identity`）を提供する側は既存のコードをほぼそのまま使いまわせる。もちろん `Applicative` のインスタンスも用意する必要はあるが、おそらくCPPを使わなくても対応可能だろう。

さて、新しく `MyMonad` クラスを実装したいパッケージがあったとして、そこでもこれまでと同様に `return` や `fail` を含む `MyMonadV1` を実装させるのは無駄である。そこで、新時代の `MyMonad` クラスを実装するにふさわしい「インスタンス提供者用のクラス・newtype」も用意する。

```haskell
-- 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](https://qiita.com/mod_poppo/items/cf6b66ff16464c170ac2)

`Unboxable` クラスはメソッドを公開しておらず（associated type familyは公開している）、インスタンスを手書きしようとしてもデフォルト実装にしかならない。

デフォルト実装以外を使いたい場合は、DerivingViaとそれ用のnewtypeを使って、 `Generic` や `Enum` 等のインスタンスから `Unboxable` を導出する形になる。

```haskell
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 等を使って無理やり

```haskell
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` クラスに

```haskell
class MyMonad m where
  ...
  join :: m (m a) -> m a
```

というメソッドを追加してしまうと、 `MyMonad` クラスのインスタンスを作るのに DerivingVia は使えなくなる[^derivingvia-join]。joinメソッドの `m (m a)` という型がsafe coercionと相性が悪いからである。

[^derivingvia-join]: StandaloneDeriving+QualifiedConstraintsでroleに関する制約を記述してやればDerivingViaできるかもしれない。

また、細かいことを言うとassociated type familyでGND/DerivingViaをやるにはUndecidableInstances拡張が必須となる。

最後に、この技法はボイラープレートがやや多い。クラスメソッドに `bind_` を定義して、メソッドじゃない関数を `(>>=)` と定義してインスタンス提供者用のクラスに `bindV1` と定義するのは冗長である。言語側でのサポートが欲しいところである。