TypeScriptでデザインパターンを理解する ③Decoratorパターン


前回の記事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クラスです。

Burger.ts
export abstract class Burger{
    public abstract cost():number
}
export class Whapper extends Burger{
    cost(){
        return 5.0
    }
}

次に今回の主役であるDecoratorを定義しているSet.tsです

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です。

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インスタンスを引数に取ることでburgercostメソッドを使用できるのです。
つまり、下のコードのように使います。

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())