実例によるPureScript

ウェブのための関数型プログラミング

Phil Freeman, "PureScript by Example - Functional Programming for the Web"

12 コールバック地獄

12.1 この章の目標

この章では、これまでに見てきたモナド変換子やApplicative関手といった道具が、現実世界の問題解決にどのように役立つかを見ていきましょう。ここでは特に、コールバック地獄(callback hell)の問題を解決について見ていきます。

12.2 プロジェクトの準備

この章のソースコードは、gruntでコンパイルし、NodeJSを使って実行することができます。

12.3 問題

通常、JavaScriptの非同期処理コードでは、プログラムの流れを構造化するためにコールバック(callbacks)を使用します。たとえば、ファイルからテキストを読み取るのに好ましいアプローチとしては、readFile関数を使用し、コールバック、つまりテキストが利用可能になったときに呼び出される関数を渡すことです。

function readText(onSuccess, onFailure) {
  var fs = require('fs');
  fs.readFile('file1.txt', { encoding: 'utf-8' }, function (error, data) {
    if (error) {
      onFailure(error.code);
    } else {
      onSuccess(data);
    }   
  });
}

しかしながら、複数の非同期操作が関与している場合には入れ子になったコールバックを生じることになり、すぐに読めないコードになってしまいます。

function copyFile(onSuccess, onFailure) {
  var fs = require('fs');
  fs.readFile('file1.txt', { encoding: 'utf-8' }, function (error, data1) {
    if (error) {
      onFailure(error.code);
    } else {
      fs.writeFile('file2.txt', data, { encoding: 'utf-8' }, function (error) {
        if (error) {
          onFailure(error.code);
        } else {
          onSuccess();
        }
      });
    }   
  });
} 

この問題に対する解決策のひとつとしては、独自の関数に個々の非同期呼び出しを分割することです。

function writeCopy(data, onSuccess, onFailure) {
  var fs = require('fs');
  fs.writeFile('file2.txt', data, { encoding: 'utf-8' }, function (error) {
    if (error) {
      onFailure(error.code);
    } else {
      onSuccess();
    }
  });
}

function copyFile(onSuccess, onFailure) {
  var fs = require('fs');
  fs.readFile('file1.txt', { encoding: 'utf-8' }, function (error, data) {
    if (error) {
      onFailure(error.code);
    } else {
      writeCopy(data, onSuccess, onFailure);
    }   
  });
} 

この解決策は一応は機能しますが、いくつか問題があります。

次に、これらの問題を解決するために、これまでに学んだ手法を使用する方法について説明していきます。

12.4 継続モナド

copyFileの例をFFIを使ってPureScriptへと翻訳していきましょう。PureScriptで書いていくにつれ、計算の構造はわかりやすくなり、purescript-transformersパッケージで定義されている継続モナド変換子ContTが自然に導入されることになるでしょう。

まず、FFIを使ってreadFilewriteFileに型を与えなくてはなりません。型同義語をいくつかと、ファイル入出力のための作用を定義することから始めましょう。

foreign import data FS :: !

type ErrorCode = String
type FilePath = String

readFileはファイル名と2引数のコールバックを引数に取ります。ファイルが正常に読み込まれた場合は、2番目の引数にはファイルの内容が含まれますが、そうでない場合は、最初の引数がエラーを示すために使われます。

今回はreadFileを2つのコールバックを引数としてとる関数としてラップすることにします。先ほどのcopyFilewriteCopyとまったく同じように、エラーコールバック(onFailure)と結果コールバック(onSuccess)の2つです。簡単のためにData.Functionの多引数関数の機能を使うと、このラップされた関数readFileImplは次のようになるでしょう。

foreign import readFileImpl
  "function readFileImpl(path, onSuccess, onFailure) {\
  \  return function() {\
  \    require('fs').readFile(path, \
  \      { encoding: 'utf-8' }, \
  \      function(error, data) {\
  \        if (error) {\
  \          onFailure(error.code)();\
  \        } else {\
  \          onSuccess(data)();\
  \        }\
  \      }\
  \    );\
  \  };\
  \}" :: forall eff. Fn3 FilePath
                         (String -> Eff (fs :: FS | eff) Unit)
                         (ErrorCode -> Eff (fs :: FS | eff) Unit)
                         (Eff (fs :: FS | eff) Unit)

