より実用的なPureScriptのFFI(purescript-aff編)
はじめに
前回の記事(より実用的なPureScriptのFFI(基礎編) - Qiita)ではPureScriptからJavaScriptを呼び出す仕組みであるFFIの基礎について述べました。
今回はJavaScriptのコールバックを非同期モナドにする purescript-aff について紹介したいと思います。
前回と今回の方法を合わせて使えば、簡単に既存のJavaScriptの関数を、PureScriptらしい高機能で統一されたインターフェースで使うことができるようになります。
JavaScriptのコールバック地獄
JavaScriptはブラウザでもNodeでも基本的にシングルスレッドで動作するために非同期処理が中心になります。
このため次の処理をするコールバックを受け取るような関数多くなります。
これが多段になり、エラー処理も入ってくるようになるとコールバック地獄と呼ばれるような状態になることはよく指摘されます。
JavaScriptではこの問題を解決するためにpromiseやgeneratorを用いるのが主流ですが、
PureScriptではRead PureScript by Example | Leanpubの「12. Callback Hell」にContTを使った方法が紹介されています。
今回紹介するpurescript-affはそれをより発展された方法と言えます。
purescript-affの概要
slamdata/purescript-affではpurescript-affについてErrorT (ContT Unit (Eff e) a
と同じようなものと紹介されています。
これはつまりpurescript-affは非同期処理、エラー処理、副作用の三つの機能があるということです。
実際問題として、JavaScriptのコールバックはこの三つがセットになっていることが多いので、purescript-affでラップすることによりそれらをまとめて扱うことができるようになるわけです。
また purescript-aff はSemigroup、Monoid、Apply、Applicative、Bind、Monad、Alt、Plus、MonadPlus、MonadEff、MonadErrorのインスタンスが既に定義されているので、Affにするだけで多くの機能が使えるようになります。
コールバックを受け取るような関数をAffにするには?
まずPureScriptの型をつける(前回の復習)
ここではRead PureScript by Example | Leanpubと同じようにNode.jsのFile Systemを題材にコールバックを受け取るJavaScriptの関数をpurescript-affに対応させる方法を解説します。
AffにするのはreadFileとwriteFileの二つです。
TypeScriptのDefinitelyTyped/node.d.ts at master · borisyankov/DefinitelyTypedの型を見てみますと以下のようになっています。
export function readFile(filename: string, callback: (err: ErrnoException, data: Buffer) => void): void;
export function writeFile(filename: string, data: any, callback: (err: ErrnoException) => void): void;
それぞれreadFileはエラー値とデータが引数のコールバックを受け取り、writeFileはエラー値のコールバックを受け取ります。
これらの関数に復習ついでに前回説明した方法でPureScriptの型を付けてみます。
まずPureScriptのコールバックの型を考えてみます。
type ReadCallback eff = Either Error String -> Eff (fs :: FS | eff) Unit
type WriteCallback eff = Maybe Error -> Eff (fs :: FS | eff) Unit
ReadCallback
はエラー値もしくはデータを受け取るので、引数をEitherで取るようにします。
WriteCallback
はエラー時にエラー値を受け取るので、引数をMaybeで取るようにします。
しかし、このままではJavaScriptにコールバックとしては渡せないので、JSコールバック用の型も考えます。
type JSReadCallback eff = ExportEffFn2 (Nullable Error) String (Eff (fs :: FS | eff) Unit)
type JSWriteCallback eff = ExportEffFn1 (Nullable Error) (Eff (fs :: FS | eff) Unit)
前回説明したとおりExportEffFn2
は2引数を受け取るJSコールバックを表現するデータコンストラクタです。
ExportEffFn2
は1引数のものです。
このPureScriptのコールバックとJavaScriptのコールバックを変換する関数も作ります。
toJSReadCallback :: forall eff. ReadCallback eff -> JSReadCallback eff
toJSReadCallback f = mkExportEffFn2 \e s -> f $ maybe (Right s) Left (toMaybe e)
toJSWriteCallback :: forall eff. WriteCallback eff -> JSWriteCallback eff
toJSWriteCallback f = mkExportEffFn1 $ toMaybe >>> f
これでコールバックの準備はできました。
そして次はJavaScriptのreadFile
とwriteFile
にPureScriptの型をつけてみます。
前回やった直接型を付ける方法を採用します。
JavaScript側は例によってこれだけです。
"use strict";
// module FS
exports.fs = require('fs');
PureScript側はまとめてレコードで表現します。
foreign import fs ::
{ readFile :: forall eff. ImportEffFn2 String (JSReadCallback eff) (Eff (fs :: FS | eff) Unit)
, writeFile :: forall eff. ImportEffFn3 String String (JSWriteCallback eff) (Eff (fs :: FS | eff) Unit)
}
これでreadFile
とwriteFile
にPureScriptの型を付けることができました。
前回述べたとおりこれではちょっと使いづらいので、使いやすくするようなラッパー関数を作ります。
readFile :: forall eff. String -> ReadCallback eff -> Eff (fs :: FS | eff) Unit
readFile path callback = runImportEffFn2 fs.readFile path $ toJSReadCallback callback
writeFile :: forall eff. String -> String -> WriteCallback eff -> Eff (fs :: FS | eff) Unit
writeFile path dat callback = runImportEffFn3 fs.writeFile path dat $ toJSWriteCallback callback
これでJavaScriptのreadFile
とwriteFile
をPureScriptの世界に持ってくることできました。
ここまでが前回やった内容になります。
しかし、型付けできたはいいけど、使いづらい…
PureScriptの型を付けることができましたが、このままではJavaScriptのコールバックの問題がそのまま残っています。
たとえば上記の関数をそのまま使ってファイルのコピーをしようと思うと以下のような冗長な記述が必要になるでしょう。
copyFile :: forall eff. String -> String -> CopyCallback eff -> Eff (fs :: FS | eff) Unit
copyFile from to callback =
readFile from $ either
(\error -> callback $ Just error)
(\dat -> writeFile to dat callback)
コールバックのネストと、エラーコールバックの呼び出しが二箇所に登場しています。
これが多段になるとさらに恐ろしいことになるのはJavaScriptと同じです。
そこでAffにする
そこでこのコールバックの問題を解決するためにreadFile
とwriteFile
をAffにすることを考えてみます。
AffにするにはControl.Monad.Aff
のmakeAff
という関数を使います。
makeAff :: forall e a. ((Error -> Eff e Unit) -> (a -> Eff e Unit) -> Eff e Unit) -> Aff e a
makeAff
はちょっとややこしいですが、エラー用のコールバックと正常系のコールバックを受け取るようなEff関数に対して、Affを作ってくれます。
readFile
とwriteFile
は、このmakeAff
の引数の型とはちょっと異なりますが、基本的に同様の機能を持つ関数です。
makeAff
とeither
関数やmaybe'
などを使って、この二つの関数をAffにしてみましょう。
readFileAff :: forall eff. String -> Aff (fs :: FS | eff) String
readFileAff path = makeAff \errorCallback datCallback -> readFile path $ either errorCallback datCallback
writeFileAff :: forall eff. String -> String -> Aff (fs :: FS | eff) Unit
writeFileAff path dat = makeAff \errorCallback unitCallback -> writeFile path dat $ maybe' unitCallback errorCallback
readFileAff
とwriteFileAff
がreadFile
とwriteFile
をAff化した関数になります。
readFileAff
とwriteFileAff
の型を見てみますと、readFile
とwriteFile
に比べてコールバックを受け取らなくなり、かわりに返り値のAffの値がUnit
ではなくなり、それぞれコールバックで受け取っていたデータの型になっています。
たとえばreadFile
ではファイルから読み込んだデータはコールバックに渡されていましたが、readFileAff
では返り値のAffの値になっています。
writeFileAff
も同様です。
Affを使ってみる
では、readFileAff
とwriteFileAff
を使ってcopyFile
のAff版であるcopyFileAff
を作ってみましょう。
これは非常に簡単です。
copyFileAff :: forall eff. String -> String -> Aff (fs :: FS | eff) Unit
copyFileAff from to = readFileAff from >>= writeFileAff to
Affはモナドなので、いつもの見慣れたモナド関数の合成で終わってしまいました。
Affを実行する
Affを実行するにはいくつかの手段があります。
適当に実行させて結果も気にしないというのであればlaunchAff
を使うことができます。
先ほどの例で言えば
main :: Eff (fs :: FS, err :: EXCEPTION) Unit
main = launchAff $ copyFileAff "a.txt" "b.txt"
で、このmain
関数を実行するとファイルのコピーをおこなうことができます。
launchAff
は実質エラー処理をおこなわない場合にしか使うことができません。
Affが非同期に実行された場合(たいてい場合そうだと思いますが)、launchAff
の例外も非同期に発生するためです。
ちゃんとエラー処理をしたい場合はrunAff
を使います。
main :: Eff (fs :: FS, console :: CONSOLE) Unit
main = runAff (const $ log "error!") (const $ log "done") $ copyFileAff "a.txt" "b.txt"
エラー処理の有無でこの二つを使い分けて実行してください。
EffをAffに組み込む
逆にEff関数をAffの中で使いたい場合はliftEff
を使います。
たとえばコピーするときに中身のデータをダンプしたい場合は、以下のように途中にliftEff
を使ってEff関数を挟むことができます。
copyFileAndLogAff :: forall eff. String -> String -> Aff (fs :: FS, console :: CONSOLE | eff) Unit
copyFileAndLogAff from to = do
dat <- readFileAff from
liftEff $ log dat
writeFileAff to dat
ファイルを読み込んだあとで中身をlog
関数を使って標準出力に出力し、ファイルに書き込む関数ができあがりました。
以上、Affを使えばコールバックが不必要になることが理解していただけたと思います。
Affのエラー処理
次にAffのエラー処理を見てみます。
上記で紹介したようにrunAff
でもエラー処理をおこなうことができますが、他にも色々な方法があります。
Attempt
エラーから復旧したい場合はattempt
を使います。
通常Affの値は正常値の値になるわけですが、attemptを使うとEitherでエラー値も取り出すことができます。
これによりエラーから復旧することができます。
たとえばファイルを読み込むのに成功したらコピーをおこない、失敗した場合はデフォルトの値をファイルに書き込む関数を作ってみます。
copyFileWithDefaultAff :: forall eff. String -> String -> String -> Aff (fs :: FS | eff) Unit
copyFileWithDefaultAff from to defaultData = do
errorOrData <- attempt $ readFileAff from
writeFileAff to $ either (const defaultData) id errorOrData
この関数はファイルの読み込みに失敗したら引数のdefaultDataの値が保存されます。
Alt
最初に述べたようにAffにはAltのインスタンスがあります。
AffのAltインスタンスは最初に成功した結果を返すような動作をします。
instance altAff :: Alt (Aff e) where
alt a1 a2 = attempt a1 >>= either (const a2) pure
たとえば二つのファイルのうち最初に読み込みに成功したデータを保存するような関数は以下のように書けます。
copyFileOrAff :: forall eff. String -> String -> String -> Aff (fs :: FS | eff) Unit
copyFileOrAff fromA fromB to = do
dat <- readFileAff fromA <|> readFileAff fromB
writeFileAff to dat
この関数はまずfromA
のファイルを読み込もうとし成功した場合はそのまま中身をコピーし、失敗した場合はfromB
のファイルの内容をコピーしようとします。
またAffはPlusのインスタンスもあります。
empty
はエラーになります。
instance plusAff :: Plus (Aff e) where
empty = throwError $ error "Always fails"
よって、たとえば複数のファイルのうち最初に読み込みに成功したデータを保存するような関数は以下のように書けます。
copyFileFromOneOfAff :: forall eff. Array String -> String -> Aff (fs :: FS | eff) Unit
copyFileFromOneOfAff froms to = do
dat <- foldr alt empty $ readFileAff <$> froms
writeFileAff to dat
この関数は受け取ったファイル名の配列のうち最初に読み込みに成功した内容をコピーします。
MonadError
では、関数中でエラーを発生させたい場合はどうすればいいでしょうか。
その場合MonadErrorの関数を使うことができます。
たとえばファイルをコピーする際にあるサイズを越えるものはエラーにしたい場合を考えてみると、MonadErrorのthrowError
を使って以下のように書けます。
copyFileWithinAff :: forall eff. String -> String -> Int -> Aff (fs :: FS | eff) Unit
copyFileWithinAff from to within = do
dat <- readFileAff from
when (length dat > within) $ throwError $ error "Size over!"
writeFileAff to dat
これでファイルの中身のサイズがwithin
を越えるものはエラーにすることができました。
またMonadErrorのcatchError
を使ってエラーから復旧することもできます。
attempt
で書いたcopyFileWithDefaultAff
をcatchError
を使って書き直すと以下のようになります。
copyFileWithDefaultAff :: forall eff. String -> String -> String -> Aff (fs :: FS | eff) Unit
copyFileWithDefaultAff from to defaultData = do
dat <- readFileAff from `catchError` recover
writeFileAff to dat
where
recover :: Error -> Aff (fs :: FS | eff) String
recover = const $ pure defaultData
以上、Affで様々なエラー処理を記述できることを確認しました。
Affにはまだまだ色々な機能があるよ
Affには以上説明した機能の他に
- Affのキャンセル機能
- Aff間で共有できる変数
- Affの並列実行
などの機能もありますが、これらはFFIを説明するという目的を越えますので、説明はまたの機会にします。
まとめ
今回はFFIで作ったコールバックを受け取る関数をpurescript-affに対応する方法を紹介しました。
JavaScriptに多いコールバック呼び出しをAffにすることで、非同期処理をモナド関数として書くことができ、エラー処理を分離し、Effと同じようにrow typesで副作用を扱うこともできます。
前回と今回の方法を合わせればJavaScriptの関数をPureScriptらしい高機能で統一されたインターフェースで使うことができるようになります。