LoginSignup
11
7

More than 5 years have passed since last update.

より実用的なPureScriptのFFI(purescript-aff編)

Last updated at Posted at 2015-10-13

より実用的な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のreadFilewriteFileに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)
  }

これでreadFilewriteFileに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のreadFilewriteFileを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にする

そこでこのコールバックの問題を解決するためにreadFilewriteFileをAffにすることを考えてみます。

AffにするにはControl.Monad.AffmakeAffという関数を使います。

makeAff :: forall e a. ((Error -> Eff e Unit) -> (a -> Eff e Unit) -> Eff e Unit) -> Aff e a

makeAffはちょっとややこしいですが、エラー用のコールバックと正常系のコールバックを受け取るようなEff関数に対して、Affを作ってくれます。
readFilewriteFileは、このmakeAffの引数の型とはちょっと異なりますが、基本的に同様の機能を持つ関数です。
makeAffeither関数や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

readFileAffwriteFileAffreadFilewriteFileをAff化した関数になります。
readFileAffwriteFileAffの型を見てみますと、readFilewriteFileに比べてコールバックを受け取らなくなり、かわりに返り値のAffの値がUnitではなくなり、それぞれコールバックで受け取っていたデータの型になっています。

たとえばreadFileではファイルから読み込んだデータはコールバックに渡されていましたが、readFileAffでは返り値のAffの値になっています。
writeFileAffも同様です。

Affを使ってみる

では、readFileAffwriteFileAffを使って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で書いたcopyFileWithDefaultAffcatchErrorを使って書き直すと以下のようになります。

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らしい高機能で統一されたインターフェースで使うことができるようになります。

11
7
0

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
11
7