Help us understand the problem. What is going on with this article?

「タイプセーフプリキュア!」を支える技術

More than 3 years have passed since last update.

真のキュアエンジニアたるもの、自分の好きな言語でプリキュアを実装しなければならない
--- @sue445 (が、言ったということにしたい @igrep)

と、いうわけで先週私はHaskellでプリキュアを実装しました。
typesafe-precureと言います。

先週書いたプリキュアAdvent Calendarの記事では技術的な説明を極力控えておりましたので、今回はもうちょっとHaskell Haskellした紹介をさせてください。

TypeSafe PreCure!!

ご存知(?)、rubicureをはじめとするプリキュア実装のHaskell版です。
他のプリキュア実装と異なり、名前の通り型安全であることに大きく重きをおいています。
また、プリキュアだけでなく変身アイテムや必殺技を放つときのアイテム(「特殊アイテム」とここでは呼びます)を収録している点も、他のプリキュア実装と一線を画す特徴でしょう。

例えば初代「ふたりはプリキュア」でなぎさとほのかが変身するには、2人で手をつなぎつつ、それぞれのカードコミューンを使用する必要があります。
以下が2人で変身する時の台詞を取得する場合のコードです。

> transformationSpeech (Nagisa, Honoka) (CardCommune_Mepple, CardCommune_Mipple)
[ "デュアル・オーロラ・ウェイブ!!"
, "光の使者、キュアブラック!"
, "光の使者、キュアホワイト!"
, "ふたりはプリキュア!"
, "闇の力のしもべ達よ!"
, "とっととお家に帰りなさい!"
]

間違っても、1人で変身したり、

> transformationSpeech Nagisa CardCommune_Mepple

<interactive>:3:1: error:
    ? No instance for (Transformation Nagisa CardCommune_Mepple)
        arising from a use of transformationSpeech
    ? In the expression: transformationSpeech Nagisa CardCommune_Mepple
      In an equation for it:
          it = transformationSpeech Nagisa CardCommune_Mepple

違う変身アイテムを使用して変身するなんてことはできません。

> transformationSpeech (Nagisa, Honoka) (LinkleStoneDia, LinkleStoneDia)

<interactive>:4:1: error:
    ? No instance for (Transformation
                         (Nagisa, Honoka) (LinkleStoneDia, LinkleStoneDia))
        arising from a use of transformationSpeech
    ? In the expression:
        transformationSpeech
          (Nagisa, Honoka) (LinkleStoneDia, LinkleStoneDia)
      In an equation for it:
          it
            = transformationSpeech
                (Nagisa, Honoka) (LinkleStoneDia, LinkleStoneDia)

型が合わない変身なんて、興味がありません! :triumph:
「ふたりはプリキュア!!」って2人で叫んでいるのに1人で変身できるとか、変じゃないですか!
「光の使者、キュアブラック!」ってキュアブラックが言った後「光の使者、キュアホワイト!」ってキュアホワイトが言ってるのに片方の台詞しかないとか、変じゃないですか!

それから、プリキュア全員が同時に行う必殺技(「浄化技」と呼ぶこともあります)についても、当然全員が揃わないと実行できないよう作られています。
以下は「Go! プリンセスプリキュア」の終盤で使用される必殺技、「プリキュア・グラン・プランタン」の台詞を取得する際のコードです。

> purificationSpeech (CureFlora_ModeElegantRoyal, CureMermaid_ModeElegantRoyal, CureTwinkle_ModeElegantRoyal, CureScarlet_ModeElegantRoyal) (MusicPrincessPalace RoyalDressUpKey)
[ "はっ!"
, "響け!遙か彼方へ!"
, "プリキュア! グラン・プランタン!"
, "ブルーミング"
, "ごきげんよう。"
]

「プリキュア・グラン・プランタン」を使用する際、「Go! プリンセスプリキュア」の5人は「モードエレガント・ロイヤル」という特殊なフォームに変身していなければなりません。なので、これらにも別の型を割り当てました。

