JavaScript
C#
オブジェクト指向
TypeScript

ポリモーフィズムを活用するとなぜ if や switch が消えるのか?

突然ですが、プログラミングをしていてこんなコードに出くわしませんか?

if (売上.勘定科目 == 勘定科目.現金) {
    // 計算するロジック
} else if (売上.勘定科目 == 勘定科目.売掛金) {
    // 計算するロジック
} else if (売上.勘定科目 == 勘定科目.有価証券) {
    // 計算するロジック
}

このようなコードはしばしばスパゲッティ:spaghetti:になりがちですし、
項目が増えるたびに、条件分岐を増やさないといけないので保守も大変:sweat:です。

売上の課目ごとに計算方法が違いますが金額計算するという振る舞いは同じです。
このコードからif文を駆逐するにはどうしたらいいでしょうか?

型やフラグ、enumによる条件分岐はたいていの場合、ポリモーフィズムによって消し去ることができます。
ポリモーフィズムとは異なる型のオブジェクトを同一視し、そのオブジェクトの型によって動作を切り替えることです。

ポリモーフィズムは動的型付け言語ではダックタイピング、
静的型付け言語ではインターフェースや抽象クラスで実現できます。

この記事では『三角、円、四角がある。それらの面積を計算する。』という例で考えてみたいと思います。

動的型付け言語で ifswitch を消す例

動的型付け言語は JavaScript を例に説明したいと思います。

手続き型による条件分岐のコード

以下のように三角、円、四角の各図形を用意しました。
図形は三角、円、四角か見分けるためにshapeflagを持ちます。

app.js
const triangle = {
  shapeflag: "triangle",
  base: 5,
  height: 4
};

const circle = {
  shapeflag: "circle",
  radius: 3
};

const rectangle = {
  shapeflag: "rectangle",
  width: 4,
  height: 4
};

さて、ここから各図形の面積を計算する処理を実装する場合、どこに書いたらよいでしょうか?
とりあえず、図形を引き数に、図形のフラグによって計算方法を分岐するメソッドを書きました。

function computeArea(shape) {
  switch (shape.shapeflag) {
    case "triangle":
      return shape.base * shape.height / 2;
    case "circle":
      return shape.radius * shape.radius * Math.PI;
    case "rectangle":
      return shape.width * shape.height;
    default:
      throw new Error();
  }
}

配列に三角、円、四角を入れて、順番に計算してみます。

for (const shape of [triangle, circle, rectangle]) {
  console.log(computeArea(shape));
}
> node app.js
10
28.274333882308138
16

図形にあった計算方法が呼び出されました。
しかし、この方法では図形が増えるたびにswitch文のcaseを増やさないといけません。

この程度だとさほど問題にもなりませんが、冒頭にあげたような例ですと、
プログラムの成長に従ってこれと似たような条件分岐の構造が繰り返し現れたり、
巨大なユーティリティが出来上がったりしてどんどん複雑化していきます。

ダックタイピングによって条件分岐を消したコード

ところで、オブジェクト指向設計の有名な原則に『Don't ask, tell.』というものがあります。
『求めるな、命じよ。』とか『聞くな、言え。』などと訳されます。
オブジェクトに尋ねるのではなく、命じなさいという意味です。

今回の例だと、
『図形は何ですか?』
:neutral_face:『三角形です。』
『底辺と高さは何ですか』
:neutral_face:『底辺は 5 、高さは 4 です』
『なら、面積は 5 × 4 ÷ 210 ですね。』
ではなく、
『面積を計算しなさい。』
:neutral_face:10 です。』
になります。

ダックタイピングを活用しswitchifを消し去るには、
振る舞いが適切な場所に定義される必要があります。

図形共通の振る舞いは面積を求められることです。
『Don't ask, tell.』に従って図形自身に面積を計算させましょう。

const triangle = {
  base: 5,
  height: 4,
  area: function() {
    return this.base * this.height / 2;
  }
};

const circle = {
  radius: 3,
  area: function() {
    return this.radius * this.radius * Math.PI;
  }
};

const rectangle = {
  width: 4,
  height: 4,
  area: function() {
    return this.width * this.height;
  }
};

三角、円、四角はareaという共通のメソッド1を持ちました。

配列に三角、円、四角を入れて、順番に面積を計算してみます。

for (const shape of [triangle, circle, rectangle]) {
  console.log(shape.area());
}
> node app.js
10
28.274333882308138
16

きちんと計算できています。
そして、if文やフラグは消えました。

ここで大事なのはshapeareaメソッドが実行されていますが、
実際に呼び出されているのは三角、円、四角それぞれのareaメソッド1であり、
同じareaメソッドでも動作が切り替わっていることです。

静的型付け言語で ifswitch を消す例

静的型付け言語は C# を例に説明したいと思います。

三角形、円、図形を定義しましょう。
同じように『Don't ask, tell.』に従って、図形自身に面積を計算させます。

class Triangle {
    public double Base { get; set; }
    public double Height { get; set; }

    public double Area() => Base * Height / 2;
}

