Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C++  Module 23 – Design, idioms and maintainable C++

Reading and modernising legacy C++

Read C++98-era code, record what it does before changing it, and modernise it one checked step at a time with clang-tidy, both compilers and sanitizers.

  • Intermediate
  • 20 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 8 of 20 minutes free
Free: Every C++98 pattern has a modern replacement · A C++23 build shows what C++17 removed · One checked change at a time keeps the behaviour · Practise: read it, then modernise it safely. With a pass: 2 in-depth parts and 3 interview answers, about 12 minutes.

What you will learn

  • Read C++98-era code and name the modern replacement of each old pattern
  • Predict what each compiler reports when C++98 code is built as C++23
  • Refactor legacy code step by step without changing its behaviour
  • Automate safe mechanical changes with clang-tidy's modernize checks
  • Recognise changes that look mechanical but change what a program does

Before you start

What you’ll learn · Before you start

What you will learn

  • Read C++98-era code and name the modern replacement of each old pattern
  • Predict what each compiler reports when C++98 code is built as C++23
  • Refactor legacy code step by step without changing its behaviour
  • Automate safe mechanical changes with clang-tidy's modernize checks
  • Recognise changes that look mechanical but change what a program does

Before you start

On this page

Legacy C++ is code written before C++11, or in its style: raw new and delete with ownership kept in comments, null pointer constant: nullptr, or an integer literal whose value is zero such as 0: an expression that converts to any pointer type as a null pointer. Because 0 is also an int, nullptr is the one that cannot be mistaken for a number. More in “C++ function overloading and default arguments”, typedef, macros for constants, iterator loops written out by hand and C strings. Reading it means recognising each of these patterns and the rule it was working around. Modernising legacy C++ means replacing them with what the language now offers, without changing what the program does. That takes three habits. Record the program’s current behaviour in a test before touching anything. Change one thing at a time, letting linter: A tool that reads source code and reports patterns that are likely bugs, older idioms or poor style, without running the program. clang-tidy is a linter for C and C++. More in “clang-tidy, clang-format and static analysis”’s modernize-* checks make the mechanical changes. And check every step with both compilers and the sanitizers. Modernising legacy C++ also includes deciding where not to: working code that nobody needs to change is often best left alone.

Every C++98 pattern has a modern replacement

Each row pairs an old pattern with its replacement, the edition that brought it and its clang-tidy check:

C++98 pattern Its replacement Since clang-tidy check
NULL or 0 as a null pointer nullptr C++11 modernize-use-nullptr
typedef std::vector<int> IntList; using IntList = std::vector<int>; C++11 modernize-use-using
An iterator loop over a whole container A range-based for loop C++11 modernize-loop-convert
Private copy operations that are never defined = delete C++11 modernize-use-equals-delete
#define LOW_STOCK 5 constexpr int low_stock = 5; C++11 modernize-macro-to-enum (an enum; constexpr by hand)
<string.h>, <stdlib.h> <cstring>, <cstdlib> C++98 already modernize-deprecated-headers
new with a hand-written delete std::make_unique, containers, std::string C++11, C++14 modernize-make-unique for the new; the delete by hand
std::auto_ptr std::unique_ptr Removed in C++17 modernize-replace-auto-ptr
throw(std::bad_alloc) on a function, or throw() Nothing or noexcept(false); noexcept for throw() Removed in C++17; throw() in C++20 modernize-use-noexcept
std::random_shuffle, std::bind1st std::shuffle, a lambda Removed in C++17 modernize-replace-random-shuffle; bind1st by hand
register int total; int total; Removed in C++17 None: delete the word

The first rows make code shorter and safer but leave it valid. The last four stop code from compiling as C++17 or later, which is often the first reason a team starts reading old code closely. Walk through the editions that changed them:

Timeline · 6 events in order The editions that retired the patterns of C++98 Walk through the editions in order. Each one added the replacement for an old pattern, and C++17 removed the old patterns themselves.
  1. C++98

    The patterns this lesson reads

    Code of this era keeps a single owner in std::auto_ptr, writes NULL or 0 for a null pointer, names types with typedef, spells out iterator loops and defines constants with #define.

  2. C++11

    The replacements arrive new syntax

    C++11 added nullptr, type aliases with using, the range-based for loop, = default and = delete, constexpr and std::unique_ptr. It deprecated std::auto_ptr, std::bind1st, the register keyword and dynamic exception specifications.

  3. C++14

    std::make_unique

    C++14 added std::make_unique, so a std::unique_ptr can be created without writing new. It deprecated std::random_shuffle in favour of std::shuffle.

  4. C++17

    The old patterns are removed breaking change

    C++17 removed std::auto_ptr, std::random_shuffle, std::bind1st and its relatives, the meaning of register (the word stays reserved) and dynamic exception specifications, keeping throw() only as another spelling of noexcept(true).

  5. C++20

    Formatting and ranges

    C++20 added the formatting library, std::format, and the ranges library, whose algorithms take a whole container instead of a pair of iterators. It also removed throw(), the last form of dynamic exception specification, which noexcept replaces.

  6. C++23

    std::print and std::ranges::to

    C++23 added std::print and std::println, and std::ranges::to, which collects the elements of a range into a new container.

