最近、達人プログラマー第二版を読んで、特に第二章の前半で話題になっている「DRY原則」と「直交性」が印象的であったので、考えてみる。
良い設計とは?
良い設計は悪い設計よりも変更しやすい
第2章の初めの Tips である。確かにその通りだとおもう。
良い設計とは?と聞かれるとパッと答えるのは難しいが「悪い設計とは?」と聞かれたときには以下のようなものが思い浮かぶ。
- 文字通り暗号みたい(code)で読むのが難しい
- 同じ処理があちこちに書かれている
- 機能を追加・変更したときに、一見関係ない部分が壊れる
他にも例は挙げられると思うが、概ね同意してもらえるはず。
一番上は可読性的な意味で例えばリーダブルコードのような本があったりする。2番目は「DRY原則」が、3番目が「直交性」に対応していると思う。
DRY 原則
DRY 原則とは Don't Repeat Yourself の略で、「繰り返しを避けろ」という原則。
これに違反している例で真っ先に思いつくのは、以下のように全く同じコードをコピペしまくった以下のようなコードだと思う。
※他にも 変数の知識の重複による不整合を防ぎたい(DRY原則とオブジェクト指向) - Memo: で書いた知識の二重化があり変数間の不整合の原因になる
unitPrice * quantity
の計算や、商品ごとの金額の表示部分は繰り返されてしまっている。こういったコードを見ると、関数化・メソッド化をしたり、クラス作成をしたくなる。抽象的なコードを作成しそれを使いまわすことで繰り返しを排除できる。
class Item { constructor(public name: string, public unitPrice: number, public quantity: number) {} } const apple = new Item("Apple", 150, 1); // リンゴを1個 const orange = new Item("Orange", 90, 2); // みかんを2個 const pork = new Item("Pork", 2, 200); // 豚肉200g // それぞれの金額と合計金額を表示する console.log(`${apple.name} x ${apple.quantity} = ${apple.unitPrice * apple.quantity}円`); console.log(`${orange.name} x ${orange.quantity} = ${orange.unitPrice * orange.quantity}円`); console.log(`${pork.name} x ${pork.quantity} = ${pork.unitPrice * pork.quantity}円`); const totalPrice = apple.unitPrice * apple.quantity + orange.unitPrice * orange.quantity + pork.unitPrice * pork.quantity; console.log(`合計 ${totalPrice}円`); /* Apple x 1 = 150円 Orange x 2 = 180円 Pork x 200 = 400円 合計 730円 */
例えば、以下のようにリファクタリングできる。unitPrice * quantity
の計算を calcPrice() メソッドで行うようにし、商品ごとの金額表示を printeLine() メソッドで行うようにした。これで各処理は1か所だけになった。ついでにメソッドの呼び出しを1か所にするために items という配列で持つようにした。
これで二重化を排除することができた。しかし、これは本当にいいコードだろうか...?
class Item { constructor(public name: string, public unitPrice: number, public quantity: number) {} calcPrice(): number { return this.unitPrice * this.quantity; } printLine(): void { console.log(`${this.name} x ${this.quantity} = ${this.calcPrice()}円`); } } const items = [ new Item("Apple", 150, 1), // リンゴを1個 new Item("Orange", 90, 2), // みかんを2個 new Item("Pork", 2, 200), // 豚肉200g ]; // それぞれの金額と合計金額を表示する for (const item of items) { item.printLine(); } const totalPrice = items.reduce( (totalPrice: number, item: Item) => totalPrice + item.calcPrice(), 0, ); console.log(`合計 ${totalPrice}円`);
仕様に変更があるとどうなるか?
ソフトウェアにおいて仕様が変わることはよくあることだ。もし、さっきの二重化を排除したコードで以下のような仕様にしてほしいといわれたとする。
- 商品と合計金額の後ろに (税込み)と追加してほしい
これは二重化をを排除しているので簡単だ。 Item の printLine と、一番最後の合計金額を表示している部分の変更だけで済む。
では、次のような仕様変更があった場合どうすればいいだろうか?
- 肉の商品は表示には「g」単位を入れてほしい
これは少し難しいと思う。メソッドを共通化したため calcPrice の中で「果物」なのか「肉」なのか判別が必要になってしまう。
とりあえず ItemTypes
を追加して、メソッドの中で分岐を作成することにした...。
enum ItemTypes { Meat = "Meat", Fruit = "Fruit", } class Item { constructor( public name: string, public type: ItemTypes, public unitPrice: number, public quantity: number, ) {} calcPrice(): number { return this.unitPrice * this.quantity; } printLine(): void { let unit; if (this.type === ItemTypes.Meat) { // 👈 肉専用の分岐を追加した unit = "g"; } console.log(`${this.name} x ${this.quantity}${unit} = ${this.calcPrice()}円`); } } const items = [ new Item("Apple", ItemTypes.Fruit, 150, 1), // リンゴを1個 new Item("Orange", ItemTypes.Fruit, 90, 2), // みかんを2個 new Item("Pork", ItemTypes.Meat, 2, 200), // 豚肉200g ]; // それぞれの金額と合計金額を表示する for (const item of items) { item.printLine(); } const totalPrice = items.reduce( (totalPrice: number, item: Item) => totalPrice + item.calcPrice(), 0, ); console.log(`合計 ${totalPrice}円`);
今はまだマシかもしれないが、このような変更が繰り返されていくと if の大量発生によりクラスが肥大化することは想像できる。2か月後以下のように変更して欲しいと言われるかもしれない。
- 肉の単価は 100g あたりにしてほしい
- 肉を 1kg 以上買ったときに、10% 値引きしてほしい
- 果物は最低3つ以上でないと購入できないようにしてほしい
更に、この変更で良くないのは今回関係ない「リンゴ」と「オレンジ」も変更する必要があり、壊れてしまう可能性があることだ。
そして実は先ほどのコードでは、「リンゴ」と「オレンジ」の表示が壊れてしまっている!(printLine で unit = "" と初期化する必要があった)。
Apple x 1undefined = 150円 Orange x 2undefined = 180円 Pork x 200g = 400円 合計 730円
記事の冒頭で「良い設計は悪い設計よりも変更しやすい」と引用した。
「肉」についての仕様を変更しただけで、その変更とは関係ない「果物」が壊れてしまうというのは変更しにくい、悪い設計といえると思う。
この例のようにコードの二重化を排除したとき、逆に変更に弱くなってしまうことがある。これは二重化をなくしたときの副作用として依存関係が生まれるからだと思う。「肉」の処理を変更するために Item.printLine() を修正したが。以下のような依存関係があることで Item.printLine() に依存していた「果物」まで影響を受けてしまったことが原因である。
直交性
ある変更をしたときに 関係ない部分に影響を及ぼしてしまう可能性がある 状態を「直交していない」状態であると達人プログラマーでは述べられている。逆に、関係ない部分のそれぞれが独立していたり、依存が少なかったり、疎結合になっている状態が直交している良い状態である。
個人的に、二重化を排除することよりも直交させることが難しいと思う。依存関係というのは目に見えにくいこと。そして二重化を排除すると必ず依存ができるが、どのように依存させるかの判断がむずかしいからだ。
前の例では、商品といっても「肉」と「果物」で扱いが異なりそうであることが分かった。この二つを別のクラスとして分離してみる。
abstract class Item { abstract name: string; abstract unitPrice: number; abstract quantity: number; calcPrice(): number { return this.unitPrice * this.quantity; } printLine(): void { console.log(`${this.name} x ${this.quantity} = ${this.calcPrice()}円`); } } // 👇 一つだった Item クラスを Fruit と Meat に分離した。 class Fruit extends Item { constructor(public name: string, public unitPrice: number, public quantity: number) { super(); } } class Meat extends Item { constructor(public name: string, public unitPrice: number, public quantity: number) { super(); } } const items: Item[] = [ new Fruit("Apple", 150, 1), // リンゴを1個 new Fruit("Orange", 90, 2), // みかんを2個 new Meat("Pork", 2, 200), // 豚肉200g ]; // それぞれの金額と合計金額を表示する for (const item of items) { item.printLine(); } const totalPrice = items.reduce( (totalPrice: number, item: Item) => totalPrice + item.calcPrice(), 0, ); console.log(`合計 ${totalPrice}円`);
上のコードは「DRY 原則」の一番最後にあるものと動きは同じだが、‘Item‘ は抽象クラスとして定義して、それを Meat と Fruit が実装している。
さて、このコードに同じ仕様変更の依頼が来たとする。
- 肉の商品は表示には「g」単位を入れてほしい
今回は Meat クラスを変更するだけでよい。かなりシンプルだし、Fruit や Item は 100% 壊れないと自信を持てる。
class Meat extends Item { constructor(public name: string, public unitPrice: number, public quantity: number) { super(); } printLine(): void { // 👇 ここに追加するだけ console.log(`${this.name} x ${this.quantity}g = ${this.calcPrice()}円`); } }
今回の依存関係は以下のようになっている。Meat に依存しているのは Pork だけなので他に影響を与えず変更することができた。 このように依存関係を制御することで、コードの二重化を防ぎつつ変更しやすさを損なわないようにできる。
更に、この3つの仕様変更を行う時どうなるかを試してみる。
- 肉の単価は 100g あたりにしてほしい
- 肉を 1kg 以上買ったときに、10% 値引きしてほしい
- 果物は最低3つ以上でないと購入できないようにしてほしい
以下のような変更だけで済んだ。比較的にシンプルに追加できたと思う。「DRY 原則」の最後のコードではこれほどシンプルに追加するのは難しいはずだ。
もし「XX は △△ じゃないと購入できない」や「○○ を □□ 以上購入で XX % 値引き」というパターンがよく出てくるようであれば、それを購入ポリシーや割引ポリシーとして Fruit や Meat クラスから分離すると良さそう(ストラテジーパターンというデザインパターン)。
更に表示用の printLine
も ItemPrinter クラスとして分離しておいた方がいいかもしれない。商品の表示の変更は料金計算などと比べると、変更頻度が高いことが予想できる。実際の表示先は紙のレシートになるかもしれないが、そのときに他を壊してしまうためだ。
abstract class Item { abstract name: string; abstract unitPrice: number; abstract quantity: number; calcPrice(): number { return this.unitPrice * this.quantity; } printLine(): void { console.log(`${this.name} x ${this.quantity} = ${this.calcPrice()}円`); } } class Fruit extends Item { constructor(public name: string, public unitPrice: number, public quantity: number) { super(); // 👇 果物は最低3つ以上でないと購入できない if (!this.isBuyable()) { throw new Error("購入できません!"); } } private isBuyable(): boolean { return this.quantity >= 3; } } class Meat extends Item { constructor(public name: string, public unitPrice: number, public quantity: number) { super(); } calcPrice(): number { const pricePer100g = this.unitPrice / 100; // 👈 肉の単価は 100g あたり const grams = this.quantity; const price = pricePer100g * grams; return this.discount(price); } private discount(price: number): number { // 👇 肉を 1kg 以上買ったときに、10% 値引き const discountRate = 0.1; const minDiscountableGrams = 1000; if (this.quantity >= minDiscountableGrams) { return price * (1 - discountRate); } return price; } printLine(): void { console.log(`${this.name} x ${this.quantity}g = ${this.calcPrice()}円`); } } const items: Item[] = [ new Fruit("Apple", 150, 4), // リンゴを4個 new Fruit("Orange", 90, 6), // みかんを6個 new Meat("Pork", 200, 1200), // 200円/100g の豚肉を1.2kg ]; // それぞれの金額と合計金額を表示する for (const item of items) { item.printLine(); } const totalPrice = items.reduce( (totalPrice: number, item: Item) => totalPrice + item.calcPrice(), 0, ); console.log(`合計 ${totalPrice}円`);
感想
このように二重化を防ぎつつ依存関係を制御することで変更しやすいコードにできる。これは SOLID 原則やデザインパターン、クリーンアーキテクチャ(特に安定依存の原則)の考え方が参考にできそうだなと思った。また、そもそもどのようなドメインをコードとして記述しているのかが分からないと、どこをどのようにするべきかの判断が難しかったりするので、やっぱりドメイン知識も重要になってきそう。
他にもこの辺の本は結構勉強になった。オブジェクト指向プログラミングの全体像的な部分では『Clean Architecture』が、具体的な活用方法については『 Java言語で学ぶデザインパターン入門』が良かった。