TypeScript にはabstract
キーワードという機能があり、これを使うことで抽象クラスや抽象メンバを宣言することができる。
この機能を上手く使ってクラスを作ることで、可読性が高く、変更にも強いコードを設計できる。
abstract
キーワードを使ったクラスやメンバの詳細な挙動については、以下の記事に書いた。
本記事では、架空のオンラインショップの開発現場を通して、具体的にabstract
をどう使えばよいのか、どのようなケースで役に立つのか、といったことを説明していく。
動作確認はv3.8.2
で行っている。
間違った共通化
このオンラインショップは書籍の販売を行っており、商品である書籍はProduct
というクラスで表現している。
class Product { productCode: string; title: string; author: string; unitPrice: number; private static CONSUMPTION_TAX_RATE = 10; constructor(arg: Omit<Product, 'getTaxIncludedPrice'>) { this.productCode = arg.productCode; this.title = arg.title; this.author = arg.author; this.unitPrice = arg.unitPrice; } getTaxIncludedPrice() { return Math.floor( this.unitPrice * ((100 + Product.CONSUMPTION_TAX_RATE) / 100) ); } }
このように書くことで、全ての書籍に共通するメソッドであるgetTaxIncludedPrice
を一箇所で定義すれば済むようになる。
このメソッドの内容を変更したり、新しく別のメソッドを追加したりする際も、Product
クラスにだけ手を加えればよいことになる。
ちなみに、小数点以下の計算を正確に行うためには様々な注意点があるのだが、本記事の主題ではないので割愛し、今回は簡単な実装で済ませている。
このコードに特に問題はなく、正しく動く。
const productList = [ new Product({ productCode: '0001', title: 'Just for Fun', author: 'Linus Torvalds', unitPrice: 1500, }), new Product({ productCode: '0002', title: 'Nineteen Eighty-Four', author: 'George Orwell', unitPrice: 1000, }), ]; productList.forEach(p => { console.log(p.getTaxIncludedPrice()); }); // 1650 // 1100
しかしビジネスモデルに変更があり、書籍以外の商材も扱うことになった。
まずは缶ビールや缶コーヒーなどの飲料の取り扱いを開始するが、他の商材も今後追加していく可能性があるらしい。
飲料にauthor
やtitle
が存在しないのは自明である。
一方で、アルコールかどうかで消費税率が変わるため、新たに、アルコールか否かを表現するデータを持つ必要がある。そしてその情報は、書籍には存在しない。
それぞれの商材にどのようなメンバが必要なのかを精査し、まとめたのが、以下の表である。
書籍 | 飲料 | |
---|---|---|
productCode | ◯ | ◯ |
unitPrice | ◯ | ◯ |
getTaxIncludedPrice | ◯ | ◯ |
title | ◯ | × |
author | ◯ | × |
name | × | ◯ |
isAlcohol | × | ◯ |
こうしてみると、共通するメンバがそれなりにある。そして今後も、全ての商品に共通する性質や機能を追加する可能性は、十分にある。また、書籍と飲料以外の商材の追加も検討中だ。
そう考えると、商材毎に完全に別々のクラスに分けてしまうのは、筋が悪そうである。
そこで、Product
にtype
という属性を持たせて、Product
が複数の商材を表現できるようにした。書籍の場合はtype
をbook
に、飲料の場合はtype
をdrink
にする。
これなら、共通の処理を一箇所にまとめつつ、複数の商材を表現できるようになる。
そうして出来上がったProduct
クラスが、こちら。
class Product { type: 'book' | 'drink'; productCode: string; unitPrice: number; title?: string; author?: string; name?: string; isAlcohol?: boolean; private static DEFAULT_CONSUMPTION_TAX_RATE = 10; private static REDUCED_CONSUMPTION_TAX_RATE = 8; constructor(arg: Omit<Product, 'getTaxIncludedPrice'>) { this.type = arg.type; this.productCode = arg.productCode; this.unitPrice = arg.unitPrice; if (arg.type === 'book') { this.title = arg.title; this.author = arg.author; } if (arg.type === 'drink') { this.name = arg.name; this.isAlcohol = arg.isAlcohol; } } getTaxIncludedPrice() { let taxRate; if (this.type === 'book') { taxRate = Product.DEFAULT_CONSUMPTION_TAX_RATE; } if (this.type === 'drink') { taxRate = this.isAlcohol ? Product.DEFAULT_CONSUMPTION_TAX_RATE : Product.REDUCED_CONSUMPTION_TAX_RATE; } if (!taxRate) throw new Error('taxRate is undefined.'); return Math.floor(this.unitPrice * ((100 + taxRate) / 100)); } }
取り敢えず、動作に問題はない。
税込価格の計算ロジックも正しく動いている。飲料は軽減税率の対象だが、アルコールは対象外となる。
const someBook = new Product({ type: 'book', productCode: '0001', unitPrice: 1500, title: 'Just for Fun', author: 'Linus Torvalds', }); const coffee = new Product({ type: 'drink', productCode: '1001', unitPrice: 200, name: 'Coffee', isAlcohol: false, }); const beer = new Product({ type: 'drink', productCode: '1002', unitPrice: 300, name: 'Beer', isAlcohol: true, }); console.log(someBook.getTaxIncludedPrice()); // 1650 console.log(coffee.getTaxIncludedPrice()); // 216 console.log(beer.getTaxIncludedPrice()); // 330
しかしこのコードは、少なくとも 3 つの問題を抱えている。
まず、生成されたインスタンスがどのようなメンバを持つのかが、定かではない。
同じProduct
から生成されたインスタンスでも、どのようなメンバを持つのかは、生成時に渡した引数によって変化する。
そのため、実際に中身にアクセスするまで、そのメンバが存在するかどうか分からない。
型推論の結果もそうなる。
// const beer: Product const beer = new Product({ type: 'drink', productCode: '1002', unitPrice: 300, name: 'Beer', isAlcohol: true, }); // const isAlcohol: boolean | undefined const {isAlcohol} = beer;
isAlcohol
はオプショナルなメンバであるため、存在するかもしれないし、undefined
かもしれない。
後からこのプロジェクトに参加した開発者は特に、挙動の把握に苦労することになる。
生成されたインスタンスがどのメンバを持っているのかは、Product
のコードを読んで調べなければ分からない。
その際に、2 つめの問題が影響してくる。
単純に、Product
の実装が複雑で、可読性が低いという問題。
Product
というひとつのクラスで複数種の商材を表現するために、type
による条件分岐が行われている。
この程度の規模ならまだよいかもしれないが、今後商材が増えれば、その度に、条件分岐は増えていくことになる。
最後の問題は、isAlcohol
などをオプショナルにしたことで、型システムの恩恵を受けられなくなってしまったこと。
例えば、飲料にはisAlcohol
が必須なのだが、忘れてしまってもエラーにならない。
// isAlcohol を忘れているがエラーにならない const highball = new Product({ type: 'drink', productCode: '1003', unitPrice: 300, name: 'Highball', }); // 軽減税率が適用されて 324 になってしまっているが、型システム上はエラーにならない console.log(highball.getTaxIncludedPrice()); // 324
逆に不要な情報を渡しても、それもエラーにはならない。
const highball = new Product({ type: 'drink', productCode: '1003', unitPrice: 300, name: 'Highball', author: 'someone', // エラーにならない });
継承を使った共通化
こうなってしまった原因は、異なる商材をひとつのクラスにまとめてしまったことにある。
共通するものをまとめてしまおう、という考え方は、ある程度は正しい。
共通するメンバがいくつかあるから、ではなく、書籍と飲料は別々の商材ではあるが、どちらも同じ「商品」であるためだ。
全く別の存在ではなく、「性質が異なる 2 種類のProduct
」と考えることができる。
「インターフェイスが似ているだけで意味としては異なるもの」をまとめてしまうのはアンチパターンだが、この記事の主題から逸れるので、今回はそれについては扱わない。
書籍と飲料のようなケースでは、継承を使うことで、綺麗に書ける。
もちろん継承が唯一の選択肢というわけではないのだが、仕様の全体像や今後の事業計画を総合的に考えた結果、今回は継承を使うことにした。
まず、Product
を親クラスとして設計し直して、書籍と飲料に共通する性質や機能を書く。
そして子クラスとしてBook
とDrink
を作り、商材毎に異なる性質や機能はそちらに書く。
この方針に基づいて、どのメンバをどのクラスに実装するか、検討した。
メンバ名 | 実装するクラス |
---|---|
productCode | Product |
unitPrice | Product |
title | Book |
author | Book |
name | Drink |
isAlcohol | Drink |
getTaxIncludedPrice | ? |
他のメンバは簡単だが、getTaxIncludedPrice
が問題。
全ての商品が持っているべきメソッドなので、Product
に書くべきかもしれない。だが、そのロジックは商材毎に微妙に異なる。
そこで、getTaxIncludedPrice
は各商材に持たせ、ロジックの共通部分を抽出してそれをProduct
に書くようにした。
具体的には、Product
の実装は以下のようになる。
class Product { productCode: string; unitPrice: number; protected static DEFAULT_CONSUMPTION_TAX_RATE = 10; protected static REDUCED_CONSUMPTION_TAX_RATE = 8; constructor(arg: Omit<Product, 'calculateTaxIncludedPrice'>) { this.productCode = arg.productCode; this.unitPrice = arg.unitPrice; } protected calculateTaxIncludedPrice(taxRate: number) { return Math.floor(this.unitPrice * ((100 + taxRate) / 100)); } }
商材毎に異なるのは消費税率の部分なので、その部分は、Book
やDrink
に書く。そしてそれを使ったロジックの大部分についてはcalculateTaxIncludedPrice
としてProduct
に書く。
Book
とDrink
はgetTaxIncludedPrice
を実装し、そのなかでcalculateTaxIncludedPrice
を呼び出して消費税率を渡す。
以下が、Book
とDrink
の実装。
class Book extends Product { title: string; author: string; constructor(arg: Omit<Book, 'getTaxIncludedPrice'>) { const {productCode, unitPrice, title, author} = arg; super({productCode, unitPrice}); this.title = title; this.author = author; } getTaxIncludedPrice() { return super.calculateTaxIncludedPrice( Product.DEFAULT_CONSUMPTION_TAX_RATE ); } } class Drink extends Product { name: string; isAlcohol: boolean; constructor(arg: Omit<Drink, 'getTaxIncludedPrice'>) { const {productCode, unitPrice, name, isAlcohol} = arg; super({productCode, unitPrice}); this.name = name; this.isAlcohol = isAlcohol; } getTaxIncludedPrice() { const taxRate = this.isAlcohol ? Product.DEFAULT_CONSUMPTION_TAX_RATE : Product.REDUCED_CONSUMPTION_TAX_RATE; return super.calculateTaxIncludedPrice(taxRate); } }
処理の流れが理解しやすくなっており、動作も問題ない。
const someBook = new Book({ productCode: '0001', unitPrice: 1500, title: 'Just for Fun', author: 'Linus Torvalds', }); const coffee = new Drink({ productCode: '1001', unitPrice: 200, name: 'Coffee', isAlcohol: false, }); const beer = new Drink({ productCode: '1002', unitPrice: 300, name: 'Beer', isAlcohol: true, }); console.log(someBook.getTaxIncludedPrice()); // 1650 console.log(coffee.getTaxIncludedPrice()); // 216 console.log(beer.getTaxIncludedPrice()); // 330
生成されたインスタンスがどのようなメンバを持つのか、自明になった。
インスタンス生成時の引数によってメンバの有無が変化することはなく、同じクラスから生成されたインスタンスなら必ず同じメンバを持つ。
型推論も行われる。
// const beer: Drink const beer = new Drink({ productCode: '1002', unitPrice: 300, name: 'Beer', isAlcohol: true, }); // const isAlcohol: boolean const {isAlcohol} = beer;
インスタンス生成時の入力ミスも、TypeScript が検知してくれる。
// isAlcohol がないので Error const beer = new Drink({ productCode: '1002', unitPrice: 300, name: 'Beer', });
const beer = new Drink({ productCode: '1002', unitPrice: 300, name: 'Beer', isAlcohol: true, author: 'someone', // author を渡しているので Error });
抽象クラスと具象クラス
新しく書き直されたProduct
は、全ての商品に共通する性質や機能をまとめたものであり、何か具体的な商品を表しているわけではない。非常に抽象的な存在である。
そのため、Product
のインスタンスが作られることはない。Product
は、それを継承した子クラスを作るためだけに存在すると言える。
このようなクラスのことを、抽象クラスという。
それに対して、抽象クラスを継承し、具体的な商品を表現するために作られたBook
やDrink
のようなクラスを、具象クラスという。
そして TypeScript は、abstract
キーワードを使って、抽象クラスであるということを明示的に宣言することができる。
abstract
をつけたクラスは抽象クラスになり、インスタンスを作ろうとするとエラーになる。
abstract class Product { productCode: string; unitPrice: number; protected static DEFAULT_CONSUMPTION_TAX_RATE = 10; protected static REDUCED_CONSUMPTION_TAX_RATE = 8; constructor(arg: Omit<Product, 'calculateTaxIncludedPrice'>) { this.productCode = arg.productCode; this.unitPrice = arg.unitPrice; } protected calculateTaxIncludedPrice(taxRate: number) { return Math.floor(this.unitPrice * ((100 + taxRate) / 100)); } } // Cannot create an instance of an abstract class. const someProduct = new Product({ productCode: '9001', unitPrice: 1000, });
この機能によって、誤って抽象クラスのインスタンスを作ってしまうことを防止できる。
さらに、abstract
キーワードをつけることで、コードがドキュメントとして機能するようになる。
このプロジェクトに新しく入った開発者でも、「Product
は抽象クラスなんだな」ということをすぐに理解できる。
抽象メンバ
開発は順調に進んでいたが、またも仕様の追加が発生した。書籍と飲料の他に、カレンダーも扱うことになったという。
だが商材の追加は予想されていたことであり、だからこそProduct
クラスを作ったのだ。
Product
を継承することで、商品として必ず持つべき振る舞いと、カレンダーに固有の振る舞い、その両方を持ったクラスを簡単に作れる。
継承を使うことで、カレンダーを表現するクラスにはカレンダーに固有の振る舞いだけを書けばよく、商品全般に関する振る舞いを改めて記述せずに済む。
カレンダーが独自に持つべき振る舞いは何なのかを検討した結果、当面は「何年のカレンダーか」が分かればよいということになった。
そこで、Product
を継承し、独自にyear
メンバを持つCalendar
クラスを実装した。
class Calendar extends Product { year: number; constructor(arg: Omit<Calendar, 'getTaxIncludedPrice'>) { const {productCode, unitPrice, year} = arg; super({productCode, unitPrice}); this.year = year; } } const calendar = new Calendar({ productCode: '2001', unitPrice: 800, year: 2020, }); console.log(calendar.year); // 2020
一見問題なく動いているが、Calendar
にgetTaxIncludedPrice
を忘れてしまっている。
しかしこのコードはエラーにはならない。
なぜなら、「Product
を継承した具象クラスはgetTaxIncludedPrice
を実装しなければならない」とは、どこにも定義されていないからだ。
この規模のコードなら、getTaxIncludedPrice
の実装を忘れてしまう可能性は低いかもしれない。
だが、実装が不可欠なメンバの数が増えたり、たくさんの具象クラスを書くようになったりすれば、ミスをすることは十分にあり得る。
そもそも、「getTaxIncludedPrice
が必須である」という情報は、開発者の頭のなかにしかない。これまでの開発の経緯を把握している開発者でないと、知り得ない情報だ。
getTaxIncludedPrice
は全ての具象クラスが実装すべきものなのか、それともBook
とDrink
に必要だっただけで必須ではないのか、現状の実装では判断がつかない。
整備された仕様書が存在すればよいが、そうでない場合、周囲の開発者に尋ねたり、コード全体の流れを追って調査したりしないといけない。
getTaxIncludedPrice
は、抽象クラスであるProduct
自体には実装しないが、Product
を継承した具象クラスには必ず実装しないといけない。
このようなメンバを、抽象メンバという。
そして TypeScript では、抽象メンバの表現も可能になっている。
抽象メンバの表現にも、abstract
キーワードを使う。
下記のgetTaxIncludedPrice
は、「引数は受け取らず数値を返す抽象メンバ」として定義されている。
constructor
の引数の型情報にgetTaxIncludedPrice
を追記するのも忘れないようにする。
abstract class Product { productCode: string; unitPrice: number; protected static DEFAULT_CONSUMPTION_TAX_RATE = 10; protected static REDUCED_CONSUMPTION_TAX_RATE = 8; constructor( arg: Omit<Product, 'getTaxIncludedPrice' | 'calculateTaxIncludedPrice'> ) { this.productCode = arg.productCode; this.unitPrice = arg.unitPrice; } abstract getTaxIncludedPrice(): number; protected calculateTaxIncludedPrice(taxRate: number) { return Math.floor(this.unitPrice * ((100 + taxRate) / 100)); } }
これで、具象クラスにgetTaxIncludedPrice
を実装することが必須となり、忘れたり、型が間違っていたりすると、エラーを出すようになった。
// Non-abstract class 'Calendar' does not implement inherited abstract member 'getTaxIncludedPrice' from class 'Product'. class Calendar extends Product { year: number; constructor(arg: Omit<Calendar, 'getTaxIncludedPrice'>) { const {productCode, unitPrice, year} = arg; super({productCode, unitPrice}); this.year = year; } }
Product
にabstract
をつけたときと同様にドキュメントとしての効果もあり、Product
の実装を見るだけで、具象クラスには必ずgetTaxIncludedPrice
メンバを実装しないといけないということが分かる。
これで、Calendar
の実装も無事に終わった。
class Calendar extends Product { year: number; constructor(arg: Omit<Calendar, 'getTaxIncludedPrice'>) { const {productCode, unitPrice, year} = arg; super({productCode, unitPrice}); this.year = year; } getTaxIncludedPrice() { return super.calculateTaxIncludedPrice( Product.DEFAULT_CONSUMPTION_TAX_RATE ); } } const calendar = new Calendar({ productCode: '2001', unitPrice: 800, year: 2020, }); console.log(calendar.getTaxIncludedPrice()); // 880
抽象クラスと具象クラスの役割分担を見極める
これで盤石と思われたが、getTaxIncludedPrice
の仕様変更をキッカケに、この実装にはまだ改善の余地があることが発覚した。
getTaxIncludedPrice
の引数として商品の個数を渡して、その個数における税込価格を返すことになった。
引数を渡さなかった場合は、これまで通り 1 個あたり税込価格を返す。
つまり、以下のように動作することが求められる。
const coffee = new Drink({ productCode: '1001', unitPrice: 200, name: 'Coffee', isAlcohol: false, }); console.log(coffee.getTaxIncludedPrice()); // 216 console.log(coffee.getTaxIncludedPrice(1)); // 216 console.log(coffee.getTaxIncludedPrice(2)); // 432
「getTaxIncludedPrice
のインターフェイスが変わるときに何が起こるのか」をシンプルなコードで表現したくて、このような例にした。
getTaxIncludedPrice
にこの機能をもたせることの是非は、今回は問わない。
だが、getTaxIncludedPrice
に渡す引数が変わったり、返り値の構造が変わったりすることは、現実的に十分にあり得る。
変更自体は、大して難しくない。
getTaxIncludedPrice
の引数としてquantity
を受け取るようにして、そのデフォルト値を1
にする。
そして、calculateTaxIncludedPrice
の返り値にquantity
を掛ければよい。
// Drink の場合 getTaxIncludedPrice(quantity = 1) { const taxRate = this.isAlcohol ? Product.DEFAULT_CONSUMPTION_TAX_RATE : Product.REDUCED_CONSUMPTION_TAX_RATE; return super.calculateTaxIncludedPrice(taxRate) * quantity; }
最後に、Product
でのgetTaxIncludedPrice
の型定義にも、引数を追加しておく。
abstract getTaxIncludedPrice(quantity?: number): number;
これで完了。複雑なことは何もない。
問題は、全ての具象クラスのgetTaxIncludedPrice
を変更しなければならないことだ。
もっと具象クラスが増えていたタイミングで同様の変更が発生したら、具象クラスの数だけ手間が掛かる。変更の内容が複雑なものだった場合、相当な手間になってしまう。
getTaxIncludedPrice
をそれぞれの具象クラスに実装してしまったために、このような状況になってしまった。
getTaxIncludedPrice
の実装が一箇所にまとまっていれば、インターフェイスが変わることになっても、変更箇所は一箇所で済む。
getTaxIncludedPrice
を抽象クラスではなく具象クラスに担当させたのは、設計ミスだった。
全ての商品に共通する振る舞いは抽象クラスに書く、という原則から考えても、Product
に書くべきだ。
確かに、税率に関するロジックは商材毎に異なる。しかし、その異なる部分こそを具象クラスに切り出すべきであり、getTaxIncludedPrice
自体はProduct
に持たせたほうがよい。
quantity
オプションのことは一旦忘れ、getTaxIncludedPrice
をProduct
に移す。
代わってconsumptionTaxRate
という抽象メンバを用意して、それをgetTaxIncludedPrice
の中から参照する。
abstract class Product { productCode: string; unitPrice: number; protected static DEFAULT_CONSUMPTION_TAX_RATE = 10; protected static REDUCED_CONSUMPTION_TAX_RATE = 8; abstract consumptionTaxRate: number; constructor( arg: Omit<Product, 'consumptionTaxRate' | 'getTaxIncludedPrice'> ) { this.productCode = arg.productCode; this.unitPrice = arg.unitPrice; } getTaxIncludedPrice() { return Math.floor(this.unitPrice * ((100 + this.consumptionTaxRate) / 100)); } } class Drink extends Product { name: string; isAlcohol: boolean; consumptionTaxRate: number; constructor(arg: Omit<Drink, 'getTaxIncludedPrice' | 'consumptionTaxRate'>) { const {productCode, unitPrice, name, isAlcohol} = arg; super({productCode, unitPrice}); this.name = name; this.isAlcohol = isAlcohol; this.consumptionTaxRate = this.isAlcohol ? Product.DEFAULT_CONSUMPTION_TAX_RATE : Product.REDUCED_CONSUMPTION_TAX_RATE; } } // Book と Calendar も同様に、consumptionTaxRate を実装し getTaxIncludedPrice を削除すればよい // consumptionTaxRate は抽象メンバなので、実装し忘れても TypeScript がそのことを検知してくれる
これで、商材毎に異なる部分をconsumptionTaxRate
として具象クラスに切り出し、getTaxIncludedPrice
そのものはProduct
に書くことができた。
これなら、getTaxIncludedPrice
の仕様変更が発生しても、Product
だけを編集すれば済む。
// Product の getTaxIncludedPrice を書き換える getTaxIncludedPrice(quantity = 1) { return ( Math.floor(this.unitPrice * ((100 + this.consumptionTaxRate) / 100)) * quantity ); }
具象クラスの抽象クラスへの依存を避ける
先程の設計の見直しによって、getTaxIncludedPrice
をProduct
に移した。
このメソッドは、具象クラスがconsumptionTaxRate
メンバを持っていることを知っている。そしてこのメンバが各商品の消費税率を示していることも、知っている。
一方で具象クラス側は、抽象クラスがgetTaxIncludedPrice
のなかでconsumptionTaxRate
を使っていることを知らない。consumptionTaxRate
が抽象クラスでどのように使われているのか、全く関知しない。
このように、具象クラスはできるだけ、抽象クラスに対する知識を持たないようにするのが望ましい。
抽象クラスがどのような実装になっているのか知るべきではない。
具象クラスが抽象クラスについて知っていればいるほど、依存関係が深くなる。そうなると、抽象クラスの変更の影響が、具象クラスにも波及しやすくなってしまう。
実は、まさにそのような状況になってしまっている箇所が既に存在する。それが、具象クラスのconstructor
メソッドである。
例として、Book
のconstructor
を見てみる。
constructor(arg: Omit<Book, 'getTaxIncludedPrice' | 'consumptionTaxRate'>) { const {productCode, unitPrice, title, author} = arg; super({productCode, unitPrice}); this.title = title; this.author = author; this.consumptionTaxRate = Product.DEFAULT_CONSUMPTION_TAX_RATE; }
super
の箇所が問題である。
このコードは、親クラス、つまりProduct
のconstructor
が{productCode, unitPrice}
を受け取ることを知ってしまっている。Product
の知識を持ってしまっている。
このせいで、Product
のconstructor
に変更が発生した場合、具象クラスもその影響を受けてしまう。
例えば、在庫数を表現するために、Product
に新たにstock
メンバを持たせることになったとする。
abstract class Product { productCode: string; unitPrice: number; + stock: number; protected static DEFAULT_CONSUMPTION_TAX_RATE = 10; protected static REDUCED_CONSUMPTION_TAX_RATE = 8; ) { this.productCode = arg.productCode; this.unitPrice = arg.unitPrice; + this.stock = arg.stock; }
この変更によって、全ての具象クラスのconstructor
でエラーが発生する。
super
にはproductCode
、unitPrice
、stock
の 3 つを渡すべきなのに、stock
を渡していないからだ。
そのため、全ての具象クラスで以下の変更作業を行わなければならない。
constructor(arg: Omit<Book, 'getTaxIncludedPrice' | 'consumptionTaxRate'>) { - const {productCode, unitPrice, title, author} = arg; - super({productCode, unitPrice}); + const {productCode, unitPrice, stock, title, author} = arg; + super({productCode, unitPrice, stock}); this.title = title; this.author = author;
これが、具象クラスが抽象クラスに依存することで生まれる弊害である。抽象クラスで変更が発生する度に、具象クラスもそれに振り回されてしまう。
この状況を改善し、依存を弱めたのが以下のコードである。
Book
を例にしたが、他の具象クラスも同じ要領で対応できる。
class Book extends Product { title: string; author: string; consumptionTaxRate: number; constructor( productProperty: Omit< Product, 'consumptionTaxRate' | 'getTaxIncludedPrice' >, bookProperty: Omit<Book, keyof Product> ) { super(productProperty); this.title = bookProperty.title; this.author = bookProperty.author; this.consumptionTaxRate = Product.DEFAULT_CONSUMPTION_TAX_RATE; } } const book = new Book( { productCode: '0001', unitPrice: 1500, }, { title: 'Just for Fun', author: 'Linus Torvalds', } );
constructor
の引数を 2 つに分け、第一引数で抽象クラス用のデータを、第二引数で具象クラス用のデータを受け取るようにした。
この設計の利点は、super
にはproductProperty
をそのまま渡せばよく、その中身については関知せずに済むことだ。
そのため、先程のstock
の例のように抽象クラスにメンバを増やしても、具象クラスには一切手を加えずに済む。
このように、具象クラスが抽象クラスの知識を持たないようにすることで、抽象クラスの変更の影響が具象クラスに波及しにくくなる。
まとめ
こうして、共通部分をProduct
クラスに共通化しつつ、変更にも強いクラスを設計できた。
全ての商品に共通する性質や機能を抽象クラスにまとめるようにしているため、stock
のように全ての商品に対して新しくメンバを追加するようなケースでも、それを抽象クラスに追加するだけで対応できる。
そして適切に設計することで、抽象クラスを編集しても、その影響が具象クラスに波及することはない。getTaxIncludedPrice
やstock
の例で見た通りである。
逆に、具象クラスで変更が発生しても、その影響が抽象クラスや他の具象クラスに及ぶことはない。
例えば、Calendar
クラスに、表紙のデザインに関する情報を持つためのcover
メンバを追加したとする。この変更の影響範囲はCalendar
だけに留まり、Product
クラスや他の具象クラスには影響しない。
TypeScript では、abstract
キーワードを使うことで、抽象クラスや抽象メンバであることを明示的に宣言できる。そのため、抽象クラスと具象クラスを使った設計を JavaScript よりも行いやすいという利点がある。
今回説明した設計が常に正しいというわけでは勿論ないが、抽象クラスや抽象メンバの使い方や利点は、イメージできたと思う。
参考資料
この記事の内容は、『オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方』の第 6 章をベースにしている。
この書籍の概要は以下のリポジトリにまとめており、今回もこれを読み返して参考にした。