LoginSignup
12
1

More than 5 years have passed since last update.

その継承、本当に大丈夫? 型安全なクラスをめざす

Last updated at Posted at 2017-12-16

弊プロジェクトでは、クライアント開発をTypeScriptで行っています。

昨年リリースされたTypeScript 2.0以降は--strictNullChecksのおかげで、「ここはundefinedとかnullとかチェックが必要なのか?」と悩むことがなくなり、開発が圧倒的に楽になりました。

ですが、それでもときどき型定義上ありえないはずのundefinedが混入していることがあるのです。なぜでしょうか? :thinking:

サーバーから送られてきたJSONに欠けがあったりもしますが、(コンパイルは通るのに)定義の時点で型が間違っている場合もあります。
今回は案外ハマりがちな罠のひとつ、クラス継承時のメソッドの型の不整合について紹介します。

ありがちな継承

継承、便利ですよね。

一般的な処理は親クラスで共通化しておいて、細かいところだけ子クラスに作れば、スッキリ書けますもんね。

/** 動物 */
class Animal {
  public walk() { }
  public run() { }
  public sleep() { }
}

/** 人間 */
class Human extends Animal {
  public think() { }
}

歩いたり走ったり寝たりするのはだいたい他の動物と一緒なので基底クラスに任せて、人間クラスは人間らしいことだけに集中できます。便利ですね。

さて、食べるメソッドでも追加してみましょう:meat_on_bone:。人間はグルメなので、食べ物にこだわりがあります。

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'.

のようにコンパイルエラーが出るようにできます。:yum:
(注:他の一部言語での挙動については後述します)

さて、これで本当に人間が生肉を食べなくなったのでしょうか?

それだとundefinedが混入する

答えは否です。
残念ながらこれでは保証できません。

HumanAnimalを継承している以上、文脈によってはAnimalとして扱うことができるはずです。

let animals: Array<Animal> = [];
animals.push(lukeSkywalker);

animals.forEach(animal => animal.eatMeat(beef)); // NO ERROR

animalsに人間をpushすると、もはや何の動物だったのかわからないので、生肉を食べさせてもコンパイルエラーが出なくなります。このときlukeSkywalker

console
undefinedのステーキうめ~!

と出力しています。コンパイルエラーがないのに、undefinedが登場してしまいました。:scream:

TypeScriptにはオーバーライド時のメソッド引数の反変性チェックがない

上のような例はうっかりやってしまいがちですが、型安全ではない有名な例でもあります。

クラスの継承を行う場合、リスコフの置換原則を守るべきと言われます。「子クラスは親クラスの代わりとして使える必要がある」とでも言えばいいでしょうか。
これの原則を守るためには、ざっくり言うと

  • 子クラスの入力の型は、親クラスと同じかより緩い
  • 子クラスの出力の型は、親クラスと同じかより厳しい

という2点を満たす必要があります。

入力の型については、子クラス(厳しい型)で緩い型が必要なので「反変性 contravariance が必要」、
出力の型については、子クラス(厳しい型)が厳しい型を返すので「共変性 covariance が必要」と言われたりします。

上の例では、HumanAnimalより厳しい型なのに、勝手にeatMeatの入力にも厳しい型を要求しようとしたためにバグったのでした。
子クラスで制限を緩めるっていうところが、ちょっと立ち止まらないと「アレ?」ってなりがちなので気を付けましょう。

こういうのはTypeScriptがコンパイルエラーにしてくれてもいいと思うのですが、--strictFunctionTypesオプションが導入された現在でも、オーバーライド時には反変性チェックは入っていません。:disappointed_relieved: われわれ実装者が気を配っておく必要があります。

じゃあどうすればよかったのか

気の利いた実行時エラーにしておく

HumanAnimalである以上、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を駆使した型を書きたくなっちゃいますが:stuck_out_tongue_winking_eye:

他の言語での挙動

このあたりの挙動は言語によって差があるようです。
あまり数は調べてはいませんが、手近にあった例を紹介します。

Flowだと

JavaScriptに型をつけるなら、最近ではTypeScriptかFlowが採用されることが多いのではないかと思います。
Flowでは、オーバーライド時に引数の反変性をチェックしてくれているようで、はじめの例でもちゃんとコンパイルエラーが出ます。Facebookさすがですね。 :thumbsup:

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型を受け取る基底クラス側のメソッドが実行されて

console
肉うめ~

が出力されます。
ちゃんと@OverrideアノテーションをHuman#eatMeatにつけておけば、コンパイルエラーにしてくれます。
@Overrideめちゃ大切です。 :innocent:

おわりに

以上の話はぐぐればたくさん出てきはしますが、ちょっと気を抜いただけでうっかりミスってしまうので、ときどき思い出しましょう。

12
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
1