Admittedly something small.

ちょっと小さいのはたしかですが。

HaskellerのためのPureScriptことはじめ

2015年2月6日 ( 10年前に投稿 )

JavaScript Haskell altjs purescript






【注意事項】この記事はとてもとても古いです。現在のPureScriptとは大きく言語仕様が変わっており、この記事の情報はあまり宛てにならないと思います。あまりに現状のPureScriptとかけ離れているので削除も考えましたが、何かの参考になるかもしれないので一応残しておきます。PureScriptについては、もし英語でも構わないのであれば、PureScriptのオリジナルの開発者であるPhilさん本人によるPureScript by Exampleがもっとも信頼できる情報源です。どうしても日本語の情報を探しているなら、かなり古いですが関数型なAltJS、PureScriptの入門書を邦訳しました。も参考にしてください。








**PureScript**とは:

  • Haskellライクな構文とライブラリ
  • Row Polymorphismによる柔軟な型システム
  • JavaScriptへトランスコンパイルされるAltJS

そんなPureScriptについて調べた内容のざっくりとしたメモです。べつにHaskellユーザじゃなくても読めますのでどうぞ。

言語仕様編

正格評価

PureScriptは正格評価です

型とKind

* (Type)

Haskellと同じように、*は普通のデータ型を表すkindです。* -> *は型をひとつとって型を返すkindです。例: Number :: *, String :: *, Maybe :: * -> *, Maybe Number :: *, (->) :: * -> * -> *, Number -> Number :: *, [] :: * -> *, [] Number :: *, [Number] :: *

! (Effect)

PureScriptのkindには*とは別に!というものもあります。この!は入出力や副作用を表すEffectの型のkindです。例: Data.Trace.Trace :: !, Control.Monad.Eff.Random.Random :: !

foreign importを使えば自分で新たなEffectを定義することもできます。

foreign import data Counter :: !

自分で定義したこの!は、FFIでEffと一緒に使います。

# (Row)

Rowは名前と型の組み合わせを複数個順序なしでまとめたものです。副作用を表す型Effでどんな副作用が含まれているかを表したり、オブジェクト型を作り出したりするのに使います。# *はフィールドに*だけをもつRowで、# !はフィールドに!だけを持つRowのことです。Rowを作るには()を使います。

()

例: () :: # * または () :: # !, ( number :: Number ) :: # *, ( number :: Number, string :: String ) :: # *, ( trace :: Trace ) :: # !, ( number :: Number, trace :: Trace ) は * と ! を混ぜているのでエラー

Object

Object# *から*を作ってくれます。JavaScriptのオブジェクトのような型は()でRowを作ってからObjectに渡すと作ることができます。

例: Object :: # * -> *, Object () :: *, Object ( number :: Number ) :: *, Object ( trace :: Trace ) はObjectは # ! は受け取れないのでエラー

Object ( number :: Number )numberという名前でNumberという型のプロパティを持ったオブジェクトの型に相当します。

earth :: Object ( answer :: Number )
earth = { answer : 42 }
{}

{}Object ()のシンタックスシュガーです。JavaScriptのオブジェクトの型を作るときにちょっと便利です。

例: {} :: *, { number :: Number } :: *, { trace :: Trace }Object ( trace :: Trace ) と同じ理由でエラー

earth :: { answer :: Number }
earth = { answer : 42 }
そのほか

例: Eff :: # ! -> * -> *, Eff () :: * -> *, Eff ( trace :: Trace ) :: * -> *, Eff ( trace :: Trace ) Number :: *, Eff ( number :: Number )Eff# * を受け取れないのでエラー, Eff ( trace :: Trace ) Trace*! を渡そうとしているのでエラー

オブジェクト(レコード)

{ answer : 42 }というようなRecord LiteralでJavaScriptのようなオブジェクトリテラルを書けます。レコードでパターンマッチングもできます。

f { foo = "Foo", bar = n } = n
f _ = 0

