実例によるPureScript

ウェブのための関数型プログラミング

Phil Freeman, "PureScript by Example - Functional Programming for the Web"

目次に戻る

第14章 領域特化言語

14.1 この章の目標

この章では、多数の標準的な手法を使ったPureScriptにおける領域特化言語(domain-specific language, DSL) の実装について探求していきます。

領域特化言語とは、特定の問題領域での開発に適した言語のことです。領域特化言語の構文および機能は、その領域内の考え方を表現するコードの読みやすさを最大限に発揮すべく選択されます。本書の中では、すでに領域特化言語の例を幾つか見てきています。

この章では、領域特化言語の実装において、いくつかの標準的な手法による構造的なアプローチを取ります。これがこの話題の完全な説明だということでは決してありませんが、独自の目的に対する具体的なDSLを構築するには十分な知識を与えてくれるでしょう。

この章で実行している例は、HTML文書を作成するための領域特化言語になります。正しいHTML文書を記述するための型安全な言語を開発することが目的で、少しづつ実装を改善することによって作業していきます。

14.2 プロジェクトの準備

この章で使うプロジェクトには新しいBower依存性が追加されます。これから使う道具のひとつであるFreeモナドが定義されている purescript-freeライブラリです。

このプロジェクトのソースコードは、PSCiを使ってビルドすることができます。

14.3 HTMLデータ型

このHTMLライブラリの最も基本的なバージョンは Data.DOM.Simpleモジュールで定義されています。このモジュールには次の型定義が含まれています。

newtype Element = Element
  { name         :: String
  , attribs      :: Array Attribute
  , content      :: Maybe (Array Content)
  }

data Content
  = TextContent String
  | ElementContent Element

newtype Attribute = Attribute
  { key          :: String
  , value        :: String
  }

Element型はHTMLの要素を表しており、各要素は要素名、属性のペア​​の配列と、要素の内容でで構成されています。 contentプロパティでは、 Maybeタイプを使って要素が開いている(他の要素やテキストを含む)か閉じているかを示しています。

このライブラリの鍵となる機能は次の関数です。

render :: Element -> String

この関数はHTML要素をHTML文字列として出力します。 PSCiで明示的に適当な型の値を構築し、ライブラリのこのバージョンを試してみましょう。

$ pulp repl

> import Prelude
> import Data.DOM.Simple
> import Data.Maybe
> import Control.Monad.Eff.Console

> :paste
… log $ render $ Element
…   { name: "p"
…   , attribs: [
…       Attribute
…         { key: "class"
…         , value: "main"
…         }
…     ]
…   , content: Just [
…       TextContent "Hello World!"
…     ]
…   }
… ^D

Hello World!

unit

現状のライブラリにはいくつかの問題があります。

この章では、さまざまな手法を用いてこれらの問題を解決し、このライブラリーをHTML文書を作成するために使える領域特化言語にしていきます。

14.4 スマート構築子

最初に導入する手法は方法は単純なものですが、とても効果的です。モジュールの使用者にデータの表現を露出する代わりに、モジュールエクスポートリスト(module exports list)を使ってデータ構築子 ElementContentAttributeを隠蔽し、正しいことが明らかなデータだけ構築する、いわゆるスマート構築子(smart constructors)だけをエクスポートします。

例を示しましょう。まず、HTML要素を作成するための便利な関数を提供します。

element :: String -> Array Attribute -> Maybe (Array Content) -> Element
element name attribs content = Element
  { name:      name
  , attribs:   attribs
  , content:   content
  }

次に、 element関数を適用することによってHTML要素を作成する、スマート構築子を作成します。

a :: Array Attribute -> Array Content -> Element
a attribs content = element "a" attribs (Just content)

p :: Array Attribute -> Array Content -> Element
p attribs content = element "p" attribs (Just content)

img :: Array Attribute -> Element
img attribs = element "img" attribs Nothing

最後に、正しいデータ構造だけを構築することがわかっているこれらの関数をエクスポートするように、モジュールエクスポートリストを更新します。

module Data.DOM.Smart
  ( Element
  , Attribute(..)
  , Content(..)

  , a
  , p
  , img

  , render
  ) where

