なんか考えてることとか

変な人が主にプログラミング関連で考えていることをまとめる。

PureScriptに入門した

仕事柄、Webアプリケーション開発に携わることはあまりないが、JavaScript(以下JS)で辛い思いするのはもう嫌になったので次携わることになったときのために、JSを改善したAltJSやフロントエンドフレームワークを使えるようにしておこうと思い、PureScript(以下PS)に入門することにした。

PureScriptのロゴはクリエイティブ・コモンズ・ライセンス(表示4.0 国際)に基づいています。
ロゴ製作: Gareth Hughes, 2014

PureScriptについて

PSは、JSにコンパイルするいわゆるAltJSであり、大きな特徴としては、「純粋関数型プログラミング言語である」という点が挙げられる。
純粋関数型プログラミング言語についてはこのブログでは何度も説明しているため、具体的な説明は割愛するが、「厳密に数学的な『関数』を使ってコーディングしていく」ものである、と覚えておけば良いだろう。

Haskellをベースとしており、それでいてHaskellにあった欠陥の改善、歴史ある言語であるがゆえの言語拡張と同等の機能がすでに提供されている、より段階的な型クラス階層など、Haskellよりも多くが改善、または洗練されている。

なんでTypeScriptではなくPureScript?

さて、AltJSというと、PSよりも、TypeScript(以下TS)のほうを思い浮かぶ人のほうが多いだろう。しかし、私は今回あえてTSを触らないことにした。
これにはいろいろ理由がある。

まず、TSは「JSのスーパーセット」という設計思想で作られた言語なので、型強制やASIといったJSの嫌な部分をそのまま受け継いでいる。
また、代数的データ型の表現が冗長であったり、型がややこしかったりと、TS自体にも使いたくない要因がある。

理由の詳細


もちろん、私のような人だけでなく、以上の点も含めてTSが好きな人がいることも十分理解しているが、そもそもJSが嫌いな私がTSを使わない理由としてはあまりにも十分すぎた。

だから私はTSではなくPSに入門した。

PureScriptの開発環境を構築

PSの開発環境を構築するためには、最低限以下の環境を構築している必要がある。

  • Node.js
  • (npm)
    • 現在ではNode.jsをインストールすればデフォルトでインストールされるはず
  • Git

それぞれのインストール方法や設定方法はほかの記事に任せる。

npmパッケージのインストール

では早速PureScript環境のインストールを始める。今回はローカル環境でインストールを行いたいので、cmd.exeなどを使って、まずは任意の場所で以下のコマンドを実行する。

npm init

そして、以下のコマンドを実行する。

npm install -D esbuild purescript spago@next

これによってインストールされたのは以下のnpmパッケージである。

  • esbuild
    • Spagoでバンドル*2するために必要
  • purescript
    • PureScriptコンパイラ本体。ちなみにこのパッケージ要因でインストール時にWARNINGが出るが、とりあえず無視して良いと思う。
  • spago
    • Spago。現在公式で推奨されているPSのパッケージ管理およびビルドツール

spago@nextについて

Hello, Worldプログラム

Spagoでビルドするには、任意の場所で以下のコマンドを実行してプロジェクトを立ち上げる必要がある。

npx spago init

ちなみにここでGitの環境構築がしっかりできていないと、プロジェクトの立ち上げに失敗するので注意。

次に以下のコマンドでビルドと実行を行う。

npx spago run

これで環境構築が正常にできていれば最終的に以下が出力されるはず。


早速PureScriptを触ってみる

Hello, Worldプログラムから読み取れる機能

まずは先ほどのHello, Worldプログラム。

module Main where

import Prelude

import Effect (Effect)
import Effect.Console (log)

main :: Effect Unit
main = do
  log "🍝"

  • 先頭のmodule ... whereが必須になっている

私の記憶が正しければHaskellでは先頭にmodule ... whereは必須ではなかったはずだが、PSでは必須になっているらしい。

  • Preludeのインストール必須化

Haskellでは何もせずともimportされていたPreludeもPSではパッケージとなり、インストールおよびimportが必須になっている。
そういう意味ではPSのライブラリ周りの仕様は汎用的なライブラリですら外部ライブラリと見なすRustよりもストイックな仕様となっていると言えるだろう*3

  • HaskellIO型がPSではEffect型になっている

