はじめに
遅くなってしまい申し訳ありません。師走の忙しさにかまかけて前回の投稿から空いてしまい、その間Phillさんが PureScript のメンテナを降りるなど衝撃的なニュースもありましたが、元気に進めていきましょう。
今回は Generics について書きたいと思います。今まで Generics は何となく知っていましたが、最近少し学んだので共有したいと思います。対象は PureScript はある程度(代数的データ構造、型クラスなど)知っているけど、Genericsって何?という方になります。
PureScript の Generics
PureScriptでGenericsと言った場合、型の構造に基づいた多相性を指します1。代数的データ型は0個以上のコンストラクタを持ち、各コンストラクタは0個以上の引数を取ります。つまり代数的データ構造は構造を持っています。Generics を使えば、この構造に対して関数を定義することができます。このような関数をgenericな関数を呼びます。
例えばShowインスタンスの定義に使う genericShow
はgenericな関数です。後ほど説明しますが、型a が rep 型で表現される構造を持ち(Generic a rep
)、その構造がGenericShow のインスンタス(GenericShow rep
)の場合に、genericShow の引数として渡せます。
genericShow :: forall a rep. Generic a rep => GenericShow rep => a -> String
他にもデータ型を JSONにシリアラズ/JSONからデシリアライズを行なうgenericな関数が purescript-argonaut-aeson-generic
パッケージで提供されています(Haskell の aeson 相当)。
Generic型クラス、表現(Represention)
purescript-generics-rep
の Data.Generic.Rep モジュールの Generics
型クラスが、ある型に対する表現(representation)の型を対応させます。
class Generic a rep | a -> rep where
to :: rep -> a
from :: a -> rep
a
が対象の型、 rep
が表現の型です。 Functional Dependency
が使われています。つまり a の型が決まれば、rep の型は一意的に定まります。一つの型に対して表現の型は一つのみです。
インスタンス定義は自動導出を使います2。rep の型は_(アンダースコア)を指定します。具体的な表現の型はコンパイラが決めます。
data Foo
= F1 Int Boolean
| F2 String
| F3
derive instance genericFoo :: Generic Foo _
上記の例の場合、表現の型を見てみると以下のような型になっていることが分かります。
Sum (Constructor "F1" (Product (Argument Int) (Argument Boolean)))
(Sum (Constructor "F2" (Argument String))
(Constructor "F3" NoArguments))
Fooデータ型が表現されているのが分かるかと思います。Sum 型によって複数コンスラクタが合さっており、複数引数がある箇所は Product 型が使われています。表現に使用される型は Data.Generic.Rep
モジュールに定義されています3。
data Sum a b = Inl a | Inr b -- A representation for types with multiple constructors.
data NoConstructors -- A representation for types with no constructors.
newtype Constructor (name :: Symbol) a = Constructor a -- A representation for constructors which includes the data constructor name as a type-level string.
data Product a b = Product a b -- A representation for constructors with multiple fields.
data NoArguments = NoArguments -- A representation for constructors with no arguments.
newtype Argument a = Argument a -- A representation for an argument in a data constructor.
コンストラクタが
- 0個の場合は NoConstructors
- 1個の場合は Constructor
- 2個以上の場合は Sum を使って複数の Constructor
によって表現されます。各コンストラクタに対して型引数は
- 0個の場合は NoArguments
- 1個の場合は Arugment
- 2個以上の場合は Product を使って複数の Arugment
によって表現されます。
genericsな関数例: genericShow
実際に generic な関数を実装方法を見てみます。基本的には冒頭に挙げた genericShow
のように、表現型に対する型クラスを定義及びそのインスタンスを実装します。genericShow の場合は GenericShow
型クラスが定義されています。
genericShow :: forall a rep. Generic a rep => GenericShow rep => a -> String
実装を見てみます。まず GenericShow
型クラスの定義です。
class GenericShow a where
genericShow' :: a -> String
まあ普通ですね。次にコンスラクタ回りの表現型のインスタンスを見てみます。Sum は単に下の Constructor の実装を呼び出しているだけです。Constructorは、型引数がなければコンストラクタ名だけの文字列、型引数があれば (Foo1 "hoge")
のようにコンストラクタ名+型引数の各show文字列を括弧で囲んだ文字列を返すようになっています。
instance genericShowSum :: (GenericShow a, GenericShow b) => GenericShow (Sum a b) where
genericShow' (Inl a) = genericShow' a
genericShow' (Inr b) = genericShow' b
instance genericShowConstructor
:: (GenericShowArgs a, IsSymbol name)
=> GenericShow (Constructor name a) where
genericShow' (Constructor a) =
case genericShowArgs a of
[] -> ctor
args -> "(" <> intercalate " " ([ctor] <> args) <> ")"
where
ctor :: String
ctor = reflectSymbol (SProxy :: SProxy name)
型引数系の表現型は GenericShowArgs
型クラスのインスタンスを定義しています。Product は単に連結しています。Argument は型引数が Show のインスタンスであることを要求しています。そのため型引数がShow型クラスのインスタンスになっていないものデータ型は genericShow
関数に渡せないようになっています。
class GenericShowArgs a where
genericShowArgs :: a -> Array String
instance genericShowArgsProduct
:: (GenericShowArgs a, GenericShowArgs b)
=> GenericShowArgs (Product a b) where
genericShowArgs (Product a b) = genericShowArgs a <> genericShowArgs b
instance genericShowArgsArgument :: Show a => GenericShowArgs (Argument a) where
genericShowArgs (Argument a) = [show a]
実装を全部は抜粋していませんが、何となく雰囲気は分かりました。
サンプル: Genericsを使ってデータ型からコマンドラインパーサを生成する
恣意的になってしまいますが実際にGenericsを使って何か作ってみます。Parsec の使い方は知っているという前提で進めます。
コマンド cmd を作成する場面を考えます。コマンドはサブコマンドをいくつか持っており、各サブコマンドは0個以上の数値か真偽値の引数を取れるとします。
$ ./cmd Hoge 124 43 true -- Hogeサブコマンドは引数として数値を二つ、真偽値を一つ取る
$ ./cmd Bar -- Barサブコマンドは引数を取らない
$ ./cmd Foo true 42 -- Fooサブコマンドは引数として真偽値を一つ、数値を一つ取る
コマンドラインのパーサを parsec を使って作成します。パーサの結果は以下のデータ型を返します。
data SubCommand
= Hoge Int Int Boolean
| Bar
| Foo Boolean Int
subCommandParser :: Parser SubCommand
これぐらいであれば subCommandParser
を手動で書いたとしても面倒ではないかと思います。ただサブコマンドが数十個以上になってくるとパーサを手動で書くのは怠くなってくるでしょう。
パースするのに必要な情報は全てSubCommand型の構造が持っているはずです。Genericsを使って自動的にパーサを作成してしまいましょう。
まず GenericParser
型クラスを作成します。
class GenericParser rep where
genericParser' :: Parser rep
次にコンスラクタ系の表現型のインスタンスを定義します。Sum はまず左辺のパースを試してみて、駄目なら右辺のパースを試みるようにします。Parserの <|>
を使います。各Constructor はまずコンスラクタ名を照合し、各型引数のパースを試みます。
instance genericParserSum
:: ( GenericParser c
, GenericParser cs
) => GenericParser (Sum c cs) where
genericParser' = (Inl <$> c) <|> (Inr <$> cs)
where
c = genericParser' :: Parser c
cs = genericParser' :: Parser cs
instance genericParserConstructor
:: ( IsSymbol name
, GenericParser args
) => GenericParser (Constructor name args) where
genericParser' = Constructor <$> (string name *> args)
where
name = reflectSymbol (SProxy :: SProxy name)
args = genericParser' :: Parser args
型引数系のインスタンス定義です。NoArgumentの場合は型引数はないので、何もしないパーサを返します。Product は左辺と右辺を続けてパースします。Argument は、IsArugment型クラスを用いて型引数のパーサを返します。引数間の空白もここで消費するようにします。
instance genericParserNoArguments :: GenericParser NoArguments where
genericParser' = pure NoArguments
instance genericParserProduct
:: ( GenericParser a
, GenericParser b
) => GenericParser (Product a b) where
genericParser' = Product <$> a <*> b
where
a = genericParser' :: Parser a
b = genericParser' :: Parser b
instance genericParserArgument
:: IsArgument a => GenericParser (Argument a) where
genericParser' = spaces *> (Argument <$> argParser)
where
spaces = many1 (char ' ')
最後に Int と Boolean に対してIsArugment型クラスのインスタンスを定義します(面倒なので unsafePartial 使ちゃってますが見逃してください)。
class IsArgument a where
argParser :: Parser a
instance isArgumentInt :: IsArgument Int where
argParser = toInt <$> digits
where
digits = many1 anyDigit
toInt = unsafePartial $ foldMap singleton >>> fromString >>> fromJust
instance isArgumentBoolean :: IsArgument Boolean where
argParser = string "true" *> pure true <|> string "false" *> pure false
後はよさげな関数を用意したら完成です。
genericParser
:: forall rep a
. Generic a rep
=> GenericParser rep
=> Parser a
genericParser = to <$> genericParser'
実際にパースできるか確かめてみます。
derive instance genericSubCommand :: Generic SubCommand _
subCmanndParser :: Parser SubCommand
subCmanndParser = genericParser
v0 = runParser (subCommandParser <* eof) "Hoge 123 42 true" -- 成功
v1 = runParser (subCommandParser <* eof) "Hoge 12 false" -- 失敗(引数が足りない)
v2 = runParser (subCommandParser <* eof) "Bar" -- 成功
v3 = runParser (subCommandParser <* eof) "Moge" -- 失敗(Mogeというコンスラクタはない)
また表現が GenericParser 型クラスを満たさないデータ型についてはgenericParser は使えません。コンパイルエラーになります。
data BadSubCommand = Bad String Int -- String はIsArugmentのインスタンスではない
badSubCommandParser :: Parser BadSubCommand
badSubCommandParser = genericParser -- コンパイルエラー
まとめ
Generics便利!出来ること広がる!!
おまけ: もう一つの Generics
ややこしいのですが、歴史的経緯により PurescriptにはGenericsの仕組みが二つあります。
-
purescript-generics
パッケージの Data.Genreics -
purescript-generics-rep
パッケージの Data.Generics.Rep
先に作られたのは Data.Generics です。Haskell のgenerics-sop を参考に作られています。本記事で説明したGenerics は Data.Generics.Rep のほうです。こちらは後から GHC.Generics を参考に作られました。
0.12.0から Data.Generics は DEPRECATED になる予定です。そのため本記事では説明しませんでした。Data.Generics は Data.Generics.Rep と違い型レベルでは表現を持たず、値レベルのみの構造を知ることができます。Data.Generics を用いたプログラミングはとっつきやすいのですが、Data.Generics.Rep に比べ、出来ることが貧弱になります。
値レベルでしか見れないため、データ型の型引数にプリミティブ以外の型を使っている場合、その型に応じて処理を行なう、ということが出来ません。また型レベルでの表現を持たないため、制約を付けることができないというデメリットがあります。例えば型引数がShowインスタンスが定義されていない型引数を持つデータ型でも generics な show関数に渡せてしまいます(その場合、Showインスタンスが定義されていない値は単純に無視するしかありません)。
恐らくですが、上記デメリットが Data.Generics は DEPRECATED になり、Data.Generics.Rep のみが今後サポートされる理由だと思っています。
Footnotes
1 Generics と言えば某Go言語やC++でも聞きますが、それらの言語でGenericsと言うとParametric多相、Adhoc多相(PureScirptにおける型パラメータや 型クラスによる制約に相当)を指すため、PureScript でいう Genericsとは異なります。
2 手動でもインスタンス定義できますが、あまり意味はありません。
3 レコードに関する Rec
と Field
もありますが、これは 0.12.0からは取り除かれる予定です。RowToList により Generics を使わなくても直接レコード型のインスタンス定義が可能になったためです。