モジュールエクスポートリストはモジュール名の直後の括弧内に書きます。各モジュールのエクスポートは次の3種類のいずれかです。

ここでは、 Elementをエクスポートしていますが、データ構築子はエクスポートしていません。もしデータ構築子をエクスポートすると、モジュールの使用者が不正なHTML要素を構築できてしまいます。

AttributeContent型についてはデータ構築子をすべてエクスポートしています(エクスポートリストの記号 ..で示されています)。これから、これらの型にスマート構築子の手法を適用していきます。

すでにライブラリにいくつかの大きな改良を加わっていることに注意してください。

Content型にもとても簡単にこの手法を適用することができます。単にエクスポートリストから Content型のデータ構築子を取り除き、次のスマート構築子を提供します。

text :: String -> Content
text = TextContent

elem :: Element -> Content
elem = ElementContent

Attribute型にも同じ手法を適用してみましょう。まず、属性のための汎用のスマート構築子を用意します。最初の試みとしては、次のようなものになるかもしれません。

attribute :: String -> String -> Attribute
attribute key value = Attribute
  { key: key
  , value: value
  }

infix 4 attribute as :=

この定義では元の Element型と同じ問題に悩まされています。存在しなかったり、名前が間違っているような属性を表現することが可能です。この問題を解決するために、属性名を表すnewtypeを作成します。

newtype AttributeKey = AttributeKey String

それから、この演算子を次のように変更します。

attribute :: AttributeKey -> String -> Attribute
attribute (AttributeKey key) value = Attribute
  { key: key
  , value: value
  }

AttributeKeyデータ構築子をエクスポートしなければ、明示的にエクスポートされた次のような関数を使う以外に、使用者が型 AttributeKeyの値を構築する方法はありません。いくつかの例を示します。

href :: AttributeKey
href = AttributeKey "href"

_class :: AttributeKey
_class = AttributeKey "class"

src :: AttributeKey
src = AttributeKey "src"

width :: AttributeKey
width = AttributeKey "width"

height :: AttributeKey
height = AttributeKey "height"

新しいモジュールの最終的なエクスポートリストは次のようになります。もうどんなデータ構築子も直接エクスポートしていないことに注意してください。

module Data.DOM.Smart
  ( Element
  , Attribute
  , Content
  , AttributeKey

  , a
  , p
  , img

  , href
  , _class
  , src
  , width
  , height

  , attribute, (:=)
  , text
  , elem

  , render
  ) where

PSCiでこの新しいモジュールを試してみると、コードが大幅に簡潔になり、改良されていることがわかります。

$ pulp repl

> import Prelude
> import Data.DOM.Smart
> import Control.Monad.Eff.Console
> log $ render $ p [ _class := "main" ] [ text "Hello World!" ]

Hello World!

unit

しかし、基礎のデータ表現が変更されていないので、 render関数を変更する必要はなかったことにも注目してください。これはスマート構築子による手法の利点のひとつです。外部APIの使用者によって認識される表現から、モジュールの内部データ表現を分離することができるのです。

演習

  1. (簡単)Data.DOM.Smartモジュールで renderを使った新しいHTML文書の作成を試してみましょう。

  2. (やや難しい) checkeddisabledなど、値を要求しないHTML属性がありますが、これらは次のような空の属性として表示されるかもしれません。

    <input disabled>
    

    空の属性を扱えるように Attributeの表現を変更してください。要素に空の属性を追加するために、 attributeまたは :=の代わりに使える関数を記述してください。

14.5 幻影型

次に適用する手法についての動機を与えるために、次のコードを考えてみます。

> log $ render $ img
    [ src    := "cat.jpg"
    , width  := "foo"
    , height := "bar"
    ]

<img src="cat.jpg" width="foo" height="bar" />
unit

ここでの問題は、 widthheightについての文字列値を提供しているということで、ここで与えることができるのはピクセルやパーセントの単位の数値だけであるべきです。

AttributeKey型にいわゆる幻影型(phantom type)引数を導入すると、この問題を解決できます。

newtype AttributeKey a = AttributeKey String

定義の右辺に対応する型 aの値が存在しないので、この型変数 a幻影型と呼ばれています。この型 aはコンパイル時により多くの情報を提供するためだけに存在しています。任意の型 AttributeKey aの値は実行時には単なる文字列ですが、そのキーに関連付けられた値に期待されている型を教えてくれます。

