Aikido

Comment privilégier la composition plutôt que l'héritage pour un code maintenable et flexible

Maintenabilité

Règle
Composition composition sur l'héritage
Profondeur héritage hiérarchies créer couplage couplage
et rendent code plus à comprendre et maintenir.

Langues prises en charge : 45+

Introduction

L'héritage crée un couplage fort entre les classes parentes et enfants, rendant le code fragile et difficile à modifier. Lorsqu'une classe hérite d'un comportement, elle devient dépendante des détails d'implémentation de son parent. Les sous-classes qui surchargent des méthodes mais appellent toujours super sont particulièrement problématiques, mêlant leur propre logique à un comportement hérité de manière à provoquer des ruptures lorsque le parent change. La composition résout ce problème en permettant aux objets de déléguer à d'autres objets, créant un couplage lâche et une séparation claire des préoccupations.

Pourquoi c'est important

Préoccupations diverses et couplage fort : L'héritage force des préoccupations non liées dans la même hiérarchie de classes. Une classe de paiement récurrent qui hérite d'un processeur de paiement mélange la logique de planification avec le traitement des paiements. Lorsque vous devez appeler super.process() et ensuite ajouter votre propre comportement, vous êtes étroitement couplé à l'implémentation du parent. Si le parent process() si la méthode change, la classe enfant se comporte de manière inattendue.

Hériter d'un comportement indésirable : Les sous-classes héritent de tout de leurs parents, y compris des méthodes dont elles n'ont pas besoin ou qui nécessitent des implémentations différentes. Un paiement récurrent hérite refund() logique conçue pour les paiements uniques, mais les remboursements d'abonnement fonctionnent différemment. Vous surchargez les méthodes et créez de la confusion, ou vous vivez avec un comportement hérité inapproprié.

Problème de la classe de base fragile : Les modifications apportées aux classes parentes se propagent à toutes les sous-classes. Modifier la manière dont Paiement par carte de crédit traite les paiements affecte Paiement récurrent par carte de crédit même si le changement est sans rapport avec la planification. Cela rend le refactoring dangereux car vous ne pouvez pas prédire quelles sous-classes seront affectées.

Complexité des tests : Tester des classes profondément imbriquées dans une hiérarchie d'héritage nécessite de comprendre le comportement de la classe parente. Pour tester la planification des paiements récurrents, vous devez également gérer la logique de traitement des cartes de crédit, les appels d'API Stripe et la validation. La composition vous permet de tester la planification avec un simple objet de paiement simulé (mock).

Exemples de code

❌ 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
}

Pourquoi c'est incorrect : Paiement récurrent par carte de crédit hérite de la logique de traitement des paiements mais sa véritable préoccupation est la planification, pas les paiements. Elle doit appeler super.process() et l'envelopper d'un comportement de planification, créant un couplage étroit. La classe hérite refund() du parent, mais le remboursement des abonnements nécessite une logique différente de celle des paiements uniques. Les modifications apportées à Paiement par carte de crédit affecter Paiement récurrent par carte de crédit même lorsque ces changements sont sans rapport avec la planification.

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

Pourquoi c'est important : Paiement récurrent par carte de crédit se concentre uniquement sur la planification et délègue le traitement des paiements au composant Paiement par carte de crédit instance. L'absence d'héritage signifie l'absence de couplage fort avec l'implémentation de la classe parente. Les modifications apportées au traitement des cartes de crédit n'affectent pas la logique de planification. L'instance de paiement peut être remplacée par n'importe quelle méthode de paiement sans modifier le code de planification.

Conclusion

Utilisez la composition pour séparer les préoccupations au lieu de les mélanger par l'héritage. Lorsqu'une classe a besoin de la fonctionnalité d'une autre classe, acceptez-la comme une dépendance et déléguez-lui plutôt que d'en hériter. Cela crée un couplage faible, facilite les tests et empêche les modifications dans une classe de casser une autre.

FAQ

Des questions ?

Quand devrais-je utiliser l'héritage vs. la composition ?

Utilisez l'héritage uniquement pour les véritables relations « est-un » où la sous-classe est réellement une version spécialisée du parent. Un Carré étendant Rectangle a du sens si les carrés sont des rectangles dans votre domaine. Utilisez la composition pour les relations « a-un » ou « utilise-un ». Un paiement récurrent utilise un processeur de paiement, ce n'est pas un type de processeur de paiement. En cas de doute, privilégiez la composition.

Que se passe-t-il si je dois réutiliser du code provenant de plusieurs sources ?

La composition gère cela naturellement via de multiples dépendances. Une classe peut composer un processeur de paiement, un ordonnanceur et un notificateur sans se heurter aux restrictions de l'héritage multiple. L'héritage vous contraint aux langages à héritage unique ou à des hiérarchies d'héritage multiple complexes. La composition est plus claire : chaque dépendance est explicite dans le constructeur.

Comment refactoriser l'héritage en composition ?

Identifiez ce que la sous-classe fait réellement par rapport à ce qu'elle hérite. Dans l'exemple, RecurringCreditCardPayment planifie les paiements mais hérite de la logique de traitement. Extrayez la fonctionnalité parente dans une classe distincte, puis passez-la comme dépendance. Remplacez extends Parent par un paramètre de constructeur. Remplacez les appels super.method() par this.dependency.method(). Testez chaque étape.

La composition ne génère-t-elle pas plus de boilerplate ?

La configuration initiale nécessite des dépendances explicites, mais cette clarté est précieuse. Vous voyez exactement ce dont chaque classe a besoin sans avoir à fouiller dans les hiérarchies parentes. Les frameworks modernes d'injection de dépendances réduisent le boilerplate. L'explicité prévient les bugs liés aux comportements hérités implicites. Quelques lignes de code de configuration supplémentaires valent la flexibilité et la maintenabilité.

Qu'en est-il des classes de base abstraites et des interfaces ?

Les interfaces sont excellentes pour définir des contrats sans coupler les implémentations. Utilisez les interfaces pour spécifier le comportement requis par une classe, puis injectez des implémentations concrètes. Les classes abstraites ne sont que de l'héritage avec quelques méthodes non implémentées ; elles présentent les mêmes problèmes de couplage. Préférez les interfaces avec composition aux classes abstraites avec héritage.

Comment gérer les méthodes utilitaires partagées ?

Extrayez-les dans des classes utilitaires ou des services distincts. Au lieu d'hériter de la logique de validation partagée, injectez un service `Validator`. Dans l'exemple, si les paiements uniques et récurrents nécessitent la même validation, créez un `PaymentValidator` partagé que les deux peuvent utiliser par composition. Cela rend la logique partagée plus découvrable et testable que les méthodes cachées dans les classes parentes.

Sécurisez votre environnement dès maintenant.

Sécurisez votre code, votre cloud et votre environnement d’exécution dans un système centralisé unique.
Détectez et corrigez les vulnérabilités rapidement et automatiquement.

Aucune carte de crédit requise | Résultats en 32 secondes.