LoginSignup
11
7

More than 3 years have passed since last update.

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

Posted at

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

ネタとしては11月に行われた Haskell Day 2019 のLTで筆者が話したものである。その時使ったスライドは

で読める。

問題

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

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

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

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

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

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

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

例:Monadの場合

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

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

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

class-instance-user.png

しかし、ある日モナドクラスの定義が置き換えられて、スーパークラスに 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 のインスタンスの定義に手を加える必要がある。

class-instance-user-2.png

(Monadクラスの変更については Haskell: Monadクラスのこれまでとこれから を参照)

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

例:MonadFailの場合

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

-- 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.TypeLitsGHC.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

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

メソッドが非公開なクラスで独自のインスタンスを作るために利用できる方法といえば、デフォルト実装(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と、次のようになる:

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する7

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

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

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

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

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

-- 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

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

さて、新しく MyMonad クラスを実装したいパッケージがあったとして、そこでもこれまでと同様に returnfail を含む 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を使って、 GenericEnum 等のインスタンスから 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.EnumGHC.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 と定義するのは冗長である。言語側でのサポートが欲しいところである。


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

  2. 筆者の中でNo. 2はTemplateHaskellです(小声) 

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

  4. unsafeCoerce を使って無理やり KnownNat クラスの辞書を実行時にでっち上げるようなことをしない限りは、誰も。 

  5. メソッドが undefined (もしくは、デフォルト実装が存在すればデフォルト実装)で埋められたインスタンスならもちろん定義できるが、そういうのじゃなくて。 

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

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

  8. StandaloneDeriving+QualifiedConstraintsでroleに関する制約を記述してやればDerivingViaできるかもしれない。 

11
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
7