-- cureName CureFlora と同じ文字列を返す。
> cureName CureFlora_ModeElegantRoyal
"キュアフローラ"

> variation CureFlora_ModeElegantRoyal
"モードエレガント・ロイヤル"

-- 特殊フォームでない場合は空文字列
> variation CureFlora
""

当然、「モードエレガント・ロイヤル」に変身する際には4人全員が揃い、専用のアイテムを使用する必要があります。

> transformationSpeech (CureFlora, CureMermaid, CureTwinkle, CureScarlet) (MusicPrincessPalace RoyalDressUpKey)
["モードエレガント・ロイヤル!", "ドレスアップ・ロイヤル!"]

> transformationSpeech CureFlora (MusicPrincessPalace RoyalDressUpKey)
<interactive>:8:1: error:
    ? No instance for (Transformation
                         CureFlora (MusicPrincessPalace RoyalDressUpKey))
        arising from a use of transformationSpeech
    ? In the expression:
        transformationSpeech
          CureFlora (MusicPrincessPalace RoyalDressUpKey)
      In an equation for it:
          it
            = transformationSpeech
                CureFlora (MusicPrincessPalace RoyalDressUpKey)

PreCureMonad

Haskellでプリキュアを実装する上で外せない機能が、もう一つあります。
そう、Monadです!

「タイプセーフプリキュア!」では、各種変身シーンや浄化技発動時の台詞をリストに組み立てるためのMonadを実装しました。
先ほどのGo! プリンセスプリキュアがモードエレガント・ロイヤルに変身してから浄化技を発動するまでの台詞を組み合わせてみましょう。

> :{
composeEpisode $ do
  goPrincessPreCureRoyal <- transform (CureFlora, CureMermaid, CureTwinkle, CureScarlet) (MusicPrincessPalace RoyalDressUpKey)
  purify goPrincessPreCureRoyal (MusicPrincessPalace RoyalDressUpKey)
> :}
[ "モードエレガント・ロイヤル!"
, "ドレスアップ・ロイヤル!"
, "はっ!"
, "響け!遙か彼方へ!"
, "プリキュア! グラン・プランタン!"
, "ブルーミング"
, "ごきげんよう。"
]

単に文字列のリストを組み立てるだけじゃ面白くないので、rubicureが実装していた機能にならい、セリフを一行ずつ表示する機能を実装しました。
ぜひぜひ試してみてください!

> :{
printEpisode $ do
  goPrincessPreCureRoyal <- transform (CureFlora, CureMermaid, CureTwinkle, CureScarlet) (MusicPrincessPalace RoyalDressUpKey)
  purify goPrincessPreCureRoyal (MusicPrincessPalace RoyalDressUpKey)
> :}

PreCureMonadはsyocyさんも「Haskellライブラリ所感2016」という記事で紹介されていたmonad-skeletonというライブラリーを使ってさくっと作りました。
でも、この程度ならWriter Monadをnewtypeするので十分な気はしますが。まぁ使ってみて面白かったのでよしとしましょう。

使用しているGHCの拡張

さて、ライブラリー自体の紹介はこの辺にして、ここからは開発の際に使用したGHCの拡張について解説しましょう。
すべてメジャーなものだと思うので、少しでも参考にしていただければ幸いです。

MultiParamTypeClasses

「タイプセーフプリキュア!」では、一人一人の女の子、プリキュア、一つ一つのプリキュアの変身スタイル、変身アイテムそれぞれに固有の型を割り当てています。
PreCureSpecialItemなどといった型は存在していません。
そのため、「特定の組み合わせでないと変身したり浄化したりすることができない」ということを型レベルで保証できるようにするためには、
型の「組み合わせ」に対して性質を与える必要があります。

