Deux techniques de base pour le code Legacy

Cet article présente Sprout Method et Wrap Method, deux techniques très utiles quand :

  • on travaille sur du code non testé (une des définitions possibles de “code legacy”)
  • on souhaite y ajouter une fonctionnalité couverte par des tests (la “reason to change”).

Ces deux techniques sont les premières techniques présentées par le livre “Working Effectively with Legacy Code”, de Michael Feathers (WEWLC). Elles permettent d’ajouter du code testé dans du code difficile à tester, et ce sont aussi de bonnes premières étapes vers un meilleur design.

Note : les deux techniques sont illustrées par les exemples de code du livre. On évoquera aussi en passant les variantes Sprout Class et Wrap Class, mais on ne rentrera pas dans les détails volontairement pour se concentrer sur les versions “Method”.


Sprout Method

Faire germer une méthode.

Quand on veut ajouter une nouvelle fonctionnalité à un système existant non testé et qu’il est possible d’ajouter cette nouvelle fonctionnalité dans une nouvelle méthode indépendante, on peut utiliser Sprout Method.

Comme cette nouvelle méthode est indépendante, elle va être beaucoup plus facile à tester. On pourra aussi appeler cette méthode à chaque endroit qui a besoin de la nouvelle fonctionnalité.

Par exemple, si on a le code non testé suivant dans une classe “TransactionGate”, qui “poste” une liste d’”Entries” :

public class TransactionGate {
    public void postEntries(List<Entry> entries) {
        for (var entry : entries) {
            entry.postDate();
        }
        transactionBundle.getListManager().add(entries);
    }

    ...
}

Supposons qu'on souhaite le modifier, pour qu’il évite d’ajouter deux fois le même objet “Entry”.

Ce nouveau comportement peut être implémenté comme un dédoublonnage de la liste “entries” passée en paramètre : ça peut tout à fait être une méthode indépendante de l’ancien code.

Sprout Method va bien pouvoir s’appliquer dans ce cas. Voilà un exemple de comment on peut faire pour ajouter ce nouveau comportement avec des tests.

  • On identifie l’endroit de l’ancien code où on va ajouter le nouveau comportement
  • On ajoute un appel commenté à une méthode "uniqueEntries(List<Entry> entries)" qui n’existe pas encore à cet endroit. Ça nous permet de visualiser à quoi ça va ressembler, et de commencer à définir l’interface de cette nouvelle méthode (arguments nécessaires et type de retour) :
public class TransactionGate {
    public void postEntries(List<Entry> entries) {
        // entries = uniqueEntries(entries)
        for (var entry : entries) {
            entry.postDate();
        }
        transactionBundle.getListManager().add(entries);
    }

    ...
}
  • On crée la méthode "uniqueEntries(List<Entry> entries)", qui dédoublonne la liste, en Test-Driven Development (TDD).
  • C’est prêt, on dé-commente la ligne et on utilise sa valeur de retour :
public class TransactionGate {
    public void postEntries(List<Entry> entries) {
        entries = uniqueEntries(entries)
        for (var entry : entries) {
            entry.postDate();
        }
        transactionBundle.getListManager().add(entries);
    }
    
    ...
}

Résultat : on a ajouté un nouveau comportement testé à l’ancien code. On a aussi introduit une mutation du paramètre en entrée dans l’ancien code : attention à bien maîtriser les impacts de ce changement dans le code original non testé. Mais c’est bien mieux que si on avait implémenté ce dédoublonnage inline dans le code non testé. Si on avait fait ça, on aurait ajouté bien plus de complexité à l’ancien code, et on serait bien en peine pour tester notre nouveau comportement en plein milieu du code non testé.

Avantages :

  • Sépare le nouveau code de l’ancien code
  • Définit une interface claire entre ancien code et nouveau code
  • Rend visible toutes les variables nécessaires au nouveau code
  • C’est bien mieux que d’implémenter le nouveau traitement inline, ou pire, de dupliquer le nouveau traitement à plusieurs endroits

Inconvénients :

  • On laisse de côté l’ancien code sans essayer de l’améliorer
  • Parfois quand on regarde l’ancien et le nouveau code, on ne comprend pas pourquoi une partie du travail est effectué ailleurs, ça peut laisser la méthode originale dans un état bizarre

Astuce : quand la classe qui accueille la nouvelle méthode est très difficile à instancier à cause de dépendances, on peut parfois lui passer des valeurs nulles pour l’amadouer, ou, si ça ne fonctionne pas, ajouter la nouvelle méthode comme méthode statique de la classe. C’est un peu inhabituel, mais on est dans un code legacy, on a besoin d’étapes intermédiaires qui sont souvent un compromis.

Variante : Sprout Class

Faire germer une classe.

Quand il est très difficile d’instancier la classe originale à cause des dépendances, ce qui rend le setup des tests compliqué, on peut utiliser la variante Sprout Class. Au prix d’une complexité un tout petit peu plus élevée, cette variante permet de simplifier les tests de la nouvelle fonctionnalité en l’encapsulant dans un contexte mieux isolé de l’existant. Voir [WEWLC] pour un exemple.

Si vous travaillez dans un contexte Orienté Objet, il est possible que votre nouvelle classe puisse bien s'intégrer à des classes existantes, suivant un pattern logique, c'est quelque-chose à explorer. Voir aussi [WEWLC] pour un exemple.

Wrap Method

Envelopper une méthode.

Parfois on veut ajouter un comportement dans une méthode existante non testée, mais on sent que le nouveau comportement est peu lié à la méthode existante, et que dès qu'on aura plus de tests et un meilleur endroit où ranger le nouveau comportement, on pourra le déplacer. Pour ça, on souhaite éviter d’introduire du couplage entre ancien et nouveau code, qui vont évoluer différemment, et qui ne sont maintenant dans la même fonction que parce-qu’ils doivent se produire dans le même traitement.