The timeline has 6 events, from C++98 to C++23.

Checkpoint

Check: Old patterns and their replacements

  1. Question 1 of 2 Which line of a C++98 file still compiles under the C++17 and C++23 standards?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: typedef std::vector<int> IntList;

    The first rows of the table make code shorter and safer but leave it valid; the last four, auto_ptr, the old exception specifications, random_shuffle and bind1st, and register, stop code from compiling as C++17 or later.

  2. Question 2 of 2 clang-tidy's modernize-make-unique has turned every new Item(...) into std::make_unique<Item>(...). What is left to do by hand?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Removing the matching delete expressions, and making sure each object now has one owner

    Mechanical checks change one pattern each. Turning new into std::make_unique is mechanical; deciding who owns each object and deleting the old delete expressions is not, so it is done by hand and checked by the tests.

A C++23 build shows what C++17 removed

This program uses one of each removed feature. It compiles cleanly as C++98 with warnings on, and the run shows what each compiler says when the same file is built as C++23:

cpp98/legacy.cpp C++ · cpp98/legacy.cpp
// A program in the style of C++98: each line marked "removed" uses something that C++17 took out of the standard.
#include <algorithm>
#include <functional>
#include <iostream>
#include <memory>
#include <new>
#include <stdlib.h>
#include <vector>

#define MAX_ITEMS 8

typedef std::vector<int> IntList;

class Parser {
public:
    virtual ~Parser() throw() {}
    virtual int parse(const char* text) throw(std::bad_alloc) { return atoi(text); }  // removed: throw(type)
};

int main() {
    register int total = 0;                   // removed: register
    std::auto_ptr<Parser> parser(new Parser);  // removed: auto_ptr
    IntList values;
    for (int i = 0; i < MAX_ITEMS; ++i) values.push_back(parser->parse("7") * i);
    std::random_shuffle(values.begin(), values.end());  // removed: random_shuffle
    std::transform(values.begin(), values.end(), values.begin(), std::bind1st(std::plus<int>(), 1));  // removed: bind1st
    for (IntList::const_iterator it = values.begin(); it != values.end(); ++it) total += *it;
    const char* label = NULL;
    std::cout << "total " << total << (label == NULL ? "" : label) << std::endl;
    return 0;
}
As C++98, then as C++23, on both compilers Python · cpp98/cpp98_session.py
from session import run

# The file built as C++98 by both compilers, then as C++23: every error and warning each compiler reports.
run("""
clang++ -std=c++98 -Wall -Wextra legacy.cpp -o legacy && ./legacy
g++-16 -std=c++98 -Wall -Wextra legacy.cpp -o legacy-gcc && ./legacy-gcc
clang++ -std=c++23 legacy.cpp -o legacy23 2>&1 | grep -E 'error:|warning:'
g++-16 -std=c++23 legacy.cpp -o legacy23-gcc 2>&1 | grep -E 'error:|warning:'
""")

Output