readFileImplはファイルパス、成功時のコールバック、失敗時のコールバックという3つの引数を取り、空(Unit)の結果を返す副作用のある計算を返す、ということをこの型は言っています。コー​​ルバック自身にも、その作用を追跡するためにEffモナドを使うような型が与えられていることに注意してください。

このreadFileImplの実装がその型の正しい実行時表現を持っている理由を、よく理解しておくようにしてください。

writeFileImplもよく似ています。違いはファイルがコールバックではなく関数自身に渡されるということだけです。実装は次のようになります。

foreign import writeFileImpl
  "function writeFileImpl(path, data, onSuccess, onFailure) {\
  \  return function() {\
  \    require('fs').writeFile(path, data, \
  \      { encoding: 'utf-8' }, \
  \      function(error) {\
  \        if (error) {\
  \          onFailure(error.code)();\
  \        } else {\
  \          onSuccess();\
  \        }\
  \      }\
  \    );\
  \  };\
  \}" :: forall eff. Fn4 FilePath
                         String
                         (Eff (fs :: FS | eff) Unit)
                         (ErrorCode -> Eff (fs :: FS | eff) Unit)
                         (Eff (fs :: FS | eff) Unit)

これらのFFIの宣言が与えられれば、readFilewriteFileの実装を書くことができます。Data.Functionライブラリを使って、多引数のFFIバインディングを通常の(カリー化された)PureScript関数へと変換するので、もう少し読みやすい型になるでしょう。

さらに、成功時と失敗時の2つの必須のコールバックに代わって、成功か失敗のどちらか(Either) に対応した単一のコールバックを要求するようにします。つまり、新しいコールバックは引数としてEither ErrorCodeモナドの値をとります。

readFile :: forall eff. 
  FilePath -> 
  (Either ErrorCode String -> Eff (fs :: FS | eff) Unit) -> 
  Eff (fs :: FS | eff) Unit
readFile path k = 
  runFn3 readFileImpl 
         path 
         (k <<< Right) 
         (k <<< Left)

writeFile :: forall eff. 
  FilePath -> 
  String -> 
  (Either ErrorCode Unit -> Eff (fs :: FS | eff) Unit) -> 
  Eff (fs :: FS | eff) Unit
writeFile path text k = 
  runFn4 writeFileImpl 
         path 
         text 
         (k $ Right unit) 
         (k <<< Left)

Effモナドはこれらの型シグネチャの両方に現れます。次のような新しい型同義語を導入すると、型を​​簡素化できます。

type M eff = Eff (fs :: FS | eff)

readFile :: forall eff. 
  FilePath -> 
  (Either ErrorCode String -> M eff Unit) -> 
  M eff Unit

writeFile :: forall eff. 
  FilePath -> 
String
  (Either ErrorCode Unit -> M eff Unit) -> 
  M eff Unit

ここで、重要なパターンを見つけることができます。これらの関数は何らかのモナド(この場合はM eff)で値を返すコールバックをとり、同一のモナドで値を返します。これは、最初のコールバックが結果を返したときに、そのモナドは次の非同期関数の入力に結合するためにその結果を使用することができることを意味しています。実際、copyFileの例で手作業でやったことがまさにそれです。

これはpurescript-transformersControl.Monad.Cont.Transモジュールで定義されている継続モナド変換子(continuation monad transformer)の基礎となっています。

ContTは次のようなnewtypeとして定義されます。

newtype ContT r m a = ContT ((a -> m r) -> m r)

継続(continuation)はコールバックの別名です。継続は計算の残余(remainder)を捕捉します。ここで「残余」とは、非同期呼び出しが行われ、結果が提供された後に起こることを指しています。