HaskellIO型は「外部への作用」を値として表現した型であるが、IO型だと名前的に入出力に限定されているように解釈することもできてしまう(実際には、IO型は入出力以外も用いる)ので、Effectのほうが適切な名前と言える。

評価戦略と再帰

Haskellでは遅延評価だったが、PSでは正格評価となっている。その影響は細かい点を挙げればキリがないが、特に影響してくるのは再帰関数だろう。

まず前提として覚えておいてほしいのが、再帰には、末尾再帰とそうでないものの2種類があって*4関数型プログラミングのうえでは一般に末尾再帰を定義し、コンパイラに末尾呼び出し最適化をしてもらうことで、スタックオーバーフローを回避している。

Haskellにおいては、以下のような再帰の場合は、末尾再帰でないにも関わらずスタックオーバーフローにならない。

-- 簡易的な`map`関数
map' :: (a -> b) -> [a] -> [b]
map' _ [] = []
map' f (x:xs) = f x : map' f xs

PSではスタックオーバーフローの発生するJSを生成するのでスタックオーバーフローとなる。

-- 事前に`List`型の`import`が必要
import Data.List (List(..), (:))

-- 簡易的な`map`関数
map' :: forall a b. (a -> b) -> List a -> List b
map' _ Nil = Nil
map' f (x:xs) = f x : map' f xs

実際に生成されたJSコードの一部を見てみると、確かにオーバーフローが発生するコードとなっていることがわかる(簡単のため、実際のコードから省いたりコメントを入れたりしているので注意)。

var map$prime = function (v) {
    return function (v1) {
        /* 省略 */
            // WARNING: `map$prime`が末尾呼び出しされていない
            return new Data_List_Types.Cons(v(v1.value0), map$prime(v)(v1.value1));
        /* 省略 */
    };
};

逆に、以下のような末尾再帰を定義すると、Haskellではスタックオーバーフローとなる。これはacc + xの部分を正格評価にすることによって、解決することができる*5

-- 簡易的な`sum`関数(末尾再帰バージョン)
sum' :: Num a => [a] -> a
sum' xs = sum'' xs 0
  where sum'' [] acc = acc
        sum'' (x:xs) acc = sum'' xs $ acc + x

一方で、PSでは末尾呼び出し最適化によって、再帰ではない関数に変換されたJSコードを生成するので、スタックオーバーフローが発生しない。

import Data.List (List(..), (:))

-- 簡易的な`sum`関数(末尾再帰バージョン)
sum' :: List Number -> Number
sum' xs = sum'' xs 0.0
  where sum'' Nil acc = acc
        sum'' (x:xs) acc = sum'' xs $ acc + x

生成されたJSコードを見てみても、再帰ではない関数になっていることがわかる(これも簡単のために省略したり、コメントを加えたりしているので注意)。

var sum$prime$prime = function ($copy_v) {
    return function ($copy_v1) {
        /* 省略 */
        // `$tco_done === true`となるまで`$tco_loop`を呼び出し続ける
        while (!$tco_done) {
            $tco_result = $tco_loop($tco_var_v, $copy_v1);
        };
        // `$tco_loop`によって得られた結果を返す
        return $tco_result;
    };
};

まだわかってないけど、PSでは遅延評価をするためのパッケージもあるので、それを使った場合のmap'がどうなるのかも調べてみたい。

RecordとRow Polymorphism

PSにおけるRecordは、JS/TSにおけるObjectに相当する。

type R = { fi1 :: a, fi2 :: b, ... }

JS/TSではObjectはレコードとしてだけではなく、連想配列としても使えてしまっていたのだが、PSではちゃんとレコードに使い方が限定されている。

さらにその表現力はTSよりも強力だ。たとえばTSで以下のような構造的型付けを使った型定義があったとしよう。

// `f1`を返す関数
const f = (r: { fi1: number }): number => r.fi1;

// `fi1`と`fi2`を含むオブジェクトを宣言
const r = { fi1: 42, fi2: "foo" };
// 構造的部分型付けによって、これはOKとなる
f(r);

PSでは以下のように定義できる。

-- `fi1`を返す関数
f :: forall r. { fi1 :: Number | r } -> Number
f { fi1: x } = x

-- これはOK
f { fi1: 42.0, fi2: "foo" }

