弊プロジェクトでは、クライアント開発をTypeScriptで行っています。
昨年リリースされたTypeScript 2.0以降は--strictNullChecks
のおかげで、「ここはundefined
とかnull
とかチェックが必要なのか?」と悩むことがなくなり、開発が圧倒的に楽になりました。
ですが、それでもときどき型定義上ありえないはずのundefined
が混入していることがあるのです。なぜでしょうか?
サーバーから送られてきたJSONに欠けがあったりもしますが、(コンパイルは通るのに)定義の時点で型が間違っている場合もあります。
今回は案外ハマりがちな罠のひとつ、クラス継承時のメソッドの型の不整合について紹介します。
ありがちな継承
継承、便利ですよね。
一般的な処理は親クラスで共通化しておいて、細かいところだけ子クラスに作れば、スッキリ書けますもんね。
/** 動物 */
class Animal {
public walk() { }
public run() { }
public sleep() { }
}
/** 人間 */
class Human extends Animal {
public think() { }
}
歩いたり走ったり寝たりするのはだいたい他の動物と一緒なので基底クラスに任せて、人間クラスは人間らしいことだけに集中できます。便利ですね。
さて、食べるメソッドでも追加してみましょう。人間はグルメなので、食べ物にこだわりがあります。
interface Meat {
type: "牛" | "豚" | "鶏";
}
/** 火を通した肉 */
interface GrilledMeat extends Meat {
grillLevel: "レア" | "ミディアム" | "ウェルダン";
}
class Animal {
public eatMeat(meat: Meat) {
console.log("肉うめ~");
}
}
class Human extends Animal {
// 人間が肉を食べるときは火を通さないといけない
public eatMeat(meat: GrilledMeat) {
console.log(`${meat.grillLevel}のステーキうめ~!`);
}
}
Animal
に与える肉はなんでもよい一方、人間には焼いた肉を与えるように定義してみました。GrilledMeat
には焼き加減プロパティがあります。
こう定義しておけば、適当な人間を登場させて生肉を食べさせようとすると
let beef: Meat = { type: "牛" };
let lukeSkywalker: Human = new Human();
lukeSkywalker.eatMeat(beef);
// ERROR: Argument of type 'Meat' is not assignable to parameter of type 'GrilledMeat'.
// Property 'grillLevel' is missing in type 'Meat'.
のようにコンパイルエラーが出るようにできます。
(注:他の一部言語での挙動については後述します)
さて、これで本当に人間が生肉を食べなくなったのでしょうか?
それだとundefined
が混入する
答えは否です。
残念ながらこれでは保証できません。
Human
はAnimal
を継承している以上、文脈によってはAnimal
として扱うことができるはずです。
let animals: Array<Animal> = [];
animals.push(lukeSkywalker);
animals.forEach(animal => animal.eatMeat(beef)); // NO ERROR
animals
に人間をpush
すると、もはや何の動物だったのかわからないので、生肉を食べさせてもコンパイルエラーが出なくなります。このときlukeSkywalker
は
undefinedのステーキうめ~!
と出力しています。コンパイルエラーがないのに、undefined
が登場してしまいました。
TypeScriptにはオーバーライド時のメソッド引数の反変性チェックがない
上のような例はうっかりやってしまいがちですが、型安全ではない有名な例でもあります。
クラスの継承を行う場合、リスコフの置換原則を守るべきと言われます。「子クラスは親クラスの代わりとして使える必要がある」とでも言えばいいでしょうか。
これの原則を守るためには、ざっくり言うと
- 子クラスの入力の型は、親クラスと同じかより緩い
- 子クラスの出力の型は、親クラスと同じかより厳しい
という2点を満たす必要があります。
入力の型については、子クラス(厳しい型)で緩い型が必要なので「反変性 contravariance が必要」、
出力の型については、子クラス(厳しい型)が厳しい型を返すので「共変性 covariance が必要」と言われたりします。
上の例では、Human
はAnimal
より厳しい型なのに、勝手にeatMeat
の入力にも厳しい型を要求しようとしたためにバグったのでした。
子クラスで制限を緩めるっていうところが、ちょっと立ち止まらないと「アレ?」ってなりがちなので気を付けましょう。
こういうのはTypeScriptがコンパイルエラーにしてくれてもいいと思うのですが、--strictFunctionTypes
オプションが導入された現在でも、オーバーライド時には反変性チェックは入っていません。 われわれ実装者が気を配っておく必要があります。
じゃあどうすればよかったのか
気の利いた実行時エラーにしておく
Human
はAnimal
である以上、Meat
を受け取らざるを得ないので、もし生だったら例外でも投げておきましょうか。
TypeError: undefined is not an object
とかよりはよっぽどマシな例外になります。
class Human extends Animal {
public eatMeat(Meat meat) {
if (!meat.hasOwnProperty("grillLevel")) {
throw new Error("焼いてくれ!");
}
}
}
要件によっては、エラーにしなくても焼き直すなり別の犬に食わすなり、他の方策も考えられます。
ジェネリクス
もうひとつの手として、ジェネリクスを活用するという手があります。
class Animal<EdibleMeatType extends Meat> {
public eatMeat(meat: EdibleMeatType) { }
}
class Human extends Animal<GrilledMeat> {
public eatMeat(meat: GrilledMeat) { }
}
これなら、生でも食べるようなAnimal
たちの配列にはHuman
を突っ込めず、またAnimal<GrillerMeat>
には生のMeat
を食べさせられないので、型安全となります。
let animals: Array<Animal<Meat>> = [];
animals.push(lukeSkywalker); // ERROR!
let gourmetAnimals: Array<Animal<GrilledMeat>> = [];
gourmetAnimals.push(lukeSkywalker); // OK
gourmetAnimals.forEach(animal => animal.eatMeat(beef)); // ERROR!
じゃあめっちゃジェネリクス書けばいいの?
これって、引数が変わるようなメソッドが増えたらその分ジェネリクスも増えていくということでしょうか?
class Animal<
EdibleMeatType extends Meat,
EdibleVegetableType extends Vegetable,
HabitableArea extends Area,
WalkOptions,
RunOptions,
SleepOptions,
MoreOptions,
MoreAndMoreOptions
> {
}
これには2通りの答えがあると思います。
①そうです。
ジェネリクスをしっかりつかって型安全なコードを書くことで、予期しないundefined
をなくすことができます。
「ボタンを押しても反応しない」というユーザーからの問い合わせ、コンソールに出てくるundefined is not an object
、そしてパラメータ不足を探して延々と続くデバッグ、...とはおさらばしましょう。
TypeScriptなら型のインデックスアクセスを活用することで、クラス定義時のジェネリクスの引数は減らすこともできます。
interface EdibleMeatTypeMap {
human: GrilledMeat;
dog: Meat;
cat: Meat;
}
class Animal<AnimalType extends "human" | "dog" | "cat"> {
public eatMeat(meat: EdibleMeatTypeMap[AnimalType]) { }
}
class Human extends Animal<"human"> {
public eatMeat(meat: GrilledMeat) { }
}
②そんなことはありません。
型は(少なくともTypeScriptでは)開発の補助のためのものです。
ミスを減らすため(+入力補完をきかせるため)だけの情報で、コンパイルしたら消滅します。
実行時には何の影響も与えません。
型情報によって実行速度の最適化がかかるわけでもありません。
安全と開発効率は必ず天秤の両側にあります。
気合いの入った型定義は、つくるだけでもそれなりの工数がかかりますし、難解すぎる型定義はメンテナンス性を下げる可能性もあります。
型はある程度自由にしておいて、コードレビューで「子クラスが勝手に型しぼって大丈夫?」と聞けば十分な場合も多いでしょう。
(型安全信奉者としてはがんばってインデックスアクセスとMapped Typeを駆使した型を書きたくなっちゃいますが)
他の言語での挙動
このあたりの挙動は言語によって差があるようです。
あまり数は調べてはいませんが、手近にあった例を紹介します。
Flowだと
JavaScriptに型をつけるなら、最近ではTypeScriptかFlowが採用されることが多いのではないかと思います。
Flowでは、オーバーライド時に引数の反変性をチェックしてくれているようで、はじめの例でもちゃんとコンパイルエラーが出ます。Facebookさすがですね。
Javaだと
弊プロジェクトではサーバサイドにはJavaを利用しているので、ついでに調べてみました。
そもそもJavaでは、オーバーライド時に引数の型を変えることが認められていません。
@Override
アノテーションもつけていなかった場合、代わりにオーバーロードしたものとして扱われます。
// Animal.java
class Animal {
public void eatMeat(Meat meat) {
System.out.println("肉うめ~");
}
}
// Human.java
class Human extends Animal {
public void eatMeat(GrilledMeat meat) {
System.out.println("ステーキうめ~");
}
}
みたいなときに
Meat meat = new Meat();
Human lukeSkywalker = new Human();
lukeSkywalker.eatMeat(meat);
とかすると、Meat
型を受け取る基底クラス側のメソッドが実行されて
肉うめ~
が出力されます。
ちゃんと@Override
アノテーションをHuman#eatMeat
につけておけば、コンパイルエラーにしてくれます。
@Override
めちゃ大切です。
おわりに
以上の話はぐぐればたくさん出てきはしますが、ちょっと気を抜いただけでうっかりミスってしまうので、ときどき思い出しましょう。