AttributeKeyの新しい形式で受け取るように、 attribute関数の型を次のように変更します。

attribute :: forall a. IsValue a => AttributeKey a -> a -> Attribute
attribute (AttributeKey key) value = Attribute
  { key: key
  , value: toValue value
  }

ここで、幻影型の引数 aは、属性キーと属性値が互換性のある型を持っていることを確認するために使われます。使用者は AttributeKey aを型の値を直接作成できないので(ライブラリで提供されている定数を介してのみ得ることができます)、すべての属性が正しくなります。

IsValue制約は、キーに関連付けられた値がなんであれ、その値を文字列に変換し、生成したHTML内に出力できることを保証します。 IsValue型クラスは次のように定義されています。

class IsValue a where
  toValue :: a -> String

StringInt型についての型クラスインスタンスも提供しておきます。

instance stringIsValue :: IsValue String where
  toValue = id

instance intIsValue :: IsValue Int where
  toValue = show

また、これらの型が新しい型変数を反映するように、 AttributeKey定数を更新しなければいけません。

href :: AttributeKey String
href = AttributeKey "href"

_class :: AttributeKey String
_class = AttributeKey "class"

src :: AttributeKey String
src = AttributeKey "src"

width :: AttributeKey Int
width = AttributeKey "width"

height :: AttributeKey Int
height = AttributeKey "height"

これで、不正なHTML文書を表現することが不可能で、 widthheight属性を表現するのに数を使うことが強制されていることがわかります。

> import Prelude
> import Data.DOM.Phantom
> import Control.Monad.Eff.Console

> :paste
… log $ render $ img
…   [ src    := "cat.jpg"
…   , width  := 100
…   , height := 200
…   ]
… ^D

<img src="cat.jpg" width="100" height="200" />
unit

演習

  1. (簡単) ピクセルまたはパーセントの長さのいずれかを表すデータ型を作成してください。その型について IsValueのインスタンスを書いてください。この型を使うように widthheight属性を変更してください。

  2. (難しい) 幻影型を使って真偽値 truefalseについての表現を最上位で定義することで、 AttributeKeydisabledchackedのような空の属性を表現しているかどうかを符号化することができます。

    data True
    data False
    

    幻影型を使って、使用者が attribute演算子を空の属性に対して使うことを防ぐように、前の演習の解答を変更してください。

14.6 Freeモナド

APIに施す最後の変更は、 Content型をモナドにしてdo記法を使えるようにするために、Freeモナドと呼ばれる構造を使うことです。Freeモナドは、入れ子になった要素をわかりやすくなるよう、HTML文書の構造化を可能にします。次のようなコードを考えます。

p [ _class := "main" ]
  [ elem $ img
      [ src    := "cat.jpg"
      , width  := 100
      , height := 200
      ]
  , text "A cat"
  ]

これを次のように書くことができるようになります。

p [ _class := "main" ] $ do
  elem $ img
    [ src    := "cat.jpg"
    , width  := 100
    , height := 200
    ]
  text "A cat"

しかし、do記法だけがFreeモナドの恩恵だというわけではありません。モナドのアクションの表現をその解釈から分離し、同じアクションに複数の解釈を持たせることをFreeモナドは可能にします。

Freeモナドは purescript-freeライブラリの Control.Monad.Freeモジュールで定義されています。 PSCiを使うと、次のようにFreeモナドについての基本的な情報を見ることができます。

> import Control.Monad.Free

> :kind Free
(Type -> Type) -> Type -> Type

Freeの種は、引数として型構築子を取り、別の型構築子を返すことを示しています。実は、 Freeモナドは任意の FunctorMonadにするために使うことができます!

モナドのアクションの表現を定義することから始めます。これを行うには、サポートする各モナドアクションそれぞれについて、ひとつのデータ構築子を持つ Functorを作成する必要があります。今回の場合、2つのモナドのアクションは elemtextになります。実際には、 Content型を次のように変更するだけです。

data ContentF a
  = TextContent String a
  | ElementContent Element a