(チュートリアルを読んでると、{ answer :: Number }みたいなのをHaskellと同様にRecordと呼んでいたり、{ answer : 42 }はRecord Literalと呼んだり、パターンマッチングするときはRecord Patternなのですが、Rowから型を作るときの型コンストラクタはObjectだし、RecordとObjectという呼び方が混在している気もします。このあたりまだよくわかりません)

dataの直積型とオブジェクトの使い分け:
  • Haskellと同様に、のちほどフィールドを追加しそうなデータ型はオブジェクトにします。type で別名も定義しておくと良さそうです
  • インスタンスが必要な場合や、データ型が循環する場合では、newtypeでさらにオブジェクトを包むデータ型を別に定義します
  • 直和が必要な場合はnewtypeではなく dataでオブジェクトを包むことになります
  • 2次元ベクトル型や単方向リストのように、フィールドの内容が明らかで今後増減しそうになくフィールドの個数が少ないデータ型は、直接dataでデータ型を定義しても構いません。ただし、パターンマッチングは関数定義かcase式のみで使用可能で、(関数ではない)値の定義やlet式ではパターンマッチングが使えないらしく、直積型をdataで定義すると不便なことがあります。dataによる直積型とオブジェクト型(レコード型)は異なるので、Haskellのように一旦dataで直積型を定義しておいてあとからフィールドラベルを付け加えるというようなことは簡単にはできません。筆者もいろいろ調査中ですが、この点についてはもしかしたら今後改善されるのかもしれません。
module Main(main) where

import Debug.Trace

-- dataで直積型を定義。
data Vector2 = Vector2 Number Number

p :: Vector2
p = Vector2 10 20

-- 関数定義でのパターンマッチングは可能
addVector :: Vector2 -> Vector2 -> Vector2
addVector (Vector2 x1 y1) (Vector2 x2 y2) = Vector2 (x1 + x2) (y1 + y2)

-- case式でもパターンマッチングは可能
px :: Number
px = case p of
    Vector2 x y -> x

-- do記法の <- でもパターンマッチングが可能
main = do Vector2 x y <- return $ Vector2 10 20
          trace $ show x



-- パースエラー(Haskellでは可能)
Vector2 x y = Vector2 10 20

-- パースエラー(Haskellでは可能)
py = let Vector2 x y = p in y

-- パースエラー(Haskellでは可能)
main = do let Vector2 x y = Vector2 10 20
          trace $ show x

-- こういうWorkaroundは可能だけど、さすがにちょっとよみづらい
py = (\(Vector2 x y) -> y) p

-- dataを直接使う場合は、フィールドを取り出す関数を自力で定義するのが正攻法か
xop (Vector2 x _) = x
py = xop p

Row Polymorphism

( name::String, age::Number )というRowはname::Stringage::Numberという2つのフィールドのみを持った『閉じた』Rowです。それに対し、型変数tを加えた( name::String, age::Number | t)少なくともname::Stringage::Numberという2つのフィールドを持つ『開いた』Rowです。

Object ()のシンタックスシュガーである{}も同様で、{ name::String, age::Number }は閉じたオブジェクト、{ name::String, age::Number | t}は開いたオブジェクトの型になります。

閉じたRowには過不足なく同じ名前で同じ型のフィールドが揃っていなければその型の値として扱えません。

type Entry = { firstName :: String, lastName :: String, phone :: String }

john :: Entry
john = { firstName: "John", lastName: "Smith", phone: "555-555-5555" }

fullName :: { firstName :: String, lastName :: String } -> String
fullName person = person.firstName ++ " " ++ person.lastName

main = trace $ fullName john       -- Johnには余計なフィールドphoneが含まれているからERROR

それに対し、開いたRowにすると余分なフィールドがあっても受け取れます。rにその場に応じて適切な型が補完されると考えるとよいでしょう。この場合、fullNameの呼び出しでは型変数rphone :: Stringであるとすれば型を一致させることができます。

fullName :: forall r. { firstName :: String, lastName :: String | r } -> String  ---r を追加
fullName person = person.firstName ++ " " ++ person.lastName

main = trace $ fullName John       -- Johnには余計なフィールドphoneが含まれているけどOK