class Circle {
    public double Radius { get; set; }

    public double Area() => Radius * Radius * Math.PI;
}

class Rectangle {
    public double Width { get; set; }
    public double Height { get; set; }

    public double Area() => Width * Height;
}

このまま三角、円、図形を面積を計算できるものとして統一して扱いたいところですが、
一般的な静的型付け言語はこのままではポリモーフィズムを実現できません。

面積を求められるという共通の振る舞いを抽象化した図形インターフェースを定義する必要があります。

interface IShape {
    double Area();
}

図形インターフェースを三角、円、四角に実装します。
今回は元からAreaメソッドを持っているためクラスの内容に変化はありません。

class Triangle : IShape { /* 略 */ }

class Circle : IShape { /* 略 */ }

class Rectangle : IShape { /* 略 */ }

これで三角、円、四角は面積を求められる型(IShape)として統一的に扱えるようになりました。
実際に上記のクラスを使ったコードは以下のようになります。

static void Main() {
    IShape[] shapes = {
        new Triangle { Base = 5, Height = 4 },
        new Circle { Radius = 3 },
        new Rectangle { Width = 4, Height = 4 },
    };

    foreach (var shape in shapes) {
        Console.WriteLine($"AREA: {shape.Area()}");
    }
}
> dotnet run
AREA: 10
AREA: 28.2743338823081
AREA: 16

インターフェースを定義するのが回りくどいように感じますが、
Areaが実行できること100パーセント保証してくれたり、エディタの支援が強いなどのメリットもあります。

例えば、IShapeを実装しない型が配列に入らないので実行時エラーを起こりませんし、
Areaをタイポしてもリアルタイムでエラーを教えてくれたり、補完がゴリゴリ効きます。
また、面積を計算できる型として振舞えるかどうかユニットテストする(ダックテスト)必要もなくなります。

静的型付けでもダックタイピングできる言語

静的型付け言語でも TypeScriptGolang などは一歩進んだダックタイピングが可能です。
TypeScript は複数の型を統一的に扱った場合、共用体型Union Typeとして扱われます。2
Golang はあるinterfaceの定義を全て満たす構造体は暗黙的にそのinterfaceを実装していることになります。
参考:golangでダックタイピングをしてみよう

Golang は詳しくないので TypeScript について説明してみます。

以下のコードはそのまま TypeScript のコンパイルが通ります。(JSのコードと全く同じです)
もちろんtsconfig.jsonnoImplicitAny: trueです。3

const triangle = {
  base: 5,
  height: 4,
  area: function() {
    return this.base * this.height / 2;
  }
};

const circle = {
  radius: 3,
  area: function() {
    return this.radius * this.radius * Math.PI;
  }
};

const rectangle = {
  width: 4,
  height: 4,
  area: function() {
    return this.width * this.height;
  }
};

for (const shape of [triangle, circle, rectangle]) {
  console.log(shape.area());
}
  • any型を許容していないのにfor (const shape of [triangle, circle, rectangle])がエラーにならないけどshapeの型はどうなっているの?
  • shape.area()が確実に呼び出せるのをどうやって保証してるの?

といった疑問が出てきますが、答えは画像の通りです。

ts-union.PNG

shapeは三角、円、四角の共用体型になっています。
TypeScript コンパイラは三角、円、四角がarea: () => numberを持つことを推論します。

ためしに、circleからareaをコメントアウトしてみます。

const circle = {
  radius: 3
  // area: function() {
  //   return this.radius * this.radius * Math.PI;
  // }
};

すると、エラーが出ています。

image.png

プロパティ 'area' は型 '{ base: number; height: number; area: () => number; } | { radius: number; } | { width: number; h...' に存在しません。
プロパティ 'area' は型 '{ radius: number; }' に存在しません。

円にareaが定義されておらず、円でshape.areaすると実行時エラーが発生することを教えてくれます。
残念ながら、素の JavaScript だと、エディタ上でエラーは出ずに、実行時に例外をスローします。

JavaScriptと同じように手軽にダックタイピング可能で、
エラーになるものはエディタ上でリアルタイムに教えてくれる TypeScript 凄く賢いです...:blush:

まとめ

  • 共通の振る舞いを持ちながら、実装の違いによって現れるifswitchはポリモーフィズムを見逃している証
  • ポリモーフィズムは動的型付けではダックタイピング、静的型付けでは抽象クラスの継承やインターフェースの実装をすることで実現できる

2018/07 追記
間違って記事を削除してしまい再投稿しました。:scream::scream::scream:
ストックしていただいた方、申し訳ありません:bow::bangbang:


SI 企業所属の2年目プログラマーです。
エンジニアの方とつながれると嬉しいです!:grinning:
twitter: のさ@nosa_programmer


  1. 正確にはareaプロパティの持つ関数式です。 

  2. interfaceの定義して実装することも可能です。 

  3. 「暗黙のANYを許容しない」にしないと「TS は動的(any)型付けとしても扱えるからコンパイルが通る」という理由になるので必要です。