ContTデータ構築子の引数はreadFilewriteFileの型ととてもよく似ています。実際、もし型a を型Either ErrorCode StringrUnitm をモナドM effというように選ぶと、readFileの型の右辺を復元することができます。

今回の目的ではEffモナドを変換するために常にContTを使い、型rは常にUnitになりますが、このことは必須ではありません。

ContT構築子を適用するだけで、readFilewriteFileContT Unit (M eff)モナドの計算として扱うことができます。

type C eff = ContT Unit (M eff)

readFileCont :: forall eff. 
  FilePath -> 
  C eff (Either ErrorCode String)
readFileCont path = ContT $ readFile path

writeFileCont :: forall eff. 
  FilePath -> 
  String -> 
  C eff (Either ErrorCode Unit)
writeFileCont path text = ContT $ writeFile path text

ここでContTモナド変換子に対してdo記法を使うだけで、ファイル複製処理を書くことができます。

copyFileCont :: forall eff. FilePath -> FilePath -> C eff (Either ErrorCode Unit)
copyFileCont src dest = do
  e <- readFileCont src
  case e of
    Left err -> return $ Left err
    Right content -> writeFileCont dest content

readFileContの非同期性がdo記法によってモナドの束縛に隠されていることに注目してください。これはまさに同期的なコードのように見えますが、ContTモナドは非同期関数を書くのを手助けしているのです。

継続を与えてrunContTハンドラを使うと、この計算を実行することができます。この継続は次に何をするか、例えば非同期なファイル複製処理が完了した時に何をするか、を表しています。この簡単な例では、型Either ErrorCode Unitの結果をコンソールに出力するprint関数を単に継続として選んでいます。

import Debug.Trace

import Control.Monad.Eff
import Control.Monad.Cont.Trans

main = runContT 
  (copyFileCont "/tmp/1.txt" "/tmp/2.txt") 
  print

演習

  1. (簡単) readFileContwriteFileContを使って、2つのテキストフ​​ァイルを連結する関数を書いてください。

  2. (やや難しい) FFIを使って、setTimeout関数に適切な型を与えてください。また、ContTモナド変換子を使った次のようなラッパー関数を書いてください。

    type Milliseconds = Number
    
    foreign import data Timeout :: !
    
    setTimeoutCont :: forall eff. 
      Milliseconds -> 
      ContT Unit (Eff (timeout :: Timeout | eff)) Unit

12.5 ErrorTを機能させる

この方法はうまく動きますが、まだ改良の余地があります。

copyFileContの実装において、次に何をするかを決定するためには、パターン照合を使って(型Either ErrorCode Stringの)readFileCont計算の結果を解析しなければなりません。しかしながら、Eitherモナドは対応するモナド変換子ErrorTを持っていることがわかっているので、ErrorTを使って非同期計算とエラー処理の2つの作用を結合できると期待するのは理にかなっています。

実際にそれは可能で、ErrorTの定義を見ればそれがなぜかがわかります。

newtype ErrorT e m a = ErrorT (m (Either e a))

ErrorTは基礎のモナドの結果を単純にaからEither e aに変更します。現在のモナドスタックをErrorT ErrorCode変換子で変換するように、copyFileContを書き換えることができることを意味します。それは現在の方法にErrorTデータ構築子を適用するだけなので簡単です。型同義語を与えると、ここでも型シグネチャを整理することができます。

type EC eff = ErrorT ErrorCode (C eff)

readFileContErr :: forall eff. FilePath -> EC eff String
readFileContErr path = ErrorT $ readFileCont path

writeFileContErr :: forall eff. FilePath -> String -> EC eff Unit
writeFileContErr path text = ErrorT $ writeFileCont path text

非同期エラー処理がErrorTモナド変換子の内部に隠されているので、このファイル複製処理ははるかに単純になります。

copyFileContErr :: forall eff. FilePath -> FilePath -> EC eff Unit
copyFileContErr src dest = do
  content <- readFileContErr src
  writeFileContErr dest content

演習

  1. (やや難しい) 任意のエラーを処理するために、ErrorTを使用して2つのファイルを連結しする先ほどの解決策を書きなおしてください。