そこで今回はMultiParamTypeClassesというGHCの拡張を使用しました。
名前の通り、これは型クラスが複数の型パラメーターをとることができるようになる拡張です。
例えば以下は、浄化技を使用できるプリキュアと、その際に必要な特殊アイテムの組み合わせを表す型クラスです。

class Purification p' i' where
  purificationSpeech :: p' -> i' -> [String]

インスタンス宣言は次のようになります。
ちゃんとPurificationに複数の型引数を与えて宣言していますね!

instance Purification CurePeach CureStickPeachRod where
  purificationSpeech _ _ =
    [ "届け!愛のメロディ!"
    , "キュアスティック・ピーチロッド!"
    , "悪いの悪いの飛んでいけ!"
    , "プリキュア!ラブサンシャイン・フレッシュ!"
    ]

このようにMultiParamTypeClassesは、複数の型の組み合わせごとに、メソッドを実装しわけたい場合に使います。
(この程度ならJavaオーバーロードとかで十分できるとかそういうこと言わない)
オブジェクト指向の世界の用語で言えば、マルチディスパッチとちょっと似ています。

身近な使用例: MonadState, MonadWriter, MonadReader

MultiParamTypeClassesの大変身近な使用例と言えば、すごいH本にもちょっとだけ出てくるmtlというライブラリーの、
MonadState, MonadWriter, MonadReaderなどの型クラスでしょう。

これらはそれぞれState Monad, Writer Monad, Reader Monadっぽく振る舞う型、全般を束ねる型クラスです。
これら3つのMonadはそれぞれState s a, Writer w a, Reader r aといった具合に2つの型パラメーターを受けとるように定義されているため、型クラスとして抽象化するには型クラス自体にも2つの型パラメーターが必要です 1
そのためMultiParamTypeClassesが使用されています。
結果、例えばMonadState型クラスは以下のような定義となっています(説明のために大幅に簡略化しています)。

class Monad m => MonadState s m where
    get :: m s
    put :: s -> m ()
    state :: (s -> (a, s)) -> m a

型パラメーター化されたMonad型クラスのインスタンス(m)が、もう一方の型パラメーターであるsを囲っていますね!

mtlでのMultiParamTypeClassesの使用方法は「マルチディスパッチ」的な使用方法とは大分毛色が違います 2が、このように、型パラメーターを複数とる型を型クラスとして抽象化したいときにも使えます。

FlexibileInstances

MultiParamTypeClassesのお陰で「型の組み合わせ」に対してメソッドを定義できるようになりました。これでプリキュアと変身アイテムの「組み合わせ」に対して個別の実装を割り当てることができるようになります。
ところが、プリキュアの世界には更に複雑なケースが存在します。実はすでに紹介した例に登場しているのですがお気づきでしょうか...?
そう、プリキュアは2人以上揃っていなければ変身できないことがあるのです!
Haskellの仕様上、2人以上の女の子が必要だからといってtransformationSpeech関数やtransform関数の引数を増やすわけにも行きません。
そこで「タイプセーフプリキュア!」では、2人以上の女の子やプリキュアが変身・浄化技に必要な場合は、タプルを使って表現することにしました。
先ほどのなぎさとほのかの例が典型的ですね。

> transformationSpeech (Nagisa, Honoka) (CardCommune_Mepple, CardCommune_Mipple)

繰り返しになりますが、「タイプセーフプリキュア!」ではすべての女の子やプリキュア・変身アイテムに個別の方を割り当てているので、
(Nagisa, Honoka) (CardCommune_Mepple, CardCommune_Mipple)という型の組み合わせで型クラスのインスタンスを宣言するには、それらのタプルに対してインスタンス宣言をしなければなりません。
これを可能にするためにはFlexibileInstancesが必要です。
この拡張を使わない場合、型クラスは仕様上、タプルのような型パラメーターを受け取る型をインスタンスとして宣言する際、型パラメーターをすべて抽象化した状態で(単純な型引数として)適用しなければなりません。

例えば、以下のようなインスタンス宣言は、FlexibileInstancesを使わなければエラーになります。