$ clang++ -std=c++98 -Wall -Wextra legacy.cpp -o legacy && ./legacy
total 204
$ g++-16 -std=c++98 -Wall -Wextra legacy.cpp -o legacy-gcc && ./legacy-gcc
total 204
$ clang++ -std=c++23 legacy.cpp -o legacy23 2>&1 | grep -E 'error:|warning:'
legacy.cpp:17:41: error: ISO C++17 does not allow dynamic exception specifications [-Wdynamic-exception-spec]
legacy.cpp:21:5: error: ISO C++17 does not allow 'register' storage class specifier [-Wregister]
legacy.cpp:22:10: error: no template named 'auto_ptr' in namespace 'std'; did you mean 'out_ptr'?
legacy.cpp:22:26: error: expected ';' after expression
legacy.cpp:22:5: error: reference to overloaded function could not be resolved; did you mean to call it?
legacy.cpp:22:27: error: use of undeclared identifier 'parser'; did you mean 'Parser'?
legacy.cpp:24:58: error: use of undeclared identifier 'parser'
legacy.cpp:25:10: error: no member named 'random_shuffle' in namespace 'std'
legacy.cpp:26:71: error: no member named 'bind1st' in namespace 'std'
$ g++-16 -std=c++23 legacy.cpp -o legacy23-gcc 2>&1 | grep -E 'error:|warning:'
legacy.cpp:17:41: error: ISO C++17 does not allow dynamic exception specifications
legacy.cpp:21:18: warning: ISO C++17 does not allow 'register' storage class specifier [-Wregister]
legacy.cpp:22:10: warning: 'template<class> class std::auto_ptr' is deprecated: use 'std::unique_ptr' instead [-Wdeprecated-declarations]
legacy.cpp:25:24: warning: 'void std::random_shuffle(_RAIter, _RAIter) [with _RAIter = __gnu_cxx::__normal_iterator<int*, vector<int> >]' is deprecated: use 'std::shuffle' instead [-Wdeprecated-declarations]
legacy.cpp:26:78: warning: 'std::binder1st<_Operation> std::bind1st(const _Operation&, const _Tp&) [with _Operation = plus<int>; _Tp = int]' is deprecated: use 'std::bind' instead [-Wdeprecated-declarations]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cpp98_session.py

Apple clang rejected all five features that C++17 removed, with nine errors in total because the missing auto_ptr also left parser undeclared further down. GCC rejected only the A list of the exceptions a function may throw, written after its parameters, as in throw(std::bad_alloc). C++11 deprecated it, C++17 removed it except for the empty throw(), and C++20 removed that too: noexcept replaces both.. It reported register as a warning, and its library still provides auto_ptr, random_shuffle and bind1st, deprecated. The standard removed all of them, but compilers and libraries may keep what it removed, so GCC’s build hides problems that the next compiler will not. Reading legacy code before an upgrade means building it with every compiler you ship with. Even then, neither compiler said anything about the destructor’s throw(), which C++20 removed as well.

Reading C++98 code: where to look first

  • Find the owners. Search for new, delete and malloc, and for each allocation read which class or function frees it, on every path out, early returns included.
  • Find the conventions. Comments such as “the caller must free” or “returns NULL on failure” are how old code wrote ownership and errors into an interface.
  • Find the copies. A class whose destructor frees something needs copy operations that copy it, or none at all.
  • Find what the build hides. Compile with -std=c++23 -Wall -Wextra -Wdeprecated on every compiler you ship with; today’s deprecation warnings are the errors of the next upgrade.

Checkpoint

Check: What a C++23 build reports

  1. Question 1 of 2 A C++98 file builds as C++23 with GCC, with deprecation warnings only, and fails with Apple clang. Which conclusion is right?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The file uses features that C++17 removed, which GCC's library still provides; the next compiler may refuse them too

    The standard removed these features, but a library may keep removed names for old code. Reading legacy code before an upgrade therefore means building it with every compiler you ship with.

  2. Question 2 of 2 Why did Apple clang report nine errors for five removed features?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The missing auto_ptr also left parser undeclared, so later lines that use it failed too

    Error counts overstate the work: one removed feature can cause several errors further down. Fix the first error in each group and build again.

One checked change at a time keeps the behaviour

Modernising is a loop: record once, then change, check and commit:

Record what the code does, then repeat: one change, a check on both compilers against the record, and a commit of its own.1. Record what the code doesa characterisation testand its golden output2. Make one changeby clang-tidy --fix,or by hand3. Check itboth compilers, sanitizers,the same golden output4. Commit it alonea small diffwith one purposesame outputdifferent output:undo or rethinknext change

Modernising without changing behaviour

Text description of the diagram

Four boxes, one under the other, with arrows between them.

  1. Record what the code does: a characterisation test and its golden output.
  2. Make one change, by clang-tidy --fix or by hand.
  3. Check it: both compilers, sanitizers, and the same golden output. An arrow labelled "same output" leads on to step 4; a dashed arrow labelled "different output: undo or rethink" leads back to step 2.
  4. Commit it alone: a small diff with one purpose. A dashed arrow labelled "next change" leads back to step 2.
  • A characterisation test runs the old code and records its output as it is today, wrong or right, in a A file holding an output that a person reviewed once. A test compares each new output with it and fails when anything differs. that every later step must reproduce.
  • One change per commit keeps each diff small enough to review and to undo on its own.
  • clang-tidy’s --fix applies a modernize check everywhere at once, so mechanical changes need no hand edits.
  • Changes of ownership and interfaces are made by hand, because they need decisions about lifetimes and callers.
  • Both compilers and the sanitizers check every step, because each catches what the other misses.

