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関数のコメントアウトを外すと、Privatefoo fieldがimportされていないので以下のようなコンパイルエラーが出る。

• No instance for (HasField "foo" Private Int)
    arising from a use of ‘getField’

つまり、GHC.Records.HasField typeclassは実装隠蔽の設計を正しく反映してくれそうだ。

OverloadedRecordDot拡張がない場合、GHC.Records.HasFieldtypeclassを使うにはgetField関数を明示的に書く必要はあるが、それでも十分すぎるくらい便利なのでは。