12.6 HTTPクライアント

ContTを使って非同期機能を処理する例として、この章のソースコードのNetwork.HTTP.Clientモジュールについても見ていきましょう。このモジュールでは、NodeJSの非同期HTTPリクエストをラップするために継続を使っています。

httpモジュールを使った典型的なGETリクエストは次のようになります。

function getRequest(onChunk, onComplete) {
  return function() {
    require('http').request({
      host: 'www.purescript.org',
      path: '/' 
    }, function(res) {
      res.setEncoding('utf8');
      res.on('data', function (chunk) {
        onChunk(chunk);
      });
      res.on('end', function () {
        onComplete();
      });
    }).end();
  };
}

httpモジュールのrequestメソッドは、ホストとパスを指定するオブジェクトをとり、レスポンスオブジェクトを返します。レスポンスオブジェクトは今回扱う2種類のイベントを発します。

上の例では、dataendイベントが発生した時に呼び出される2つのコールバック onChunkonCompleteを渡しています。

Network.HTTP.Clientモジュールでは、 requestメソッドは以下のようなAPIを持つ関数getImplとしてラップされています。

foreign import data HTTP :: !

type WithHTTP eff = Eff (http :: HTTP | eff)

newtype Request = Request
  { host :: String
  , path :: String
  }

newtype Chunk = Chunk String

getImpl :: forall eff. 
  Fn3 Request
      (Chunk -> WithHTTP eff Unit)
      (WithHTTP eff Unit)
      (WithHTTP eff Unit)

再びData.Functionモジュールを使って、これを通常のカリー化されたPureScript関数に変換します。先ほどと同じように、2つのコールバックを型Maybe Chunkの値を受け入れるひとつのコールバックに変換しています。コールバックに渡されたNothingの値はendイベントに対応しており、Just chunkの値はdetaイベントに対応しています。

getChunk :: forall eff. 
  Request ->
  (Maybe Chunk -> WithHTTP eff Unit) ->
  WithHTTP eff Unit
getChunk req k = 
  runFn3 getImpl 
         req 
         (k <<< Just) 
         (k Nothing)

ここでもContTデータ構築子を適用することにより、この非同期関数をこの継続モナドの演算に変換しています。

getCont :: forall eff. 
  Request -> 
  ContT Unit (WithHTTP eff) (Maybe Chunk)
getCont req = ContT $ getChunk req

readFileの例では、ファイルの内容が利用可能になったとき(または、エラーが発生したとき)、コールバックは一度だけ呼ばれていました。しかし今度は、レスポンスのそれぞれのチャンクについて1回づつ、複数回コールバックが呼び出されることが期待されます。

演習

  1. (やや難しい) runContTを使ってHTTP応答の各チャンクをコンソールへ出力することで、getContを試してみてください。

  2. (難しい) getImplgetCont関数は非同期エラーを処理しません。getImplerrorイベントに対応するよう変更し、ErrorTを使って非同期エラーを表現するgetContの亜種を書いてください。

    ヒントreadFileの例で取ったのと同じアプローチに従うことができます。

12.7 チャンク応答の畳み込み

これでHTTP応答の個々のチャンクを集めることができるようになりましたが、すべての応答が利用可能になったときだけ継続が呼び出される非同期関数を作ると便利な時があるかもしれません。このような関数を実装する方法のひとつは、HTTP応答のチャンクに対する畳み込みを書くことです。

継続に渡された複数の結果を畳み込む関数foldCを書きましょう。foldC関数はこの章のソースコードのControl.Monad.Cont.Extrasモジュールで定義されています。

累積値を追跡するために、EffモナドでRef作用を使います。次の型同義語を使って型シグネチャを整理します。

type WithRef eff = Eff (ref :: Ref | eff)

type ContRef eff = ContT Unit (WithRef eff)

これらの同義語を使うと、foldCには次のような型を与えることができます。

foldC :: forall eff a b r. 
  (b -> a -> Either b r) -> 
  b -> ContRef eff a -> ContRef eff r

