Aikido

Come privilegiare la composizione rispetto all'ereditarietà per ottenere codice manutenibile e flessibile

Manutenibilità

Regola
Privilegia la composizione rispetto all'ereditarietà
Gerarchie di ereditarietà profonde creano un accoppiamento stretto e rendono il codice più difficile da comprendere e manutenere.


Linguaggi supportati: 45+

Introduzione

L'ereditarietà crea un forte accoppiamento tra classi genitrici e figlie, rendendo il codice fragile e difficile da modificare. Quando una classe eredita un comportamento, diventa dipendente dai dettagli implementativi della sua superclasse. Le sottoclassi che sovrascrivono i metodi pur continuando a chiamare super risultano particolarmente problematiche, poiché mescolano la propria logica con il comportamento ereditato in modi che si rompono quando il genitore subisce modifiche. La composizione risolve questo problema consentendo agli oggetti di delegare ad altri oggetti, creando un accoppiamento lasco e una chiara separazione delle responsabilità.

Perché è importante

Responsabilità miste e forte accoppiamento: L'ereditarietà forza responsabilità non correlate all'interno della stessa gerarchia di classi. Una classe di pagamenti ricorrenti che eredita da un processore di pagamento mescola la logica di pianificazione con l'elaborazione dei pagamenti. Quando devi chiamare super.process() e successivamente aggiungere il tuo comportamento personalizzato, ti ritrovi strettamente accoppiato all'implementazione del genitore. Se l'implementazione del genitore process() i metodi cambiano, la classe figlia si rompe in modo inaspettato.

Ereditarietà di comportamenti indesiderati: Le sottoclassi ereditano tutto dai genitori, inclusi i metodi di cui non hanno bisogno o che richiedono implementazioni differenti. Un pagamento ricorrente eredita refund() una logica progettata per pagamenti una tantum, ma i rimborsi degli abbonamenti funzionano diversamente. O si sovrascrivono i metodi creando confusione, o si convive con un comportamento ereditato non idoneo.

Problema della classe base fragile (Fragile base class problem): Le modifiche alle classi genitori si propagano a tutte le sottoclassi. Modificare il modo in cui CreditCardPayment elabora i pagamenti influisce su RecurringCreditCardPayment anche se la modifica è irrilevante per la pianificazione. Questo rende il refactoring pericoloso poiché non è possibile prevedere quali sottoclassi si romperanno.

Complessità dei test: Testare classi profonde in una gerarchia di ereditarietà richiede la comprensione del comportamento della classe genitrice. Per testare la pianificazione dei pagamenti ricorrenti, devi anche gestire la logica di elaborazione delle carte di credito, le chiamate API di Stripe e la validazione. La composizione permette di testare la pianificazione con un semplice oggetto di pagamento mock.

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é è errato: RecurringCreditCardPayment eredita la logica di elaborazione dei pagamenti ma il suo vero obiettivo è la pianificazione, non i pagamenti. Deve chiamare super.process() e racchiuderla con il comportamento di pianificazione, creando un forte accoppiamento (tight coupling). La classe eredita refund() dal genitore, ma il rimborso degli abbonamenti richiede una logica diversa rispetto ai pagamenti una tantum. Le modifiche a CreditCardPayment influiscono su RecurringCreditCardPayment anche quando tali modifiche sono irrilevanti per la pianificazione.

✅ 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é questo è importante: RecurringCreditCardPayment si concentra esclusivamente sulla pianificazione e delega l'elaborazione dei pagamenti all'istanza CreditCardPayment composta. L'assenza di ereditarietà significa nessun forte accoppiamento con l'implementazione della classe genitrice. Le modifiche all'elaborazione 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

Usa la composizione per separare le responsabilità (separation of concerns) invece di mescolarle tramite l'ereditarietà. Quando una classe ha bisogno delle funzionalità di un'altra, accettala come dipendenza e delega ad essa anziché ereditare. Questo crea un accoppiamento lasco (loose coupling), semplifica i test e impedisce che le modifiche in una classe ne rompano un'altra.

FAQ

Hai domande?

Quando dovrei usare l'ereditarietà rispetto alla composizione?

Usa l'ereditarietà solo per vere relazioni "è un tipo di" (is-a), in cui la sottoclasse è autenticamente una versione specializzata del genitore. Quadrato che estende Rettangolo ha senso se i quadrati sono rettangoli nel tuo dominio. Usa la composizione per relazioni "ha un" (has-a) o "usa un" (uses-a). Un pagamento ricorrente usa un elaboratore di pagamenti, non è un tipo di elaboratore di pagamenti. Nel dubbio, favorisci la composizione.

‍

E se ho bisogno di riutilizzare codice da più fonti?

La composizione gestisce questo aspetto in modo naturale attraverso dipendenze multiple. Una classe può comporre un elaboratore di pagamenti, un pianificatore e un notificatore senza scontrarsi con le restrizioni dell'ereditarietà multipla. L'ereditarietà costringe a usare linguaggi a ereditarietà singola o gerarchie di ereditarietà multipla complesse. La composizione è più chiara: ogni dipendenza è esplicita nel costruttore.

‍

Come posso rifattorizzare l'ereditarietà in composizione?

Identifica ciò che la sottoclasse fa effettivamente rispetto a ciò che ereditia. Nell'esempio, RecurringCreditCardPayment pianifica i pagamenti ma ereditia la logica di elaborazione. Estrai la funzionalità del genitore 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.

‍

La composizione non crea più codice boilerplate?

La configurazione iniziale richiede dipendenze esplicite, ma questa chiarezza è preziosa. Vedi esattamente di cosa ha bisogno ogni classe senza dover cercare tra le gerarchie genitrici. I moderni framework di dependency injection riducono il boilerplate. L'esplicitezza previene bug derivanti da comportamenti ereditati impliciti. Qualche riga di codice di configurazione in più vale la pena per la flessibilità e la manutenibilità.

‍

Che dire delle classi base astratte e delle interfacce?

Le interfacce sono eccellenti per definire contratti senza accoppiare le implementazioni. Usa le interfacce per specificare quale comportamento richiede una classe, quindi inietta implementazioni concrete. Le classi astratte sono solo ereditarietà con alcuni metodi non implementati e presentano gli stessi problemi di accoppiamento. Preferisci le interfacce con la composizione alle classi astratte con l'ereditarietà.

Come gestisco i metodi di utilità condivisi?

Estrarli in classi di utilità o servizi separati. Invece di ereditare la logica di validazione condivisa, inietta un servizio Validator. Nell'esempio, se sia i pagamenti una tantum che quelli ricorrenti richiedono la stessa validazione, crea un PaymentValidator condiviso che entrambi possano usare tramite composizione. Questo rende la logica condivisa più facilmente individuabile e testabile rispetto a metodi nascosti nelle classi genitrici.

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.