class SomeTypeClass a where
  someMethod :: a -> Foo

-- 型引数が両方とも具体的。なのでダメ。
instance SomeTypeClass (Int, Int)

-- 2つ目の型引数が具体的。なのでこれもダメ。
instance SomeTypeClass (Int, a)

-- 1つ目の要素の型が具体的。なのでこれもダメ。
instance SomeTypeClass ([a], b)

-- 2つ目の要素の型が1つ目の型と同じでなければならない、なのでこれもダメ。
instance SomeTypeClass (a, a)

-- これはOK
instance SomeTypeClass (a, b)

なぜダメなんでしょう?
例えば以下のような使い方ができてしまうからです。

instance SomeTypeClass (a, Int) where
  someMethod = ...

instance SomeTypeClass (Int, a) where
  someMethod = ...

のようにインスタンス宣言をしていたとして、someMethod (1, 3)を呼んだ場合、
instance SomeTypeClass (a, Int)someMethodと、instance SomeTypeClass (Int, a)someMethod、どちらのsomeMethodを呼ぶことになるのでしょうか?
(1, 3)の型は(Int, Int)と解釈できるので、(a, Int)(Int, a)もどちらも当てはまってしまいます。
このように、型クラスの解決が困難になってしまう場面が存在するため、現在のHaskell標準ではこうしたインスタンス宣言を禁止しています。
この制限を緩和してくれるのがFlexibileInstancesです。
FlexibileInstancesを有効にした場合、(a, Int)(Int, a)のように曖昧になるインスタンス宣言が存在しない限り、型引数に具体的な型を当てはめたインスタンス宣言を認めてくれるようになります。
詳しいルールについては結構複雑なのでGHCのドキュメントのこのへんをご覧ください。

TypeFamilies

TypeFamiliesは、type family, 「型を受け取って型を返す関数」を使えるようにしてくれます。
ごく単純なものを例えるなら、「型と型を紐付ける連想配列」といった方がピンとくるでしょう。
「タイプセーフプリキュア!」でもそのような使い方しかしていません。

例えば、以下のようなHaskellの型をキーにしたJSON(みたいなもの)をイメージしてみてください。

SomeTypeFamily =
  {
    String: [Double],
    Int: Char,
    Bool: (Maybe a)
  }

これをtype familyの文法で書くと次のようになります。

-- 型を一つ受け取って紐付けた型を返すtype family
type family SomeTypeFamily a

type instance SomeTypeFamily String = [Double]
type instance SomeTypeFamily Int = Char
type instance SomeTypeFamily Bool = Maybe Integer

ちょっと冗長ですが、

  • Stringを受け取ったら[Double]を返し、
  • Intを受け取ったらCharを返し、
  • Boolを受け取ったらMaybe Integerを返す関数である

ということが伝わったでしょうか?

実際に使う時はそのまんま型宣言に含めて使います(といって、この使い方ではあまり役に立ちませんが :sweat_smile:)。

-- SomeTypeFamilyにStringを適用すると[Double]を返し、
-- SomeTypeFamilyにIntを適用するとCharを返すので、
-- 下記の関数は[Double]を受け取ってCharを返す関数となる。
someFunctionUsingTypeTypeFamily :: SomeTypeFamily String -> SomeTypeFamily Int
someFunctionUsingTypeTypeFamily x = ...

このようにtype familyは文字通り「型を受け取って型を返す関数」なのです。実は再帰呼出しをしたりもできるので、本当に関数のように使えます。

さてこのtype familyについて、もっと頻繁に使われる使い方を示しましょう。
普通の関数が型クラスのメソッドとして宣言することができるのと同じように、type familyも型クラスのメソッドとして宣言することができるのです。

「タイプセーフプリキュア!」でもやはりそうした使い方をしています。
「変身できる女の子と、変身に必要な特殊アイテム」を表す型クラス Transformation を見てみましょう。
さっきから何度も出ているtransformationSpeechを定義しているのはこの型クラスです。

