Refactoring Legacy C++ Code: A Step-by-Step Guide
Working with legacy C++ code is one of the most challenging but inevitable tasks in a developer's career. Legacy code often lacks unit tests, contains magic numbers, memory leaks, and tangled dependencies. However, refactoring such code without risking breaking the system is possible by following proven strategies. In this article, we will break down key approaches to refactoring legacy C++ code with practical examples.
1. Preparation: Creating a Safety Net
Before changing the code, it is necessary to create a "safety net" of tests. For C++, this is especially important, as errors can lead to undefined behavior or program crashes.
Step 1.1: Characterization Testing
Write tests that capture the current behavior of the code, even if it is incorrect. Use frameworks like Google Test or Catch2.
// Example of a characterization test for a legacy function#include <gtest/gtest.h>
// Legacy function we will refactorint calculatePrice(int quantity, float basePrice) { if (quantity > 100) { return static_cast<int>(basePrice * quantity * 0.9); } else { return static_cast<int>(basePrice * quantity); }}
TEST(PriceCalculatorTest, LegacyBehavior) { EXPECT_EQ(calculatePrice(50, 10.0f), 500); EXPECT_EQ(calculatePrice(150, 10.0f), 1350); // 150*10*0.9}Step 1.2: Integration into CI/CD
Add the tests to the continuous integration system. This ensures that changes do not break existing logic.
2. Strategy: "Smart" Refactoring Without Pain
Do not try to rewrite everything at once. Use an incremental approach.
2.1. Extract Interface
If the code is tightly coupled with global states or specific classes, start by extracting abstractions.
Problem: The class works directly with the file system.
// Legacy codeclass DataProcessor {public: void process(const std::string& filename) { std::ifstream file(filename); // complex processing logic }};Solution: Introduce an interface and dependency injection.
// New interfaceclass IFileReader {public: virtual std::string read(const std::string& path) = 0; virtual ~IFileReader() = default;};
// Refactoringclass DataProcessor {public: explicit DataProcessor(std::unique_ptr<IFileReader> reader) : reader_(std::move(reader)) {} void process(const std::string& filename) { auto data = reader_->read(filename); // processing logic }
private: std::unique_ptr<IFileReader> reader_;};2.2. Replace Magic Numbers with Constants
Legacy code often contains numbers whose meaning is unclear.
// Before refactoringif (buffer.size() > 4096) { resizeBuffer(8192);}
// After refactoringconstexpr size_t kMaxBufferSize = 4096;constexpr size_t kResizeTarget = 8192;
if (buffer.size() > kMaxBufferSize) { resizeBuffer(kResizeTarget);}3. Combating Memory Leaks and Unsafe Constructs
C++ legacy code often uses raw pointers and manual memory management. Replace them with smart pointers and RAII wrappers.
3.1. Replace new/delete with std::unique_ptr
// Old code (risk of leak on exception)void processData() { Data* data = new Data(); // ... work with data ... delete data;}
// New code (safe)void processData() { auto data = std::make_unique<Data>(); // ... work with data ... // delete not needed}3.2. Use std::vector Instead of Raw Arrays
//