Row Polymorphismにより、入出力の時にいろんなEffを混ぜて書くのが楽になります。逆に言えば、EffTraceするためのEff、例外を扱うためのEff、乱数を生成するためのEff……という風に細かく分けて定義し、必要に応じて合成させることでより詳細に制御できます。Effについて詳しくは後述します。

型変数

型変数を導入する際 forallは 必須です

main

エントリポイントである main の型は main :: Eff :: # ! -> * -> *というような型です。mainTraceRandomEffを混ぜて使いたい時のmainの型は次のようになります。

module Main where

import Prelude
import Control.Monad.Eff
import Control.Monad.Eff.Random
import Debug.Trace

main :: Eff (trace :: Trace, random :: Random) Unit
main = do
  n <- random
  print n

もっとも、mainの型注釈は書かなくても型推論でたぶんなんとかなります。

モジュール

module ... where は必須です。

magic-do

do>>=のシンタックスシュガーですから、

do trace "hoge"
   trace "piyo"

のようなコードは>>=の入れ子になってしまいそうですが、Effはコンパイラが特別扱いしてうまく平坦なコードにして出力してくれます。このmagic-doはコンパイラオプションでオフにすることもできます。magic-doが効くのはEffだけですから、それ以外の独自のモナドで副作用を記述しようとすると恐ろしいコードが吐かれます(後述)。

末尾再帰

末尾再帰もちゃんと最適化してwhileのループにしてくれますので心配いりません。

go n = do
  print n
  go (n + 1)

main = go 1
var go = function (__copy_n) {
    return function __do() {
        var n = __copy_n;
        tco: while (true) {
            Debug_Trace.print(Prelude.showNumber)(n)();
            var __tco_n = n + 1;
            n = __tco_n;
            continue tco;
        };
    };
};

セクション

演算子のセクションはありません。演算子を()で囲んで関数にし、(+) 42みたいに書くことはできます。

ライブラリ編

PureScript や PureScript Contrib を覗くといろいろあります。

Prelude

Preludeに入っているのは本当に基本的なものだけです。MaybeEitherも別モジュールです。ちゃんとFanctor -> Applicative -> Monad の階層になっていたり、後発なだけあって全体的に整理されてすっきりしている印象です。

Boolean/Number/String

Boolean, Number, StringがJavaScriptのboolean, number, stringにそのまま対応します。Booleanな値は小文字から始まるtruefalseです。truefalseはパターンマッチングでも使えるデータコンストラクタなのに!ふしぎ!

標準出力

trace, printがそれぞれHaskellのputStrLn, printに相当します。

関数合成

Haskellのような.による関数合成はできません。関数はSemigroupoidという型クラスのインスタンスがあって、f . gf <<< gg >>> fと書きます。

Unit

UnitはHaskellのような() :: ()ではなくunit :: Unitです。

Foreign Function Interface

PureScriptのFFIは単純で、

  • PureScriptから呼ぶ関数はカリー化して定義しておく(PureScriptの関数はJavaScriptレベルでもカリー化されて定義されるので、逆にJavaScriptからPureScriptの関数を呼ぶ場合は引数を一つづつ渡す)
  • Effモナドは単にnullary function
  • Haskell同様の foreign import " ... " hoge :: Hoge -> Piyo というような構文で呼び出す先の型を定義する

というだけです。仕組みは簡単なものの、何しろカリー化しなければならないので愚直にforeign importキーワードを使ってPureScriptからJavaScriptの関数を呼ぶのはとても面倒です。ffiを使う場合はpurescript-easy-ffiというモジュールを使いましょう。unsafeForeignFunctionという関数を呼ぶだけです。

stringify :: forall a. Number -> a -> String
stringify = unsafeForeignFunction ["n", "x"] "JSON.stringify(x, null, n)"

purescript-easy-ffiがあれば、 foreign importで直接外部の関数を定義する方法や、Data.Functionを組み合わせる方法はほとんど使う必要はないと思います。

参考

例外処理

JavaScriptのtry/catchのようなEffまみれの例外処理をしたければ、purescript-exceptionsControl.Monad.Eff.Exception をインポートします。 errorで例外オブジェクトを作り throwExceptionで投げてcatchExceptionで受けとります。

