Open Closed Principle (開放閉鎖原則)

 
“クラスは拡張に対して開いていなければならず 修正に対して閉じていなければならない”

「このマシンは CPUもメモリもディスクも拡張可能にできているのさ。
 それに変更がケースの内側だけなら、ユーザーや外部デバイスも気づかないだろ?」

OCPは クラスの拡張性を最大化し、デグレードのリスクを最小化するための原則です。修正が外部に影響しないようにクラス外との窓口を限定し(Close)、修正自体を阻害しないようにクラス内の拡張ポイントを意識して設計します(Open)。

概要

冒頭の例では、マシン(コンピュータ)を引き合いに出して説明しました。マシンは内部に多くの拡張ポイントを用意している一方で、外部との接触はPCケースによって限定され一部のボタンや端子からしかやり取りできません。

例えば、マシンのケースを開ければ HDDやメモリのスロットによりパーツの追加・交換を簡単に行うことができます。これが「拡張に対して開かれている」部分です。逆に、専用メモリが基盤に埋め込まれており追加・交換ができなければ「拡張に対して開かれていない」ことになります。

一方で、マシンのケース自体は外部との接触を限定する役割を持ちます。ケースはマザーボード等の脆弱な内部基盤を隠蔽し、最小限のインターフェイスを外部に公開します(カプセル化)。具体的には「電源ボタン・ディスクドライブ・USB端子・電源ソケット」という最小限の窓口のみ露出され残りはケースで覆われます。露出部分の挙動さえ担保できれば、それを利用する側に修正の影響は及びません。これが「修正に対して閉じられている」部分です。修正に対して閉じられていれば、デグレードの危険性や再テストの必要性を最小化できます。

修正に対して閉じられている構造は、何もマシンに限った構造ではありません。自動車や船舶等の機械製品はもちろんのこと、人間や動物の体だって修正に対しては閉じられています。ですから運転手が直接エンジンを触って始動することは不可能ですし、動物が食べ物を口を介さずに胃に押し込めることも不可能です。イグニッションキーによる始動や口で咀嚼してからの嚥下で問題ありませんし、むしろその方が便利で安全にできています。例えば、ブレーキが踏まれていなければ始動できませんし、毒草を口にした際には味覚が異常を検知してくれます。なお、これをプログラムで表現すると Setter内に Validation機能を付加した状態に相当します。

特徴

OCPの特徴は、スコープ視点では最適化の範囲がクラスに特化している点です。また、この最適化が、クラスには最適な責務が割り当てられていることが前提で、その責務における変更には継承委譲を用いて開放し、他クラスからの干渉にはカプセル化を用いて閉鎖するという手段をとります。

コードサンプル

実装に話を移します。コード上での Open-Closed-Principle(開放閉鎖原則) は継承委譲による縦方向(自身の関連)の開放と、カプセル化による横方向(他者との関連)の閉鎖によって実現します。縦方向と横方向と表現しているのは、一般的にクラス図上では自身の継承関係は親クラスを上方に、子クラスを下方に配置して表現するからです。

OCP

閉鎖

閉鎖部分は変数やメソッドの可視性で制御します。これらの可視性を private (Javaなら protectedも可) にすることで外部アクセスを遮断します。こうすれば外部のクラスから変数やメソッドにアクセスされることはありません。

public class Machine {

    private boolean powered;
    private CPU cpu;
    private Memory memory;
    private HardDisk hardDisk;
    private Set<USBDevice> usbDevices = new HashSet();
    private Set<HDMIDevice> hdmiDevices = new HashSet();

    // 振る舞いを省略
}

(Javaによる閉鎖)

外部から変数へのアクセスが必要な場合は、Getter / Setter等の publicなアクセッサメソッドを通じてアクセスを許可します。結局はアクセスさせてしまうのであれば最初から変数を publicにするのと同じだと思われるかも知れませんが、以下の点が異なります。

  • Get / Set の片系統のみ許可することができます
    • Getterのみ用意することで Immutableパターンを実現することも可能です
  • アクセス時に処理を挟むことができます (以下に例を示します)
    • Set時の Validation処理で内部状態を保護することができます
    • Get / Set時に参照のコピーを取得・返却することで、参照元でのポインタ更新による影響を無効化できます
public class Machine {
    // (中略)
    public CPU getCPU() {
        return cpu;
    }

    public void setCPU(CPU cpu) {
        this.cpu = cpu;
    }
    // (中略)
    public void addHDMLDevice(HDMIDevice hdmi) {
        this.hdmiDevices.add(hdmi);
    }

    public Set<HDMIDevice> getHDMIDeviceSet() {
        return new HashSet(this.hdmiDevices);
    }
}

(Javaによる Setter/Getter)

Golangにおいても閉鎖部分は変数の可視性で制御します。しかし Golangのstructは Javaのclassと異なり、struct単位での可視性を制御できません (パッケージ単位となります)。ですので 利用側とは別のパッケージを用意し、その中で可視性を private(Javaの package private相当)にすることで閉鎖を実現します。