foldCに渡された関数は、現在の累積値と継続に渡された値を受け取り、新しい累積値か新しい継続に渡される結果のどちらかを返します。

foldCが実装されれば、応答のデータ本体の様々なチャンクの収集を可能にする簡単な関数collectを書くことができます。

collect :: forall eff a. 
  ContRef eff (Maybe a) -> 
  ContRef eff [a]
collect = foldC f []
  where
  f xs Nothing = Right xs
  f xs (Just x) = Left (xs ++ [x])

foldCの実装では、累積値の初期値を持つ新しい参照を作成して開始します。この参照は、コールバックの本体でそれが変更されるときに、累積器を追跡し続けるために使用されます。

foldC f b0 c = do
  current <- lift $ newRef b0

また、foldC現在の継続とともに呼び出す(call with current continuation)を略してcallCCと呼ばれる関数を使っています。callCCは引数として関数をひとつ取りますが、この関数は現在の継続、つまり現在のdo記法ブロックのcallCCあとのコードを表しています。現在の継続に返り値を渡すと、callCC内のコードのブロックからいつでも早期に返ることができます。

  callCC $ \k -> quietly $ do

ここでkは現在の継続です。これは foldCを定義するdo記法ブロックの最後の式であるため、現在の継続は実際にはfoldCに渡されたちょうどその継続です。畳み込み関数の結果が累積の結果を表しているとき、foldCの最終的な値にこれを使います。

quietlyコンビネータはwhere宣言で定義されており、あとでその定義について見ていきます。quietlyコンビネータの役目は、ここで明示的にkを呼び出さない場合に、callCCの内側のコードがその継続へ値を返すのを妨げることです。これが必要な理由はすぐに明らかになるはずです。

次に、foldCは非同期関数cの結果を名前aに束縛します。

    a <- c

もとの計算によって新しい値が非同期に生成されたとき(この場合は、HTTP応答の新しいチャンクが利用可能になったとき)、この行の後ろのコードが実行されるでしょう。それが起こるとき、畳み込み関数を適用したいので、次のように累積器の現在の値を読み取る必要があります。

    r <- lift $ readRef current

最後に、畳み込み関数を評価し、その結果に応じて2つの場合に場合分けします。畳み込み関数が新しい累積値を返すなら、参照を新しい値で更新します。畳み込み関数が結果を返すなら、これを継続kに渡します。

    case f b a of
      Left next -> lift $ writeRef current next
      Right r -> k r

ここでquietly関数が必要だった理由が明らかになったと思います。callCC内部のコードの結果 をquietly関数で黙らせなかったら、畳み込み関数がLeft構築子で包んだ値を返すとき、型rの結果を生成しなければならなくなったでしょう。しかし、そのような結果を生成する方法は一切ありません!

quietly関数の定義は次のようになっています。quietlyは非同期関数の結果の型を変更できるようにします。これは継続関数を変換することを可能にするwithContT関数を使って書かれています。

  where
  quietly :: forall m a b. (Monad m) => ContT Unit m a -> ContT Unit m b
  quietly = withContT (\_ _ -> return unit)

foldC関数とその亜種collectは特に、チャンクが利用可能になった時に連結することで、完全なHTTP応答本体を累積することを可能にします。

newtype Response = Response [Chunk]

getAll :: forall eff. 
  Request -> 
  ContT Unit (WithHTTP (ref :: Ref | eff)) Response
getAll req = Response <$> collect (getCont req)

これで、Stringとして応答本体を次のように取得することができます。

getResponseText :: forall eff. 
  Request -> 
  ContT Unit (WithHTTP (ref :: Ref | eff)) String
getResponseText req = responseToString <$> getAll req
  where
  responseToString :: Response -> String
  responseToString (Response chunks) = joinWith "" $ map runChunk chunks

例えば、次のように継続の中でgetResponseTexttraceアクションを使えば、HTTP応答本体の長さをコンソールに出力することができるでしょう。

