-
Notifications
You must be signed in to change notification settings - Fork 7
Home
読者の方から、メールやGitHub Issueを介してコードの不具合の報告を受けました。 ここでは、指摘された不具合についての訂正と解説をします。
なお書籍の正式な正誤表は、リックテレコム社のサイトにあります。 同サイトと比べて本ページは、より技術的な詳細を記したものです。
7.3章「クロージャで不変なデータ構造を作る」のリスト7.32「カリー化された不変なオブジェクト型のテスト」(220頁)では、不変なオブジェクトを初期化する際にobject.empty()
を渡しています。
var obj = object.set("C3PO", "Star Wars")(object.empty());
^^^^^^^^^^^^^^
ところがこのコードには不具合があります。 なぜならば、以下のように存在しないキー(下記の例では"鉄腕アトム")を指定するとエラーになるからです。
object.get("鉄腕アトム")(obj)
簡約の過程を追跡すると、エラーの源がわかります。
object.get("鉄腕アトム")(obj)
=>
object.get("鉄腕アトム")(
object.set("C3PO", "Star Wars")(object.empty())
)
=>
object.get("鉄腕アトム")(
(queryKey) => {
if("C3PO" === queryKey) {
return "Star Wars";
} else {
return object.get(queryKey)(object.empty());
}
}
)
=>
object.get("鉄腕アトム")(
(queryKey) => {
if("C3PO" === queryKey) {
return "Star Wars";
} else {
return object.empty()(queryKey)
}
}
)
=>
((queryKey) => {
if("C3PO" === queryKey) {
return "Star Wars";
} else {
return object.empty()(queryKey)
}
}
)("鉄腕アトム")
=>
if("C3PO" === "鉄腕アトム") {
return "Star Wars";
} else {
return object.empty()("鉄腕アトム")
}
=>
return object.empty()("鉄腕アトム")
=>
return null("鉄腕アトム")
=>
ここでnullを評価しようとしてエラーとなる
つまり、存在しないキーで検索すると、最後にnullを評価しようとしてエラーになるのです。
この問題を解決するには、不変なオブジェクトを初期化する際にobject.empty()
ではなく、object.emptyを渡すのが正解です。
// object.setの場合
var obj = object.set("C3PO", "Star Wars")(object.empty);
^^^^^^^^^^^^
// composeでobject.setを合成する場合
var robots = compose( // object.setを合成する
object.set("C3PO", "Star Wars"),
object.set("HAL9000","2001: a space odessay")
)(object.empty);
^^^^^^^^^^^^
これがなぜ正しいかは、objectモジュールの各関数についてその型を考察すると明らかです。 以下に、不変なオブジェクト型を実現したobjectモジュールのコードを再掲します。
var object = { // objectモジュール
empty: () => {
return null;
},
// (STRING,Any) => FUN[STRING => Any] => STRING => Any
set: (key, value) => {
return (obj) => {
return (queryKey) => {
if(key === queryKey) {
return value;
} else {
return object.get(queryKey)(obj);
}
};
};
},
// (STRING) => FUN[STRING => Any] => Any
get: (key) => {
return (obj) => {
return obj(key);
};
}
// 以下、省略
object.set
関数は、(STRING,Any) => FUN[STRING => Any] => STRING => Any
の型を持つことになります。
同様にしてobject.get
関数は、 (STRING) => FUN[STRING => Any] => Any
の型を持ちます。
なお、Anyは全ての型の基底型を表現するものとします。
さて問題となっているコードは、以下のように、object.set
関数を用いて不変なオブジェクト型を構築しようとしています。
var obj = object.set("C3PO", "Star Wars")(???);
ここでobject.set
関数の第1引数の(STRING,Any)
には("C3PO", "Star Wars")
が渡されています。
これは問題ありません。
ところ第2引数にはFUN[STRING => Any]
の型をもつ関数を実引数として渡さなければならないのに、実引数として渡されているのはobject.empty()
です。
object.empty()
はnullという値なので、本来渡すべきFUN[STRING => Any]
という型と合致しません。
結局、正しく FUN[STRING => Any]
の関数を渡すには、???はobject.empty()
ではなく、object.empty
でなければならないのです。
さらにこれを明示的に表現するには object.empty
関数の定義を以下のようにしたほうがより正確です。
// empty:: FUN[STRING => Any]
empty: (key) => {
return null;
},
以上を前提とすれば、コードの全貌は以下のようになります。
var object = { // objectモジュール
// empty:: STRING => Any
empty: (key) => {
return null;
},
// set:: (STRING,Any) => FUN[STRING => Any] => STRING => Any
set: (key, value) => {
return (obj) => {
return (queryKey) => {
if(key === queryKey) {
return value;
} else {
return object.get(queryKey)(obj);
}
};
};
},
// get:: (STRING) => FUN[STRING => Any] => Any
get: (key) => {
return (obj) => {
return obj(key);
};
}
};
// **リスト7.32** カリー化された不変なオブジェクト型のテスト
var robots = compose( // object.setを合成する
object.set("C3PO", "Star Wars"),
object.set("HAL9000","2001: a space odessay")
)(object.empty);
// )(object.empty());
expect(
object.get("HAL9000")(robots)
).to.eql(
"2001: a space odessay"
);
expect(
object.get("C3PO")(robots)
).to.eql(
"Star Wars"
);
// 該当するデータがなければ、nullが返る
expect(
object.get("鉄腕アトム")(robots)
).to.eql(
null
);
}
今回の不具合から以下の教訓が得られました。
- 型の整合性をきちんと確認する
- 単体テストでいろいろなケース(特にコーナーケース)を網羅する
この問題はIssue #1 で取りあげられ、書籍では284頁から286頁に関係します。 解決策は、tanagumoさんに提供していただきました。
'a'を出力するIOアクションと'b'を出力するIOアクションをIO.flatMapで合成してみます。
本来ならばIOアクションを合成したものはIOアクションであり、それはIO.run
関数で実行するまでは遅延されるはずです。
つまりIO.run
によってIOモナドから値が取り出されてはじめて、副作用が実行されなければなりません。
ところが以下のコードでわかるように、IO.run(ab)
の実行前(つまりIOアクションabが定義された時点)ですでにab
が表示されてしまっているのです。
var ab = IO.flatMap(IO.putChar('a'))((_) =>
IO.flatMap(IO.putChar('b'))((_) =>
IO.done()
)
);
abundefined // この時点で副作用(画面表示)が発生して、'ab'が出力されてしまう
> IO.run(ab);
undefined // 本来はこの時点で表示されるべきなのに、IO.runの時点では副作用は発生しない。
このように書籍に掲載したIOモナドの定義には、副作用の実行タイミングに問題があるのです。
そこでIO.flatMap
関数とIO.putChar
関数の定義を振り返ってみます。
IO.flatMap
関数は、リスト7.99「外界を明示しないIOモナドの定義」(284頁)で以下のように定義されています。
/* flatMap:: IO[T] => FUN[T => IO[U]] => IO[U] */
flatMap : (instanceA) => {
return (actionAB) => { // actionAB:: a -> IO[b]
return IO.unit(IO.run(actionAB(IO.run(instanceA))));
};
},
この定義では、IO.flatMap(instanceA)(actionAB)
の結果はactionAB(IO.run(instanceA))
の評価結果になります。
JavaScriptは実引数を正格評価するため、IO.run(instanceA)
が評価されて、この時点で副作用が発生してしまうのです。
また、IO.putChar
関数は、リスト7.101(286頁)にて次のように定義されています。
/* putChar:: CHAR => IO[Unit] */
putChar: (character) => {
/* 1文字だけ画面に出力する */
process.stdout.write(character);
return IO.unit(null);
},
IO[Unit]
の型はT => IO[T]
のため、上記のputChar
の定義は型レベルではCHAR => IO[Unit]
となっています。
しかし、上記の実装ではputChar
を文字に適用した時点でprocess.stdout.write(character)
が実行されてしまいます。
問題の本質は、flatMap
、putChar
共にIOアクションを実行するタイミングが早すぎることでした。
書籍にも説明したとおり、高階関数を活用することで実行のタイミングを遅らせることができます。
そこで、無名関数を利用してIO.flatMap関数を以下のように定義しなおしてみます。
// flatMap :: IO[A] => FUN[A => IO[B]] => IO[B]
flatMap : (instanceA) => {
return (actionAB) => { // actionAB:: A -> IO[B]
return (_) => { // 無名関数
return IO.run(actionAB(IO.run(instanceA)));
}
};
},
この定義ではIO.flatMap(instanceA)(actionAB)
としたとき、(_) => {...}
の無名関数の中身はまだ実行されません。
そしてIO.run(actionAB(IO.run(instanceA)))
としてIOアクションを実行すると初めて、この無名関数が評価されることになります。
IO.putChar
関数も同様に下記の通り訂正する必要があります。
// putChar :: CHAR => IO[Unit]
putChar: (character) => {
return (_) => {
process.stdout.write(character);
return null;
};
},
最後に、上記の要請を満たすIOモナドの定義(必要箇所のみ)を下記に掲載します。
// type IO[A] = FUN[() => A]
var IO = {
// unit :: A => IO[A]
unit: (any) => {
return () =>
return any;
},
// run :: IO[A] => A
run: (instance) => {
return instance();
},
// flatMap :: IO[A] => FUN[A => IO[B]] => IO[B]
flatMap : (instanceA) => {
return (actionAB) => { // actionAB:: A -> IO[B]
return (_) => {
return IO.run(actionAB(IO.run(instanceA)));
}
};
},
// putChar :: CHAR => IO[Unit]
putChar: (character) => {
return (_) => {
process.stdout.write(character);
return null;
};
}
};
今回の不具合で、以下の教訓が得られました。
- 型は実行の順番までは保証しない
- 副作用は実行の順番が重要である
読者の方から、「Windows環境でgithubレポジトリのテストが実行できない」との指摘を受けました。 書籍が執筆された2016年から数年の歳月が経過しているため、書籍に記された方法は陳腐化したものと思われます。 そこで、WSL(Window Subsystem for Linux)を使ってWindows10上で単体テストを実行する手順をあらためて紹介します。 なお、WSL環境のインストールについては詳しく解説することを避けます。 随時紹介されているリンク先の情報を参照してください。
まずは、下記サイト等を参考にWindows10上にWSLをインストールしてください。 WSL上のlinuxディストリビューションは、ubuntu 16.04 を推奨します。
- Windows 10でLinuxプログラムを利用可能にするWSLをインストールする(バージョン1803以降対応版)
- Windows Subsystem for Linuxをインストールしてみよう!
- WSL : Windows で Ubuntu環境 を実行する
Ubuntu上での日本語環境のインストールには、下記サイトを参考にしてください。
次にWSL上のUbuntu16.04 を起動します。 起動後はコンソール上で下記コマンドを実行します。 ここで、コマンドの引数が、書籍で示されたものとは若干異なる箇所あります。 これは、できるだけ書籍が執筆された時点の環境にそろえるためです。
# nvmをインストール
$ curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.31.1/install.sh | bash
$ source ~/.bashrc
# 書籍執筆時のnode.jsのバージョン 0.12.0をインストールする
$ nvm install v0.12.0
# github上の functionaljsレポジトリーをクローンする
$ git clone https://github.com/akimichi/functionaljs.git
$ cd functionaljs
$ nvm use
$ npm install -g [email protected]
$ npm install -g [email protected]
$ npm install
以上でテスト環境のインストールは、すべて完了です。 functionaljsのディレクトリに移動して、下記コマンドを実行すると書籍の単体テストが実行されます。
$ mocha --harmony
「第1章2節に掲載されているチューリング機械のコードを実行する方法を知りたい」という要望がありました。 チューリング機械のコードは、リスト1.1に掲載されています。 ところが、このコードは単体テストに埋め込まれていて、それだけで独立して実行することはできません。
チューリング機械のコードを独立して実行できるように、lib/turing.js を作成しました。 このコードの特徴は、node.jsのモジュール化機構 module.exports を利用している点です。 module.exports で公開された関数やオブジェクトは、外部のファイルやコンソール上で require関数を用いて呼び出すことが可能になります。
"use strict";
var machine = (program,tape,initState, endState) => {
var position = 0; /* ヘッドの位置 */
var state = initState; /* 機械の状態 */
var currentInstruction = undefined; /* 実行する命令 */
/* 以下のwhileループにて、現在の状態stateが最終状態endStateに到達するまで命令を繰り返す */
while(state != endState) {
var cell = tape[String(position)];
if (cell) {
currentInstruction = program[state][cell];
} else {
currentInstruction = program[state].B;
}
if (!currentInstruction) {
return false;
} else {
tape[String(position)] = currentInstruction.write; /* テープに印字する */
position += currentInstruction.move; /* ヘッドを動かす */
state = currentInstruction.next; /* 次の状態に移る */
}
}
return tape;
};
// module.exports を利用して、machine関数をモジュールとして外部に公開する
module.exports = machine;
外部に公開された machine関数は、以下のようにして、node.jsの対話的コンソールから呼び出して利用します。
$ cd functionaljs
$ node --harmony
> var machine = require("./lib/turing.js")
undefined
> var tape = { '0':'1', '1':'0' };
undefined
> var program = {
'q0': {"1": {"write": "1", "move": 1, "next": 'q0'},
"0": {"write": "0", "move": 1, "next": 'q0'},
"B": {"write": "B", "move": -1, "next": 'q1'}},
'q1': {"1": {"write": "0", "move": -1, "next": 'q1'},
"0": {"write": "1", "move": -1, "next": 'q2'},
"B": {"write": "1", "move": -1, "next": 'q3'}},
'q2': {"1": {"write": "1", "move": -1, "next": 'q2'},
"0": {"write": "0", "move": -1, "next": 'q2'},
"B": {"write": "B", "move": 1, "next": 'q4'}},
'q3': {"1": {"write": "1", "move": 1, "next": 'q4'},
"0": {"write": "0", "move": 1, "next": 'q4'},
"B": {"write": "B", "move": 1, "next": 'q4'}}
};
undefined
> machine(program, tape, 'q0', 'q4')
{ '0': '1', '1': '1', '2': 'B', '-1': 'B', }