Skip to content
C++
Tooling
since C++11
Intermediate

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:

bash
# 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:

bash
# 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++17

Run 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:

cpp
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

bash
# 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' \
               -fix

Key modernize checks

CheckWhat it fixes
modernize-use-nullptrNULL, 0 β†’ nullptr
modernize-use-overrideAdds override to virtual method overrides
modernize-loop-convertfor (int i=0; i<v.size(); i++) β†’ range-for
modernize-use-autoExplicit types β†’ auto where deducible
modernize-make-uniquenew T(...) wrapped in unique_ptr β†’ make_unique<T>(...)
modernize-make-sharednew T(...) wrapped in shared_ptr β†’ make_shared<T>(...)
modernize-replace-auto-ptrauto_ptr β†’ unique_ptr
modernize-use-usingtypedef T U β†’ using U = T
modernize-use-emplacepush_back(T(...)) β†’ emplace_back(...)
modernize-avoid-bindstd::bind(...) β†’ equivalent lambda
modernize-use-default~T() {} β†’ ~T() = default
modernize-use-equals-deleteCommented-out copy constructor β†’ = delete
modernize-pass-by-valueCopy constructor params β†’ pass-by-value + std::move

Pattern-by-pattern: C++98/03 β†’ C++11

1. NULL and 0 β†’ nullptr

cpp
// 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*) β€” correct

nullptr has type std::nullptr_t β€” it won't accidentally match integer overloads.

2. typedef β†’ using

cpp
// 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

cpp
// 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

cpp
// 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.

cpp
// 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

cpp
// 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

cpp
#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

cpp
// 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

cpp
// 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 memory

2. std::optional for functions that might not return

cpp
// 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 default

3. Structured bindings for pairs and tuples

cpp
// 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

cpp
// 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 iterator

5. std::variant instead of union + tag

cpp
// 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

cpp
// 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++ vs libc++)
  • Compiler version (major ABI changes happen between GCC major releases)
  • C++ standard (-std=c++17 changes 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.

bash
# 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 GLIBCXX

Modernization checklist

Legacy patternModern replacementStandard
NULL, 0 for pointersnullptrC++11
typedef T U;using U = T;C++11
virtual f() {} without overridef() override {}C++11
Index-based for loopsRange-for for (auto& x : v)C++11
std::auto_ptr<T>std::unique_ptr<T>C++11
new T(...) returned/storedstd::make_unique<T>(...)C++14
std::bind(f, _1, val)Lambda [=](auto x) { return f(x, val); }C++11
private: copy ctor (never defined)= deleteC++11
Manual copy-init in each ctorIn-class member initializersC++11
const std::string& paramsstd::string_viewC++17
Sentinel return values (-1, nullptr)std::optional<T>C++17
Tagged unionsstd::variant<T1, T2, ...>C++17
result.first / result.secondStructured bindingsC++17
it = map.find(k); if (it != ...)if (auto it = map.find(k); it != ...)C++17
POSIX/Win32 file I/Ostd::filesystemC++17
#include <boost/optional.hpp>#include <optional>C++17