はじめに
最近、「良いコード/悪いコードで学ぶ設計入門」を読んでいるので、本書の中で自分が疑問に感じたポイントをQ&A形式で振り返ってみたいと思います。
この記事では、各セクションの簡単な説明の後に、[Q]で疑問に感じたこと、[A]で調べてわかったこと、そして[NOTE]で覚えておきたいことをまとめています。
なお、サンプルコードにJavaを使用していますが、言語を問わず広く有効な設計手法なので、他言語でも応用できる内容になっています。
第1~4章については、すでに別の記事にまとめているので、こちらもぜひご覧ください!
それでは、第5〜8章を振り返りながら、一緒に「良いコード/悪いコード」について学んでいきましょう!
初期化ロジックの分散
以下のコードは、ギフトポイントに関する処理をカプセル化したクラスの例です。
class GiftPoint {
private static final int MIN_POINT = 0;
final int value;
GiftPoint(final int point) {
// 中略
value = point;
}
// 省略
}
一見よさそうですが、コンストラクタが公開されているため、次のようにさまざまな箇所から任意の値でインスタンス化されてしまいます。
GiftPoint standardMemberShipPoint = new GiftPoint(3000);
GiftPoint premiumMemberShipPoint = new GiftPoint(10000);
こうなると、入会ポイントの値を変更したい場合に、ソースコードの全ての該当箇所を探して修正しなければなりません。
こうした初期化ロジックの分散を防ぐためには、コンストラクタを private
にして、代わりにインスタンスの生成を専門に行うファクトリメソッドを目的別に用意しましょう。
class GiftPoint {
private static final int MIN_POINT = 0;
private static final int STANDARD_MEMBERSHIP_POINT = 3000;
private static final int PREMIUM_MEMBERSHIP_POINT = 10000;
final int value;
// 外部からはインスタンス生成できない。
// クラス内部でのみインスタンス生成できる。
private GiftPoint(final int point) {
// 中略
value = point;
}
/**
* @return 標準会員向け入会ギフトポイント
*/
static GiftPoint forStandardMembership() {
return new GiftPoint(STANDARD_MEMBERSHIP_POINT);
}
/**
* @return プレミアム会員向け入会ギフトポイント
*/
static GiftPoint forPremiumMembership() {
return new GiftPoint(PREMIUM_MEMBERSHIP_POINT);
}
// 省略
}
このようにすることで、新規入会ポイントに関連するロジックが GiftPoint
クラスに集約され、仕様変更の際もこのクラスのみを修正すればよくなります。
- GiftPoint standardMemberShipPoint = new GiftPoint(3000);
- GiftPoint premiumMemberShipPoint = new GiftPoint(10000);
+ GiftPoint standardMemberShipPoint = GiftPoint.forStandardMembership();
+ GiftPoint premiumMemberShipPoint = GiftPoint.forPremiumMembership();
[NOTE]
static
メソッドはインスタンス変数を使えないので、データとデータを操作するロジックがバラバラになってしまい、カプセル化できない構造になりがちです。
しかし、ファクトリメソッドのように「インスタンスを返すだけ」の static
メソッドであれば、生成されたインスタンスにデータとロジックがしっかり閉じ込められているため、設計として問題ありません。
責任が単一になるようクラスを設計する
単一責任原則とは、「クラスが担う責任は、たったひとつに限定すべき」とする設計原則です。
例えば、通常割引用のロジックを夏季割引サービスを担うクラスで流用すると、片方の割引に仕様変更があったときにもう片方も変更されてしまい、不具合の原因になります。
そうした問題を避けるため、通常割引価格と夏季割引価格は、それぞれ個別に責任を負うクラスとして設計します。
class RegularDiscountedPrice {
private static final int MIN_AMOUNT = 0;
private static final int DISCOUNT_AMOUNT = 400;
final int amount;
RegularDiscountedPrice(final RegularPrice price) {
int discountedAmount = price.amount - DISCOUNT_AMOUNT;
if (discountedAmount < MIN_AMOUNT) {
discountedAmount = MIN_AMOUNT;
}
amount = discountedAmount;
}
}
class SummerDiscountedPrice {
private static final int MIN_AMOUNT = 0;
private static final int DISCOUNT_AMOUNT = 300;
final int amount;
SummerDiscountedPrice(final RegularPrice price) {
int discountedAmount = price.amount - DISCOUNT_AMOUNT;
if (discountedAmount < MIN_AMOUNT) {
discountedAmount = MIN_AMOUNT;
}
amount = discountedAmount;
}
}
[Q]
これって DISCOUNT_AMOUNT
以外すべて同じだから、共通化できそうじゃない?
[A]
共通化できそうですが、将来的に「夏季割引価格は定価より5%オフにする」という仕様に変わったとしたら、DISCOUNT_AMOUNT
ではなく DISCOUNT_RATE
のような別のインスタンス変数を持ち、計算ロジックも変わるかもしれません。
同じようなロジック、似ているロジックであっても、目的が違うロジックは共通化してはいけないのです。
継承による関心の混在
継承はかなり注意して扱わないと、すぐに関心が混在します。
書籍の中でも以下のように指摘されています。
まずお伝えしたいのは、継承はよっぽど注意して扱わないと危険、継承は推奨しませんというのが本書のスタンスです。
継承関係にあるクラスどうしでは、スーパークラスに変更が入るとサブクラスに波及しやすく、思わぬバグの原因になります。
スーパークラス依存による混乱を避けるため、継承より委譲を使いましょう。
委譲とは、コンポジション構造にすることです。
利用したいクラスをスーパークラスとして継承するのではなく、private
なインスタンス変数として持ち、呼び出す、という使い方をします。
例えば、以下の FighterPhysicalAttack
クラスは、PhysicalAttack
クラスを継承するのではなく、委譲を使っています。
class FighterPhysicalAttack {
private final PhysicalAttack physicalAttack;
// 省略
int singleAttackDamage() {
return physicalAttack.singleAttackDamage() + 20;
}
int doubleAttackDamage() {
return physicalAttack.doubleAttackDamage() + 10;
}
}
[NOTE]
継承する前に「本当に親クラスのすべての振る舞いが、子クラスでも自然に成り立つか?」を考えましょう。
1つでも怪しいなら、継承せずにコンポジションを検討するべきです。
interface
設計の考え方を身につけよう
条件に応じた処理の切り替えに switch
文を多用すると、切り替えたいものが多くなるにつれて、ロジックがどんどん肥大化してしまいます。
以下は、会員ランクに応じて年間ポイントボーナスを計算する例です。
public ShoppingPoint yearlyPointBonus(final CustomerRank customerRank, final PurchaseHistory history) {
int pointBonus = 0;
switch (customerRank) {
case normal:
break;
case silver:
if (history.yearlyAmount() >= 100000) {
pointBonus = (int) (history.yearlyAmount() * 0.01);
}
break;
case gold:
pointBonus = 1000 + (int) (history.yearlyAmount() * 0.02);
break;
}
return new ShoppingPoint(pointBonus);
}
このような条件分岐は、拡張に弱い構造です。
条件分岐で機能を切り替えるしくみから、interface
で機能を取り換えるしくみに変えることで、構造をシンプルにしましょう。
まず、共通の振る舞いを定義した CustomerBenefit
インターフェースを作成します。
interface CustomerBenefit {
ShoppingPoint yearlyPointBonus(final PurchaseHistory history);
}
次に、ノーマル、シルバー、ゴールドそれぞれの会員特典を表すクラスを用意して、interface
を実装します。
class NormalCustomerBenefit implements CustomerBenefit {
// 省略
}
class SilverCustomerBenefit implements CustomerBenefit {
// 省略
}
class GoldCustomerBenefit implements CustomerBenefit {
// 省略
}
最後に、機能を取り換えるしくみとして、ランクに応じた会員特典を返す CustomerBenefits
クラスを用意します。
interface
を用いたしくみでは、enum
と Map
を使用して機能を取り換えられます。
enum CustomerRank {
normal,
silver,
gold
}
class CustomerBenefits {
private final Map<CustomerRank, CustomerBenefit> benefits;
CustomerBenefits() {
benefits = Map.of(
CustomerRank.normal, new NormalCustomerBenefit(),
CustomerRank.silver, new SilverCustomerBenefit(),
CustomerRank.gold, new GoldCustomerBenefit()
);
}
CustomerBenefit select(final CustomerRank customerRank) {
return benefits.get(customerRank);
}
}
これで完成です。
実際に年間ポイントボーナスを参照したい場合は、単に select
メソッドでランクに応じた会員特典を選択し、yearlyPointBonus
メソッドを呼び出すだけになります。
final CustomerBenefit customerBenefit = customerBenefits.select(customerRank);
final ShoppingPoint yearPointBonus = customerBenefit.yearlyPointBonus(purchaseHistory);
[Q]
Map
を使わずに、enum
に直接ロジックを書いちゃダメ?
enum CustomerRank {
normal,
silver,
gold;
CustomerBenefit getBenefit() {
return switch (this) {
case normal -> new NormalCustomerBenefit();
case silver -> new SilverCustomerBenefit();
case gold -> new GoldCustomerBenefit();
};
}
}
[A]
この方法だと、CustomerRank
が「ランク定義」と「ロジックの分岐・生成」という2つの責任を負うことになり、単一責任原則に反します。
おわりに
今回は、「良いコード/悪いコードで学ぶ設計入門」の第5~8章を題材に、自分が疑問に感じたポイントを紹介しました。
この記事が、同じように設計を学ぶ方の助けになったら嬉しいです!
今後も読み進めながら、気づきがあればこうした形でまとめていきたいと思います!