Comprendre du code legacy grâce au refactoring

Je travaille actuellement sur un vieux projet vieux comportant un legacy douloureux. Je suis souvent confronté à du code que je ne comprends pas et sur lequel je ne peux pas obtenir d’aide : les autres développeurs n’en savent pas plus que moi, et les Product Owner ont perdu la connaissance fonctionnelle.

C’est un phénomène commun. Le code legacy s’entoure d’une perte de connaissance technique et métier. L’équipe ne comprends plus ce qui se passe dans le code, et ne sait plus trop ce que ça doit faire fonctionnellement.

Dans ces moments-là, la seule réponse restante se trouve dans le code. Le code est la seule vérité. La vérite d’un programme en production. Mais comprendre du code legacy est difficile. Alors comment le faire parler ? 

Mes deux méthodes préférées sont le dessin et le Refactoring. Encore récemment, j’ai utilisé le refactoring pour gagner en compréhension, et ça m’a donné envie d’écrire sur ce sujet.


Gagner de la compréhension

Si modifier du code pour mieux le comprendre vous surprend, regardons une définition (simple) d’un refactoring : c’est améliorer le code sans en changer le comportement. Rendre du code plus lisible, plus facile à comprendre, c’est améliorer le code. C’est exactement ce que l’on cherche.

Lorsque je bloque sur un bout de code, je le modifie. J’essaie de me l’approprier, de gagner en compréhension. Si je casse quelque chose ce n’est pas grave, je retourne en arrière avec Git ou tout autre VCS.

Qu’est-ce que l’on peut faire comme type de refactoring ? Je dirais tout ce qui nous aide. Pour illustrer mon utilisation des différentes techniques de refactoring, j’ai écrit une petite fonction. Ce sera notre legacy.

fun hit(p1: Player, p2: Player) {
    var name = "King"
    if (p1 != null && p2 != null) {
        if (!(p1.firstName == "Arthur" && p1.lastName == "Pendragon" && p1.weapon.name == "Excalibur")) {
            var multiplier = 1
            if (p1.lord) {
                multiplier = 7
                // will multiply the power!!
                var power = p1.strength * multiplier
                p1.strength += 10
                power = p1.strength
            }
            var health = p1.strength + p1.weapon.damage
            if (p2.shield != null && p2.shield.defense > p1.weapon.damage && p2.speed > p1.speed + 1) {
                health = health / 2
            }
            p1.weapon.worn += 1
            p2.health -= health
        } else {
            p1.kill(p2)
        }
        // ...
    }
}

Je commence souvent par des actions simples comme renommer. Renommer une variable, une fonction, une classe. Je fais également du nettoyage : en supprimant du code non utilisé, j’enlève le superflux.

