こんにちは!
今回はオープン・クローズドの原則というコーディングの原則について話します。
一体この原則は何なのか?
実例を使って、初心者でもわかりやすく解説します。
では見ていきましょう!
コードは成長する
コードの成長と変化は、多くのソフトウェアプロジェクトで日常茶飯事です。
新しい機能の追加、既存のバグの修正、テクノロジーの進化などが原因で、コードベースは日々変わり続けます。
問題は、既存のコードを変更するとデグレードを生んでしまう可能性があるということです。
デグレード(デグレ)とはプログラムの変更や修正により他のプログラムに思わぬ影響を与えてしまい、ソフトウェアの品質が落ちてしまうことです。
今まで問題なく動いていた機能にバグができてしまうと、多くのユーザーに損害を与えてしまいます。
そのため既存のコードはできれば触りたくないというのは多くの開発者の共通した思いだと思います。
そんなコードの成長と変化に対処するためにソフトウェア設計におけるいくつかのコツがあります。
その中の一つが今回取り上げる「オープンクローズドの原則」です。
オープンクローズドの原則とは
オープンクローズドの原則は、有名なソフトウェア設計の原則である「SOLID原則」の中の一つです。
SOLID原則とは、変更に強いソフトウェア設計を行うコツとしてロバート・マーティンという開発者が提唱した5つの原則のことです。
SOLIDとは5つの原則の頭文字を表していて、オープン・クローズド(Opne-Closed)はこの中の「O」に当たります。
オープン・クローズドの原則では以下の2つの要素を守ることを提唱しています。
オープン (Open):
システムは新しい機能や振る舞いの追加に対して「オープン」であるべきということです。
どういう事かというと、変更があった場合にも素早く柔軟に対応できるように、あらかじめ変更しやすい設計にしておくべきだということです。
クローズド (Closed):
既存のコードが正常に機能している場合、それは「クローズド」であるべきということです。
つまり、変更があった場合に既存のコードをなるべく触らなくてよいような設計をするべきということです。
この2点を守ることで、より保守性の高いソフトウェアを作ることができます。
オープンクローズドの原則を実際に適用する方法はケースバイケースなので、例としてこの記事では2つのケースを取り上げます。
1つは簡単なケースでこの原則の意味をさくっと理解できると思います。
2つ目はこの原則を解説するときによく使われるインターフェースを使った例をご紹介します。
例1
下記はあるショッピングサイトでメンバー会員の種類に合わせて割引率を取得する場面を想定したコードです。
簡潔にするため、関数の使用箇所はログ出力するだけとしています。
// 会員種類を定義
type memberType = "Gold" | "Silver" | "Green";
// 会員の割引率を取得(単位:%)
const discountRate = (type: memberType): number => {
if (type === "Gold") {
return 10;
} else if (type === "Silver"){
return 5;
} else if (type === "Green"){
return 2;
} else {
// 通常通らない
return 0;
}
}
// ユーザーの会員種類を取得したと想定
const userMemberType: memberType = "Silver";
console.log(`${userMemberType}会員の割引率は${discountRate(userMemberType)}%です。`);
// 出力:Silver会員の割引率は5%です。
discountRateは会員の割引率を返す関数ですが、会員の種類を判別するためif文を使っています。
この実装の場合、新たな会員が追加されるとどのような修正が必要でしょうか?
例としてGold会員の上のPremium会員(15%OFF)が追加されたとしましょう。
下記のように定義の変更に加え、discountRateのif文にも手を加えなければなりません。
// 会員種類を定義
type memberType = "Premium" | "Gold" | "Silver" | "Green";
// 会員の割引率を取得(単位:%)
const discountRate = (type: memberType): number => {
if (type === "Premium") {
return 15;
} else if (type === "Gold") {
return 10;
} else if (type === "Silver"){
return 5;
} else if (type === "Green"){
return 2;
} else {
// 通常通らない
return 0;
}
}
if文は記述が入り組んでいるので変更の途中で誤ってロジックを壊してしまう可能性があります。
今回はシンプルな関数なのでマシですが、もっと複雑な処理だと関数内の他の箇所に思わぬ影響を与えてしまうかもしれません。
このような実装は変更に対して強いとは言えないので、これにオープン・クローズドの原則を当てはめてリファクタしてみましょう。
下記の例では、会員種類と割引率が対照できるmapを用意し、discountRateではそれを参照するだけにしています。
// 会員種類を定義
type memberType = "Gold" | "Silver" | "Green";
// 会員種類と割引率のmap
const memberTypeMap = new Map<memberType, number>([
['Gold', 10],
['Silver', 5],
['Green', 2],
]);
// 会員の割引率を取得(単位:%)
const discountRate = (type: memberType) => {
return memberTypeMap.get(type);
}
// ユーザーの会員種類を取得したと想定
const userMemberType: memberType = "Silver";
console.log(`${userMemberType}会員の割引率は${discountRate(userMemberType)}%です。`);
// 出力:Silver会員の割引率は5%です。
この実装であれば新たな会員種類が追加された場合、定義とmapを1行更新するだけで済みます。
// 会員種類を定義
type memberType = "Premium" | "Gold" | "Silver" | "Green";
// 会員種類と割引率のmap
const memberTypeMap = new Map<memberType, number>([
['Premium', 15],
['Gold', 10],
['Silver', 5],
['Green', 2],
]);
// 会員の割引率を取得(単位:%)
const discountRate = (type: memberType) => {
return memberTypeMap.get(type);
}
discountRateを触らなくてよいということは、この関数は閉じて(close)いて、新たな不具合を起こす可能性が低いことを意味します。
このような実装、設計はオープン・クローズドの原則に沿っていると言えると思います。
例2
オープンクローズドの法則でよく取り上げられるのは、インターフェースを使った設計です。
インターフェースを使って共通のクラスをまとめることで、新たに変更が入った場合もクラスを追加するだけで済むようにします。
具体例を見てみましょう。
まずはオープンクローズドの法則に即していないコードです。
下記のコードにはprosessOrderという支払いを行う関数があります。
この関数では引数に渡された文字列によって支払い方法を判定し、それぞれの支払い処理を行います。
現在、支払い方法は「カード」と「paypal」に対応しています。
簡潔にするため、実際の支払い処理は割愛して代わりにログ出力するだけとします。
class Order {
// 支払い処理を行う
processOrder(paymentMethod: string, amount: number) {
if (paymentMethod === "credit_card") {
console.log("クレジットカードで ¥" + amount + " を支払いました。");
} else if (paymentMethod === "paypal") {
console.log("PayPalで ¥" + amount + " を支払いました。");
} else {
throw new Error("無効な支払い方法です");
}
}
}
// 使用例
const order = new Order();
order.processOrder("credit_card", 3000);
order.processOrder("paypal", 3000);
// 出力
// クレジットカードで ¥3000 を支払いました。
// PayPalで ¥3000 を支払いました。
ここに新しい支払い方法「bitcoin」が追加されたとしましょう。
processOrderの条件分岐を追加することで対応できます。
class Order {
processOrder(paymentMethod: string, amount: number) {
if (paymentMethod === "credit_card") {
console.log("クレジットカードで ¥" + amount + " を支払いました。");
} else if (paymentMethod === "paypal") {
console.log("PayPalで ¥" + amount + " を支払いました。");
} else if (paymentMethod === "bitcoin") {
console.log("Bitcoinで ¥" + amount + " を支払いました。");
} else {
throw new Error("無効な支払い方法です");
}
}
}
// 使用例
const order = new Order();
order.processOrder("credit_card", 3000);
order.processOrder("paypal", 3000);
order.processOrder("bitcoin", 3000);
// 出力
クレジットカードで ¥3000 を支払いました。
PayPalで ¥3000 を支払いました。
Bitcoinで ¥3000 を支払いました。
このコードの問題点は、新たな支払い方法が加わるたびにprocessOrderに変更を加える必要があるということです。
そのため、何らかの記述を誤って他の処理に影響を与えてしまう可能性があります。
それではこのコードをオープンクローズドの原則に沿って修正してみましょう。
まずはprocessOrder内の各支払い処理をそれぞれ別のクラスに切り出します。
こうすることで、最終的には新たに支払い方法が追加されたらクラスを新たに追加すれば良い状態にします。
class CreditCardPayment {
processPayment(amount: number) {
console.log("クレジットカードで ¥" + amount + " を支払いました。");
}
}
class PayPalPayment {
processPayment(amount: number) {
console.log("PayPalで ¥" + amount + " を支払いました。");
}
}
class Order {
// 支払い処理を行う
processOrder(paymentMethod: string, amount: number) {
if (paymentMethod === "credit_card") {
// クラスに切り出したので削除
} else if (paymentMethod === "paypal") {
// クラスに切り出したので削除
} else {
throw new Error("無効な支払い方法です");
}
}
}
次に、processOrderの引数に文字列ではなく各支払い方法のクラスのインスタンスを渡すように修正します。
こうすることで、processOder内で条件分岐をする必要がなく、ただ渡されたインスタンスの支払い処理を実行するだけにします。
現状ではCreditCardPaymentとPayPalPaymentは型が違うので、processOrderでは統一した型で受け取る必要があります。
そのため、インターフェースを作成し、各クラスはそのインターフェースを実装します。
interface PaymentMethod {
processPayment(amount: number): void;
}
class CreditCardPayment implements PaymentMethod {
processPayment(amount: number) {
console.log("クレジットカードで ¥" + amount + " を支払いました。");
}
}
class PayPalPayment implements PaymentMethod {
processPayment(amount: number) {
console.log("PayPalで ¥" + amount + " を支払いました。");
}
}
class Order {
// 支払い処理を行う
processOrder(paymentMethod: PaymentMethod, amount: number) {
paymentMethod.processPayment(amount);
}
}
// 使用例
const order1 = new Order(new CreditCardPayment());
order1.processOrder(3000);
const order2 = new Order(new PayPalPayment());
order2.processOrder(3000);
// 出力
クレジットカードで ¥3000 を支払いました。
PayPalで ¥3000 を支払いました。
これでオープンクローズドの原則に沿った修正は完了です。
最後に全体のコードを見てみましょう。
一点だけ、paymentMethodをリファクタしています。
支払い方法は一度選択されたらそのプロセス中変更することは少ないと思うので、Orderクラスのデータとして持ち、コンストラクタで代入するようにします。
class Order {
private paymentMethod: PaymentMethod;
constructor(paymentMethod: PaymentMethod) {
this.paymentMethod = paymentMethod;
}
processOrder(amount: number) {
this.paymentMethod.processPayment(amount);
}
}
interface PaymentMethod {
processPayment(amount: number): void;
}
class CreditCardPayment implements PaymentMethod {
processPayment(amount: number) {
console.log("クレジットカードで ¥" + amount + " を支払いました。");
}
}
class PayPalPayment implements PaymentMethod {
processPayment(amount: number) {
console.log("PayPalで ¥" + amount + " を支払いました。");
}
}
// 使用例
const order1 = new Order(new CreditCardPayment());
order1.processOrder(3000);
const order2 = new Order(new PayPalPayment());
order2.processOrder(3000);
// 出力
クレジットカードで ¥3000 を支払いました。
PayPalで ¥3000 を支払いました。
このコードに対して改めてBitcoinの支払い方法を追加します。
今回はクラスを一つ追加するだけで、processOrderは変更する必要はありません。
class Order {
private paymentMethod: PaymentMethod;
constructor(paymentMethod: PaymentMethod) {
this.paymentMethod = paymentMethod;
}
processOrder(amount: number) {
this.paymentMethod.processPayment(amount);
}
}
interface PaymentMethod {
processPayment(amount: number): void;
}
class CreditCardPayment implements PaymentMethod {
processPayment(amount: number) {
console.log("クレジットカードで ¥" + amount + " を支払いました。");
}
}
class PayPalPayment implements PaymentMethod {
processPayment(amount: number) {
console.log("PayPalで ¥" + amount + " を支払いました。");
}
}
class BitcoinPayment implements PaymentMethod {
processPayment(amount: number) {
console.log("Bitcoinで ¥" + amount + " を支払いました。");
}
}
// 使用例
const order1 = new Order(new CreditCardPayment());
order1.processOrder(3000);
const order2 = new Order(new PayPalPayment());
order2.processOrder(3000);
const order3 = new Order(new BitcoinPayment());
order3.processOrder(3000);
// 出力
クレジットカードで ¥3000 を支払いました。
PayPalで ¥3000 を支払いました。
Bitcoinで ¥3000 を支払いました。
このようにオープンクローズドの原則に従うと、既存のコードの修正を最小限に抑えつつ、変更しやすいコードを書くことが出来ます。
終わり
いかがでしたでしょうか。
今回取り上げた2つの例のように、オープン・クローズドの原則を当てはめることで変更に強いソフトウェアを作ることができます。
今後もこの原則を思い出し、もっと変更しやすい設計はないか?既存コードを触らないで変更できるよう作れないか?を問いながら設計・実装できれば良いと思いました。
では、ここまで読んでいただきありがとうございました!☺