Golangの場合は変数名や関数名を小文字始まりにすることで可視性を privateとします。具体的には以下の様な形になります。これでパッケージの外部からはアクセスできません。この様に、内部の状態や操作を外部から隠蔽することを カプセル化と呼びます。

// (変数・メソッドを privateにすることで閉鎖を実現)
type Machine struct {
	powered     bool
	cpu         CPU
	memory      Memory
	hd          HardDrive
	usbDevices  []USBDevice
	hdmiDevices []HDMIDevice

	startUp  func(*Machine) error
	shutDown func(*Machine) error
}

(Golangによる閉鎖)

開放

閉鎖の対象は参照元でしたが、開放の対象はサブクラスです。そのためにはサブクラスに対する拡張ポイントの可視性を緩めなければなりません。

Javaでは開放する変数やメソッドの可視性をprotectedとすることで、サブクラスからの変数アクセスやメソッドオーバーライドを許可し、これにより拡張に対して開いた状態を作ります。可視性が privateだと継承が不可能になりますし publicでは外部公開されてしまうので、一般的な拡張ポイントとしては protected程度が妥当です。尚、Javaには無印のpackage privateも存在しますが、こちらはパッケージ内を含めて公開されてしまうので(Golangのパッケージよりも広いイメージ)クラス境界をはみ出してしまい、少し開放し過ぎです。

public class Machine {
	  // (中略)
    protected void startUp() {
        // Do Something
    }

    protected void shutDown() {
        // Do Something
    }
}

(Javaでの拡張ポイントの設置)

可視性を privateにするか、protectedにするかはサブクラスに対する開放度の差となります。すべてを開放するなら protectedを、サブクラスと言えど開放すべきでない部分は privateを選択します。将来の拡張を予測しづらければ全体的に protectedとするのも良いですし、本質的に動かない部分を明示できれるのなら、その部分を privateとします。理想を言えば TemplateMethodパターンの様に、サブクラスが拡張すべき部分を見抜き、そこだけピンポイントで protectedにする設計が美しいですが、そこまで確証を持てないのであれば全体的に protectedとしておくことで最低限 拡張の邪魔をしないことを保証できます。(ここを見誤ると修正に対して閉じられてしまうためさじ加減が重要です)

Golangでは同一パッケージ内であれば struct内の変数にアクセスできますので、変数の可視性は privateのままで事足ります。一方でJavaと異なり継承によるメソッドのオーバーライドができないので、修正を想定する振る舞いはメソッドではなく struct内の関数として定義しておく必要があります。この関数の実装を入れ替えることで、継承によるオーバーライドと同等の拡張性を担保する訳です。要するに「継承がだめなら委譲を使え」「Goの関数は第一級関数なので、Interfaceと同様に委譲ができる」という感じです。

具体的には、先の Machineにおける startUp関数と, shutDown関数に注目してみてください。

type Machine struct {
	// (中略)
	startUp  func(*Machine) error
	shutDown func(*Machine) error
}

 
これらの関数を入れ替えることで、継承に頼らないでも修正を可能にしておきます。つまりここを拡張ポイントとして設計しておくイメージです。ここが、修正に対して開かれた状態となります。

例えば、以下のメソッドはスイッチ押下のイベントを表現しています。このメソッドでは電源OFFの際にスイッチが押下されたら起動、電源ONの際に5秒以上押下されていたら終了というフローは定義していますが、実際の起動・終了処理の詳細にまでは踏み込んでおらず、拡張ポイントの関数に処理を委譲しています。これは起動・終了の部分は拡張可能に設計されているからです。

// マシンの電源スイッチを押下
func (m *Machine) PushPowerSwitch(d time.Duration) {
	if !m.powered {
		// 起動処理に委譲
		if err := m.startUp(m); err != nil {
			log.Fatal(err)
		}
	} else {
		if (5 * time.Second) < d {
			// 終了処理に委譲
			if err := m.shutDown(m); err != nil {
				log.Fatal(err)
			}
		}
	}
}

(Golangによる拡張部分の活用)

実際の設計ではこの様に「緩めるところ(拡張ポイント)」と「固めるところ(固定ポイント)」を見極めながら薦めてゆきます。例えば、デザインパターンの Template Methodでは、処理のコントロール部分を固めて、個々の処理単体を緩めることで拡張ポイントを最適化しています。

まとめ

今回は Open Closed Principle(開放閉鎖原則)を学びました。OCPはカプセル化を用いた閉鎖により境界線を確立し 継承や委譲を用いた開放により境界内の拡張性を確立する手法でした。これを上手く使いこなすと、クラス間の疎結合を拡張性を犠牲にせずに実現できます。美しいクラス設計においては必須の原則ですので、使いこなせるようになってしまいましょう。

では、OCPのある良いプログラミングライフを!