class Transformation g' i' where
  type Style g' i'
  transformedStyle :: g' -> i' -> Style g' i'
  transformationSpeech :: g' -> i' -> [String]

type familyを型クラスのメソッドとして宣言するのはよくある使い方だし、曖昧にもならないので、上記のようにfamilyを省略してtype <type family名>と宣言するだけで定義できます。
このように型クラスを定義することによって、Transformationを実装する型は、Style type familyに該当する型も定義しなければならなくなります。

Transformation型クラスのtype family Styleには、「変身した後のプリキュア、プリキュアの特殊フォーム(を表す型)」を定義しなければなりません。
例えば「Yes! プリキュア5 GoGo!」における「夢原のぞみはキュアモを使ってキュアドリームに変身する」という設定を表すインスタンス宣言は次のようになります。

instance Transformation Nozomi CureMo where
  type Style Nozomi CureMo = CureDream
  transformedStyle _ _ = CureDream
  transformationSpeech _ _ =
    [ "プリキュア!メタモルフォーゼ!"
    , "大いなる希望の力、キュアドリーム!"
    ]

何度も繰り返しますが、「タイプセーフプリキュア!」では一人ひとりのプリキュアやプリキュアの変身スタイルを異なる型で表現しているので、
transformedStyle, すなわち「変身した後のスタイル」を取得するための関数を定義するためには、
Transformationインスタンスごとに異なる型の値を返せるようにする必要があります。
そこでtype familyが必要になったのです。

もっと実践的な使用例

せっかくなんでプリキュアよりももっともっとも〜っと実践的な使用例を示しましょう。
個人的にぱっと思いついたのはmono-traversableです(もっといろいろな例があると思うので教えてほしい)。

mono-traversableは、標準ライブラリーにおける「コンテナーっぽいデータ型の型クラス」Functor, Foldable, Traversableをそれぞれ一般化した、MonoFunctor, MonoFoldable, MonoTraversableを提供してくれます。
どのように一般化したのでしょう?
例えばByteStringのような、「コンテナーっぽいけれど、中に含んでいる要素が一種類しかない」型も抽象化できるようにしてくれるのです。

標準ライブラリーのFunctor, Foldable, Traversableのインスタンスとなる型は、いずれも型引数を1つとる型でなければいけません。
対してmono-traversableが提供するMonoFunctorなどは、Elementというtype familyで、
取りうる要素を型ごとに指定することで、ByteStringTextみたいな、要素として使える型が一種類しかないコンテナーもサポートします。

論より証拠で、Element type familyのソースを一部見てみましょう。

type family Element mono
type instance Element ByteString = Word8
type instance Element Text = Char
type instance Element [a] = a
type instance Element (Maybe a) = a

:point_up: のとおり、ByteStringTextのような型に対してはWord8Charといった決まった型を返し、リストやMaybeのように任意の型を要素として使える型に対しては要素の型をそのまま返すように作られています。
結果、mapのmono-traversable版であるomapという関数は、次のような型となります。

> :t omap
omap :: MonoFunctor mono => (Element mono -> Element mono) -> mono -> mono

omapは第二引数の型がByteStringであれば第一引数の型をWord8 -> Word8だと判断し、リスト [a]であれば(普通のリストのmapと似たように)第一引数の型をa -> aだと判断するようになるのです。

TemplateHaskell

TemplateHaskellは強力なメタプログラミング機構です。Yesodやaeson, Lensなどを使う際にも直接使うことがあるので、みなさんも他の拡張より親しみがあるかもしれません。
Q ExpQ [Dec]など、Haskellの構文木を表した型を返す関数を定義・使用することで、コンパイル時にHaskellのソースコードを自動生成します。
「コンパイル時限定のLispのマクロのようなもの」と言えばピンとくる方もいらっしゃるかもしれません。
似たような関数・型をたくさん宣言したい時に記述を簡略化したり、
データベースのスキーマや設定ファイルといった、Haskellのソースコードの「外にあるもの」から自動で関数・型を定義したい時など、ユースケースはいろいろあります。