instance functorContentF :: Functor ContentF where
  map f (TextContent s x) = TextContent s (f x)
  map f (ElementContent e x) = ElementContent e (f x)

ここで、この ContentF型構築子は以前の Contentデータ型とよく似ています。 Functorインスタンスでは、単に各データ構築子で型 aの構成要素に関数 fを適用します。

これにより、最初の型引数として ContentF型構築子を使うことで構築された、新しい Content型構築子を Freeモナドを包むnewtypeとして定義することができます。

type Content = Free ContentF

型のシノニムの代わりにnewtypeを使用して、使用者に対してライブラリの内部表現を露出することを避ける事ができます。 Contentデータ構築子を隠すことで、提供しているモナドのアクションだけを使うことを仕様者に制限しています。

ContentFFunctorなので、 Free ContentFに対する Monadインスタンスが自動的に手に入り、このインスタンスを Content上の Monadインスタンスへと持ち上げることができます。

Contentの新しい型引数を考慮するように、少し Elementデータ型を変更する必要があります。モナドの計算の戻り値の型が Unitであることだけが要求されます。

newtype Element = Element
  { name         :: String
  , attribs      :: Array Attribute
  , content      :: Maybe (Content Unit)
  }

また、 Contentモナドについての新しいモナドのアクションになる elemtext関数を変更する必要があります。これを行うには、 Control.Monad.Freeモジュールで提供されている liftF関数を使います。この関数の(簡略化された)型は次のようになっています。

liftF :: forall f a. (Functor f) => f a -> Free f a

liftFは、何らかの型 aについて、型 f aの値からFreeモナドのアクションを構築できるようにします。今回の場合、 ContentF型構築子のデータ構築子を次のようにそのまま使うだけです。

text :: String -> Content Unit
text s = liftF $ TextContent s unit

elem :: Element -> Content Unit
elem e = liftF $ ElementContent e unit

他にもコードの変更はありますが、興味深い変更は render関数に対してのものです。ここでは、このFreeモナドを解釈しなければいけません。

14.7 モナドの解釈

Control.Monad.Freeモジュールでは、Freeモナドで計算を解釈するための多数の関数が提供されています。

runFree
  :: forall f a
   . Functor f
  => (f (Free f a) -> Free f a)
  -> Free f a
  -> a

runFreeM
  :: forall f m a
   . (Functor f, MonadRec m)
  => (f (Free f a) -> m (Free f a))
  -> Free f a
  -> m a

runFree関数は、純粋な結果を計算するために使用されます。 runFreeM関数は、フリーモナドの動作を解釈するためにモナドを使用することを可能にします

厳密には、 MonadRecのより強い制約を満たすモナド mを使用する制限がされています。これはスタックオーバーフローを心配する必要がないことを意味します。なぜなら mは安全な末尾再帰モナド(monadic tail recursion)をサポートするからです。

まず、アクションを解釈することができるモナドを選ばなければなりません。 Writer Stringモナドを使って、結果のHTML文字列を累積することにします。

新しい renderメソッドは補助関数 renderElementに移譲して開始し、 Writerモナドで計算を実行するため execWriterを使用します。

render :: Element -> String
render = execWriter <<< renderElement

renderElementはwhereブロックで定義されています。

  where
    renderElement :: Element -> Writer String Unit
    renderElement (Element e) = do

renderElementの定義は簡単で、いくつかの小さな文字列を累積するために Writerモナドの tellアクションを使っています。

      tell "<"
      tell e.name
      for_ e.attribs $ \x -> do
        tell " "
        renderAttribute x
      renderContent e.content

次に、同じように簡単な renderAttribute関数を定義します。

    where
      renderAttribute :: Attribute -> Writer String Unit
      renderAttribute (Attribute x) = do
        tell x.key
        tell "=\""
        tell x.value
        tell "\""

renderContent関数は、もっと興味深いものです。ここでは、 runFreeM関数を使って、Freeモナドの内部で補助関数 renderContentItemに移譲する計算を解釈しています。

      renderContent :: Maybe (Content Unit) -> Writer String Unit
      renderContent Nothing = tell " />"
      renderContent (Just content) = do
        tell ">"
        runFreeM renderContentItem content
        tell "</"
        tell e.name
        tell ">"