Step through two turns of the loop, one made by a tool and one by hand:

Interactive · stage by stage Two turns of the loop that keeps behaviour Step through one change made by a tool and one made by hand. The arrows on the right go back to the next change, or back to a change whose output differs.
Two turns of the loop that keeps behaviour, step 7 of 7. Showing Record what it does, Make one change (by hand: char buffer to string), Check it (same golden output), Commit it alone.output differs: rethinknext changeRecord what it doesa characterisation testMake one changeby hand: char buffer to stringCheck itsame golden outputCommit it aloneone purpose per commit

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Record first. Before anything changes, a characterisation test runs the old code and saves its output, wrong or right, as a golden file. A name that the old code cuts to 32 characters stays cut in the record.
  2. A change made by a tool. The first change is mechanical: clang-tidy --fix applies modernize-use-using, and every typedef becomes a using declaration. Nobody edits a line by hand.
  3. Checked on both compilers. Both compilers build the new code, the sanitizer build runs the test, and the output matches the golden file exactly.
  4. Committed alone. The change goes into a commit of its own, so its diff has one purpose and can be undone on its own. Then the loop starts again.
  5. A change made by hand. The next change needs decisions: a class's raw char buffer becomes a std::string, which changes who owns the characters. A tool cannot decide that, so it is made by hand.
  6. When the output differs. Had the output changed, the check would send the change back: undo it or rethink it. The golden file is updated only when a change of behaviour is decided on purpose, in a commit of its own.
  7. The same output, the next commit. With the same output on both compilers, the hand change is committed alone too, and the loop goes on, one checked step at a time.

Reading old code well also means reading its quirks as requirements until proven otherwise: a name cut to 32 characters or a price that an update ignores may be a bug or a promise that some caller depends on. A characterisation test keeps such behaviour until someone decides to change it on purpose, in a commit of its own.

Key idea

Record what the code does first, then make one change at a time, check it on both compilers against that record and commit it alone.

Text Diff Compare the golden output of the old code with the output of a modernised build, line by line.

Checkpoint

Check: One checked change at a time

  1. Question 1 of 2 One commit runs clang-tidy's modernize-use-nullptr and also changes a class to own its name as a std::string, and the golden output changes. What should happen next?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Split it into the tool's change and the hand change, and check each against the golden output

    A step that mixes a mechanical fix with a redesign hides the cause of any difference. Small commits with one purpose keep every diff reviewable and every regression easy to find and undo.

  2. Question 2 of 2 A characterisation test shows that updating an existing item ignores its new price. A modernised version would apply the price. What should the modernisation do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Keep the old behaviour, and change it later on purpose, in a commit of its own with an updated golden file

    Read the old code's quirks as requirements until proven otherwise. The characterisation test keeps them, and a deliberate change of behaviour gets its own commit and its own updated record.

Practise: read it, then modernise it safely

Eleven questions on the old patterns, the removed features and the loop, weakest idea first.

Practice that adapts to you

Practise this lesson

