実例によるPureScript

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

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

目次に戻る

第12章 コールバック地獄

12.1 この章の目標

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

12.2 プロジェクトの準備

この章のソースコードは、 pulp runを使ってコンパイルして実行することができます。 また、 requestモジュールをNPMを使ってインストールする必要があります。

npm install

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 :: Effect

type ErrorCode = String
type FilePath = String

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

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

foreign import readFileImpl
  :: forall eff
   . Fn3 FilePath
         (String -> Eff (fs :: FS | eff) Unit)
         (ErrorCode -> Eff (fs :: FS | eff) Unit)
         (Eff (fs :: FS | eff) Unit)

外部JavaScriptモジュールでは、readFileImplは次のように定義されます。

exports.readFileImpl = function(path, onSuccess, onFailure) {
  return function() {
    require('fs').readFile(path, {
      encoding: 'utf-8'
    }, function(error, data) {
      if (error) {
        onFailure(error.code)();
      } else {
        onSuccess(data)();
      }
    });
  };
};

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

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

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

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

これらの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 (fs :: FS | 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の型ととてもよく似ています。実際、もし型aErrorCode String型、rUnitmをモナドEff(fs :: FS | eff)というように選ぶと、readFileの型の右辺を復元することができます。

readFilewriteFileのような非同期のアクションを組み立てるために使うAsyncモナドを定義するため、次のような型同義語を導入します。

type Async eff = ContT Unit (Eff eff)

今回の目的では Effモナドを変換するために常に ContTを使い、型 rは常に Unitになりますが、必ずそうしなければならないというわけではありません。

ContTデータ構築子を適用するだけで、 readFilewriteFileAsyncモナドの計算として扱うことができます。

readFileCont
  :: forall eff
   . FilePath
  -> Async (fs :: FS | eff) (Either ErrorCode String)
readFileCont path = ContT $ readFile path

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

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

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

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

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

import Prelude

import Control.Monad.Eff.Console (logShow)
import Control.Monad.Cont.Trans (runContT)

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

演習

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

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

    type Milliseconds = Int
    
    foreign import data TIMEOUT :: Effect
    
    setTimeoutCont
      :: forall eff
       . Milliseconds
      -> Async (timeout :: TIMEOUT | eff) Unit
    

12.5 ExceptTを機能させる

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

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

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

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

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

readFileContEx
  :: forall eff
   . FilePath
  -> ExceptT ErrorCode (Async (fs :: FS | eff)) String
readFileContEx path = ExceptT $ readFileCont path

writeFileContEx
  :: forall eff
   . FilePath
  -> String
  -> ExceptT ErrorCode (Async (fs :: FS | eff)) Unit
writeFileContEx path text = ExceptT $ writeFileCont path text

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

copyFileContEx
  :: forall eff
   . FilePath
  -> FilePath
  -> ExceptT ErrorCode (Async (fs :: FS | eff)) Unit
copyFileContEx src dest = do
  content <- readFileContEx src
  writeFileContEx dest content

演習

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

  2. (やや難しい) 入力ファイル名の配列を与えて複数のテキストファイルを連結する関数 concatenateManyを書く。 ヒントtraverseを使用します。

12.6 HTTPクライアント

ContTを使って非同期機能を処理する例として、この章のソースコードの Network.HTTP.Clientモジュールについても見ていきましょう。このモジュールでは Asyncモナドを使用して、NodeJSの非同期を requestモジュールを使っています。

requestモジュールは、URLとコールバックを受け取り、応答が利用可能なとき、またはエラーが発生したときにHTTP(S)リクエストを生成してコールバックを呼び出す関数を提供します。 リクエストの例を次に示します。

require('request')('http://purescript.org'), function(err, _, body) {
  if (err) {
    console.error(err);
  } else {
    console.log(body);
  }
});

Asyncモナドを使うと、この簡単な例をPureScriptで書きなおすことができます。

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

foreign import data HTTP :: Effect

type URI = String

foreign import getImpl
  :: forall eff
   . Fn3 URI
         (String -> Eff (http :: HTTP | eff) Unit)
         (String -> Eff (http :: HTTP | eff) Unit)
         (Eff (http :: HTTP | eff) Unit)
