『テスト駆動開発』をTypeScriptで写経していて、Javaとの違いで大きくつまずいた。
結論からいえば、モジュールの循環参照で空オブジェクトが生まれていた、というシンプルな原因だったのだが、明らかにできるまでにはかなり時間がかかった。鮮やかとは程遠い手際の調査をしてしまったので、どのように当たりをつけて、どのように調べていったか、その過程で知ったこと、などを書いておく。
なお、TypeScriptの環境は前回の記事で構築したものだ。
TypeScriptはJavaと構文が近しい言語だから、Javaのサンプルコードを見ながら写経をするのはハードルが高くないし、それでいて細かい部分でのJavaとTypeScriptとの違いを把握することができると考えたからだ。
TypeScriptのユニットテストをmocha + power-assert で書く - こまどブログ
これは完全にフラグだった*1。
つまずき
スーパークラスでサブクラスのコンストラクタを呼び出す
つまずくことになったのは、第8章「実装を隠す」で、サブクラス(DollarとFranc)を消してしまいたいという意図から、MoneyクラスにDollarクラスのインスタンスを返すstaticメソッドを追加するところだ。
public class Money { protected int amount; public boolean equals(Object object) { Money money = (Money) object; return amount == money.amount && this.getClass().equals(money.getClass()); } static Dollar dollar(int amount) { return new Dollar(amount); } }
staticメソッドでインスタンスを返すというのはよくある。特に疑問を持つことなく、以下のようにTypeScriptで記述して、テストを実行したところ、エラーになってしまった。
import { Dollar } from "./dollar"; export class Money { constructor(protected amount: number){} static dollar(amount: number):Dollar{ return new Dollar(amount); } }
当初の推測
TypeError: Object prototype may only be an Object or null: undefined
。この時点で、「あークラスの循環参照が悪いのかなー」と予想した。JavaとTypeScript(JavaScript)のクラスは構文は似ていても、クラスベースのオブジェクト指向言語とプロトタイプペースのオブジェクト指向言語で実装が全然違う、というのは知っていたので、Javaでは許されることがTypeScript(JavaScript)で許されなくても不思議はない。とはいえ、あっさり「じゃあ循環依存やめればいいんでしょ」としてしまうと学びがないので、地道に追っかけてみることにした。
謎解き
JavaScriptにコンパイルしてコードを追う
at setPrototypeOf (<anonymous>)
と言われても、そんな関数はTypeScriptのコードの中では使っていない。そこで、JavaScriptにコンパイルしたものにテストを実行してみることにした。tsc
コマンドで出力されたjsファイルをコピーして、pure JavaScript用のテスト環境でテストを実行した。tsconfig.jsonで設定するバージョンはes5にした。
dollar.jsのextendStatics(d, b)
というところでエラーになっているらしい。以下に問題のdollar.jsを示す。JavaScript初心者にも読めなくはないコードだ。
"use strict"; var __extends = (this && this.__extends) || (function () { var extendStatics = Object.setPrototypeOf || ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; }; return function (d, b) { extendStatics(d, b); // ←どうやらこれらしい(_extendsが呼び出されると実行される) function __() { this.constructor = d; } d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); }; })(); Object.defineProperty(exports, "__esModule", { value: true }); var money_1 = require("./money"); var Dollar = /** @class */ (function (_super) { __extends(Dollar, _super); // 上記の関数が呼び出されるのはここ function Dollar(amount) { return _super.call(this, amount) || this; } Dollar.prototype.times = function (multiplier) { return new Dollar(this.amount * multiplier); }; return Dollar; }(money_1.Money)); exports.Dollar = Dollar; //# sourceMappingURL=dollar.js.map
console.log
で調べてみたところ、extendStatics
には、setPrototypeOf
が代入されていた。論理演算子が使われており、前から順番に評価して、定義されているものがあれば代入するというコードになっている。ここでは、最初のObject.setPrototypeOf
が代入されていることから、ES2015の環境で実行されているということだろう(getPrototypeOf
はES5から、setPrototypeOf
はES2015から)。
問題の特定
ここで、Object.setPrototypeOf
の仕様を見てみると、第二引数のprototype
がオブジェクトあるいはnullである必要があると書かれている。再びconsole.log
で追いかけてみると、__extends(Dollar, _super)
の手前で_super
が未定義となっている。どうやらこれがエラーの直接の原因だ。
では、なぜ_super
は未定義となっているのか。_super
はあくまで仮引数だから、実引数として渡されているのが何かを考えなければいけない。答えはすぐ近くにある。関数リテラルの{}
の直後、()
で渡されているmoney_1.Money
が実引数だ。そしてmoney_1
は、以下の部分で定義されている。
var money_1 = require("./money");
このrequire
はCommonJSの仕様らしい。自分の場合はNode.js上でmochaを動かしているので、Node.jsのrequireの実装ということになる(はず)。存在しているファイルを指定しているのに未定義となるというのは、どういうことなのだろうか。バッチリ疑問に答えてくれる記事があった。
CommonJSモジュールのrequireの仕組み
- CommonJSモジュールでは、モジュールがrequireされた際に、module.exportsというオブジェクトが空オブジェクトで初期化される。
- module.exportsに値を加えたり、オブジェクトを置き換えたりすることでそのモジュールをrequireした戻り値としてmodule.exportsの中身を使えるようになる。
- さらに、CommonJSでは同じファイルを二度requireしようとした場合、二度目はスキップされてその時点で定義されているmodule.exportsの内容を戻り値とする。
- これら2つの仕組みが重なり、循環参照が発生した際、多くの場合では片方のモジュールは空オブジェクトになってしまう
今回、先にテストコードからはmoneyモジュールをrequireしていて、その中でdollarモジュールをrequireしている。そして、dollarモジュールの中で、moneyモジュールを再度requireしているのが上の箇所だ。確認のため、requireの直後にconsole.log(money_1)
を実行するようにしてみると、{}
と表示された。まさに上記の記事にあるように、空オブジェクトがmoney_1
に代入されている。undefined
と言われていたのは、money_1
にはMoney
などというプロパティが存在しないからだということがわかった。
その他諸
クラスの循環参照自体に問題はない
ここまできて、自分の最初の推測「クラスの循環参照が原因だ」が誤りだったということがわかった。問題なのは、クラスの循環参照ではなく、モジュール同士の循環参照だ。では、モジュールの循環参照を無くせば正常に動作するのだろうか。
これを試すのは簡単だ。DollarクラスをMoneyクラスと同じファイルに移動してやればいい。久しぶりにTypeScriptの世界に戻ってコードを書き換えたところ、エラーは発生せず、テストも通すことができた。クラスの循環参照自体は、Javaと同様に問題を産まないようだ*2。
活かしきれなかった先人の知恵
実は、この答えにはもっと早くたどり着けるはずだった。JavaScriptにコンパイルして実行してみるということをしている最中に、JavaScriptで写経をしているGitHubのリポジトリを見つけていた。そのコミットログから第8章以降のコードをみたところmoney.jsという1つのファイルにMoney, Dollar, Francクラスが全て収められていた。ES2015のclass構文で記述されていたので、そちらが何か影響を及ぼしているのかと考えてスルーしていたのだが、第7章が終わったあとから第8章が終わったところの差分は以下のようになっていた。
この方も、当初はMoneyとDollarを別ファイルで作成していたのに、このコミットで1つのファイル(money.js)にまとめていたのだ。これを早くに見ていれば、問題がクラスの循環参照にはないことはわかったはずだった。迂闊だったとしか言いようがない。
Java、C++におけるクラスの循環参照
Javaの場合は、別々のファイルに書かれているクラス同士が循環参照になってもエラーにはならないことは事実としてはわかっている。せっかくなので、クラスの循環参照について調べてみた。
https://ameblo.jp/blueskyame/entry-10372584472.htmlameblo.jp
はっきりとしたことはわからなかったが、C++とJavaで挙動が違うこと、メモリの使い方が関わっているらしいことがわかった。コンパイラが何をしているかとも関係しているようだ。踏み込むとまた面白いのだろうけれど、一旦退却することにした。
おわりに
上の記事などをみていて、思い出したことがある。新人研修時代、「オブジェクト指向面白そうだな」と手にとった『オブジェクト指向でなぜ作るのか』で、ヒープ領域とスタック領域、静的領域が・・・という説明がされていた。それまでstatic変数/メソッドが何なのかをよく理解できていなかったのが、その説明でだいぶ腑に落ちた気がしたのだった。
新しい言語やツールに触れる機会があっても、あまり「どうしてそうなっているのか」を考えなくなっていたかもしれない。今回のようにゆっくり時間をとってつまずいた箇所を掘ってみるといいことがあるなぁと思う一方で、日頃の知的怠惰を痛感させられた。