「タイプセーフプリキュア!」では、総勢45人ものプリキュアとそれに変身する女の子や変身アイテムなど、大量の型に対してTransformationPurificationなどの型クラスを定義しなければならないため、インスタンス宣言は直接書かず、Template Haskellで定義したマクロを使用して定義しました。

そのため、先ほど出てきた「夢原のぞみはキュアモを使ってキュアドリームに変身する」という設定を表すインスタンスは、実際には次のように宣言されています。

transformationInstance [t| Nozomi |] [t| CureMo |] [t| CureDream |] [| CureDream |] transformationSpeech_Dream

あまり短くない?
まぁこれでも少しは楽ができました :relaxed:

とはいえ、それでも実際のソースを見ると、大量の類似コードに圧倒され、いささか複雑な気持ちになります :cold_sweat:特に魔法つかいプリキュアのこの辺

もうちょっとよい抽象化方法を早く思いつきたかった... :sweat:

今後について

みなさんにお願い: 変身や浄化技・必殺技の台詞を募集します!

前回のブログ記事にも書きましたが、ここでも募集させていただきます。

正しさにこだわって実装したtypesafe-precureですが、残念ながら浄化技やプリキュアの特殊なフォームは一部しか実装されていません。時間の力には勝てませんでした。

そのため、みなさんの好きなあの技・あのフォームが定義されていない可能性が十分にあります。そうした場合は、こちらのルールにしたがって報告していただけると幸いです

また、もちろん誤字・脱字や設定の大きな間違いについても受け付けます(キリがなくなりそうな状態になると私の一存でクローズするかもしれません。あらかじめご容赦を)。

お気づきの場合は以下から報告してください。お待ちしてます!

New Issue · igrep/typesafe-precure

追加したい機能とか

GHCのいろいろな拡張を試すつもりで実装した「タイプセーフプリキュア!」でしたが、思いついたアイディアを試すもなかなかうまく行かず、思ったよりもシンプルな実装に落ち着いてしまったな、と思います。
非互換な変更になってはしまいますが、気が向いたらがさくっと設計を変更するかもしれません :innocent:
例えば個別の型を定義するのではなく、型レベル文字列Symbolを使ってプリキュアや女の子の「名前」にTransformationなどのインスタンスを定義するのも面白いでしょう(修正量多いので多分やらないな、うん :relieved:)。

それからもっと大事なこととして、ここまで挙げたコード例から察せられるとおり、typesafe-precureは現状非常に冗長で、非実用的な実装になってしまっています。
これでは使っていただけないでしょうし、収録していない台詞を集めていただくモチベーションも上がらないと思います。
そこで収録しているプリキュアの情報をJSONなどに変換して出力する機能を検討しています。
これはTemplate Haskellを使えば型クラスの定義から自動的に作れるはずです。
rubicureがyamlで定義したプリキュアの情報をRubyから使えるようにしているのと、ちょうど逆のことをやろうとしているわけですね。

そんなわけで、来年のプリキュアハッカソンやプリキュアAdvent Calendarのネタがもう けって~い! してしまったようです。
来年も私はプリキュアとHaskellをバリバリ追いかけて行きたいと思いますので、よろしくお願いします vim(_ _)mer
やるっしゅ!


  1. 厳密に言うと「2つの型パラメーターを受けとる型を型クラスで束ねる」すべてのケースでMultiParamTypeClassesが必要になるわけではないはずですが、ちょっと私はうまく説明できません...。 

  2. 事実、MonadState, MonadWriter, MonadReaderFunctionalDependenciesという別の拡張を併用して定義されているので、「型の組み合わせ」によってメソッドの実装を分けるといった使い方はできません。FunctionalDependenciesについては今回は使用していないので割愛します。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした