Vous venez d’hériter d’une base de code C++ legacy : que faire maintenant ?
(gaultier.github.io)- Si vous reprenez une base de code C++ legacy vaste et complexe, l’objectif n’est pas d’obtenir un « code propre », mais d’amener la sécurité, l’expérience développeur, la justesse et les performances à un niveau acceptable
- Le point de départ consiste à obtenir l’adhésion de l’organisation et une limite de temps, à consigner les plateformes prises en charge dans le README, puis à faire en sorte que la compilation locale et les tests passent de manière fiable
- L’amélioration de la vitesse de build et de test doit commencer par des améliorations faciles mesurées, comme supprimer les tests des dépendances, nettoyer les cibles inutiles ou essayer un linker rapide comme
mold - Après avoir réduit le code inutilisé et le code destiné à des plateformes non prises en charge, il faut mettre en place une boucle de validation automatique avec
clang-tidy,cppcheck,clang-format,-fsanitize=address,undefinedet la CI - La réécriture ou l’adoption d’un standard C++ plus récent ne sont pas des objectifs mais des moyens, et une réécriture dans un langage à mémoire sûre ne mérite d’être envisagée qu’en présence de raisons solides
Au début, on met de l’ordre dans les personnes et les प्रक्रdures avant le code
- Améliorer une base de code C++ legacy ne doit pas être un effort solitaire mené le soir après le travail ni une « marche de la mort » de longue durée, mais un travail d’ingénierie logicielle soutenable
- Il faut expliquer les problèmes, les solutions et les contraintes de temps à son manager, à ses collègues et aux personnes non techniques en termes simples
- Si une nouvelle recrue a mis 3 semaines à réussir un build local et à faire sa première contribution, on peut proposer de ramener cela à quelques minutes
- Si une configuration simple de fuzzing fait planter l’application 253 fois en quelques secondes, cela fournit un argument concret pour expliquer le risque en production
- Si le déploiement dépend d’un unique serveur de build FreeBSD 9 non pris en charge depuis 8 ans, une panne de ce serveur bloque les livraisons
- Si un linter standard du secteur détecte immédiatement un comportement indéfini à l’origine de bugs en production, il y a de bonnes raisons de l’exécuter à chaque changement
- Si une bibliothèque de chiffrement copiée et modifiée à la main empêche d’évaluer l’impact des vulnérabilités, il faut rationaliser les dépendances et mettre en place des alertes automatiques
- Les approches à éviter sont également claires
- Faire une mise à niveau complète vers le dernier standard C++ pendant 2 semaines alors qu’il n’y a pas de tests
- Produire pendant des mois de gros changements dans une branche séparée en espérant qu’ils seront fusionnés un jour
- Démarrer une réécriture complète dès le départ en supposant que tout sera terminé en quelques semaines
- Lancer une « amélioration de la base de code » sans savoir quoi faire ni pour quand
Une manière sûre d’améliorer
- Tous les changements doivent être petits et incrémentaux, et l’application doit fonctionner avant comme après, avec des tests et des linters qui passent
- Les correctifs urgents doivent rester possibles comme avant, sans être bloqués par le travail d’amélioration
- Chaque changement doit apporter une amélioration mesurable et pouvoir être expliqué ou démontré même à des non-spécialistes
- Même si l’ensemble du travail est interrompu à cause des priorités ou du budget, il doit rester un bénéfice net mesurable par rapport à l’état initial
Indiquer les plateformes prises en charge dans le README
- Le README doit lister les couples
<architecture>-<operating-system>officiellement pris en charge- Par exemple :
x86_64-linux,aarch64-darwin
- Par exemple :
- Cette liste sert de référence pour vérifier que le projet compile sur toutes les plateformes prises en charge, et de justification plus tard pour supprimer le code destiné à des plateformes non prises en charge
- Si nécessaire, on peut aussi préciser des versions d’architecture comme ARMv6 ou ARMv7
- La liste des plateformes prises en charge permet de répondre à des questions comme :
- Peut-on dépendre du support matériel pour les nombres en virgule flottante, SIMD ou SHA256 ?
- Faut-il prendre en charge le 32 bits ?
- Le logiciel tourne-t-il sur des plateformes big-endian ?
- Faut-il envisager que
charpuisse être sur 7 bits ?
- Les stations de travail des développeurs doivent absolument figurer dans cette liste
Stabiliser d’abord la compilation locale et les tests
- Même des bases de code C++ au cœur de produits à succès ne compilent souvent pas de façon fiable, et l’objectif n’est pas un « build qui marche parfois », mais un build cohérent sur toutes les plateformes prises en charge
- La meilleure situation est de pouvoir compiler et exécuter directement sur la machine du développeur
- Si le projet est trop gros et manque de RAM, on peut louer un gros serveur pour les builds
- Si une API spécifique à la plateforme comme
io_uringest nécessaire, on peut implémenter un shim ou builder dans une machine virtuelle sur le poste de travail - Malgré tout, le build local direct reste la meilleure option
- S’il n’y a pas de tests, il faut d’abord en écrire avant de modifier le code
- Le point de départ le plus simple consiste souvent à capturer les entrées et sorties du programme réel pour créer des tests end-to-end
- Ces tests ne garantissent pas que le comportement existant soit correct, mais ils limitent les régressions lors des changements
- S’il existe une suite de tests mais que certains échouent, il faut d’abord les désactiver et obtenir un état où l’ensemble passe, même si cela prend des heures
Documenter la manière de builder et de tester
- Le README doit expliquer comment builder et tester l’application
- La forme idéale est une seule commande de build et une seule commande de test
- Si la procédure est complexe au départ, on peut masquer cette complexité derrière
build.shettest.sh - L’objectif est qu’une personne qui n’est pas experte C++ puisse lancer le build et les tests sans poser de questions
- Il vaut mieux documenter la structure du projet ou l’architecture plus tard, une fois le code inutile supprimé
Réduire le temps de build et de test avec des améliorations faciles
- Avant de refondre le système de build ou de tenter des optimisations héroïques, il faut mesurer des améliorations faciles
- Premiers points à vérifier
- Vérifier si les tests des dépendances sont rebuildés et relancés à chaque fois
- Avec
unittest++utilisé comme sous-projet CMake, on a vu des cas où même les tests du framework de test étaient rebuildés et exécutés à chaque fois
- Avec
- Vérifier si les programmes d’exemple des dépendances sont buildés et exécutés
mbedtlspermettait aussi de désactiver le build des exemples via une variable CMake
- Vérifier si votre projet, lorsqu’il est inclus comme sous-projet d’un autre, build ses tests et exemples par défaut
- Il est recommandé de désactiver par défaut des variables de build comme
MYPROJECT_TEST, et de ne les activer que pendant le développement direct
- Il est recommandé de désactiver par défaut des variables de build comme
- Vérifier si toutes les dépendances tierces sont buildées alors qu’en pratique seule une petite partie est utilisée
mbedtlsfournit beaucoup de flags de compilation pour désactiver ce qui n’est pas nécessaire
- Vérifier si les dépendances entre cibles sont incorrectes et forcent un rebuild complet à cause d’un petit changement
- De nombreux systèmes de build peuvent afficher le graphe de dépendances
- Essayer un linker rapide comme
mold - Comparer aussi les compilateurs quand c’est possible
- Sur certains projets, clang a été 2 fois plus rapide que gcc ; sur d’autres, il n’y avait pas de différence
- Vérifier si les tests des dépendances sont rebuildés et relancés à chaque fois
- Autres pistes à tester, mais dont le gain peut être faible voire négatif
- Activer/désactiver/thin sur LTO
- Séparer les informations de debug
- Comparer Make et Ninja
- Tester le type de système de fichiers et ses réglages
- Si les builds prennent trop de temps, modifier le code devient en soi irréaliste
Supprimer le code inutile
- Dans certains cas, plus de 30 % de la base de code était totalement morte, et ce code continue d’augmenter les coûts de compilation et de refactorisation
- Méthodes de suppression
- Utiliser les warnings
-Wunused-xxxdu compilateur- Par exemple :
-Wunused-function - Le plus souvent, il suffit de supprimer puis de recompiler et retester, mais cela peut plus rarement signaler un bug où la mauvaise fonction est appelée ; il faut donc se méfier d’une automatisation totale
- Par exemple :
- Utiliser un linter comme
cppcheckpour trouver des fonctions inutilisées ou des champs de classe non utilisés- Il peut y avoir beaucoup de faux positifs avec l’héritage et les fonctions virtuelles, mais l’outil peut repérer des éléments inutilisés que le compilateur ne voit pas
- On peut aussi demander au linker de placer chaque fonction dans une section séparée et de signaler les sections inutilisées, mais le bruit venant des fonctions de la bibliothèque standard peut rendre cela peu pratique
- Comparer l’assembleur généré avec le code source fonctionne mal avec les fonctions virtuelles
- Utiliser les warnings
- Utiliser la liste des plateformes prises en charge pour supprimer le code destiné à des plateformes non prises en charge
- Du vieux code de support Solaris dans un projet qui ne tournait en pratique que sur FreeBSD
- Du code avec son propre générateur aléatoire alors que les plateformes réelles disposent toujours d’un RNG
- Du code de compatibilité pour l’absence de POSIX 2001 alors que l’application ne s’exécute que sur Linux et macOS modernes
- Du code qui détecte les CPU big-endian pour faire des byte swaps
- Du code ajouté il y a des années mais qui n’a jamais débouché sur une fonctionnalité réelle
- De grosses PR de suppression de code peuvent montrer à la fois une baisse du temps de build et une baisse du coût de maintenance
Ajouter linter, formateur et sanitizers
- Il ne faut pas activer trop de règles de linter d’un coup, mais intégrer quelques bases au cycle de vie du développement
clang-tidyetcppcheckpeuvent être utiles, mais aussi lents et bruyants- Ne pas avoir de linter du tout n’est pas une option, et la première exécution permet souvent de trouver beaucoup de vrais problèmes que les warnings du compilateur ne détectaient pas
- Le formatage du code doit être appliqué en une seule fois, au bon moment
- Il faut attendre qu’il n’y ait pas de branche active pour éviter des conflits de fusion catastrophiques
- Mieux vaut ne pas perdre de temps dans des débats de style et formatter toute la base de code sans exception avec un outil comme
clang-format - La configuration doit être commitée en même temps
- Les sanitizers sont nécessaires pour repérer des bugs difficiles à trouver qui peuvent affecter la production
- La valeur par défaut recommandée est
-fsanitize=address,undefined - En général, il n’y a pas de faux positifs, donc si quelque chose est détecté, il faut le corriger
- Les tests doivent aussi être exécutés avec les sanitizers activés
- Si le budget de performance le permet, on peut envisager d’exécuter une partie de la production avec certains sanitizers activés
- La valeur par défaut recommandée est
- Même si le compilateur utilisé pour la livraison ne prend pas en charge les sanitizers, on peut utiliser un compilateur comme clang pour le développement et les tests
- Activer les sanitizers peut révéler des bugs et des fuites mémoire cachés depuis longtemps, et leur correction peut demander beaucoup de travail et de refactorisation
- Si possible, il vaut mieux aussi compiler les dépendances tierces avec les sanitizers pendant les tests, afin de trouver également des problèmes à l’intérieur des dépendances
Automatiser avec la CI
- La CI automatise dans un environnement propre les linters, le formatage, les tests et le reste de ce qui a été mis en place
- Il faut pouvoir produire le binaire de production à chaque changement
- La plupart des systèmes de CI prennent en charge des matrices multi-plateformes, ce qui permet de vérifier que la liste des plateformes du README build réellement
- Un pipeline typique peut être aussi simple que
make all test lint fmt - Les problèmes signalés par les linters et les sanitizers doivent faire échouer le pipeline ; sinon, personne ne les corrigera
Simplifier progressivement le code
- Une fois qu’une boucle stable de build, de test et de validation existe, on peut commencer à simplifier le code petit à petit
- On a vu des cas où une classe complexe ne faisait en pratique qu’allouer un pointeur et vérifier s’il est nul, et pouvait donc être remplacée par un booléen
- Cette étape est difficile à borner dans le temps, car plus on simplifie, plus on découvre d’autres simplifications possibles ; il faut donc rester prudent
- L’objectif doit être une valeur concrète comme la sécurité, la justesse ou les performances, et non un critère subjectif comme le « clean code »
- La mise à niveau du standard C++ n’est pas un objectif, mais un moyen
- Par exemple, cela peut aider à remplacer des incréments manuels d’itérateur par une boucle
for (auto x : items) - Si le seul besoin est
std::clamp, l’écrire soi-même peut être préférable
- Par exemple, cela peut aider à remplacer des incréments manuels d’itérateur par une boucle
Une réécriture dans un langage à mémoire sûre demande une décision distincte
- Réécrire une partie dans un langage à mémoire sûre est une option possible, mais elle s’accompagne de nombreuses précautions
- Il ne faut s’y engager qu’en présence de raisons fortes
Pour les dépendances, privilégier un modèle centré sur le build depuis les sources
- C++ ne dispose pas d’une gestion des dépendances cohérente, et beaucoup de projets utilisent le gestionnaire de paquets système
- Problèmes d’une approche fondée sur les paquets système
- Les instructions d’installation dépendent de l’OS, de la distribution et de la version de la distribution
- En passant d’Ubuntu 20.04 à 22.04, on peut se retrouver à devoir mettre à jour 100 dépendances d’un coup à cause d’un changement de version des paquets
- Les dépendances tierces absentes des paquets doivent de toute façon être buildées depuis les sources
- Les paquets ne sont pas forcément buildés avec les flags souhaités
- Les sanitizers, LTO,
-march, les informations de debug, le frame pointer ou les différences d’ABI C++ peuvent poser problème
- Les sanitizers, LTO,
- Il est difficile de consulter le code source exact de la version utilisée lors des audits, du développement ou du debug
- Il est difficile de patcher puis rebuild une dépendance
- Il est difficile d’utiliser exactement les mêmes versions de paquets sur macOS, Ubuntu et FreeBSD
- Générer automatiquement un BOM devient plus compliqué
- Il peut manquer la variante de paquet nécessaire, par exemple en bibliothèque statique ou dynamique
- Des gestionnaires de paquets C++ comme Conan ou vcpkg peuvent constituer une amélioration, mais avec des limites
- La CI peut devenir complexe et lente à cause des dépendances externes
- Toutes les versions de paquets ne sont pas forcément disponibles
- Le mbedtls de Conan saute par exemple de
2.16.12à2.23.0
- Le mbedtls de Conan saute par exemple de
- Le système peut ne pas prendre en charge l’OS ou l’architecture nécessaires
- L’approche recommandée consiste à récupérer les dépendances via des git submodules et à les compiler depuis les sources
- C’est simple
- Cela permet d’utiliser l’historique git et les diff au lieu d’un vendor manuel
- On connaît exactement la version d’une dépendance au commit près
- Une mise à niveau d’une dépendance unique peut se faire avec
git checkout - Cela fonctionne sur toutes les plateformes
- On peut choisir les flags de compilation et le compilateur pour chaque dépendance
- Même sans expérience C++, les développeurs connaissent git
- Cela fonctionne de manière récursive
- On peut builder chaque submodule avec
add_subdirectoryde CMake ougit submodule foreach make - Si les submodules sont trop contraignants, une approche à la Neovim, avec un script unique qui récupère et build les dépendances, est également possible
- Si le graphe de dépendances est très grand, il peut devenir nécessaire d’utiliser un système de build hybride local/distants comme Buck2 et de réutiliser les artefacts de build
- Les gestionnaires de paquets de langages compilés comme Go ou Rust adoptent eux aussi une approche fondée sur la compilation depuis les sources
Points renforcés par les suggestions des lecteurs
- Il faut encore plus insister sur les tests, et une suite de tests C++ doit être exécutée sous sanitizers pour éviter un faux sentiment de sécurité
- vcpkg peut être un meilleur choix que les git submodules si les exigences et la cross-compilation correspondent à vos besoins
- Nix peut jouer le rôle de gestionnaire de dépendances C++, mais sa complexité et sa lenteur posent problème
- Si vous ne corrigez qu’un bug par an, investir dans une grosse refactorisation relève d’un arbitrage ; en revanche, supprimer le code mort et ajouter des sanitizers reste utile même avec des modifications rares
- La suppression de code doit se concentrer sur les cas où l’analyse statique montre qu’il n’y a pas d’appel ; en cas de doute, mieux vaut ne pas supprimer
- Les méthodes virtuelles ont leur cible d’appel déterminée à l’exécution, donc elles sont difficiles à supprimer via une simple analyse statique
- Une conversation de 15 minutes avec les ventes, les product managers ou les utilisateurs pour savoir si une fonctionnalité ou une plateforme est vraiment utilisée peut réduire fortement le travail technique
- Mettre du code dans un LLM et lui poser des questions peut être juridiquement acceptable ; si possible, il vaut mieux l’exécuter localement et traiter les résultats avec prudence
- Il existe aussi l’idée d’utiliser des outils d’analyse de code pour générer des diagrammes et des relations entre classes afin de comprendre la structure d’ensemble
- S’il n’y a pas de système de contrôle de version, l’étape 0 consiste à mettre le code dans un VCS
- Certains considèrent que la CI doit être l’étape 1 ; c’est un point de vue défendable même si le local reste plus rapide
- Supprimer des plateformes presque jamais utilisées peut réduire la complexité combinatoire et permettre de grandes simplifications
- Des builds entièrement reproductibles ne sont peut-être pas réalistes pour une base de code C++ classique, mais des builds fiables le sont
- Pour un commit de formatage global, git peut être configuré pour ignorer ce commit dans le blame, afin de réduire le coût de suivi de l’historique
- On peut aussi utiliser les statistiques de l’historique VCS pour repérer les zones souvent modifiées et les fichiers qui changent ensemble
- Cette approche s’applique non seulement à C++, mais aussi à d’autres bases de code legacy, en dehors des parties spécifiques à C++ comme les sanitizers
Working effectively with Legacy Codeest recommandé comme livre contenant des conseils utiles- Il faut se concentrer sur la valeur réelle, mais sous l’angle de la sécurité, une grande base de code C++ peut contenir de nombreuses vulnérabilités ; la valeur n’est alors pas un gain financier, mais une réduction du risque
1 commentaires
Avis sur Hacker News
Il y a de bons conseils, et d’autres un peu plus controversés. Après avoir hérité de plusieurs énormes projets C++, quelques travaux semblent clairement très rentables au début
Commencer par mettre en place des builds reproductibles, et encapsuler l’environnement de build avec Docker ou l’outil de packaging de votre choix afin de rendre les outils et dépendances explicites et reproductibles
Faire en sorte que le projet compile sans avertissements avec
-Wallfait ressortir le mauvais code, les comportements indéfinis et les bugs, et permet ensuite de voir immédiatement les avertissements quand j’introduis du code suspectVérifier tôt les erreurs de lecture/écriture avec des outils comme
valgrindest aussi un gain facile côté stabilité, et tant qu’on ne comprend pas l’architecture globale, il est plus sûr de garder les refactorings localisésLes Dockerfile qui pointent vers des tags d’images changeants sans hash d’intégrité, ou qui appellent
apt-get, sont courants, et dans les deux cas ils laissent largement la porte ouverte à des états finaux subtilement différentsDes solutions comme Bazel ou Nix sont plus difficiles à apprendre, mais fournissent une base bien meilleure
L’étape 2 consiste à lancer tous les tests sous sanitizers et à signaler ceux qui échouent, l’étape 3 à corriger tous ces échecs sous sanitizers, et l’étape 4 correspond au reste du travail
Les conteneurs sont utiles, mais c’est une très mauvaise façon de faire comme si les problèmes n’existaient pas
Il faut regrouper toutes les dépendances et éviter un état où le logiciel ne fonctionne que si des milliards de choses sont installées dans
/usr/libdans les bonnes versions-Wall, c’est très bien, mais je suis fermement opposé à mettre-Wall -Werrordans les builds de productionCertains avertissements du compilateur relèvent presque de l’opinion, et si une nouvelle version du compilateur ajoute un nouvel avertissement, du code qui était propre jusque-là peut soudain être rejeté
Si
-Werrorest nécessaire, il vaut mieux ne l’utiliser que dans les builds de debugJ’inverserais l’ordre des points 2 et 3. Mettre d’abord en place la CI, le linting, le formatage automatique, etc., doit passer avant le fait d’arracher des choses
Parce qu’on ne sait pas encore quoi supprimer, ni quelles conséquences cela aura
Les linters et les outils d’analyse statique donnent beaucoup d’indications sur les parties du programme qui demandent du travail
Les zones signalées aujourd’hui par les outils d’analyse statique sont souvent celles où l’on pourra plus tard supprimer des fonctions, des classes ou des fichiers entiers qui réimplémentent des concepts de la STL
Une bibliothèque d’itérateurs maison, des smart pointers maison, l’usage de fonctions de chaînes C, etc., peuvent être remplacés par des algorithmes STL, de vrais smart pointers et les classes de chaînes C++
Mais tant qu’on n’a pas scanné le code, cela ne se voit pas bien, et il est aussi difficile d’évaluer le résultat avant de pousser vers des builds de test rapides ou vers le déploiement
Là où il manque quelque chose, j’ajoute des smoke tests de base
Ce travail ne ralentit pas, il accélère au contraire. Une fois une couverture correcte obtenue, on peut avancer beaucoup plus vite lors du refactoring, donc le retour sur ce petit investissement est important
Les gros projets C++ contiennent généralement non seulement des scripts de génération de code, mais aussi des scripts qui parsèrent le code pour collecter des données destinées à cette génération
Le formatage automatique peut les casser, et j’ai même vu des projets maudits où des utilisateurs parsèrent des fichiers d’en-tête publics avec des scripts fragiles
Il est très probable qu’on doive toucher à beaucoup d’endroits, et quand de nombreuses modifications s’accumulent en local, on peut facilement créer par erreur des commits mélangeant plusieurs sujets
Dans ces moments-là, la CI devient une sauveuse
Si vous suivez cette voie, je recommande au minimum de ne pas supprimer directement, mais de commenter pour garder le diff simple jusqu’à ce que la branche soit prête à être squashée puis fusionnée
Il manque les outils et techniques de compréhension du code. J’utilisais autrefois un outil écrit en Tcl/Tk appelé Source Navigator, qui était excellent pour indexer une codebase
Il permettait de consulter la hiérarchie d’appels de la méthode courante et de s’en servir pour produire des diagrammes de séquence UML
Il existe aussi Source Insight, un outil similaire mentionné ci-dessous
Et les notes sont importantes. L’essentiel est d’écrire comme si l’on enseignait à quelqu’un
Au fil des années, je suis devenu plutôt bon pour comprendre du code, et à une époque j’assurais presque seul le support actif et le développement d’une codebase Java de trading algorithmique qui traitait environ 200 millions de dollars par jour sur 4 à 5 places de marché
Il y avait 35 Mo de documentation sur ce code, et si l’on met de côté le risque lié à la personne clé, le niveau de responsabilité était appréciable
Franchement, la plupart des grandes codebases contiennent beaucoup de sur-ingénierie et de duplication
[1] References in "Source Insight" https://d4.alternativeto.net/6S4rr6_0rutCUWnpHNhVq7HMs8GTBs6...
Dans mon cours d’architecture des ordinateurs à l’université, on nous avait forcés à utiliser cette relique, parce qu’elle avait un ensemble de fonctionnalités étrange qu’on ne trouvait pas ailleurs, ainsi que la prise en charge d’un émulateur ARM
Nous l’utilisions pour programmer en assembleur ARM bare metal
Au passage disant que mettre
std::cmakedans la bibliothèque standard changerait la donne, j’ai eu l’impression que ça me retournait l’estomacJe comprends le conseil qui consiste à arracher à la tronçonneuse tout ce qui n’est pas absolument nécessaire pour fournir les fonctionnalités que l’entreprise ou le projet open source met en avant et vend, mais c’est extrêmement dangereux
C’est fondamentalement un problème de barrière de Chesterton : si l’on ne comprend pas complètement pourquoi quelque chose s’est retrouvé dans le logiciel et comment le logiciel est utilisé aujourd’hui, on ne peut pas le supprimer
Dans le pire des cas, au moment de publier environ un mois plus tard, les utilisateurs découvrent qu’une fonctionnalité importante est subtilement cassée, et l’on passe des jours à retracer exactement comment elle l’est
Ajouter de la CI, des linters, du fuzzing, du formatage automatique, etc. est une bonne idée, mais c’est aussi difficile
Si l’un utilise principalement VIM, un autre emacs, un autre QTCreator et un autre VSCode, il est très difficile de mettre tout le monde au diapason
Si l’installation de nouveaux outils est une étape facultative, elle ne se produira pas en pratique, et un linter n’aide pas non plus si, dès l’ouverture du projet, il affiche plus de 2 000 avertissements
cpplint,clang-tidyou un fuzzer intégrés à l’IDE, et ils sont trop lents pour être exécutés automatiquement à chaque frappeSeul le formatage automatique est parfois intégré
Ces choses peuvent être faites en ligne de commande, indépendamment de l’environnement de développement de chacun ; il ne faut donc pas abandonner d’emblée au motif que deux éditeurs de texte sont utilisés, mais au moins essayer
En C++, si l’équipe refuse d’installer le moindre outil, elle va au-devant de grosses difficultés. Il peut aussi valoir la peine d’envisager de conteneuriser les outils pour faciliter les choses
Nous devons travailler en professionnels, et si le travail exige de mettre en place un IDE et une chaîne d’outils, il faut apprendre à s’en servir
Le fait que ça compile et fonctionne sur mon ordinateur, dans mon IDE, à ma manière préférée, relève davantage de l’artisanat que du logiciel
Même si l’installation du hook local est facultative, si les PR échouent dans la CI, les gens finiront par le faire
Ce sont vraiment des bases, mais l’écart de connaissances sur la bonne gestion de la CI et de l’infrastructure semble assez important
Il faut permettre aux développeurs d’exécuter localement les tests, le lint, le fuzzing, le formatage, la validation du format YAML, la vérification de l’absence de saut de ligne en fin de fichier, etc., afin de les aider à éviter un échec de la CI avant de push
Si un linter émet des milliers d’avertissements à l’ouverture du projet, le développeur qui l’ajoute doit faire en sorte qu’il y ait 0 avertissement avant de merger ce changement
On peut y parvenir en désactivant certains avertissements ou certains fichiers, en corrigeant une partie des problèmes, ou en combinant ces approches
À mon avis, la première étape consiste à contacter l’ancien mainteneur, à aller le voir, à lui offrir un thé ou une bière, et à finir par parler de la codebase
Les magiciens que sont les anciens mainteneurs peuvent vous apprendre beaucoup de choses
Les autres suggestions, comme faire tourner le logiciel sur plusieurs plateformes ou faire passer les tests, me semblent être de bons stress tests menant à plus de robustesse et de compréhension
Mais je commencerais quand même par cueillir ce fruit facile : parler aux personnes qui sont passées avant
Il vaut mieux commencer par travailler un peu soi-même, se retrouver bloqué à plusieurs endroits, puis parler à l’ancien mainteneur : ce sera bien plus productif
Ils apprécieront aussi cet effort
Si vous héritez d’une codebase sans tests, dont le build échoue une fois sur deux, avec des informations de dépendances peu claires, et qui ne compile que sur un unique serveur équipé d’un OS très ancien, vous ne pouvez pas être certain que l’ancien mainteneur soit vraiment un magicien
Il faut aussi se demander si tous les problèmes viennent d’un manque de temps, ou si ce « magicien » l’a maintenue cassée pour assurer sa sécurité de l’emploi, ou parce qu’il ne voulait pas apprendre de nouvelles choses
C’est le meilleur des cas ; le cas courant, c’est que la réponse à toutes les questions soit : « c’était déjà comme ça quand j’en ai hérité »
Pendant le travail, je suis tombé sur plusieurs bugs et problèmes, et j’ai essayé de contacter les développeurs qui avaient écrit ce logiciel sur mesure pour mon employeur
Il s’est avéré qu’il avait été écrit par un seul sous-traitant, décédé quelques années plus tôt
Dans l’industrie de la défense, ce genre de situation est courant. Il y a beaucoup d’objets sur mesure et jetables, conçus pour un système particulier, et surtout côté matériel, il n’est pas rare que les ingénieurs qui ont conçu l’équipement soient partis ou à la retraite depuis longtemps
Ça aide beaucoup
Une codebase C++ avec des tests, c’est optimiste à mon avis
Je ne comprends pas vraiment pourquoi il y a autant d’insistance sur le refactoring ou l’amélioration. Si on peut ajouter une fonctionnalité en la greffant au code, il suffit de faire ça sans toucher au reste
Si le changement est suffisamment important, on peut extraire ce dont on a besoin du code legacy via un appel à une fonction externe, l’introduction d’une couche réseau, ou la séparation du code commun dans une bibliothèque, puis faire le reste dans un nouvel environnement
Je ne tenterais pas de gros refactoring, sauf si plusieurs personnes doivent travailler sur ce code à l’avenir et qu’il faut certaines hypothèses et standards pour que le groupe puisse collaborer facilement
L’approche qui consiste à ajouter par-dessus fonctionne jusqu’à un certain point, mais il arrive un moment où, après une modification, quelque chose casse soudainement et le désordre est tel qu’on met bien trop longtemps à en trouver la cause
Le propos de l’article est plutôt d’éviter les grandes réécritures, tout en faisant du rangement pour maintenir la cohésion et faciliter les changements
Parfois, il fallait ajouter la même fonctionnalité dans les deux versions
On ne traite pas du tout de la même façon un code dont on sait que la dernière release arrive bientôt et un code qu’il faudra maintenir et enrichir pendant encore des décennies
En pratique, une nouvelle fonctionnalité est souvent une modification d’un comportement déjà existant, et on se retrouve soudain à devoir faire un gros refactoring à plusieurs endroits
C’est un fil avec beaucoup de bons conseils. Pour ajouter quelque chose qui ne se limite pas à C/C++, si vous avez la possibilité d’utiliser un système de gestion de versions, il faut en exploiter pleinement la valeur
Beaucoup d’équipes s’en servent seulement comme outil de collaboration, mais il permet de faire bien plus
Il suffit de récupérer l’historique et d’en faire une petite base de données. Pas besoin que ce soit une base relationnelle : on peut commencer avec des fichiers JSON ou une feuille de calcul
Rien qu’avec une approche guidée par les données, on peut presque immédiatement extraire beaucoup d’informations utiles
Les fichiers et fonctions souvent modifiés seront probablement des hotspots des prochains travaux ; si l’on veut introduire des tests unitaires ou réduire les conflits de merge, il faut donc se concentrer dessus
Si des fichiers qui semblent éloignés changent souvent ensemble, cela peut indiquer une structure implicite qui ne se voit pas à la seule lecture du code
Le modèle réel de propriété de chaque module peut aussi être déduit de l’historique, et une propriété floue peut être un signal qu’un refactoring est nécessaire
En C/C++, l’amélioration des temps de build doit elle aussi se concentrer, à partir des données, sur les modules importants. Plutôt que de supprimer au hasard des dépendances de fichiers, on peut découper les modules qui changent souvent et même les combiner aux dépendances de headers pour attribuer un score à leur impact réel sur le temps de build
En intégrant le VCS à d’autres outils de développement, on peut faire encore davantage ; à l’ère des LLM, on pourrait aussi injecter l’historique du projet et ses métadonnées dans un modèle pour lui demander des éclairages intéressants
Sans énorme fenêtre de contexte, cela demanderait peut-être une ingénierie de modèle dédiée, mais j’ai l’intuition que ça vaut la peine d’essayer
Le conseil d’ajouter de la CI, des linters, du fuzzing, du formatage automatique, etc., mérite d’être davantage découpé
La CI doit garantir que le projet build ailleurs que sur ma machine, afin d’éviter les régressions liées à la compilation
Les avertissements du compilateur et les analyseurs statiques sont généralement plus intelligents que moi ; donc s’ils signalent quelque chose d’inquiétant parce que je fais des choses bizarres avec des pointeurs, c’est un signal fort à vérifier
Les tests unitaires doivent vérifier que le code important fait exactement ce qu’on attend de lui à bas niveau, et comme il y a de fortes chances que ce ne soit pas le cas, il faut comprendre pourquoi
Corriger quelque chose peut en faire exploser une autre, car le code existant a peut-être été écrit en supposant un comportement bogué
Le formatage automatique n’est pas une priorité, et je pense qu’il vaut mieux suivre le style des mainteneurs existants
L’idée que la dernière étape d’une base de code C++ héritée soit une réécriture dans un langage à sûreté mémoire a aussi de grandes chances de mal s’appliquer
Il est difficile d’obtenir des ressources de travail supplémentaires pour quelque chose qui n’est pas cassé ; il faut alors connaître un langage de plus en plus du C++, et les tests peuvent devenir plus complexes
Les contraintes de mémoire ou de performance peuvent aussi rendre peu adaptée une écriture en plusieurs langages ; et, au départ, hériter d’une base de code legacy revient presque à reconnaître qu’on manquait de ressources — temps, argent, connaissances — pour la réécrire
« Réécrire en X » ajoute seulement de la complexité parce que c’est à la mode
Si l’on est déjà en train de réécrire une grande partie de la base de code en C++, mieux vaut suivre un sous-ensemble plus restrictif de C++, et High Integrity C++ me semble correct
Si l’on peut se procurer la norme MISRA récente, elle sera probablement très bien aussi
Plutôt que de faire réapprendre à toute l’équipe les angles dangereux d’un nouveau langage, mieux vaut utiliser un langage qu’elle connaît déjà tout en imposant des règles pour éviter ses pièges connus
Il est étrange que l’auteur critique longuement l’automatisation de la BOM, la gestion des versions de paquets, la provenance des dépendances, etc., puis suggère que les git submodules sont préférables à un gestionnaire de paquets
Avant de formuler ce genre de critique, il faudrait essayer vcpkg
Il y a bien quelques aspérités, mais presque tout est couvert de façon intuitive par vcpkg
La mise à jour des dépendances est un peu plus difficile qu’avec des git submodules, mais j’y vois plutôt une fonctionnalité qu’un bug. Les dépendances sont buildées dans des bacs à sable séparés, puis installées dans un répertoire spécifié
vcpkg permet de conserver un caractère vendored-in en configurant un dépôt interne comme registre au lieu du dépôt officiel, de chaîner la toolchain pour que tout soit compilé avec un jeu de flags fixe, et autorise aussi les personnalisations par port
C’est parce que ce type d’abstraction est utile que les gestionnaires de paquets sont populaires, et que tout le monde n’a pas à manipuler directement des chaînes interminables de flags de compilation, de macros, d’avertissements, etc.
L’article était intéressant et j’y ai appris des choses. Je me demandais toutefois à quels langages les gens font référence quand ils parlent de « réécrire dans un langage sûr pour la mémoire »
Veulent-ils dire réécrire certaines parties en Go, Java ou C#, ou bien est-ce une pique du genre « réécrivons en Rust », avec une part de dénégation possible ?
La conclusion dépend entièrement de l’équipe et des contraintes. Par exemple, il faut savoir si le garbage collection est envisageable, et si oui, si Go est un bon choix, si la sécurité est la priorité absolue, etc.
La plupart des développeurs C++ peuvent généralement utiliser Rust assez facilement et obtenir des performances équivalentes, à mon avis
Mais il y a aussi des cas où il n’y avait pas vraiment de bonne raison que le projet soit en C++ au départ, et j’ai vu des réécritures réussies en Java
Apple réécrit une partie de son code C++ en Swift
Une bonne règle empirique est de choisir un langage avec lequel l’équipe ou l’entreprise est à l’aise
On en est donc au stade où l’on soupçonne désormais l’auteur d’être un programmeur Rust maléfique et crypté. Sans doute parce que Rust n’était pas mentionné au départ, donc qu’il n’était pas possible de s’en plaindre directement