main = runContT (getResponseText request) $ \response -> do
  let responseLength = length response
  trace responseLength
  
  where
  request :: Request
  request = Request
    { host: "www.purescript.org"
    , path: "/"
    }

これはうまく動作しますが、次の演習で見るように、もっと慎重にfoldCを使うとこの方法を改良することができます。

演習

  1. (やや難しい) writeFileContを使用して、ディスク上のファイルにそのHTTP要求の応答本体を保存する関数を書いてください。

  2. (難しい) 長さを決定するのに、メモリ内のHTTP応答本体全体を連結する必要はありません。チャンクが利用可能になるたびにそのバイトサイズを調べるようにすれば、応答全体のザイズから単一のチャンクのサイズへと、この関数のメモリ使用量を低減することができます。

    collectの代わりにfoldCを直接使って、このコード例を書きなおしてください。

12.8 並列計算

ContTモナドとdo記法を使って、非同期計算を順番に実行されるように合成する方法を見てきました。非同期計算を並列に合成することもできたら便利でしょう。

Effモナドを変換するためにContTを使用している場合、単に2つの計算のうち一方を開始した後に他方の計算を開始すれば、並列に計算することができます。

次のような型シグネチャを持つ関数を書きましょう。

par :: forall a b r eff. 
  (a -> b -> r) -> 
  ContRef eff a -> ContRef eff b -> ContRef eff r

parは、2つの非同期計算とその結果を合成する関数をとり、並列に計算を実行し結果を合成するような単一の計算を返します。

(Ref作用で)変更可能な参照を使い、呼び出された2つの継続を追跡します。両方の結果が返ってきたとき、最終的な結果を計算し、メインの継続に渡すことができます。

直接ContTデータ構築子で値を構築すると、parを最も簡単に実装できます。

par f ca cb = ContT $ \k -> do

ここでfは合成を行う関数で、cacbはそれぞれ型abの値を返す非同期的な計算です。kcacbの両方が完了した時に型rの値を返すのに使う継続です。

利用可能になったときにcacbの結果を保持するために、2つの新しい参照を作成することから始めます。

  ra <- newRef Nothing
  rb <- newRef Nothing

これらの参照rarbは、それぞれ型Maybe aMaybe bの値を保持します。どちらも最初はNothiingの値が格納されていますが、計算が完了したとき値が更新されます。

次に、runContTを使用して最初の非同期計算を開始します。

  runContT ca $ \a -> do
    mb <- readRef rb
    case mb of
      Nothing -> writeRef ra $ Just a
      Just b -> k (f a b)

第二の値が利用可能であるかどうかを調べるする継続を提供します。そうである場合は、継続 kに最終結果を渡すために結合関数を使用します。そうでなければ、単に最初の値を含むように参照raを更新します。

ふたつめの計算についても同様です。

  runContT cb $ \b -> do
    ma <- readRef ra
    case ma of
      Nothing -> writeRef rb $ Just b
      Just a -> k (f a b)

parコンビネータを使うと、ふたつのファイルを並列に読んだり、2つのHTTP要求を平行して発行し、並列に結果を待つことができます。

2つのテキストファイルを並列に読み取り、連結してその結果を出力する簡単な例は次のようになります。

import Control.Apply (lift2)

main = flip runContT print $
  par (lift2 (++)) (readFileCont "/tmp/1.txt")
                   (readFileCont "/tmp/2.txt")

readFileContは型Either ErrorCode Stringの値を返すので、結合関数を作るにはlift2を使って演算子(++)Either型構築子まで持ち上げなければいけません。

演習

  1. (簡単) parを使用して、2つのHTTP要求を作成し、並列に応答本体を集めてください。結合関数は2つの応答本体を連結する必要があり、継続はtraceを使用してコンソールに結果を出力しなくてはいけません。
  2. (やや難しい) 2つの計算を並列に実行し、先に完了したほうの計算の結果を返す次のような関数を書いてください。

    race :: forall a eff. 
      ContRef eff a -> 
      ContRef eff a -> 
      ContRef eff a

    ヒント:結果が返されたかどうかを示すBooleanを格納する参照を使ってみましょう。

  3. (やや難しい) race関数をsetTimeoutCont関数と一緒に使って、次のような関数を定義してください。

    timeout :: forall a eff. 
      Milliseconds -> 
      ContRef eff a -> 
      ContRef eff (Maybe a)

    この関数は指定された計算が与えられたミリ秒以内で結果を返さないならNothingを返します。

