突然ですが、プログラミングをしていてこんなコードに出くわしませんか?
if (売上.勘定科目 == 勘定科目.現金) {
// 計算するロジック
} else if (売上.勘定科目 == 勘定科目.売掛金) {
// 計算するロジック
} else if (売上.勘定科目 == 勘定科目.有価証券) {
// 計算するロジック
}
このようなコードはしばしばスパゲッティになりがちですし、
項目が増えるたびに、条件分岐を増やさないといけないので保守も大変です。
売上の課目ごとに計算方法が違いますが金額計算するという振る舞いは同じです。
このコードからif
文を駆逐するにはどうしたらいいでしょうか?
型やフラグ、enum
による条件分岐はたいていの場合、ポリモーフィズムによって消し去ることができます。
ポリモーフィズムとは異なる型のオブジェクトを同一視し、そのオブジェクトの型によって動作を切り替えることです。
ポリモーフィズムは動的型付け言語ではダックタイピング、
静的型付け言語ではインターフェースや抽象クラスで実現できます。
この記事では『三角、円、四角がある。それらの面積を計算する。』という例で考えてみたいと思います。
動的型付け言語で if
や switch
を消す例
動的型付け言語は JavaScript を例に説明したいと思います。
手続き型による条件分岐のコード
以下のように三角、円、四角の各図形を用意しました。
図形は三角、円、四角か見分けるためにshapeflag
を持ちます。
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.』というものがあります。
『求めるな、命じよ。』とか『聞くな、言え。』などと訳されます。
オブジェクトに尋ねるのではなく、命じなさいという意味です。
今回の例だと、
『図形は何ですか?』
『三角形です。』
『底辺と高さは何ですか』
『底辺は 5
、高さは 4
です』
『なら、面積は 5 × 4 ÷ 2
で 10
ですね。』
ではなく、
『面積を計算しなさい。』
『10
です。』
になります。
ダックタイピングを活用しswitch
やif
を消し去るには、
振る舞いが適切な場所に定義される必要があります。
図形共通の振る舞いは面積を求められることです。
『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
文やフラグは消えました。
ここで大事なのはshape
のarea
メソッドが実行されていますが、
実際に呼び出されているのは三角、円、四角それぞれのarea
メソッド1であり、
同じarea
メソッドでも動作が切り替わっていることです。
静的型付け言語で if
や switch
を消す例
静的型付け言語は 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
をタイポしてもリアルタイムでエラーを教えてくれたり、補完がゴリゴリ効きます。
また、面積を計算できる型として振舞えるかどうかユニットテストする(ダックテスト)必要もなくなります。
静的型付けでもダックタイピングできる言語
静的型付け言語でも TypeScript や Golang などは一歩進んだダックタイピングが可能です。
TypeScript は複数の型を統一的に扱った場合、共用体型Union Type
として扱われます。2
Golang はあるinterface
の定義を全て満たす構造体は暗黙的にそのinterface
を実装していることになります。
参考:golangでダックタイピングをしてみよう
Golang は詳しくないので TypeScript について説明してみます。
以下のコードはそのまま TypeScript のコンパイルが通ります。(JSのコードと全く同じです)
もちろんtsconfig.json
はnoImplicitAny: true
です。
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()
が確実に呼び出せるのをどうやって保証してるの?
といった疑問が出てきますが、答えは画像の通りです。
shape
は三角、円、四角の共用体型になっています。
TypeScript コンパイラは三角、円、四角がarea: () => number
を持つことを推論します。
ためしに、circle
からarea
をコメントアウトしてみます。
const circle = {
radius: 3
// area: function() {
// return this.radius * this.radius * Math.PI;
// }
};
すると、エラーが出ています。
プロパティ 'area' は型 '{ base: number; height: number; area: () => number; } | { radius: number; } | { width: number; h...' に存在しません。
プロパティ 'area' は型 '{ radius: number; }' に存在しません。
円にarea
が定義されておらず、円でshape.area
すると実行時エラーが発生することを教えてくれます。
残念ながら、素の JavaScript だと、エディタ上でエラーは出ずに、実行時に例外をスローします。
JavaScriptと同じように手軽にダックタイピング可能で、
エラーになるものはエディタ上でリアルタイムに教えてくれる TypeScript 凄く賢いです...
まとめ
- 共通の振る舞いを持ちながら、実装の違いによって現れる
if
やswitch
はポリモーフィズムを見逃している証 - ポリモーフィズムは動的型付けではダックタイピング、静的型付けでは抽象クラスの継承やインターフェースの実装をすることで実現できる
2018/07 追記
間違って記事を削除してしまい再投稿しました。
ストックしていただいた方、申し訳ありません