C’est ce que permet la technique Wrap Method.

Si on a le code non testé existant suivant, qui calcule la paie d’un employé en fonction de ses heures pointées, et qui déclenche le système de paye avec le résultat :

public class Employee
{
    // ...
    public void pay() {
        var amount = new Money();
        for (Timecard card : timecards) {
            if (payPeriod.contains(date)) {
                amount.add(card.getHours() * payRate);
            }
        }
        payDispatcher.pay(this, date, amount);
    }
}

Supposons qu’on souhaite ajouter un log dans un système de reporting à chaque fois qu’un employé est payé. C’est un nouveau comportement qui n’a pas grand chose à voir avec le fait de payer un employé, si ce n’est que ça doit se produire ensemble. Pour cette raison, on souhaite introduire le minimum de couplage possible entre ancien comportement et nouveau comportement.

C’est un cas typique pour appliquer la technique Wrap Method, voilà comment on pourrait faire.

  • On identifie la méthode qu'on a besoin de modifier
  • On renomme l’ancienne méthode, on la rend privée, et on crée une nouvelle méthode avec le même nom et surtout la même signature (c’est très important) que l’ancienne méthode. La nouvelle méthode appelle l’ancienne méthode :
public class Employee
{
    // ...
    public void pay() {
        dispatchPayment();
    }

    private void dispatchPayment() {
        var amount = new Money();
        for (Timecard card : timecards) {
            if (payPeriod.contains(date)) {
                amount.add(card.getHours() * payRate);
            }
        }
        payDispatcher.pay(this, date, amount);
    }
}
  • On développe le nouveau comportement en TDD
  • On appelle le nouveau comportement testé dans la nouvelle méthode pay() :
public class Employee
{
    // ...
    public void pay() {
        logPayment();
        dispatchPayment();
    }

    private void dispatchPayment() {
        var amount = new Money();
        for (Timecard card : timecards) {
            if (payPeriod.contains(date)) {
                amount.add(card.getHours() * payRate);
            }
        }
        payDispatcher.pay(this, date, amount);
    }

    private void logPayment() {
        // ...
    }
}

Les clients qui appellent la méthode pay() n’ont pas besoin de savoir que le nouveau comportement a été ajouté, c’est transparent pour eux.

Note : le livre [WEWLC] décrit une variante de cette technique, à la page 69.

Avantages :

  • Wrap Method ne rallonge pas la méthode existante (Sprout Method allonge la méthode existante d’au moins une ligne)
  • Avec Wrap Method, les fonctionnalités existantes et la nouvelle fonctionnalité ne se mélangent pas. Il y a moins de couplage, et les deux fonctionnalités seront faciles à séparer quand ce sera le moment.

Inconvénients :

  • Peut être source de mauvais noms : la méthode enveloppante prend le nom de l’ancienne méthode, donc il faut un nouveau nom pour l’ancienne méthode pour une raison un peu artificielle
  • La nouvelle fonctionnalité ne peut pas être mélangée à l’ancienne, elle doit s’exécuter soit avant, soit après. Heureusement, c’est très souvent bien mieux comme ça

Note rappel : la méthode qui enveloppe doit avoir la même signature que la méthode originale, c’est très important pour ne pas introduire un changement cassant dans du code sans test !

Variante : Wrap Class

Envelopper une classe.

Comme Sprout Method, Wrap Method a un équivalent au niveau classe : Wrap Class. On peut par exemple introduire le pattern Decorator, qui va effectivement envelopper et étendre l’ancien appel. Voir [WEWLC] pour deux exemples différents.

La particularité de Wrap Class, c’est qu’on ajoute le nouveau comportement sans modifier la classe existante. C’est utile quand le comportement à ajouter est très indépendant et qu’on ne veut pas polluer la classe existante, ou quand la classe existante est déjà devenue trop grosse et qu’on ne veut pas empirer les choses.

Pour résumer

Quand on veut ajouter une fonctionnalité dans du code non testé, et que cette fonctionnalité peut être exprimée entièrement comme du nouveau code, on peut utiliser Sprout Method et utiliser cette nouvelle méthode à chaque endroit qui nécessite la nouvelle fonctionnalité.

Quand on veut ajouter un nouveau comportement avec des tests à une fonction existante qui n’a pas de tests, en limitant le couplage entre nouveau comportement et ancien comportement, on peut utiliser la technique Wrap Method.

Pour aller plus loin

Pratiquez ces deux techniques, en vue de les intégrer à vos outils de tous les jours. Lisez les sections correspondantes de Working Effectively with Legacy Code (p. 59 et p. 67) pour avoir plus de détails et d’exemples.

Puis, observez que ces deux techniques sont le début du chemin, un kit minimaliste de survie. Ce sont deux moyens simples d’ajouter du comportement avec des tests dans du code qui n’en a pas, mais le comportement qu’on vient d’ajouter n’est pas forcément bien rangé. En fonction du contexte, pour mieux ranger ce nouveau comportement, on peut décider d’aller plus loin, par exemple en ajoutant des tests et en effectuant une série de refactorings vers un design plus adapté.

Enfin, ces deux techniques sont les premières présentées par le livre “Working Effectively with Legacy Code”, dans le chapitre “I Don’t Have Much Time and I Have to Change It”. Le livre est très dense et en contient beaucoup d’autres. Une fois que vous avez pratiqué et intégré ces deux premières techniques, je vous invite à parcourir la table des matières et à en explorer quelques autres.

Pointeurs & références