Modernizing Legacy C++: C++98/11 to C++17/20
Practical playbook for upgrading a C++98/03 or C++11 codebase: migration strategy, clang-tidy automation, and a pattern-by-pattern refactoring guide.
Existing C++ codebases vastly outnumber greenfield projects. This page covers the systematic approach to bringing a C++98/03 or C++11 codebase into the modern era β without breaking anything.
Migration strategy: the three phases
Phase 1 β Compile on a new compiler, same standard
Before touching the language version, switch to a modern compiler while keeping the current -std=c++03 or -std=c++11 flag. Modern compilers are stricter and may expose latent bugs:
# Example: switch from GCC 4.8 to GCC 13, same standard
cmake -DCMAKE_CXX_COMPILER=g++-13 -DCMAKE_CXX_FLAGS="-std=c++11" ..
# Fix all new warnings before proceeding
cmake -DCMAKE_CXX_FLAGS="-std=c++11 -Wall -Wextra -Wpedantic" ..Common issues found at this stage: stricter template instantiation rules, deprecated APIs removed, narrowing conversion errors, undefined behavior the old compiler silently accepted.
Phase 2 β Bump the standard, run as-is
Once the codebase compiles cleanly on the new compiler, bump the standard flag. Most C++ version upgrades are backward compatible:
# C++11 β C++17 in CMake
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) # disable -std=gnu++17; use strict -std=c++17Run your full test suite. At this point you get free performance improvements from move semantics and improved STL implementations β without changing a line of code. Record all test results as your baseline.
Phase 3 β Incrementally modernize, one pattern at a time
Modernize in small commits, one pattern per commit. This makes it easy to bisect regressions and gives a clear git history:
git: "Port to nullptr (clang-tidy modernize-use-nullptr)"
git: "Port to override/final (clang-tidy modernize-use-override)"
git: "Port to range-for (clang-tidy modernize-loop-convert)"
git: "Replace auto_ptr with unique_ptr"
git: "Replace typedef with using"clang-tidy: automated modernization
clang-tidy can automatically fix many C++98 β C++17 patterns. The modernize-* checks all have auto-fix support.
Setup with CMake
# Generate compile_commands.json (required by clang-tidy)
cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_BUILD_TYPE=Debug ..
# Run a single modernize check with auto-fix across the whole project
run-clang-tidy -header-filter='.*' \
-checks='-*,modernize-use-nullptr' \
-fix
# Run multiple checks at once (apply one at a time to keep diffs clean)
run-clang-tidy -header-filter='.*' \
-checks='-*,modernize-use-override,modernize-use-nullptr' \
-fixKey modernize checks
| Check | What it fixes |
|---|---|
modernize-use-nullptr | NULL, 0 β nullptr |
modernize-use-override | Adds override to virtual method overrides |
modernize-loop-convert | for (int i=0; i<v.size(); i++) β range-for |
modernize-use-auto | Explicit types β auto where deducible |
modernize-make-unique | new T(...) wrapped in unique_ptr β make_unique<T>(...) |
modernize-make-shared | new T(...) wrapped in shared_ptr β make_shared<T>(...) |
modernize-replace-auto-ptr | auto_ptr β unique_ptr |
modernize-use-using | typedef T U β using U = T |
modernize-use-emplace | push_back(T(...)) β emplace_back(...) |
modernize-avoid-bind | std::bind(...) β equivalent lambda |
modernize-use-default | ~T() {} β ~T() = default |
modernize-use-equals-delete | Commented-out copy constructor β = delete |
modernize-pass-by-value | Copy constructor params β pass-by-value + std::move |
Pattern-by-pattern: C++98/03 β C++11
1. NULL and 0 β nullptr
// Old:
Widget* w = NULL;
if (w == 0) { }
void f(int);
void f(Widget*);
f(NULL); // calls f(int) β bug!
// Modern:
Widget* w = nullptr;
if (w == nullptr) { }
f(nullptr); // calls f(Widget*) β correctnullptr has type std::nullptr_t β it won't accidentally match integer overloads.
2. typedef β using
// Old: typedef β opaque, no template support
typedef std::vector<std::pair<int, std::string>> PairVec;
typedef void (*Callback)(int, const char*);
// Modern: using β readable, supports templates
using PairVec = std::vector<std::pair<int, std::string>>;
using Callback = void(*)(int, const char*);
// using enables alias templates (typedef cannot do this):
template<typename T>
using StringMap = std::map<std::string, T>;
StringMap<int> ages;
StringMap<double> scores;3. override and final
// Old: silent bug β mismatched signature doesn't override
struct Base {
virtual void process(int x) {}
virtual ~Base() {}
};
struct Derived : Base {
virtual void process(long x) {} // doesn't override β hides instead
};
// Modern: compiler catches the error immediately
struct Derived : Base {
void process(int x) override {} // error if signature doesn't match Base
};
struct Leaf final : Base { // final: no further subclassing
void process(int x) override final {}
};4. Range-for instead of index loops
// Old: error-prone iteration
std::vector<std::string> names = {"Alice", "Bob"};
for (std::size_t i = 0; i < names.size(); i++) {
std::cout << names[i] << "\n";
}
// Modern:
for (const auto& name : names) {
std::cout << name << "\n";
}
// Modify in place:
for (auto& name : names) {
name += "_processed";
}When to keep index loops: when you need the index itself, need to erase while iterating, or need to iterate two containers simultaneously.
5. auto_ptr β unique_ptr
std::auto_ptr was deprecated in C++11 and removed in C++17. Its transfer-on-copy semantics are surprising and break with STL containers.
// Old (C++98): auto_ptr β broken copy semantics
std::auto_ptr<Widget> w1(new Widget(1));
std::auto_ptr<Widget> w2 = w1; // w1 is now empty β silent!
// Modern: unique_ptr β explicit ownership transfer
std::unique_ptr<Widget> w1 = std::make_unique<Widget>(1);
// std::unique_ptr<Widget> w2 = w1; // compile error β can't copy
std::unique_ptr<Widget> w2 = std::move(w1); // explicit move; w1 is empty
// In containers (auto_ptr could not be stored safely):
std::vector<std::unique_ptr<Widget>> widgets;
widgets.push_back(std::make_unique<Widget>(1));
widgets.push_back(std::make_unique<Widget>(2));6. Constructor delegation and in-class member init
// Old: duplicated init logic across constructors
class Config {
std::string path_;
int timeout_;
bool debug_;
public:
Config() : path_("/etc/app.conf"), timeout_(30), debug_(false) {}
Config(std::string p) : path_(p), timeout_(30), debug_(false) {}
Config(std::string p, int t) : path_(p), timeout_(t), debug_(false) {}
};
// Modern: in-class defaults + delegating constructors
class Config {
std::string path_ = "/etc/app.conf";
int timeout_ = 30;
bool debug_ = false;
public:
Config() = default;
Config(std::string p) : path_(std::move(p)) {}
Config(std::string p, int t) : Config(std::move(p)) { timeout_ = t; }
};7. std::bind β lambdas
#include <functional>
// Old: std::bind β hard to read, surprising argument semantics
auto add_five = std::bind(std::plus<int>(), std::placeholders::_1, 5);
auto log_call = std::bind(&Logger::log, &logger, std::placeholders::_1);
// Modern: lambdas β explicit and debuggable
auto add_five = [](int x) { return x + 5; };
auto log_call = [&logger](const std::string& msg) { logger.log(msg); };8. Default and deleted functions
// Old: commenting out copy constructor to disable copying
class File {
public:
File(const char* path);
~File();
private:
File(const File&); // declared but never defined
File& operator=(const File&); // "trick" to prevent copying
FILE* handle_;
};
// Modern: = delete is explicit and gives a better error message
class File {
public:
explicit File(const char* path);
~File() noexcept;
File(const File&) = delete;
File& operator=(const File&) = delete;
// Explicitly request compiler-generated move
File(File&&) noexcept = default;
File& operator=(File&&) noexcept = default;
private:
FILE* handle_;
};Pattern-by-pattern: C++11 β C++17
1. std::string_view for read-only strings
// C++11: const string& requires a std::string object
void process(const std::string& s); // can't pass const char* without copy
// C++17: string_view accepts string, const char*, string literal β no copy
#include <string_view>
void process(std::string_view s); // zero-copy, works with anything string-like
process("hello"); // no allocation
process(large_string); // no copy
process(large_string.substr(0, 10)); // no copy β view into existing memory2. std::optional for functions that might not return
// C++11: sentinel values (-1, nullptr, bool out-param)
int find_index(const std::vector<int>& v, int target) {
for (int i = 0; i < v.size(); i++)
if (v[i] == target) return i;
return -1; // -1 means "not found" β easy to forget to check
}
// C++17: optional makes the "might not exist" case explicit
#include <optional>
std::optional<int> find_index(const std::vector<int>& v, int target) {
for (int i = 0; i < (int)v.size(); i++)
if (v[i] == target) return i;
return std::nullopt;
}
auto idx = find_index(v, 42);
if (idx) { std::println("Found at {}", *idx); }
int i = idx.value_or(-1); // or provide a default3. Structured bindings for pairs and tuples
// C++11: verbose pair/tuple access
auto result = parse("key=value");
std::string key = result.first;
std::string value = result.second;
// C++17: structured bindings
auto [key, value] = parse("key=value");
// Works with map iteration too:
std::map<std::string, int> ages = {{"Alice", 30}, {"Bob", 25}};
for (const auto& [name, age] : ages) {
std::println("{} is {}", name, age);
}4. if with initializer
// C++11: variable leaks into outer scope
auto it = my_map.find("key");
if (it != my_map.end()) {
use(it->second);
}
// it is still in scope here β pollution
// C++17: scoped to the if block
if (auto it = my_map.find("key"); it != my_map.end()) {
use(it->second);
}
// it is gone β can't accidentally use stale iterator5. std::variant instead of union + tag
// C++11/C style: manual tagged union β error-prone
struct Result {
enum { INT, STRING, ERROR } tag;
union {
int int_val;
char str_val[64];
int error_code;
};
};
// C++17: type-safe, no UB
#include <variant>
using Result = std::variant<int, std::string, std::error_code>;
Result r = parse_input(s);
if (auto* v = std::get_if<int>(&r)) {
std::println("int: {}", *v);
} else if (auto* v = std::get_if<std::string>(&r)) {
std::println("string: {}", *v);
}
// Or with std::visit:
std::visit([](auto&& v) { std::println("{}", v); }, r);6. std::filesystem instead of POSIX/Win32
// Old: platform-specific or Boost.Filesystem
#ifdef _WIN32
// CreateDirectory, FindFirstFile...
#else
// mkdir, opendir, readdir...
#endif
// C++17: portable
#include <filesystem>
namespace fs = std::filesystem;
fs::path p = "/var/log/app";
fs::create_directories(p);
for (const auto& entry : fs::directory_iterator(p)) {
if (entry.path().extension() == ".log") {
auto size = fs::file_size(entry.path());
std::println("{}: {} bytes", entry.path().filename().string(), size);
}
}ABI considerations when upgrading
Changing the standard or compiler can break the ABI (Application Binary Interface). All translation units linked together must use the same:
- Standard library version (
libstdc++vslibc++) - Compiler version (major ABI changes happen between GCC major releases)
- C++ standard (
-std=c++17changes symbol mangling)
Rule: if you change any of these, recompile all static/dynamic libraries from source too. Mixing ABIs causes linker errors at best, silent data corruption at worst.
# Check which libstdc++ version a binary was linked against:
ldd my_binary | grep libstdc
objdump -p my_binary | grep NEEDED
# Check C++ version symbols needed by an object file:
nm -D mylib.so | grep GLIBCXXModernization checklist
| Legacy pattern | Modern replacement | Standard |
|---|---|---|
NULL, 0 for pointers | nullptr | C++11 |
typedef T U; | using U = T; | C++11 |
virtual f() {} without override | f() override {} | C++11 |
Index-based for loops | Range-for for (auto& x : v) | C++11 |
std::auto_ptr<T> | std::unique_ptr<T> | C++11 |
new T(...) returned/stored | std::make_unique<T>(...) | C++14 |
std::bind(f, _1, val) | Lambda [=](auto x) { return f(x, val); } | C++11 |
private: copy ctor (never defined) | = delete | C++11 |
| Manual copy-init in each ctor | In-class member initializers | C++11 |
const std::string& params | std::string_view | C++17 |
| Sentinel return values (-1, nullptr) | std::optional<T> | C++17 |
| Tagged unions | std::variant<T1, T2, ...> | C++17 |
result.first / result.second | Structured bindings | C++17 |
it = map.find(k); if (it != ...) | if (auto it = map.find(k); it != ...) | C++17 |
| POSIX/Win32 file I/O | std::filesystem | C++17 |
#include <boost/optional.hpp> | #include <optional> | C++17 |