LoginSignup
23
8

More than 3 years have passed since last update.

独断と偏見で語るGHCのderiving系拡張

Last updated at Posted at 2019-12-21

先日のHaskell Dayでderivingに関する発表があった

ので、触発されて私もderivingについて思うところを書いてみます。主にderiving系拡張の落とし穴・注意点に重点を置きます。

標準でderiveできるやつ、またはGHCに組み込まれているやつ (stock deriving)

標準で Eq, Ord, Enum, Bounded, Show, Read, Data.Ix.Ix がderiveできます。この手の話題で Ix はよく見落とされます。ちゅうか Haskell 2010 Language Report, Chapter 11 のderive可能なクラスの一覧からもオミットされている……。

GHC拡張を有効にすることで、他のいくつかのクラスでも型の定義に応じたderivingができるようになります。

  • DeriveGeneric

みんな大好き Generic をderiveする機能です。Generic クラスは手書きしないですよね。

  • DeriveFunctor, DeriveFoldable, DeriveTraversable

それぞれ Functor, Foldable, Traversable をderiveできるようにする機能です。

  • DeriveDataTypeable

Data.DataData クラスをderiveできるようにする機能です。元々のネーミング的には「Data クラスと Typeable クラス」だったと思われます。モジュール名 Data.Typeable ではなくて。

歴史的な経緯はともかく、現在のGHCではこの拡張は Typeable クラスとは関係がありません。DeriveDataTypeableを有効にしなくても deriving Typeable と書けますし、そもそもderiving節を書かなくても自動的に Typeable のインスタンスが用意されます。

  • DeriveLift

Template Haskell関係の Lift クラスをderiveできるようになるらしいです。筆者はTemplate Haskellをあまり使わないのでこれ以上の解説はしません。

DeriveAnyClass

この拡張を使う人はこんな拡張も使っています:DeriveGeneric

Since: GHC 7.10.1

DeriveAnyClass は、where節が空のインスタンス宣言

DeriveAnyClass不使用時
data T = ...
instance Foo T

をderiving節

DeriveAnyClass使用時
data T = ...
   deriving (Foo)

として書ける機能です(インスタンス宣言に制約が必要な場合は適宜推論で補われます)。

典型的には、 Generic に基づいたデフォルト実装1を持つ型クラスに使われます。 FromJSON, ToJSON, NFData などがそういうクラスの例です。NFData クラスのドキュメントには、まさに DeriveAnyClass と組み合わせる例が載っていますね。

この拡張に「DeriveAnyClass」という名前がついているのはいささかミスリーディングで、事実上、まともなdefault実装を用意しているクラスにしか適用できません(後述の「注意点」も参照)。

この拡張を好意的に紹介するなら「(DeriveGenericと組み合わせることで)stock derivingのような『型の構造に応じたderiving』を他の型クラスでも使えるようにする拡張」となるでしょう。

DeriveAnyClassの注意点、もしくはDeriveAnyClassを避けるべき理由

明らかにデフォルト実装を持っていないクラス、例えば Num に対して DeriveAnyClass を使ってみましょう。