renderContentItemの型は runFreeMの型シグネチャから推測することができます。関手 fは型構築子 ContentFで、モナド mは解釈している計算のモナド、つまり Writer Stringです。これにより renderContentItemについて次の型シグネチャがわかります。

      renderContentItem :: ContentF (Content Unit) -> Writer String (Content Unit)

ContentFの二つのデータ構築子でパターン照合するだけで、この関数を実装することができます。

      renderContentItem (TextContent s rest) = do
        tell s
        pure rest
      renderContentItem (ElementContent e rest) = do
        renderElement e
        pure rest

それぞれの場合において、式 restは型 Writer Stringを持っており、解釈計算の残りを表しています。 restアクションを呼び出すことによって、それぞれの場合を完了することができます。

これで完了です!PSCiで、次のように新しいモナドのAPIを試してみましょう。

> import Prelude
> import Data.DOM.Free
> import Control.Monad.Eff.Console

> :paste
… log $ render $ p [] $ do
…   elem $ img [ src := "cat.jpg" ]
…   text "A cat"
… ^D

<p><img src="cat.jpg" />A cat</p>
unit

演習

  1. (やや難しい)ContentF型に新しいデータ構築子を追加して、生成されたHTMLにコメントを出力する新しいアクション commentに対応してください。 liftFを使ってこの新しいアクションを実装してください。新しい構築子を適切に解釈するように、解釈 renderContentItemを更新してください。

14.8 言語の拡張

すべてのアクションが型 Unitの何かを返すようなモナドは、さほど興味深いものではありません。実際のところ、概ね良くなったと思われる構文は別として、このモナドは Monoid以上の機能は何の追加していません。

意味のある結果を返す新しいモナドアクションでこの言語を拡張することで、Freeモナド構造の威力を説明しましょう​​。

アンカーを使用して文書のさまざまな節へのハイパーリンクが含まれているHTML文書を生成するとします。手作業でアンカーの名前を生成すればいいので、これは既に実現できています。文書中で少なくとも2回、ひとつはアンカーの定義自身に、もうひとつはハイパーリンクに、アンカーが含まれています。しかし、この方法には根本的な問題がいくつかあります。

自分の間違いから開発者を保護するために、アンカー名を表す新しい型を導入し、新しい一意な名前を生成するためのモナドアクションを提供することができます。

最初の手順は、名前の型を新しく追加することです。

newtype Name = Name String

runName :: Name -> String
runName (Name n) = n

繰り返しになりますが、 NameStringのnewtypeとして定義しており、モジュールのエクスポートリスト内でデータ構築子をエクスポートしないように注意する必要があります。

次に、属性値として Nameを使うことができるように、新しい型 IsValue型クラスのインスタンスを定義します。

instance nameIsValue :: IsValue Name where
  toValue (Name n) = n

また、次のように a要素に現れるハイパーリンクの新しいデータ型を定義します。

data Href
  = URLHref String
  | AnchorHref Name

instance hrefIsValue :: IsValue Href where
  toValue (URLHref url) = url
  toValue (AnchorHref (Name nm)) = "#" <> nm

href属性の型の値を変更して、この新しい Href型の使用を強制します。また、要素をアンカーに変換するのに使う新しい name属性を作成します。

href :: AttributeKey Href
href = AttributeKey "href"

name :: AttributeKey Name
name = AttributeKey "name"

残りの問題は、現在モジュールの使用者が新しい名前を生成する方法がないということです。 Contentモナドでこの機能を提供することができます。まず、 ContentF型構築子に新しいデータ構築子を追加する必要があります。

data ContentF a
  = TextContent String a
  | ElementContent Element a
  | NewName (Name -> a)

NewNameデータ構築子は型 Nameの値を返すアクションに対応しています。データ構築子の引数として Nameを要求するのではなく、型 Name -> a関数を提供するように使用者に要求していることに注意してください。型 a計算の残りを表していることを思い出すと、この関数は、型 Nameの値が返されたあとで、計算を継続する方法を提供するというように直感的に理解することができます。

新しいデータ構築子を考慮するように、 ContentFについての Functorインスタンスを更新する必要があります。

