Aikido

保守性と柔軟性を高めるために継承よりもコンポジションを優先する方法

メンテナンス性

ルール
好意 構成 優先 継承
深い 継承 階層 作成 緊密な 結合
そして 作り出す コードを より困難に 理解 理解  保守

サポート言語: 45+

はじめに

継承は親クラスと子クラス間の緊密な結合を生み出し、コードを脆弱で変更しにくくします。クラスが動作を継承すると、その親クラスの実装詳細に依存するようになります。メソッドをオーバーライドしながら依然として親クラスのメソッドを呼び出すサブクラスは、 特に問題となるのは、親が変更されると機能しなくなる方法で、独自のロジックと継承された動作を混在させる点である。コンポジションはこの問題を解決し、オブジェクトが他のオブジェクトに委譲することを可能にすることで、疎結合と明確な関心事の分離を実現する。

なぜそれが重要なのか

懸念事項と緊密な結合: 継承は、無関係な処理を同じクラス階層に強制的に組み込みます。支払い処理クラスから継承した定期支払いクラスは、スケジュール処理ロジックと支払い処理を混在させます。呼び出す必要がある場合 スーパープロセス() そして独自の動作を追加すると、親の実装に強く依存することになります。親の実装が process() メソッドの変更により、子クラスが予期せぬ形で動作しなくなる。

望ましくない行動の継承: サブクラスは親クラスから全てを継承します。不要なメソッドや異なる実装が必要なメソッドも含まれます。定期支払いは継承します 返金する() 単発支払いを想定したロジックだが、定期購読の返金は動作が異なる。メソッドを上書きして混乱を招くか、不適切な継承された動作をそのまま受け入れるかの選択となる。

脆弱な基底クラス問題: 親クラスの変更はすべてのサブクラスに波及する。変更の仕方を クレジットカード決済 支払いを処理する影響 定期クレジットカード決済 たとえその変更がスケジューリングに関係なくとも。これによりリファクタリングは危険となる。なぜなら、どのサブクラスが壊れるかを予測できないからだ。

テストの複雑性:継承階層の深い位置にあるクラスをテストするには、親クラスの動作を理解する必要があります。定期支払いのスケジュール設定をテストするには、クレジットカード処理ロジック、Stripe API呼び出し、および検証も扱う必要があります。コンポジションを利用すれば、単純なモック支払いオブジェクトでスケジュール設定をテストできます。

コード例

非準拠:

class Payment {
    constructor(amount, currency) {
        this.amount = amount;
        this.currency = currency;
    }

    async process() {
        throw new Error('Must implement in subclass');
    }

    async refund() {
        throw new Error('Must implement in subclass');
    }

    async sendReceipt(email) {
        // All paymet types need receipts
        await emailService.send(email, this.buildReceipt());
    }
}

class CreditCardPayment extends Payment {
    constructor(amount, currency, cardToken, billingAddress) {
        super(amount, currency);
        this.cardToken = cardToken;
        this.billingAddress = billingAddress;
    }

    async process() {
        await this.validateCard();
        return await stripe.charges.create({
            amount: this.amount * 100,
            source: this.cardToken,
            currency: this.currency
        });
    }

    async refund() {
        await this.validateRefund();
        return await stripe.refunds.create({ charge: this.chargeId });
    }

    async validateCard() {
        // Card validation logic
    }
}

// Problem: RecurringCreditCardPayment's main concern is dealing with scheduling
// and not the actual payment
class RecurringCreditCardPayment extends CreditCardPayment {
    constructor(amount, currency, cardToken, billingAddress, schedule) {
        super(amount, currency, cardToken, billingAddress);
        this.schedule = schedule;
    }

    async process() {
        // Problem: Need to override parent's process() but also use it
        await super.process();
        await this.scheduleNextPayment();
    }

    async scheduleNextPayment() {
        // Subscription scheduling
    }

    // Problem: Inherits refund() from parent but refunding
    // subscriptions needs different logic
}

なぜそれが間違っているのか: 定期クレジットカード決済 支払い処理ロジックを継承するが、その真の関心事は支払いではなくスケジューリングである。必ず呼び出す必要がある。 スーパープロセス() そしてスケジューリング動作でそれを包み込み、緊密な結合を生み出す。このクラスは継承する 返金する() 親からですが、定期購読の返金処理は単発購入とは異なるロジックが必要です。変更点は クレジットカード決済 影響を与える 定期クレジットカード決済 たとえそれらの変更がスケジューリングに関係がない場合でも。

✅ 準拠:

