Aikido

Come privilegiare la composizione rispetto all'ereditarietà per ottenere un codice flessibile e di facile manutenzione

Manutenibilità

Regola
Preferenza composizione rispetto ereditarietà
Eredità gerarchie gerarchie creare stretto accoppiamento
e rendono il codice più da capire e da mantenere.

Lingue supportate: 45+

Introduzione

L'ereditarietà crea uno stretto accoppiamento tra classi padre e figlio, rendendo il codice fragile e difficile da modificare. Quando una classe eredita un comportamento, diventa dipendente dai dettagli di implementazione della classe padre. Le sottoclassi che sovrascrivono i metodi ma continuano a chiamare super sono particolarmente problematici, poiché mescolano la propria logica con comportamenti ereditati in modi che non funzionano più quando l'oggetto padre cambia. La composizione risolve questo problema consentendo agli oggetti di delegare ad altri oggetti, creando un accoppiamento debole e una chiara separazione dei ruoli.

Perché è importante

Preoccupazioni contrastanti e forte interconnessione: L'ereditarietà costringe a raggruppare aspetti non correlati nella stessa gerarchia di classi. Una classe di pagamenti ricorrenti che eredita da un processore di pagamenti mescola la logica di pianificazione con l'elaborazione dei pagamenti. Quando è necessario chiamare super.process() e poi aggiungi il tuo comportamento, ti ritrovi strettamente legato all'implementazione del genitore. Se il genitore process() Se il metodo viene modificato, la classe derivata smette di funzionare in modi imprevisti.

Ereditare comportamenti indesiderati: Le sottoclassi ereditano tutto dalle classi padre, compresi i metodi di cui non hanno bisogno o che richiedono implementazioni diverse. Un pagamento ricorrente eredita refund() La logica è stata progettata per i pagamenti una tantum, ma i rimborsi relativi agli abbonamenti funzionano in modo diverso. O si sovrascrivono i metodi, creando confusione, oppure si deve accettare un comportamento ereditato non appropriato.

Problema della classe base "Fragile": Le modifiche apportate alle classi padre si ripercuotono su tutte le sottoclassi. Modificando il modo in cui Pagamento con carta di credito i processi di pagamento influiscono su Pagamento ricorrente con carta di credito anche se la modifica non ha alcuna rilevanza ai fini della pianificazione. Ciò rende il refactoring pericoloso, poiché non è possibile prevedere quali sottoclassi smetteranno di funzionare correttamente.

Complessità dei test: testare classi situate ai livelli più profondi di una gerarchia di ereditarietà richiede la comprensione del comportamento della classe padre. Per testare la pianificazione dei pagamenti ricorrenti, è necessario occuparsi anche della logica di elaborazione delle carte di credito, delle chiamate all’API di Stripe e della convalida. La composizione consente di testare la pianificazione utilizzando un semplice oggetto di pagamento simulato.

Esempi di codice

❌ Non conforme:

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
}

Perché è sbagliato: Pagamento ricorrente con carta di credito eredita la logica di elaborazione dei pagamenti, ma il suo vero obiettivo è la pianificazione, non i pagamenti. Deve chiamare super.process() e lo integra con un meccanismo di pianificazione, creando un forte accoppiamento. La classe eredita da refund() dal conto principale, ma il rimborso degli abbonamenti richiede una logica diversa rispetto ai pagamenti una tantum. Le modifiche a Pagamento con carta di credito influire Pagamento ricorrente con carta di credito anche quando tali modifiche non incidono sulla programmazione.

✅ Conforme:

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(),
);

Perché è importante: Pagamento ricorrente con carta di credito si concentra esclusivamente sulla pianificazione e delega la gestione dei pagamenti al gruppo Pagamento con carta di credito istanza. L'assenza di ereditarietà implica l'assenza di un forte accoppiamento con l'implementazione della classe padre. Le modifiche alla gestione delle carte di credito non influiscono sulla logica di pianificazione. L'istanza di pagamento può essere sostituita con qualsiasi metodo di pagamento senza modificare il codice di pianificazione.

