LoginSignup
26
17

More than 3 years have passed since last update.

RowToListであそぼう!(苦悶)

Last updated at Posted at 2018-07-15

PAKU6350_TP_V.jpg

全世界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だとか型システムをどんどん意欲的に強化していて、期待が持てそうですよね。

bluebird/v2/index.d.ts
/**
 * 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と同じガバガバ型注釈です。こんなガバ定義書いてたら静的型付け一族の面汚しです。そこで、rowrow'に必要な制約を持たせてあげます。気持ち的にはこんな感じです。

擬似コード
props :: forall row row'. 
  "rowとrow'にそれっぽい制約" 
  => Record row 
  -> Aff (Record row')

さて、今度はこの制約の中身を考えていきます。たとえば、rowfoo :: Aff Stringというプロパティを持っていたら、その非同期処理の結果の値であるrow'foo :: Stringというようなプロパティを持つことになるので、それぞれのプロパティに対してAff aaに取り出すというような制約を書けばいいです。このようなときにRowToList型クラスが役に立ちます。

型レベル計算では、型クラスとインスタンスは型レベルの関数みたいなものだと捉えるとわかりやすいかと思います。型クラスの定義が『関数の型』の定義で、インスタンスが『本体』の定義です。RowToListは任意の行型# TypeRowListという型に変換する関数みたいなものです。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という制約は、『xsRowToListを適用して、その結果を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となっています。xsysのどっちが入力でどっちが出力かわかるように、fundepでxs -> ysと書いて指定しています。

RowListはリストの一種なので、Cons key (Aff value) ysというように『パターンマッチング』を行うと先頭の要素を取り出せます。このパターンマッチングで同時にAff valueからvalueも取り出しました。それから残りの要素をExtract ys zsという制約で処理し、Cons key value zsというようにして結果を返します。

リストの処理なのでNilConsで場合分けしなければなりませんが、これは複数のインスタンスを定義することで表現します。また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 rowArray (Aff heterogenous)に変えてしまっています。PureScriptのArrayはhomogenousなので、これでは本来ホモしか許されない世界にヘテロが紛れ込んでいるガバガバ定義なわけですが、propsの内部だけで使うので許してください。本体はこんな感じです。

JavaScript
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'
JavaScript
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-jsonkushiyakiのようなイケてるライブラリをいくつも公開してくれているので、ぜひ使ってみるといいと思います。それにしても型レベル計算つらい。今回書いたコードはgistに貼り付けておきました。ライブラリにするにも一発ネタすぎるので。

参考文献

26
17
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
17