GHC.Generics.Generic typeclassと実装隠蔽とGHC.Records.HasField typeclass
ここ最近、ちょっとHaskellを書く機会があり、Haskellにおける伝統的な課題「レコードフィールド名が広めの名前空間に飛び出るので衝突しまくる問題」をなんとかできないか考えている。
DuplicateRecordFields拡張で、一応レコードフィールド名の(他のレコードのフィールド名や関数名との)衝突があってもコンパイルは通るようになった。しかし、selector functionが使いづらい状況には変わりないので、レコードフィールドの値を参照する場合にかなり面倒になる。今のところ、レコードのpattern matchをしてNamedFieldPuns拡張やRecordWildCards拡張を使う、というやり方をしているが、これも一つのレコードオブジェクトのフィールド名が対象の名前空間にそのまんま飛び出てくるという点がいけすかない。複数のレコードオブジェクトを扱う場合は結局名前の衝突が起きがちだ。
GHC 9.2ではOverloadedRecordDot拡張があるので、この問題のかなりの部分が解決するのではないだろうか。
ただ、OverloadedRecordUpdate拡張は実装上、未完成かつ実験的位置づけらしい。個人的にはgetterだけでもあるととても助かるので、setterが後回しになってもまあ、構わない。
とはいえ、まだまだGHC-8系を使う機会が多いので、まだOverloadedRecordDotを使いまくるわけにはいかなさそう。
ということで、generic-lensパッケージを試してみた。このパッケージはGHC.GenericsモジュールのGeneric
instanceを持つデータ型に対して、フィールドアクセサに相当するlensを自動導出するというもの。
リファレンスマニュアルが難解な割に公式でまともなチュートリアルがないのがつらい。。 使い方については以下の記事が一番分かりやすいと思う。
とても便利なものだと思ったが、ここで一つ気になることがあった。generic-lensでは、privateなデータ型についてもフィールドアクセサを生成して、内部構造をバンバン更新できるのだ。ここでいうprivateなデータ型というのは、平たくいえばdata constructorをexportしていないデータ型、ということである。
詳しくは下記issueに書いた。
例えば以下のmodule Aがある。Private
型はdata constructorをexportしていない、privateなデータ型である。
module A (Private, newPrivate) where import GHC.Generics (Generic) -- | The data constructor is not exposed. data Private = Private { foo :: Int } deriving (Show,Generic) -- | A smart constructor newPrivate :: Int -> Private newPrivate = Private
で、これを扱うmodule Mainを定義する。
module Main (main) where import A (Private, newPrivate) import Data.Generics.Product (HasField(..)) import Lens.Micro.Platform ((.~)) main :: IO () main = putStrLn $ show $ (field @"foo" .~ 10) $ newPrivate 5
module Mainではgeneric-lensを使ってPrivate
型の中身をゴリゴリに書き換えている。このコードはコンパイルが通る。これはPrivate
型の実装を隠蔽したいという設計を突き破っているのではないだろうか?
上記issueで質問を投げかけたところ、直ちにハッとさせられる返答が返ってきた。Private
型についてGeneric
instanceをderiveした時点で、その型の内部実装をさらけ出しているのだ、と。
なるほど、言われてみればそうだ。Generic
instanceは、そのデータ型の内部構造をプログラム内で値として扱えるようにするもの。いくらdata constructorだけ隠蔽したとしても、よそのコードはGeneric
instanceを通じてそのデータ型の内部構造にアクセスできるわけだ。
そう考えると、実装隠蔽したいデータ型についてはGeneric
instanceをderiveしないほうがいい気がしてくる。今までは、なんなら「何かと便利だし、全部のデータ型についてGeneric
をderiveしておけばよくね」くらいに考えていたが、そうでもなさそうだ。
とはいえ、自分がderive Generic
したくなるユースケースは今のところToJSON
, FromJSON
のderiveのためくらいだろうか。こういったserialization用のデータ型はそもそも実装隠蔽する意味があまりないので、derive Generic
していいだろう。
なお、「データ型が定義されたモジュール内ではderive Generic
して、モジュール外には実装隠蔽したい」というケースでは、export用にnewtype wrapperをかませる手がありそう。
module A (Exported) where import GHC.Generics (Generic) data Internal = Internal { foo :: Int } deriving (Show,Generic) newtype Exported = Exported Internal
ところで、OverloadedRecordDot拡張はGHC.RecordsモジュールのHasField
typeclassに関する構文糖衣として実装されるらしい。実はGHC.RecordsとHasField
typeclass自体はGHC-8系にも既に実装されていて、使うことができる。
例えば、ghc-8でも以下のような書き方が可能なようだ。
module A (Public (..), Private, add) where import GHC.Records (HasField (..)) data Public = Public { foo :: Int } deriving (Show) data Private = Private { foo :: Int } deriving (Show) add :: Public -> Private -> Int add pub pri = getField @"foo" pub + getField @"foo" pri
GHC manualによれば、GHC.RecordsのHasField
typeclass constraintは、当該フィールド名が当該名前空間(モジュール)にimportされている場合にsolveされるらしい。
つまり、上記のmodule Aを使う場合、
module Main (main) where import GHC.Records (HasField (..)) import A (Private, Public (..)) main :: IO () main = putStrLn $ show $ add' (Public 10) 20 -- add :: Public -> Private -> Int -- add pub pri = getField @"foo" pub + getField @"foo" pri add' :: Public -> Int -> Int add' pub n = getField @"foo" pub + n
module Main内のadd関数のコメントアウトを外すと、Private
のfoo
fieldがimportされていないので以下のようなコンパイルエラーが出る。
• No instance for (HasField "foo" Private Int) arising from a use of ‘getField’
つまり、GHC.Records.HasField typeclassは実装隠蔽の設計を正しく反映してくれそうだ。
OverloadedRecordDot拡張がない場合、GHC.Records.HasField
typeclassを使うにはgetField
関数を明示的に書く必要はあるが、それでも十分すぎるくらい便利なのでは。