12.9  並列処理のためのApplicative関手

parコンビネータの型はContRef effモナドについてのlift2の型にとても良く似ています。実際に、par厳密にlift2であるような新しいApplicative関手を定義することは可能で、parContRef effに関してこれを簡単に定義することができます。

parに関するContRef effApplicativeインスタンスを定義していないのはなぜかと不思議に思われるかもしれません。これには2つの理由があります。

その代わりに、Parallel effと呼ばれるContRef effのnewtypeラッパーを次のように作成します。

newtype Parallel eff a = Parallel (ContRef eff a)

単に外側のデータ構築子を除去することで、Parallel計算をContRef effモナドにおける演算に変換する関数を書くことができます。

runParallel :: forall eff a. Parallel eff a -> ContRef eff a
runParallel (Parallel c) = c

型クラスのインスタンスは、大部分はContTの対応するインスタンスから複製することができます。しかし、Apply型クラスの場合には、(<*>)を再定義するためにparを使用してください。

instance functorParallel :: Functor (Parallel eff) where
  (<$>) f (Parallel c) = Parallel (f <$> c)

instance applyParallel :: Apply (Parallel eff) where
  (<*>) (Parallel f) (Parallel x) = Parallel (par ($) f x)

instance applicativeParallel :: Applicative (Parallel eff) where
  pure a = Parallel $ pure a

Applyインスタンスの定義では、結合関数として関数適用($)を使って、関数をその引数と結合するためにparを使っています。

Parallel型構築子を使用して並列に二つのファイルを読むように上の例を書き直すことができるようになりました。

import Control.Apply (lift2)

main = flip runContT print $ runParallel $
  lift2 (++) <$> Parallel (readFileCont "/tmp/1.txt")
             <*> Parallel (readFileCont "/tmp/2.txt")

Applicative関手では任意個引数の関数の持ち上げができるので、このApplicativeコンビネータを使ってより多くの計算を並列に実行することができます。traversesequenceのようなApplicative関手を扱うすべての標準ライブラリ関数から恩恵を受けることもできます。

必要に応じてParralelrunParallelを使って型構築子を変更することで、do記法ブロックのApplicativeコンビネータを使って、直列的なコードの一部で並列計算を結合したり、またはその逆を行ったりすることができます。

演習

  1. (簡単) traverse関数を使って、ファイルの名前の配列を与えるとその内容を並列に読み取り、内容の文字列表現の配列を返す関数readManyを書いてください。

  2. (簡単) raceコンビネータを使って、Parallel effAltインスタンスを書いてください。Alternativeのインスタンスは作れるでしょうか。

  3. (やや難しい) lift2(++)を持ち上げる代わりに、ErrorTを使ってエラー処理を行うように、並列ファイル入出力の例を書きなおしてください。解決策はParallel関手を変換するためにErrorT変換子を使用しなければいけません。

    同様の手法でreadMany関数を書き換えてください。

  4. (難しい、拡張) ディスク上のJSON文書のコレクションが与えられ、それぞれの文書はディスク上の他のファイルへの参照の配列を含んでいるとします。

    { references: ['/tmp/1.json', '/tmp/2.json'] }

    入力として単一のファイル名をとり、そのファイルから参照されているディスク上のすべてのJSONファイルをたどって、参照されたすべてのファイルの一覧を収集するユーティリティを書いてください。

    そのユーティリティは、JSON文書を解析するために purescript-foreignライブラリを使用する必要があり、単一のファイルが参照するファイルは並列に取得しなければなりません!

12.10 まとめ

この章ではモナド変換子の実用的なデモンストレーションを見てきました。