module Main where

import Prelude
import Control.Monad.Eff
import Debug.Trace
import Control.Monad.Eff.Exception

main :: forall t. Eff (trace :: Trace, ex :: Exception) Unit
main = do
    catchException (\err -> print (message err)) do
        print "begin"
        throwException (error "Exception")
        print "end"   -- throwException で抜けるのでこちらは呼ばれない

throwExceptionはJavaScriptレベルではthrowしているだけで、catchExceptiontry-catchで捕まえているだけです。

STモナド

STモナドをrunPure/runSTで走らせた場合は、コンパイラが特別に処理してくれてただの変数になります

collatz :: Number -> Number
collatz n = runPure (runST (do
  r <- newSTRef n
  count <- newSTRef 0
  untilE $ do
    modifySTRef count $ (+) 1
    m <- readSTRef r
    writeSTRef r $ if m % 2 == 0 then m / 2 else 3 * m + 1
    return $ m == 1
  readSTRef count))
var collatz = function (n) {
        return Control_Monad_Eff.runPure(function __do() {
            var _60 = n;
            var _59 = 0;
            (function () {
                while (!(function __do() {
                    _59 = 1 + _59;
                    var _58 = _60;
                    _60 = _58 % 2 === 0 ? _58 / 2 : 3 * _58 + 1;
                    return _58 === 1;
                })()) {

                };
                return {};
            })();
            return _59;
        });
    };

ただし、以下のようにSTを他のEffと混ぜて使った場合は普通にいろんな関数呼び出しにコンパイルされてました。残念。

main :: forall a . Eff (trace :: Trace, random :: Random, st :: ST a) Unit
main = do
  x <- newSTRef 0
  forE 0 100 $ \i -> do
    n <- random
    modifySTRef x $ (+) n
    return unit
  readSTRef x >>= print

CanvasとFreeモナド

ブラウザ環境でCanvasに描くためのバインディングGraphics.Canvasもあります。ゲームを作ったりするときに役に立ちそうです。

CanvasのFreeモナド版Graphics.Canvas.Freeもあるみたいです。Freeモナドじゃないほうの生のGraphics.Canvasで書こうとすると

context <- getContext2D canvas
dimensions <- getCanvasDimensions canvas
clearRect context { x:0, y:0, w:dimensions.width, h:dimensions.height }
save context
setLineWidth 2 context
setShadowOffsetX 1 context
setShadowOffsetY 1 context
...

みたいにひたすらcontextに付きまとわれるCanvasの描画コマンド群が、Freeモナドを使うとcontextが消えて次のようにすっきり書くことができます。

context <- getContext2D canvas
dimensions <- getCanvasDimensions canvas
clearRect context { x:0, y:0, w:dimensions.width, h:dimensions.height }
runGraphics context $ do
    save
    setLineWidth 2
    setShadowOffsetX 1
    setShadowOffsetY 1
    ...

Graphics.Canvasだけを使ってEffモナドで書けばmagic-doのおかげで以下のようなシンプルなJavaScriptを吐いてくれるのですが、

Graphics_Canvas.save(_53)();
Graphics_Canvas.setLineWidth(2)(_53)();
Graphics_Canvas.setShadowOffsetX(1)(_53)();
Graphics_Canvas.setShadowOffsetY(1)(_53)();
Graphics_Canvas.setShadowColor("#808080")(_53)();
Graphics_Canvas.setStrokeStyle("#FF8000")(_53)();
....

Graphics.Canvas.Freeを使うと、以下の様な恐ろしいコードを吐いてきます。