{-# LANGUAGE DeriveAnyClass #-}

data T = T deriving (Eq, Show, Num)

main = print (T-T)

この「間違った」コードをコンパイルするとどうなるでしょうか?予想してみてください!

……

このコードは、なんとコンパイルが通ります!もちろん警告は出ます:

Test.hs:3:32: warning: [-Wmissing-methods]
    • No explicit implementation for
        ‘+’, ‘*’, ‘abs’, ‘signum’, ‘fromInteger’, and (either ‘negate’
                                                              or
                                                              ‘-’)
    • In the instance declaration for ‘Num T’
  |
3 | data T = T deriving (Eq, Show, Num)
  |                                ^^^

何が起こったかというと、メソッドが全て undefined で埋められたインスタンスが生成されたのです2

「メソッドが undefined で埋められる」のは別に DeriveAnyClass 特有の挙動ではなく、単に where 節が空のインスタンスを書くことで発生します。 DeriveAnyClass は「where節が空のインスタンス定義」の省略記法なので、当然と言えば当然ですね。

data T = T deriving (Eq, Show)

instance Num T -- コンパイルが通る

main = print (T-T)

この挙動が DeriveAnyClass で特に問題になるとすれば、stock derivingに対応していないクラスをderive使用した時にGHCが「Try enabling DeriveAnyClass」みたいなサジェスチョンを行うことでしょうか。

Test.hs:1:32: error:
    • Can't make a derived instance of ‘Num T’:
        ‘Num’ is not a stock derivable class (Eq, Show, etc.)
        Try enabling DeriveAnyClass ⬅️⬅️⬅️🤔🤔🤔
    • In the data declaration for ‘T’
  |
1 | data T = T deriving (Eq, Show, Num)
  |                                ^^^

GHC のエラーメッセージに従って LANGUAGE プラグマを追加する人は多そうなので、よくわからないまま DeriveAnyClass を有効にしてコンパイルを通したらいつの間にか undefined を含む実行コードが生成された!ということは十分起こりえそうです。

もちろん警告は(-Wall なしでも)出るので、ちゃんと警告に目を通すまともなプログラマーならそんなヘマはしないと思いますが……。

ちなみに、 DeriveAnyClass は associated type family (および、associated data family) を持つクラスに対しても使えます。DeriveAnyClass という名前は伊達ではないですね!3(まともに使えるわけではry)

DeriveAnyClass の他の問題点としては、 GeneralizedNewtypeDeriving とバッティングする点があります。

{-# LANGUAGE DeriveAnyClass, GeneralizedNewtypeDeriving #-}

newtype U = U Int deriving (Show, Num) -- Num はどうやって導出される?

main = print (U 0 - U 0)

バッティングした場合には DeriveAnyClass が優先されるので、上記のコードは「使い物にならない」 Num インスタンスを生成します。この問題に関しては後述の DerivingStrategies も参照してください。

(こういう DeriveAnyClass と GeneralizedNewtypeDeriving がバッティングした場合、専用の警告メッセージが出ます。

DerivingTest.hs:3:35: warning:
    • Both DeriveAnyClass and GeneralizedNewtypeDeriving are enabled
      Defaulting to the DeriveAnyClass strategy for instantiating Num
    • In the newtype declaration for ‘U’
  |
3 | newtype U = U Int deriving (Show, Num) -- Num はどうやって導出される?
  |                                   ^^^

GHC 8.10ではこの警告に -Wderiving-defaults という名前がつき、ON/OFFを切り替えられるようになりました(GHC User's Guide)。この警告自体は以前から存在して、 -Wall なしでも有効なので、古いGHCを使っている方も安心してください。)

DeriveAnyClass はこのような問題を抱えているので、個人的な意見としては「instance NFData Foo みたいな、そもそも1行で済む定義を省略するためにわざわざ使うほどではない」と思います。プロジェクトのコーディング規約によって禁止するのもアリかもしれません。

GeneralizedNewtypeDeriving

イギリス英語 vs アメリカ英語 のアレがあるので綴りが2種類あります(-ised の方は GHC 8.6 以降)。よくGNDと省略されます。

newtype元のインスタンスをそっくりそのままnewtype先でも使い回したいことが時々あります。

GNDを使わない例
newtype T = T Int deriving (Show)

-- Num Int と同等にしたい
instance Num T where
  negate (T x) = T (negate x)
  T x + T y = T (x + y)
  T x * T y = T (x * y)
  fromInteger n = T (fromInteger n)

main = print (T 0 - T 0)

これを可能にするのがGNDです。

GNDを使う例
{-# LANGUAGE GeneralizedNewtypeDeriving #-}

newtype T = T Int deriving (Show, Num)

main = print (T 0 - T 0)

大昔のGND4には、GADTsだかtype familiesだかと組み合わせることで型安全性を破壊する問題がありましたが、 GHC 7.8 で type role と safe coercion が導入されたことにより、GNDも安全になりました。type role と safe coercion はそれだけで記事が数本書けるネタなのでここでは割愛します。

GNDの制限

GNDが安全になった代償として、ある種の型クラスに対してGNDが使用できない状況が発生するようになりました。具体例の一つは join :: m (m a) -> m a みたいなメソッドを含むクラスです5

{-# LANGUAGE GeneralizedNewtypeDeriving #-}

class Join m where
  join :: m (m a) -> m a

newtype MyMonadT m a = MyMonadT (m a)
  deriving Join
TestGND.hs:7:12: error:
    • Couldn't match representation of type ‘m (MyMonadT m a)’
                               with that of ‘m (m a)’
        arising from the coercion of the method ‘join’
          from type ‘forall a. m (m a) -> m a’
            to type ‘forall a. MyMonadT m (MyMonadT m a) -> MyMonadT m a’
      NB: We cannot know what roles the parameters to ‘m’ have;
        we must assume that the role is nominal
    • When deriving the instance for (Join (MyMonadT m))
  |
7 |   deriving Join
  |            ^^^^

この記事の主題とは外れるかもしれませんが、この問題はQuantifiedConstraintsを使って型のtype roleがrepresentationalであることを教えてやれば回避できます。詳しくは Ryan Scott 氏の How QuantifiedConstraints can let us put join back in Monad を読んでください。動作するコードだけ提示すると、次のようになります6

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE QuantifiedConstraints #-}
import Data.Coerce

class Join m where
  join :: m (m a) -> m a

newtype MyMonadT m a = MyMonadT (m a)

deriving instance (Join m, forall a b. Coercible a b => Coercible (m a) (m b)) => Join (MyMonadT m)

join以外の例としては

class Foo a where
  foo :: Applicative f => f a

があります。こっちはQuantifiedConstraintsを使って

class Foo a where
  foo :: (Applicative f, forall x y. Coercible x y => Coercible (f x) (f y)) => f a

と書いてもダメそうです(GHC 8.8.1およびGHC 8.10.1で確認)。なぜだ〜〜

GNDの制限事項としては他に、associated type familyやassociated data familyへの対応があります。associated data familyを持つ型クラスにはGNDは使えません。associated type familyにはGHC 8.2で対応しましたが、UndecidableInstances拡張が必要となります。

UndecidableInstancesというのは型検査機が停止しない可能性のあるコードをコンパイルする際に必要な拡張です。GNDにより実際に型検査機が停止しなくなる例7を挙げておきます:

{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE UndecidableInstances #-}

class Foo a where
  type Foo' a

newtype X = X X deriving Foo

x :: Foo' X -- この型はなに???
x = undefined
-- ちなみに x = x だとコンパイルが通る(偶然かもしれない)

main = return ()

DerivingVia

Since: 8.6.1

DerivingViaは、GHC 8.6で実装されたすごいやつです。ググれば色々解説が出てくると思うので、ここでは割愛します。

ここではmr_konn氏の投稿・スライドを紹介しておきます:

DerivingViaは単に便利なシンタックスシュガーというだけではなく、これまでできなかった抽象化を行えるようになります。詳しくは私の記事

を読んでください。

なお、DerivingViaはGNDの一般化であり、「GNDの制限」に書いた内容がDerivingViaにもそのまま当てはまります。

DerivingStrategies

Since: GHC 8.2.1

DerivingStrategiesを使うと、deriving戦略 (stock, newtype, anyclass, via8) をソースコード上に明示できます。例えばこんな感じです:

newtype T = T Int
  deriving stock (Eq, Generic)
  deriving newtype Show
  deriving anyclass NFData

これができるとどういう状況で嬉しいかというと、

  1. 可読性が上がる
  2. 通常はstock instanceが生成される状況においてGNDを使用することができる
  3. GNDとDeriveAnyClassがバッティングする状況でGNDを選択することができる

の3点でしょうか。

2番目の「通常はstock instanceが生成される状況においてGNDを使用」というのは、

newtype T = T Int deriving newtype Show

によって show (T 123) == "123" となるような Show T のインスタンスが得られる、という意味です。

ちなみに、stock derivingできるクラスについてstockとGNDを比較すると、

  • stockとGNDで挙動が変わらない:Eq, Ord, Ix, Bounded, Functor, Foldable
  • stockとGNDで挙動が変わる:Read, Show, Generic
  • newtypeに対するstock derivingに対応していない:Enum
  • GNDに対応していない:Data, Typeable
    • Data はtype roleの関係でダメ。 Typeable はstockしか使えない9

となります。つまり、DerivingStrategiesによって新たに Read, Show, Generic に対してGNDできるようになります。

3番目のGNDとDeriveAnyClassがバッティングする状況でGNDを選択、というのはDeriveAnyClassのところですでに説明しました。まあこの記事を読んであなたがDeriveAnyClassを避けるようになればそもそもバッティングが起こらないので、3番目のメリットは事実上存在しないかもしれません。

さて、「これからはDerivingStrategiesの時代だ!deriving戦略をガンガン書いていきたい!むしろ戦略を必須にしたい!」と思ったあなたに朗報です。GHC 8.8では警告オプション -Wmissing-deriving-strategies が導入されました。戦略が指定されていないderiving節に警告が出ます。

StandaloneDeriving

deriving節は通常はデータ型の定義箇所に書きますが、型の定義とは独立に書けると便利なことがあります。StandaloneDerivingを使うとそれが可能になります。

書き方は、例えば次のようになります:

deriving instance Show Foo

見ての通り、行頭に「deriving」がつきます。あとは通常のインスタンス宣言と同様です(ただし where 節は続かない)。

StandaloneDerivingが役に立つのはどんな状況でしょうか。

まず一つは、新たな型クラスを定義する際に既存のnewtypeに対するGNDやDerivingViaを使いたい時です。

例えば、 Num クラスみたいな数値クラスを再発明するとしましょう(詳しくは私の記事「Haskell でオレオレ Num クラスを作るための考察」を参照)。IntDouble 等の基本的な数値型はもちろんインスタンスにします。

class Additive a where
  zero :: a
  add :: a -> a -> a

instance Additive Integer where ...
instance Additive Int where ...
instance Additive IntNN where ... -- NN = 8, 16, 32, 64
instance Additive Word where ...
instance Additive WordNN where ... -- NN = 8, 16, 32, 64
instance Additive Float where ...
instance Additive Double where ...

このほか、 Foreign.C.Types で定義されるFFI用の数値型(CInt とか CLong とか)もそのクラスのインスタンスにしておきたいところです。ですが、FFI用の数値型はやたら種類が多くて大変です。

実はFFI用の数値型は IntNN, WordNN, Float, Double などのnewtypeなので、GNDが使えればすでに定義した IntNN 等のインスタンスから自動でインスタンスを導出できます。型の定義箇所とは違う場所でGNDしたい……そう、StandaloneDerivingの出番です。

...
deriving instance Additive CInt
deriving instance Additive CUInt
deriving instance Additive CLong
...

StandaloneDerivingの別のメリットとして、手動で制約を書けるという点があります。通常のderivingでは必要な制約をGHCが自動で推論しますが、StandaloneDerivingではプログラマーが手動で制約を書けます。こちらの具体例は、「GNDの制限」のところですでに紹介しました。

あとはGADTsに対して普通のderivingは使えないけどStandaloneDerivingは使える、みたいな話もあるようです。詳しくはUser's Guideを読んでください。

ちなみに、DerivingStrategiesとStandaloneDerivingを併用する際は、戦略を「deriving」と「instance」の間に書きます。DerivingViaも同様です10

newtype Foo = Foo Int

-- With DerivingStrategies:
deriving stock instance Generic Foo
deriving newtype instance Num Foo
deriving anyclass instance NFData Foo
deriving via Sum Int instance Semigroup Foo

ところで deriving anyclass instance NFData Foo ってただの文字数の無駄遣いですね。普通に instance NFData Foo って書けば良いので。

最後に

derivingはうまく使えば手書きするコード量を削減できます。derivingを使いこなして、快適なHaskellライフを送りましょう!


  1. Generic に基づいたデフォルト実装を提供する側は DefaultSignatures 拡張を使いますが、derivingを使う側は DeriveGeneric だけで十分です。 

  2. 呼び出した時のエラーメッセージはちょっと違いますが。 

  3. もちろん、 DeriveAnyClass が使えないクラスも書けます。考えてみてください。 

  4. 現在のGHCのマニュアルではGNDやGADTsが「Since 6.8.1」となっていますが、GNDとGADTsは機能としてはそれ以前から存在しました。GHC 6.8.1はあくまで、各種GHC拡張を -X オプションや LANGUAGE プラグマで指定できるようになったバージョンです(それ以前はGHC拡張は -fglasgow-exts やその他の -f オプションで制御されていました。一部の拡張は LANGUAGE で制御できたのかな?)。ちなみに TypeFamiliesは本当に GHC 6.8 頃から実装が始まったようです。 

  5. 一律ダメというわけではなくて、 newtype M a = M (Maybe a) deriving Join のような、newtypeの右辺に既知の型が来る場合は大丈夫のようです。 

  6. 標準のderiving記法では Join (MyMonadT m) の定義の際に追加の制約を記述できないので、StandaloneDerivingを使っています。 

  7. こういう病的な例のために一律でUndecidableInstancesが必要になるのはどうかと思うので、もうちょっとなんとかならないのかと思いますが、どうなんですかね? 

  8. DerivingViaはそもそもDerivingStrategiesを前提とした文法なので、話の順番が逆になっています。DerivingStrategiesによって何かが変わるというわけではありません。 

  9. deriving newtype Typeable と書いても完全に無視されます。GHCがもう少し親切なら警告を出してくれたかもしれませんが、まあこんなコードをわざわざ書く人はいないし問題にはならないでしょう。 

  10. GHC 8.8時点のマニュアルにはこの用法についての記載がありませんが、GHC 8.10のマニュアル では直りました。 

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