全世界70億人のPureScripterのみなさん、お疲れさまです。PureScriptで今一番熱い話題といえば**RowToList
**ですが、PureScript v0.12ではいろんなライブラリにこのRowToList
を使って各種のインスタンスが追加で定義されました。たとえば、値を文字列にして標準出力をする関数logShow
は、今まではlogShow { x : 10.0, y : 20.0 }
みたいに直接オブジェクトを渡すことはできなかったのですが、このRowToListのお陰でそれができるようになりました。そんなのElmとかでもできるやんけと思ったElmerの方もいらっしゃるとは思いますが、PureScriptのそれは型チェックが厳格に効いていて、logShow { x : 10.0, y : pure }
みたいにプロパティにShow
でない型を含むようなオブジェクトを渡そうとしても、ちゃんとコンパイルエラーとして検出できます。すごい!わたしもRowToList
やってみたい!(純粋)
JavaScriptでは、Object.keys
で動的にプロパティの一覧を取得したり、Object.assign
でオブジェクト同士をマージしたりというメタい操作ができますが、同じようなメタい操作を厳格な静的型付け言語であるPureScriptでも厳格なままで記述できるようになったということです。RowToListについては、Qiita上でもヘンゼルさんやジャスティンさんが解説をしてくれているので、そちらも参考にしていただくとよいかと思います。
発端
ところで以前からちょっと解決したい問題があって、それがちょうどこのRowToList
でうまく解決できそうなのです。ゲームとか作っていると、画像やオーディオといったリソースファイルを大量に読み込むことがあります。JavaScriptでいうところのPromise
みたいなAff
というデータ型を使うのですが、複数個の非同期処理を並行して実行するときの構文は次のようになるかと思います。
results <- sequential $ { foo: _, bar: _, dir: _, baz: _ }
<$> parallel (readTextFile UTF8 "foo.txt")
<*> parallel (readdir ".")
<*> parallel (pure 42 :: Aff Int)
<*> parallel (readTextFile UTF8 "baz.txt")
ここでたとえば"baz.txt"
というファイルの内容がresults.baz
に格納されるのですが、baz
というプロパティ名は1行目に、"baz.txt"
というファイル名は5行目と、この両者がすこし離れた行に別々に書かれることになるので、これが少々読み書きしづらいところがあります。Applicativeの<$>
と<*>
もいちいち挟まってきて、見た目もとにかくうるさいです。Applicativeに慣れてない人が見たらドン引きすると思います。慣れてる人でもちょっと引くと思います。本当はオブジェクトを使って次のように書きたいです。
results <- props {
foo: readTextFile UTF8 "foo.txt",
dir: readdir ".",
bar: pure 42 :: Aff Int,
baz: readTextFile UTF8 "baz.txt"
}
bluebirdにはPromise.props
というメソッドがあって、まさにこの機能を提供しています。PureScriptも負けていられません。このpropsを実現してみたいと思います。ちなみにですが、同じくAltJSの静的型付けの言語の同志であるTypeScriptでは、このPromise.props
をどのように型付けしているのでしょうか? TypeScriptはSum TypeだとかString Literal Typeだとか型システムをどんどん意欲的に強化していて、期待が持てそうですよね。
/**
* Like ``Promise.all`` but for object properties instead of array items. Returns a promise that is fulfilled when all the properties of the object are fulfilled. The promise's fulfillment value is an object with fulfillment values at respective keys to the original object. If any promise in the object rejects, the returned promise is rejected with the rejection reason.
*
* If `object` is a trusted `Promise`, then it will be treated as a promise for object rather than for its properties. All other objects are treated for their properties as is returned by `Object.keys` - the object's own enumerable properties.
*
* *The original object is not modified.*
*/
// TODO verify this is correct
// trusted promise for object
static props(object: Promise<Object>): Promise<Object>;
// object
static props(object: Object): Promise<Object>;
……。ハア~~(クソデカため息)
RowToList
を使ったprops
の型の定義
まずはこのprops
関数がどのような型を持っているか考えてみます。オブジェクト(レコード)を受け取って、Aff
な値を返すわけですから、こんな感じでしょうか。
props :: forall row row'. Record row -> Aff (Record row')
でもこれだとこの関数にどんなオブジェクトでも渡せてしまうし、非同期処理の結果としてどんなオブジェクトでも返せるという大嘘をついています。TypeScriptと同じガバガバ型注釈です。こんなガバ定義書いてたら静的型付け一族の面汚しです。そこで、row
とrow'
に必要な制約を持たせてあげます。気持ち的にはこんな感じです。
props :: forall row row'.
"rowとrow'にそれっぽい制約"
=> Record row
-> Aff (Record row')
さて、今度はこの制約の中身を考えていきます。たとえば、row
がfoo :: Aff String
というプロパティを持っていたら、その非同期処理の結果の値であるrow'
はfoo :: String
というようなプロパティを持つことになるので、それぞれのプロパティに対してAff a
をa
に取り出すというような制約を書けばいいです。このようなときにRowToList
型クラスが役に立ちます。
型レベル計算では、型クラスとインスタンスは型レベルの関数みたいなものだと捉えるとわかりやすいかと思います。型クラスの定義が『関数の型』の定義で、インスタンスが『本体』の定義です。RowToList
は任意の行型# Type
をRowList
という型に変換する関数みたいなものです。RowList
はプロパティ名、プロパティの型を要素にもった、(もちろん型レベルの)リストです。また、ListToRow
でそのRowList
を再び# Type
に戻すことができます。
props :: forall row row' list list'.
RowToList row list
=> "listからAffを取り除き、その結果をlist'に入れる",
=> ListToRow list' row'
=> Record row
-> Aff (Record row')
型レベル計算に慣れていない人はこのあたりですでに絶句すると思いますが、どうか心を強く持ってください。RowToList xs list
という制約は、『xs
にRowToList
を適用して、その結果をlist
に入れる』というような気持ちで読めばいいと思います。ListToRow
も同様です。気持ち的にはそんな感じです。自分が型検査器になろうとするのではなくて、ちょっと変な構文の動的型付け言語なんだと思うのが大事です。
"listからAffを取り除き、その結果をlist'に入れる"
という操作の部分は再帰的な操作が必要になるので、型クラスとして次のように切り出しました。
class Extract (xs :: RowList) (ys :: RowList) | xs -> ys
instance extractNil :: Extract Nil Nil
else instance extractCons :: Extract ys zs => Extract (Cons key (Aff value) ys) (Cons key value zs)
冒頭のclass Extract (xs :: RowList) (ys :: RowList) | xs -> ys
のところは、Extract
という名前の関数を定義しているみたいな気持ちで読みます。Extract
にはxs
というRowList
の引数があって、また結果を入れる変数はys :: RowList
となっています。xs
とys
のどっちが入力でどっちが出力かわかるように、fundepでxs -> ys
と書いて指定しています。
RowList
はリストの一種なので、Cons key (Aff value) ys
というように『パターンマッチング』を行うと先頭の要素を取り出せます。このパターンマッチングで同時にAff value
からvalue
も取り出しました。それから残りの要素をExtract ys zs
という制約で処理し、Cons key value zs
というようにして結果を返します。
リストの処理なのでNil
とCons
で場合分けしなければなりませんが、これは複数のインスタンスを定義することで表現します。またPureScript 0.12から入ったinstance chain groupという機能で、この『パターン』がオーバーラップしている場合でも『パターンマッチング』を厳密に定義できるようになりました。あいだに挟まっているelse
キーワードがそれです。
このExtract
が書ければ、さっきのprop
の型注釈を埋められます。こんな感じでlist
に『適用』し、結果をlist'
に『代入』します。
props :: forall row row' list list'.
RowToList row list
=> Extract list list'
=> ListToRow list' row'
=> Record row -> Aff (Record row')
天下り的にならないように、ちょっと擬似コードで回り道しながらコードを書いていく考え方をなぞってみました。ふう。
props
の本体の定義
さて、型が書けたところで、次は関数の本体の定義をやっていきます。配列に入った任意個のAff a
を平行に実行するには、parSequence
という関数を使います。オブジェクトからそれぞれのプロパティを取り出して配列に入れ直すわけで、JavaScriptでいうところのObject.values
のようなことをしたいのですが、PureScriptにはそれをやる関数がないので(多分)、FFIで定義することにします。
foreign import objectToArray :: forall row heterogenous.
Record row -> Array (Aff heterogenous)
任意のRecord row
をArray (Aff heterogenous)
に変えてしまっています。PureScriptのArray
はhomogenousなので、これでは本来ホモしか許されない世界にヘテロが紛れ込んでいるガバガバ定義なわけですが、props
の内部だけで使うので許してください。本体はこんな感じです。
exports.objectToArray = function(xs){
return Object.keys(xs).sort().map(function(key){
return xs[key]
})
}
また、結果の配列をオブジェクトに戻す、次のような関数も定義します。これもガバ定義ですが、どうせFFIなので気にしない、気にしない。ひとつめの引数Record row
はもとのオブジェクトのプロパティ名を調べるために使います。
foreign import arrayToObject :: forall row row' heterogenous.
Record row -> Array heterogenous -> Record row'
exports.arrayToObject = function(xs){
return function(values){
const row = {}
Object.keys(xs).sort().forEach(function(key, i){ row[key] = values[i] })
return row
}
}
これさえできれば、あとはprops
の本体の定義をするだけです。
props :: forall row row'. Extract row row' => Record row -> Aff (Record row')
props tasks = arrayToObject tasks <$> parSequence (objectToArray tasks)
できたー! props
の定義の本体ではFFIを使ってガバ操作が行われていますが、props
を外部から見るぶんにはちゃんと厳格に型付けされています。……なんか記事だとスムーズに書いているように見えるでしょうけれど、実際にはとても苦悶しながら書いています。
使ってみる
main :: Effect Unit
main = launchAff_ do
results <- props {
foo: readTextFile UTF8 "foo.txt",
dir: readdir ".",
bar: pure 42 :: Aff Int,
baz: readTextFile UTF8 "baz.txt"
}
logShow results
これを実行すると、それぞれの非同期処理が並列に実行されて、結果がオブジェクトの対応するプロパティに詰められて返ってきます。v0.12からレコードがShow
のインスタンスになったので、出力もlogShow
一発です。
{ bar: 42, baz: "bar", dir: [".gitignore",".psc-ide-port",".pulp-cache",".purs-repl","baz.txt","bower.json","bower_components","foo.txt","node_modules","output","package-lock.json","package.json","src","test"], foo: "foo" }
ちなみに、プロパティの順序が変わっていることに気付いた方もいるかもしれません。RowToList
を通すとプロパティが名前で自動的にソートされてRowList
へと変換されます。そのほうが便利な場面も多いし、気にしない、気にしない。
もちろん、results
の型は型推論により次のように正しく型付けされています。
results :: { foo :: String, dir :: Array String, bar :: Int, baz :: String }
そのため、results.baz
のようにすれば結果のオブジェクトの個々のプロパティを参照できますし、うっかりresults.bax
みたいにプロパティ名をタイプミスしてしまっても、ちゃんとコンパイルエラーが出ます。
log results.baz -- barと出力される
log results.bax -- コンパイルエラー!
さいごに
PureScriptの中の人たちはコンパイラを小さく保つのが好きらしく、言語組み込みの特別な型や関数というものを極力減らしています。でも、もちろん必要な柔軟性や堅牢性は確保しつつも、RowToListのような汎用性のある機能だけを最低限提供して、型システムを悪用……じゃなかった有効利用した型レベル計算を使い、外部のライブラリのレベルで機能を拡張しています。
この種の機能を実現するためには、Template Haskellみたいな邪悪な黒魔術を駆使する方法や、ElmのようにtoString : a -> String
とか動的型付けに近いゆるガバ定義にする方法もあるとは思います。それらに比べるとPureScriptの方法は、言語を小さく保ちつつも柔軟性と厳格さも確保できる、優れた方法のように思います。型レベル計算を自分で書くのはなかなかどうしてややこしいですが、誰かがすでに定義してライブラリとして提供してくれているものを使うぶんにはそれほど難しくはありません。前述のジャスティンさんがsimple-jsonやkushiyakiのようなイケてるライブラリをいくつも公開してくれているので、ぜひ使ってみるといいと思います。それにしても型レベル計算つらい。今回書いたコードはgistに貼り付けておきました。ライブラリにするにも一発ネタすぎるので。
参考文献
- http://bluebirdjs.com/docs/api/promise.props.html
- https://qiita.com/kimagure/items/7d777826acf371293a93
- https://qiita.com/hansel_no_kioku/items/289d1a186b70223613c9
- https://github.com/purescript/purescript/issues/2315
- http://package.elm-lang.org/packages/elm-lang/core/latest/Basics#toString
- https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/bluebird/v2/index.d.ts
- https://liamgoodacre.github.io/purescript/rows/records/2017/07/10/purescript-row-to-list.html