exports.getImpl = function(uri, done, fail) {
  return function() {
    require('request')(uri, function(err, _, body) {
      if (err) {
        fail(err)();
      } else {
        done(body)();
      }
    });
  };
};

再びData.Function.Uncurriedモジュールを使って、これを通常のカリー化されたPureScript関数に変換します。先ほどと同じように、2つのコールバックをMaybe Chunk型の値を受け入れるひとつのコールバックに変換しています。Either String String型の値を受け取り、ContTデータ構築子を適用してAsyncモナドのアクションを構築します。

get :: forall eff.
  URI ->
  Async (http :: HTTP | eff) (Either String String)
get req = ContT \k ->
  runFn3 getImpl req (k <<< Right) (k <<< Left)

演習

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

  2. (やや難しい)readFileContwriteFileContに対して以前に行ったように、 ExceptTを使い getをラップする関数 getExを書いてください。

1.(難しい) getExwriteFileContExを使って、ディスク上のファイルからの内容をを保存する関数を書いてください。

12.7 並列計算

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

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

purescript-parallelパッケージは型クラスParallelを定義します。この型クラスはモナドのために並列計算を提供するAsyncのようなものです。以前に本書でApplicative関手を導入したとき、並列計算を合成するときにApplicative関手がどのように便利なのかを観察しました。実はParallelのインスタンスは、(Asyncのような)モナドmと、並列に計算を合成するために使われるApplicative関手fとの対応関係を定義しているのです。

class (Monad m, Applicative f) <= Parallel f m | m -> f, f -> m where
  sequential :: forall a. f a -> m a
  parallel :: forall a. m a -> f a

このクラスは2つの関数を定義しています。

purescript-parallelライブラリは Asyncモナドの Parallelインスタンスを提供します。 これは、2つの継続(continuation)のどちらが呼び出されたかを追跡することによって、変更可能な参照を使用して並列に Asyncアクションを組み合わせます。 両方の結果が返されたら、最終結果を計算してメインの継続に渡すことができます。

parallel関数を使うとreadFileContアクションの別のバージョンを作成することもできます。これは並列に組み合わせることができます。2つのテキストファイルを並列に読み取り、連結してその結果を出力する簡単な例は次のようになります。

import Prelude
import Control.Apply (lift2)
import Control.Monad.Cont.Trans (runContT)
import Control.Monad.Eff.Console (logShow)
import Control.Monad.Parallel (parallel, sequential)

main = flip runContT logShow do
  sequential $
   lift2 append
     <$> parallel (readFileCont "/tmp/1.txt")
     <*> parallel (readFileCont "/tmp/2.txt")

readFileContEither ErrorCode String型の値を返すので、 lift2を使って Either型構築子より append関数を持ち上げて結合関数を形成する必要があることに注意してください。

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

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

演習

  1. (簡単)parallelsequentialを使って2つのHTTPリクエストを作成し、それらのレスポンス内容を並行して収集します。あなたの結合関数は2つのレスポンス内容を連結しなければならず、続けて printを使って結果をコンソールに出力してください。

  2. (やや難しい)Asyncに対応するapplicative関手は Alternativeのインスタンスです。このインスタンスによって定義される <|>演算子は2つの計算を並列に実行し、最初に完了する計算結果を返します。

    この Alternativeインスタンスを setTimeoutCont関数と共に使用して関数を定義してください。

    timeout :: forall a eff
             . Milliseconds
            -> Async (timeout :: TIMEOUT | eff) a
            -> Async (timeout :: TIMEOUT | eff) (Maybe a)
    

    指定された計算が指定されたミリ秒数以内に結果を提供しない場合、 Nothingを返します。

  3. (やや難しい)purescript-parallelExceptTを含むいくつかのモナド変換子のための Parallelクラスのインスタンスも提供します。

    lift2appendを持ち上げる代わりに、 ExceptTを使ってエラー処理を行うように、並列ファイル入出力の例を書きなおしてください。解決策は Asyncモナドを変換するために ExceptT変換子を使うとよいでしょう。

    同様の手法で複数の入力ファイルを並列に読み込むために concatenateMany関数を書き換えてください。

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

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

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

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

まとめ

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

目次に戻る