LoginSignup
8
2

More than 5 years have passed since last update.

雰囲気で PureScript の Generics プログラミングを始める

Posted at

はじめに

遅くなってしまい申し訳ありません。師走の忙しさにかまかけて前回の投稿から空いてしまい、その間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 レコードに関する RecField もありますが、これは 0.12.0からは取り除かれる予定です。RowToList により Generics を使わなくても直接レコード型のインスタンス定義が可能になったためです。

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