Conclusione

Utilizza la composizione per separare i livelli di astrazione, anziché mescolarli tramite l'ereditarietà. Quando una classe necessita delle funzionalità di un'altra classe, considerala come una dipendenza e delegale il compito, anziché ereditare da essa. Ciò crea un accoppiamento debole, semplifica i test e impedisce che le modifiche apportate a una classe compromettano il funzionamento di un'altra.

Domande frequenti

Hai delle domande?

Quando è meglio ricorrere all’ereditarietà piuttosto che alla composizione?

Utilizza l'ereditarietà solo per le relazioni "è-un" vere e proprie, in cui la sottoclasse è effettivamente una versione specializzata della classe padre. L'estensione di "Quadrato" da "Rettangolo" ha senso se, nel tuo dominio, i quadrati sono rettangoli. Utilizza la composizione per le relazioni "ha-un" o "utilizza-un". Un pagamento ricorrente utilizza un processore di pagamento, non è un tipo di processore di pagamento. In caso di dubbio, prediligi la composizione.

E se dovessi riutilizzare codice proveniente da più fonti?

La composizione gestisce questo aspetto in modo naturale attraverso dipendenze multiple. Una classe può comporre un processore di pagamento, uno scheduler e un notificatore senza incorrere nelle restrizioni dell'ereditarietà multipla. L'ereditarietà costringe a utilizzare linguaggi a ereditarietà singola o a gerarchie complesse di ereditarietà multipla. La composizione è più chiara: ogni dipendenza è esplicita nel costruttore.

Come posso riorganizzare il codice passando dall'ereditarietà alla composizione?

Individua ciò che la sottoclasse fa effettivamente rispetto a ciò che eredita. Nell'esempio, RecurringCreditCardPayment pianifica i pagamenti ma eredita la logica di elaborazione. Estrai la funzionalità della classe padre in una classe separata, quindi passala come dipendenza. Sostituisci `extends Parent` con un parametro del costruttore. Sostituisci le chiamate `super.method()` con `this.dependency.method()`. Testa ogni passaggio.

Ma la composizione non genera forse più codice standardizzato?

La configurazione iniziale richiede dipendenze esplicite, ma questa chiarezza è preziosa. Si vede esattamente di cosa ha bisogno ogni classe senza dover setacciare le gerarchie dei genitori. I moderni framework di iniezione delle dipendenze riducono il codice boilerplate. L'esplicitezza impedisce l'insorgere di bug derivanti da comportamenti impliciti ereditati. Vale la pena dedicare qualche riga in più al codice di configurazione in cambio di flessibilità e manutenibilità.

E per quanto riguarda le classi base astratte e le interfacce?

Le interfacce sono ottime per definire contratti senza accoppiare le implementazioni. Usa le interfacce per specificare il comportamento richiesto a una classe, quindi inietta le implementazioni concrete. Le classi astratte non sono altro che ereditarietà con alcuni metodi non implementati e presentano gli stessi problemi di accoppiamento. Preferisci le interfacce con composizione alle classi astratte con ereditarietà.

Come devo gestire i metodi di utilità condivisi?

Estraeteli in classi di utilità o servizi separati. Anziché ereditare la logica di convalida condivisa, iniettate un servizio Validator. Nell’esempio, se sia i pagamenti una tantum che quelli ricorrenti richiedono la stessa convalida, create un PaymentValidator condiviso che entrambi possano utilizzare tramite composizione. Ciò rende la logica condivisa più individuabile e testabile rispetto ai metodi nascosti nelle classi padre.

Metti in sicurezza ora

Metti in sicurezza il tuo codice, il cloud e il runtime in un unico sistema centralizzato.
Trova e risolvi le vulnerabilità rapidamente e automaticamente.

Nessuna carta di credito richiesta | Risultati della scansione in 32 secondi.