instance functorContentF :: Functor ContentF where
  map f (TextContent s x) = TextContent s (f x)
  map f (ElementContent e x) = ElementContent e (f x)
  map f (NewName k) = NewName (f <<< k)

そして、先ほど述べたように、 liftF関数を使うと新しいアクションを構築することができます。

newName :: Content Name
newName = liftF $ NewName id

id関数を継続として提供していることに注意してください。型 Nameの結果を変更せずに返すということを意味しています。

最後に、新しいアクションを解釈するために、解釈関数を更新する必要があります。以前は計算を解釈するために Writer Stringモナドを使っていましたが、このモナドは新しい名前を生成する能力を持っていないので、何か他のものに切り替えなければなりません。WriterTモナド変換子をStateモナドと一緒に使うと、必要な作用を組み合わせることができます。型注釈を短く保てるように、この解釈モナドを型同義語として定義しておきます。

type Interp = WriterT String (State Int)

Int型の引数は状態の型で、この場合は増加していくカウンタとして振る舞う数であり、一意な名前を生成するのに使われます。

WriterWriterTモナドはそれらのアクションを抽象化するのに同じ型クラスメンバを使うので、どのアクションも変更する必要がありません。必要なのは、 Writer Stringへの参照すべてを Interpで置き換えることだけです。しかし、この計算を実行するために使われるハンドラを変更しなければいけません。 execWriterの代わりに、 evalStateを使います。

render :: Element -> String
render e = evalState (execWriterT (renderElement e)) 0

新しい NewNameデータ構築子を解釈するために、 renderContentItemに新しい場合分けを追加しなければいけません。

renderContentItem (NewName k) = do
  n <- get
  let fresh = Name $ "name" <> show n
  put $ n + 1
  pure (k fresh)

ここで、型 Name -> Interp aの継続 kが与えられているので、型 Interp aの解釈を構築しなければいけません。この解釈は単純です。 getを使って状態を読み、その状態を使って一意な名前を生成し、それから putで状態をインクリメントしています。最後に、継続にこの新しい名前を渡して、計算を完了します。

これにより、 PSCiで、 Contentモナドの内部で一意な名前を生成し、要素の名前とハイパーリンクのリンク先の両方を使って、この新しい機能を試してみましょう。

> import Prelude
> import Data.DOM.Name
> import Control.Monad.Eff.Console

> :paste
… render $ p [ ] $ do
…   top <- newName
…   elem $ a [ name := top ] $
…     text "Top"
…   elem $ a [ href := AnchorHref top ] $
…     text "Back to top"
… ^D

TopBack to top

unit

複数回の newName呼び出しの結果が、実際に一意な名前になっていることを確かめてみてください。

演習

  1. (やや難しい) 使用者から Element型を隠蔽すると、さらにAPIを簡素化することができます。次の手順に従って、これらの変更を行ってください。

    • pimgのような(返り値が Elementの)関数を elemアクションと結合して、型 Content Unitを返す新しいアクションを作ってください。
    • Content aの引数を許容し、結果の型 Tuple Stringを返すように、 render関数を変更してください。
  2. (やや難しい) 型同義語の代わりに newtypeを使って Contentモナドの実装を隠し、 newtypeのためにデータ構築子をエクスポートしないでください。

  3. (難しい) ContentF型を変更して、次の新しいアクションをサポートするようにしてください。

    isMobile :: Content Boolean
    

このアクションは、この文書がモバイルデバイス上での表示のためにレンダリングされているかどうかを示す真偽値を返します。  
  ヒントaskアクションとReaderT型変換子を使って、このアクションを解釈してみてください。あるいは、RWSモナドを使うほうが好みの人もいるかもしれません。

まとめ

この章では、いくつかの標準的な技術を使って、単純な実装を段階的に改善することにより、HTML文書を作成するための領域特化言語を開発しました。

使用者が間違いを犯すのを防ぎ、領域特化言語の構文を改良するために、これらの手法はすべてPureScriptのモジュールと型システムを活用しています。

関数型プログラミング言語による領域特化言語の実装は活発に研究されている分野ですが、いくつかの簡単なテクニックに対して役に立つ導入を提供し、表現力豊かな型を持つ言語で作業すること威力を示すことができていれば幸いです。

目次に戻る