Réusinage de code legacy en C++ : comment transformer un « zoo » en un projet maintenable
Travailler avec du code legacy en C++ est une réalité inévitable pour de nombreux développeurs. Souvent, ce code manque de tests, regorge de comportements indéfinis (UB), de variables globales et de gestion manuelle de la mémoire. Cependant, le réusinage de code legacy en C++ n'est pas simplement un « coup de peigne » sur les fichiers, mais un processus systématique visant à améliorer la fiabilité et les performances. Dans cet article, nous allons détailler les étapes clés qui vous aideront à moderniser en toute sécurité un ancien projet sans risquer de tout casser.
1. Préparation : tests et isolation
La première et la plus importante étape est de créer un « filet de sécurité ». Sans tests, tout réusinage se transforme en jeu de roulette russe.
- Tests de caractérisation : Écrivez des tests qui figent le comportement actuel du code. Même s'il semble erroné, commencez par le figer.
- Analyse statique : Utilisez des outils comme
clang-tidyouCppcheck. Ils identifieront les fuites potentielles, le code inutilisé et les violations de style. - Sanitizers : Exécutez le code avec
AddressSanitizeretUndefinedBehaviorSanitizer. Cela aidera à trouver les UB cachés avant de commencer les modifications.
Exemple d'ajout d'un sanitizer dans CMake :
# CMakeLists.txt
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=address,undefined -fno-omit-frame-pointer")
2. Se débarrasser des pointeurs « nus » et de la gestion manuelle de la mémoire
Le C++ legacy souffre souvent de fuites mémoire et de doubles suppressions. Le C++ moderne (C++11 et versions ultérieures) propose des wrappers RAII.
- Remplacement de
new/deletepar des pointeurs intelligents :std::unique_ptrpour la propriété exclusive,std::shared_ptrpour la propriété partagée. - Utilisation de
std::vectorà la place des tableaux bruts : Cela réduit le risque de dépassement de limites.
Exemple de réusinage :
Avant :
class LegacyBuffer {
char* data;
size_t size;
public:
LegacyBuffer(size_t sz) : size(sz) {
data = new char[sz];
}
~LegacyBuffer() { delete[] data; }
// Constructeur de copie et opérateur d'affectation oubliés (violation de la "règle de trois")
};
Après :
class ModernBuffer {
std::vector<char> data;
public:
ModernBuffer(size_t sz) : data(sz) {}
// Le destructeur, la copie et l'affectation sont générés automatiquement par le compilateur
};
3. Élimination des macros et des états globaux
Les macros (#define) sont une cause fréquente de bugs difficiles à détecter. Elles doivent être remplacées par constexpr, enum class ou des templates. Les variables globales compliquent les tests et le parallélisme.
- Remplacement de
#define BUFFER_SIZE 1024parconstexpr size_t BUFFER_SIZE = 1024; - Encapsulation des globales : Extrayez l'état global dans une classe singleton ou, mieux, transmettez les dépendances via le constructeur.
Exemple avec une macro :
// Mauvais : la macro n'a pas de portée
#define MAX_CONNECTIONS 100
// Bien : constante avec type et portée
namespace Config {
constexpr int maxConnections = 100;
}
Utilisation de enum class à la place de #define pour les drapeaux :
// Au lieu de #define FLAG_READ 1
enum class FileMode : uint8_t {
Read = 1,
Write = 2,
ReadWrite = 3
};
4. Simplification des fonctions complexes et suppression du code mort
Les fonctions legacy sont souvent des « pavés » de code de 500+ lignes avec de nombreuses conditions imbriquées. Stratégie :
- Extraction de méthode : Décompo