これはRow Polymorphismと呼ばれる多相によって実現している。以上の例では、型引数rに残りのフィールドの型を渡しているので、fi1 :: Numberを含むRecordに対して適用できるというメカニズムとなっている。

ちなみに、型引数rにはNumberArrayといった一般に見られる型は渡せないようになっている。
これはRecord型の型引数のカインド*6Row Typeとなっているためである。一般に見られる型のカインドはTypeであるので、Record型の型引数としては受け付けられないという、面白い仕様になっている。

クリックカウンタを作った

というわけで早速練習(?)として、生のDOM操作を用いたクリックカウンタを作った。
ちなみにわかる人にはわかると思うが、これはElmのこのカウンタの丸パクリである。

プログラムは以下の通りとなっている。

module Main
  ( main
  )
  where

import Prelude

import Data.Either (Either(..))
import Data.Int (decimal)
import Data.Int (fromString, toStringAs) as Int
import Data.Maybe (Maybe(..))
import Data.Tuple.Nested (type (/\), (/\))
import Effect (Effect)
import Effect.Console (error)
import Web.DOM.Element (Element)
import Web.DOM.Element (toEventTarget, toNode) as Element
import Web.DOM.Internal.Types (Node)
import Web.DOM.Node (textContent, setTextContent) as Node
import Web.DOM.NonElementParentNode (getElementById) as Node
import Web.Event.EventTarget (EventListener)
import Web.Event.EventTarget (eventListener, addEventListener) as Event
import Web.HTML (window) as HTML
import Web.HTML.Event.EventTypes (load, click)
import Web.HTML.HTMLDocument (HTMLDocument)
import Web.HTML.HTMLDocument (toNonElementParentNode) as HTMLDocument
import Web.HTML.Window (Window)
import Web.HTML.Window (toEventTarget, document) as Window

-- クリックイベント
data ClickEvent = Decrement | Increment

-- HTML要素とその情報
type ElementInfo a r = { element :: a | r }
type ElementMaybeInfo = ElementInfo (Maybe Element) (id :: String)
type ButtonInfo = ElementInfo Element (event :: ClickEvent)

-- 存在が未確認なHTML要素とその情報
type ElementMaybeInfos = ElementMaybeInfo /\ ElementMaybeInfo /\ ElementMaybeInfo
-- クリックカウンタ情報
type ClickCounterInfo = { count :: Element
                        , decrement :: ButtonInfo
                        , increment :: ButtonInfo
                        }

-- 要素が存在しないエラーのメッセージ
newtype NotFound = NotFound String

-- HTML要素情報取得
getElementInfoById :: String -> HTMLDocument -> Effect ElementMaybeInfo
getElementInfoById id document = do
  element <- Node.getElementById id $ HTMLDocument.toNonElementParentNode document
  pure { element: element, id: id }

-- 要素が存在しないエラー
notFound :: String -> NotFound
notFound id = NotFound $ "\"" <> id <> "\" not found."