fun hit(attacker: Player, target: Player) {
    if (attacker != null && target != null) {
        if (!(attacker.firstName == "Arthur" && attacker.lastName == "Pendragon" && attacker.weapon.name == "Excalibur") {
            if (attacker.isLord) {
                attacker.strength += 10
            }
            var damage = attacker.strength + attacker.weapon.damage
            if (target.shield != null && target.shield.defense > attacker.weapon.damage && target.speed > attacker.speed + 1) {
                damage = damage / 2
            }
            attacker.weapon.worn += 1
            target.health -= damage
        } else {
            attacker.kill(target)
        }
        // ...
    }
}

J’aime bien inverser les if. J’ai parfois du mal à lire les conditions négatives, notamment lorsque la condition est complexe. Je dois d’abord comprendre la condition, la mapper mentalement, puis je vois la négation, j’inverse cette logique complexe… pour finalement me perdre. Parfois même je loupe ce tout petit caractère, ce point d’exclamation collé trop près de la variable. Alors j’inverse. La condition gagne en clarté, je sais pourquoi on passe dans le corps du if, et au passage ça peut permettre de réduire les niveaux d’imbrication.

fun hit(attacker: Player, target: Player) {
    if (attacker == null || target == null) return
    
    if (attacker.firstName == "Arthur" && attacker.lastName == "Pendragon" && attacker.weapon.name == "Excalibur") {    
        attacker.kill(target)
    } else {
        if (attacker.isLord) {
            attacker.strength += 10
        }
        var damage = attacker.strength + attacker.weapon.damage
        if (target.shield != null && target.shield.defense > attacker.weapon.damage && target.speed > attacker.speed + 1) {
            damage = damage / 2
        }
        attacker.weapon.worn += 1
        target.health -= damage
    }
    // ...
}

Je cherche aussi à mieux structurer et à découper pour réduire la charge mentale nécessaire pour comprendre. Je parlais des if juste avant, j’aime bien faire un extract method pour rendre la condition explicite. J’utilise aussi le extract method dans le corps des grandes fonctions, ou dans les boucles. Le code est plus lisible, la fonction est plus courte, et j’ai une indication sur son intention. Je gagne en compréhension.

fun hit(attacker: Player, target: Player) {
    if (attacker == null || target == null) return
    
    if (isArthurWithExcalibur(attacker)) {    
        attacker.kill(target)
    } else {
        if (attacker.isLord) {
            attacker.strength += 10
        }
        inflictDamage(attacker, target)
    }
    // ...
}

Pour une restructuration encore plus poussée, il m’arrive quelque fois de faire un extract class ou un move method afin d’organiser davantage les fonctions et d’améliorer la lisibilité. Par exemple, ici j’aurais pu déplacer quelques méthodes ou créer des extensions sur la classe Player. Le design du code serait amélioré, mais ce ne serait pas forcément d’une grande aide pour ma compréhension immédiate. Il faut savoir quand s’arrêter.

Avec ce type de modifications, je suis passé d’une zone très dense de 3000 lignes (difficile à mapper mentalement, avec beaucoup de choses superflux pour mon contexte) à quelques petites fonctions (bien mieux pour ma charge mental). J’ai pu recentrer mon effort sur les morceaux qui m’intéressaient le plus.

Bien sûr, il existe tout un tas d’autres techniques de refactoring. Les sites refactoring.com et refactoring.guru proposent un aperçu intéressant. Si vous voulez approfondir, je vous recommande de lire le livre Improving the Design of Existing Code de Martin Fowler ou Working Effectively with Legacy Code de Michael Feathers.

Prendre des libertés

Je ne conserve pas forcément mes changements. Parfois, mon objectif principal est le gain de compréhension. Le refactoring est un moyen pour y parvenir, ce n’est pas la finalité. Il m’arrive donc de prendre des libertés avec le code que j’écris.

Je n’essaie pas de faire le meilleur code possible, ni le meilleur design. Je m’autorise par exemple à passer des paramètres en inout par simplicité. Créer des fonctions avec de nombreux paramètres ne me dérange pas non plus.

Je ne fais pas de tests unitaires garantissant que mes modifications ne changent pas le comportement. Encore une fois, mon but est de comprendre. Je ne compte pas garder ce code, donc le comportement ne sera pas altéré.

Ces prises de libertés me permettent d’obtenir rapidement une meilleure compréhension globale d’un morceau de code legacy. Le retour en arrière est facile avec Git. On efface tout ou on utilise une autre branche (permettant de faire des petits commits de compréhension).

Faire attention

Le refactoring est une discipline. Conserver le même comportement est primordiale. Même lorsque les modifications sont destinées à être effacées, il faut faire attention à ne pas faire une grosse erreur qui fausserait notre compréhension en allant trop vite.

Pour minimiser ce risque, j’utilise au maximum des techniques de refactoring sûre, et je conserve les mêmes signatures pour avoir des vérifications au niveau de la compilation. Certains IDEs proposent également des actions de refactoring automatisées qui évitent des erreurs humaines.

Si vous prenez des libertés pour aller plus vite et que vous prévoyez d’abandonner le code, abandonnez-le. Ne vous y attachez pas. Avec la compréhension que vous aurez acquises, votre second refactoring sera encore meilleur.


Bref

Je me sers du refactoring pour faire émerger de la compréhension. Pour rendre le code plus lisible, pour voir l’intention. C’est relativement rapide et extrêmement efficace. Je m’approprie le code et c’est un point de départ intéressant pour améliorer le code legacy.

Je fais également émerger des points de douleurs, ou des zones qui soulèvent de grandes questions. C’est tout aussi important pour identifier d’éventuels problèmes à venir et les partager au reste de l’équipe.

La prochaine fois que vous tombez sur une zone difficile, essayez de faire un petit refactoring. C’est à la portée de tous, sur tous les langages et plateformes. Le refactoring est une discipline connue, de même que les techniques utilisées. Vous avez juste à y penser, et à essayer.

Et vous, que faites-vous pour comprendre du code legacy ? Quelles sont vos techniques ?