return Graphics_Canvas_Free.runGraphics(_55)(Prelude[">>="](Control_Monad_Free.bindFree(Data_Coyoneda.functorCoyoneda))(Graphics_Canvas_Free.save)(function () {
    return Prelude[">>="](Control_Monad_Free.bindFree(Data_Coyoneda.functorCoyoneda))(Graphics_Canvas_Free.setLineWidth(2))(function () {
        return Prelude[">>="](Control_Monad_Free.bindFree(Data_Coyoneda.functorCoyoneda))(Graphics_Canvas_Free.setShadowOffsetX(1))(function () {
            return Prelude[">>="](Control_Monad_Free.bindFree(Data_Coyoneda.functorCoyoneda))(Graphics_Canvas_Free.setShadowOffsetY(1))(function () {
                return Prelude[">>="](Control_Monad_Free.bindFree(Data_Coyoneda.functorCoyoneda))(Graphics_Canvas_Free.setShadowColor("#808080"))(function () {
                    return Prelude[">>="](Control_Monad_Free.bindFree(Data_Coyoneda.functorCoyoneda))(Graphics_Canvas_Free.setStrokeStyle("#FF8000"))(function () {
                        return Prelude[">>="](Control_Monad_Free.bindFree(Data_Coyoneda.functorCoyoneda))(Graphics_Canvas_Free.translate(20)(20))(function () {
                            return Prelude[">>="](Control_Monad_Free.bindFree(Data_Coyoneda.functorCoyoneda))(Graphics_Canvas_Free.scale(2.0)(1.5))(function () {
                                return Prelude[">>="](Control_Monad_Free.bindFree(Data_Coyoneda.functorCoyoneda))(Graphics_Canvas_Free.rotate(state.rotation))(function () {
                                    ....

1回きりの描画ならこれでも構わないのですが、ゲームみたいに頻繁に画面を更新するアプリケーションだとさすがにちょっと心配です。

参考文献というかSee Also

まともにPureScriptに取り組む気があるなら、**"PureScript by Example"** と "24 Days of PureScript" は必須です。それ以外はお好みに応じてどうぞ。

  • purescript/wiki とりあえずWikiがいろんな情報の入口です

  • PureScript by Example PureScript開発者本人によって書かれたチュートリアルで、これさえ読めばPureScriptについてはだいたいすべてわかります。開発環境のインストールの方法から関数型言語の基礎的な概念まで丁寧に説明されており、PureScriptの入門に最適だろうと思います。主にJavaScriptのユーザ向けに書かれており、Haskellのような言語に通じていなくても読むことができます(というか、Haskellに通じている人ならすでに知っている内容のほうが多いかもしれません)。 ~~長いので筆者はまだ全部は読んでませんが、十分な量がある充実したテキストなのでそのうち全部目を通そうと思います。~~ 読んだので邦訳しました。読んでください → 関数型なAltJS、PureScriptの入門書を邦訳しました。

  • 24 Days of PureScript PureScriptをいじろうと思うなら絶対に目を通しておくべき記事その2

  • Handling Native Effects with the Eff Monad 入出力はHaskellとだいぶ違います。

  • Getting Started with Purescript for Web Development "PureScript by Example"のサンプルコードはNode上で動かすものが多いですが、この記事はブラウザ環境で動かす方法。といってもビルドの方法がちょっと違ったりブラウザ環境専用のモジュールが必要になるだけの話です。

  • pursuit - PureScriptのドキュメント検索エンジン。Hoogleみたいなアレ

参考になりそうな小さめのプロジェクト

とりあえず筆者も何かゲームでも作ってみようと思うので、PureScriptで書かれたゲームのデモを幾つか:

  • purescript-asteroids アステロイド。DOMでイベントリスナを登録してSTを書き換えてというベタな方法で書かれている
  • purescript-is-magic マイリトルポニーをジャンプさせてTroll Faceを避けるゲーム。purescript-signalでリアクティブに書かれている
  • purescript-demo-mario purescript-signalでマリオがジャンプするデモ。これもpurescript-signal

材料

  • purescript-simple-dom 基本的なDOMの操作はひと通り揃っているみたいです。別にpurescript-dom (DOM) というモジュールもありますが、そちらはすっからかん
  • purescript-canvas Canvasへの描画。まだdrawImageすらなくて辛い

感想

PureScriptは筆者が長年追い求めていた理想のAltJSに限りなく近い。すごい。

さいごに

そのうちPureScriptで何か作ってみようと思います。いろいろわかり次第また追記するつもりです。

(この記事は同じ筆者が Qiita に投稿した記事の複製です。オリジナル記事)