-- クリックカウンタ情報に変換する。存在しない要素があればエラー
toClickCounterInfo :: ElementMaybeInfos -> Either NotFound ClickCounterInfo
toClickCounterInfo (count /\ decrement /\ increment) = do
  count'     <- case count.element of
                  Nothing         -> Left $ notFound count.id
                  Just count'     -> Right count'
  decrement' <- case decrement.element of
                  Nothing         -> Left $ notFound decrement.id
                  Just decrement' -> Right { element: decrement'
                                           , event: Decrement
                                           }
  increment' <- case increment.element of
                  Nothing         -> Left $ notFound increment.id
                  Just increment' -> Right { element: increment'
                                           , event: Increment
                                           }
  pure { count: count', decrement: decrement', increment: increment' }

-- クリックイベントを設定する
setClickEvent :: Node -> ButtonInfo -> Effect Unit
setClickEvent countNode button = do
  listener <- Event.eventListener \_ -> do
    count <- Node.textContent countNode
    case Int.fromString count of
      Nothing     -> error "Could not read count."
      Just count' -> do
        let f = case button.event of
                  Decrement -> (-)
                  Increment -> (+)
        Node.setTextContent (Int.toStringAs decimal $ f count' 1) countNode
  Event.addEventListener click listener true $ Element.toEventTarget button.element

-- `Window`読み込み直後のイベント
afterLoading :: Window -> Effect EventListener
afterLoading window = Event.eventListener \_ -> do
  document  <- Window.document window
  count     <- getElementInfoById "count" document
  decrement <- getElementInfoById "decrement" document
  increment <- getElementInfoById "increment" document
  case toClickCounterInfo (count /\ decrement /\ increment) of
    Left (NotFound notFoundError) -> error notFoundError
    Right elements                -> do
      let countNode = Element.toNode elements.count
      Node.setTextContent "0" countNode
      setClickEvent countNode elements.decrement
      setClickEvent countNode elements.increment

main :: Effect Unit
main = do
  window   <- HTML.window
  listener <- afterLoading window
  Event.addEventListener load listener true $ Window.toEventTarget window

まだまだ純粋関数型プログラミング初心者なので、拙いコードではあるが、ちょっと考えた点だけ抜粋して解説をば。

-- クリックイベント
data ClickEvent = Decrement | Increment

-- HTML要素とその情報
type ElementInfo a r = { element :: a | r }
type ElementMaybeInfo = ElementInfo (Maybe Element) (id :: String)
type ButtonInfo = ElementInfo Element (event :: ClickEvent)

早速こんな感じでRow Polymorphismを応用して、HTML要素の存在を確認できている場合とそうでない場合とで型を分けるようにした。
存在を確認できなかった場合に、その要素のiderrorで出力するようにしたかったので、idも保存するようにしている。これはもしその要素が存在しなかった場合に、idをハードコーディングするしか方法がなくなるのでこうしている。
存在が確認できた場合、そのクリックイベントがカウントを減らすか、増やすか場合分けできるようにしたかったので、ClickEvent型を定義し、それをHTML要素とともにフィールドに含めた型を定義した。

次に考えたのはここ。

-- クリックカウンタ情報に変換する。存在しない要素があればエラー
toClickCounterInfo :: ElementMaybeInfos -> Either NotFound ClickCounterInfo
toClickCounterInfo (count /\ decrement /\ increment) = do
  count'     <- case count.element of
                  Nothing         -> Left $ notFound count.id
                  Just count'     -> Right count'
  decrement' <- case decrement.element of
                  Nothing         -> Left $ notFound decrement.id
                  Just decrement' -> Right { element: decrement'
                                           , event: Decrement
                                           }
  increment' <- case increment.element of
                  Nothing         -> Left $ notFound increment.id
                  Just increment' -> Right { element: increment'
                                           , event: Increment
                                           }
  pure { count: count', decrement: decrement', increment: increment' }

ここ、もうちょっと洗練できるだろうなと思いつつも、これ以上コードを洗練するのは諦めた。前はもっと拙かったので、これでもまだマシなほう。
ここではもし3つのHTML要素のうち一つでも存在していなかった場合に、Eitherモナドを用いて計算を中断させるようにしている。
そのため、この3つのHTML要素が揃わないとクリックカウンタは動かない仕組みになっているはずだ。

その他気になった点

PSにはRead型クラス*7がない。Read型クラスは文字列を数値などに変換するための型クラスだったのだが、おそらくよくクラッシュするからなくなったのだろうか。その対極に位置するShow型クラスがあるのは不思議だが・・・。
そのため、たとえばInt型に変換したい場合、Data.IntからfromString関数をimportしてくる必要がある。

次回やりたいこと

生DOM操作は個人的にはよく型として落とし込まれていて好きだが、さすがにこれで全部やっていくのはしんどいので、次はPSを使ったフレームワーク(今のところ考えているのはHalogen)を導入してみる。

*1:JS/TSでは「型強制」と呼ぶらしい

*2:ビルドすると複数の.jsファイルができるので、それらを一つの.jsファイルにまとめる。これをバンドルと言う

*3:Rustでもpreludeはデフォルトでuseされるようになっている

*4:厳密には、さらにそこから再帰と余再帰も分けて3種類となる

*5:Haskellは遅延評価であるが、正格評価をするための関数seqおよび($!)演算子が提供されている

*6:少々複雑な概念なので、PureScriptでは型にも型があって、それがカインドと呼ばれる、と覚えておけば良いと思う

*7:型クラスとは、型に対して関数を提供する機能である。これによって、他言語で言うジェネリック型制約を課すこともできるようになる