前回の記事TypeScriptでデザインパターンを理解する ②Observerパターンに引き続きデザインパターンの解説をしていきます。
今回紹介するDecoratorパターンは次回紹介するFactoryパターンの前座のような扱いらしくいくつかの欠点があります。
いつか書籍化するかもしれないなどと色気を出したい気持ちになってきたので今回からはシチュエーション、ソースコードともに私のオリジナルで解説していくことにしました。
Decoratorパターンを使いたくなるシチュエーション
バーガーキ◯グのレジのプログラムを考えてみましょう。飲食店のメニューを例に使うくらいならオリジナリティーを損なっていないはずです。
抽象クラスItemはとりあえず以下のようにしましょう。
export abstract class Item{
public abstract cost():number
}
きっと、これを継承してハンバーガーのクラスも欲しいでしょう。具象クラスとしてみんな大好きなあのバーガーを作ります。値段は確かこんなものでしょう。(単位はドルです)
export abstract class Burger extends Item{
}
export class Whapper extends Burger{
cost(){
return 5.0
}
}
ハンバーガーなのにセットにできないじゃないか?おっしゃるとおりです!こんなハンバーガーショップは潰れてしまいます。それではお得なセットを実装していきましょう!
export abstract class Set extends Burger{
}
export class WhapperSet extends Set{
cost(){
return 7.3
}
}
これは明らかに問題を抱えていますね?ポテトにチリソースをトッピングしたらWhapperSetWithChiliSource
クラスを作ることになります。きっとクラスの数は次第に把握できなくなるでしょう。人類の総人口を超えるのも時間の問題です。
それではもう少しマシにしてみましょう。まずはItem
抽象クラスを変更します。
export abstract class Item{
isSet = false
public addSet(){
this.isSet = true
}
public abstract cost():number
}
そのあと、Whapper
クラスを書き換えてみます。
export class Whapper extends Burger{
cost(){
let cost = 5.0
if(this.isSet){
cost += 2.3
}
return cost
}
}
Whapper
クラスではなくItem
抽象クラスやBurger
抽象クラスのcost
メソッドを変更すれば個別の商品ごとに書き換える手間はなくて済むかもしれませんが、この方法には一つ問題があります。
オブジェクト指向の原則のうちの一つ開放/閉鎖原則に違反していることです。
Wikipediaによると開放/閉鎖原則とはオブジェクト指向プログラミングにおいて、クラス(およびその他のプログラム単位)は
- 拡張に対して開いて (open) いなければならず、
- 修正に対して閉じて (closed) いなければならない
という設計上の原則である。らしいです。まだ意味がわかりにくいですよね。今回の例を用いて説明します。
最初のWhapper.cost()
メソッドを実装したのが三歳児だったとしましょう。バグのない正しいプログラムを書くまでに血の滲むような努力を重ねたはずです。納品までに1年もかかりました。あなたは無情にも「セットメニューも追加して」と言い放ちます。次に正しくプログラムが動くのは何年後でしょうか?更にプログラムが正しく動く保証はありません。せっかく元のcost
メソッドは正しく動いていたのに書き換える必要があるからです。
つまり、これが修正に対して閉じて (closed) いなければならないということです。プログラムに機能を追加するときに正しく動くことがわかっている元のコードを修正(コードを書き換える)してはいけないのです。
それでは元の正しいcost
メソッドを変更することなくセットメニューの価格を計算できるように拡張(コードを追加)していきましょう。
Decoratorパターン
動くコード
いつものごとく手元で動くコードを載せておきます。簡単のためにItem
抽象クラスは考えないことにします。ハンバーガーは必ず頼みましょう。
毎回言いますが、とりあえず手元で動くように載せているだけなので今読まなくて大丈夫です。後ほど詳しく解説します。
まずは先ほど見たBurger
クラスです。
export abstract class Burger{
public abstract cost():number
}
export class Whapper extends Burger{
cost(){
return 5.0
}
}
次に今回の主役であるDecorator
を定義しているSet.ts
です
import { Burger } from "./Burger"
export abstract class SetDecorator extends Burger{
abstract burger:Burger
}
export class BurgerSet extends SetDecorator{
burger:Burger
constructor(burger:Burger){
super()
this.burger= burger
}
cost(){
return this.burger.cost() + 2.3
}
}
export class BurgerCombo extends SetDecorator{
burger:Burger
constructor(burger:Burger){
super()
this.burger= burger
}
cost(){
return this.burger.cost() + 1.5
}
}
最後に注文をするorder.ts
です。
import {Whapper} from "./Burger"
import {BurgerSet,BurgerCombo} from "./Set"
let whapper = new Whapper
console.log("Whapper $" + whapper.cost())
let whapperCombo = new BurgerCombo(whapper)
console.log("WhapperCombo $" + whapperCombo.cost())
let whapperSet = new BurgerSet(whapper)
console.log("WhapperSet $" + whapperSet.cost())
解説
今回はいたってシンプルです。解説することはあまりありません。BurgerSet
クラスを見てみましょう
export abstract class SetDecorator extends Burger{
abstract burger:Burger
}
export class BurgerSet extends SetDecorator{
burger:Burger
constructor(burger:Burger){
super()
this.burger= burger
}
cost(){
return this.burger.cost() + 2.3
}
}
Decorator
で注目すべき点はデコレータの抽象クラスであるSerDecorator
がプロパティにBurger
を持ち初期化するときにBurger
インスタンスを引数に取ることでburger
のcost
メソッドを使用できるのです。
つまり、下のコードのように使います。
let whapper = new Whapper
let whapperSet = new BurgerSet(whapper)
console.log("WhapperSet $" + whapperSet.cost())
出力
WhapperSet $7.3
このようにすることでWhapper
クラスを修正したり、新たにWhapperSet
クラスを書き換えることなく、新しいBurger
のサブクラスのインスタンスを作ることができました。
これは見事に開放/閉鎖原則に従っています。
バーガーキ◯グも新しいセットメニューを追加するたびにバグの発生するレジから解放されました。
問題点
- クラスが増えやすい傾向にあるらしい
- デコレータは何重にも重ねることができるが、それが原因でコードが読みにくくなったりするらしい。
次回説明するFactory
パターンや説明しないBuilder
パターンはこれらの問題にたいしてうまくアプローチしているらしいです。
感想
平凡というか、まあ、自分でも思いつきそうだなという感想になりました。開放/閉鎖原則に対する良いアプローチの実例を一つ知ることができたという意味合いが個人的には大きいと感じました。
次回のFactoryパターンには大きな期待を寄せたいです。
追記
クラスが増えやすいというのが欠点ならジェネリクスを使って以下のようにクラスではなく関数を用いれば解決するのでは?
export function AddSet<T extends Burger>(t:T){
let set:T = {...t}
set.cost = function(){
let cost = t.cost() + 2.3
return cost
}
return set
}
使い方は以下のようにする
let whapperSetF = AddSet(whapper)
console.log("WhapperSetF $" + whapperSetF.cost())
元のクラスのままでいれるし、こっちの方が良い気がするのだが、、、
あと、ついでに言えばBurger
クラスにaddSet
メソッドを持たせても元のコードを修正するわけではないので問題ない気がする。
export abstract class Burger{
public abstract cost():number
public addSet(){
let costFunc = this.cost
this.cost = function(){
let cost = costFunc() +2.3
return cost
}
}
}
let whapperSetC = new Whapper
whapperSetC.addSet()
console.log("WhapperSetC $" + whapperSetC.cost())