class CreditCardPayment extends Payment {
    constructor(amount, currency, cardToken, billingAddress) {
        super(amount, currency);
        this.cardToken = cardToken;
        this.billingAddress = billingAddress;
    }

    async process() {
        await this.validateCard();
        return await stripe.charges.create({
            amount: this.amount * 100,
            source: this.cardToken,
            currency: this.currency
        });
    }

    async refund() {
        await this.validateRefund();
        return await stripe.refunds.create({ charge: this.chargeId });
    }

    async validateCard() {
        // Card validation logic
    }
}

class RecurringCreditCardPayment {
    constructor(creditCardPayment, schedule) {
				this.creditCardPayment = creditCardPayment;
        this.schedule = schedule;
    }

    async scheduleNextPayment() {
        this.schedule.onNextCyle(() => {
	        await this.creditCardPayment.process();
        })
    }
}

const recurringCreditCardPayment = new RecurringCreditCardPayment(
	new CreditCardPayment(),
	new Schedule(),
);

なぜこれが重要なのか: 定期クレジットカード決済 スケジューリングのみに焦点を当て、支払い処理は構成されたものに委任する クレジットカード決済 インスタンス。継承がないということは、親クラスの実装との緊密な結合がないことを意味します。クレジットカード処理の変更はスケジューリングロジックに影響を与えません。支払いインスタンスは、スケジューリングコードを変更することなく、任意の支払い方法に置き換えることができます。

結論

継承によって関心を混在させるのではなく、コンポジションを用いて分離する。あるクラスが別のクラスの機能性を必要とする場合、それを依存関係として受け入れ、継承するのではなく委譲する。これにより疎結合が実現され、テストが容易になり、あるクラスの変更が別のクラスを壊すことを防ぐ。

よくある質問

ご質問は?

継承とコンポジションは、いつそれぞれを使うべきですか?

継承は、サブクラスが親クラスの真に特殊化されたバージョンである「真のis-a関係」にのみ使用してください。四角形が長方形であるドメインでは、長方形を拡張する四角形は意味を成します。has-aまたはuses-a関係にはコンポジションを使用してください。定期支払いは決済処理サービスを利用しますが、決済処理サービスの種類ではありません。迷った場合はコンポジションを優先してください。

複数のソースからコードを再利用する必要がある場合はどうすればよいですか?

コンポジションは複数の依存関係を通じてこれを自然に処理します。クラスは複数継承の制約と戦わずに、決済処理機能、スケジューラ、通知機能を組み立てられます。継承は単一継承言語や複雑な複数継承階層を強要します。コンポジションはより明確です:各依存関係はコンストラクタで明示的に記述されます。

継承を構成にリファクタリングするにはどうすればよいですか?

サブクラスが実際に実行する処理と継承する処理を明確に区別する。例では、RecurringCreditCardPaymentが支払いをスケジュールするが、処理ロジックは継承している。親クラスの機能を別クラスに抽出し、依存関係として渡す。Parentのextendsをコンストラクタ引数で置き換える。super.method()呼び出しをthis.dependency.method()に置き換える。各ステップをテストする。

構成は定型文を増やすことにならないか?

初期設定には明示的な依存関係が必要ですが、この明確さは価値があります。親階層を探し回る必要なく、各クラスが何を必要としているかを正確に把握できます。現代の依存性注入フレームワークは定型コードを削減します。この明示性により、暗黙的に継承された動作によるバグを防げます。数行の追加設定コードは、柔軟性と保守性のために払う価値があります。

抽象基底クラスとインターフェースについてはどうでしょうか?

インターフェースは実装を結合せずに契約を定義するのに優れています。インターフェースを使用してクラスに必要な動作を指定し、具体的な実装を注入します。抽象クラスは未実装メソッドを持つ継承に過ぎず、同じ結合の問題を抱えています。継承による抽象クラスよりも、合成によるインターフェースを優先してください。

共有ユーティリティメソッドをどのように扱えばよいですか?

それらを個別のユーティリティクラスやサービスに抽出します。共有検証ロジックを継承する代わりに、Validatorサービスを注入します。例では、単発支払いと定期支払いの両方に同じ検証が必要な場合、両方がコンポジションを通じて使用できる共有のPaymentValidatorを作成します。これにより、親クラスに隠されたメソッドよりも、共有ロジックの発見性とテスト可能性が高まります。

今すぐ安全を確保しましょう

コード、クラウド、ランタイムを1つの中央システムでセキュアに。
脆弱性を迅速に発見し、自動的に修正。

クレジットカードは不要。