11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 11 Which replaces NULL or 0 as a null pointer in modern C++?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: nullptr

    nullptr is the null pointer constant that cannot be mistaken for a number, which is why the Core Guidelines prefer it (ES.47) and clang-tidy's modernize-use-nullptr writes it.

  2. Question 2 of 11 A C++98 file has #define LOW_STOCK 5. What does the table suggest, and how much of it can clang-tidy do?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: constexpr int low_stock = 5; written by hand, since modernize-macro-to-enum turns macros into an enum instead

    Most rows have a check that makes the change; for macros, the check gives an enum and the constexpr constant is written by hand. Either is typed and scoped, which a macro never is.

  3. Question 3 of 11 Which of these stop a C++98 file from compiling as C++17 or later, by the standard? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 3 of 11

    Answer:

    • throw(std::bad_alloc) on a function
    • std::random_shuffle
    • register int total;
    • std::auto_ptr

    The last four rows of the table are removals; the others are old style that still compiles. Removals are what force a team to read old code before an upgrade.

  4. Question 4 of 11 The C++98 file used std::auto_ptr. What did each compiler do with it in C++23 mode?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: Apple clang's libc++ no longer had auto_ptr; GCC's libstdc++ still provided it with a deprecation warning

    C++17 removed auto_ptr from the standard, and a library may remove it or keep providing it. libc++ removed it, so clang reported "no template named 'auto_ptr'"; libstdc++ still declares it and warns that it is deprecated.

  5. Question 5 of 11 Which construct of the C++98 file did both compilers refuse as an error in C++23 mode?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: The dynamic exception specification throw(std::bad_alloc)

    Dynamic exception specifications that name types were removed in C++17, and both compilers reject them. register was an error for clang but only a warning for GCC, and random_shuffle depended on the standard library.

  6. Question 6 of 11 Why should a team build its legacy code with every compiler it ships with before an upgrade?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: Each standard library may keep different removed names, so one compiler's clean build can hide what another refuses

    A library may keep removed names for old code, so the compiler you test with may hide problems that the next one will not. Build with all of them, warnings on, before you plan the work.

  7. Question 7 of 11 You must modernise a C++98 module that has no tests. What do you do first?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: Write a characterisation test that records what the module does today, and keep its output

    Without a record of the current behaviour, no later check can say whether a change kept it. A characterisation test records the output as it is, quirks included, and every step must reproduce it exactly.

  8. Question 8 of 11 Why should each modernisation step be its own commit?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: If the golden output changes, a small commit shows which change did it, and it can be undone alone

    A step that mixes a mechanical fix with a redesign hides the cause of any difference. One purpose per commit keeps every diff reviewable and every regression easy to locate and revert.

  9. Question 9 of 11 Which change is a good fit for clang-tidy --fix rather than a change by hand?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: Replacing NULL with nullptr where a pointer is expected

    A mechanical change with one right answer suits a tool, which applies it everywhere consistently. Ownership and interface changes need decisions about lifetimes and callers, so they are done by hand and checked by the tests.

  10. Question 10 of 11 When is leaving legacy code as it is the right decision?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: When it works, nobody needs to change it, and modernising it would only produce a diff to review

    Every modernising change costs a review and can change what the program does, so it needs a reason. Working code that nobody needs to change is often best left alone.

  11. Question 11 of 11 Old code cuts names to 32 characters. The modernised code would keep the whole name. What should the modernisation commit do?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: Keep the 32-character cut, as the characterisation test records it

    Read the old code's quirks as requirements until proven otherwise. The characterisation test keeps the cut, and if someone decides to remove it, that decision gets its own commit and its own updated record.

A C++98 ledger modernised in five steps, with the tests passing at every step Pro: opens with a Pro, Premium or Ultimate pass

A 120-line stock ledger in C++98 style becomes 88 lines of C++23 in five commits, two of them made by clang-tidy. After every step both compilers print exactly the output recorded from the original.

In this part:

  • The original and its characterisation test
  • Steps 1 and 2, made by clang-tidy
  • Steps 3 to 5, made by hand
  • The five steps in numbers

7 runnable examples (Python, check.sh, ledger_test.cpp, step0/ledger.cpp, step0/ledger.h, step3/ledger.cpp, step4/ledger.h, step5/ledger.cpp) · about 6 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Three changes that look mechanical and are not, reproduced, and when to stop Pro: opens with a Pro, Premium or Ultimate pass

Some changes look too simple to need a test. Each one below changes behaviour, and each is caught by one of the checks of the loop: a golden file, a focused test or a sanitizer.

In this part:

  • The three changes
  • A search and replace of NULL calls another function
  • Defaulted copies over a raw buffer
  • std::format and the stream flags it no longer sets
  • When to leave legacy code alone
  • Exercise: modernise the ledger yourself, with a rubric

2 runnable examples (Python, early_rule_of_zero.cpp, null_overload.cpp, sticky_flags.cpp) · about 5 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Interview questions

  1. Senior to staff

    You inherit 200,000 lines of C++03 with no tests, and the team must build as C++23 within a quarter. Plan the first month: what you measure, what you test first, what you let tools change and what you leave alone.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A commit titled "replace NULL with nullptr, no behaviour change" passed review, and an audit log then began writing empty notes instead of user ids. How can such a commit change behaviour, and how would you make changes like it safe?

    Model answer, Pro. First step and follow-ups

  3. Senior to staff

    A stable parsing module, 15 years old, has had one bug report in three years. A teammate proposes rewriting it in modern C++ to make it maintainable. How do you decide, and what would you propose instead?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • C++17 removed auto_ptr, random_shuffle, bind1st, register and exception specifications such as throw(std::bad_alloc); C++20 removed throw() too.
  • Build legacy code with every compiler you ship with: a library may keep names that the standard removed.
  • Record what the code does with a characterisation test before changing anything, quirks included.
  • Let clang-tidy’s --fix make mechanical changes, and make ownership and interface changes by hand.
  • Check every step with both compilers and the sanitizers, and commit each step on its own.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.