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.
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:
- 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.
- 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.
- 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.
- 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).
- 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.
- 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
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:
// 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;
} 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,deleteandmalloc, 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 -Wdeprecatedon every compiler you ship with; today’s deprecation warnings are the errors of the next upgrade.
Checkpoint
Check: What a C++23 build reports
One checked change at a time keeps the behaviour
Modernising is a loop: record once, then change, check and commit:
Modernising without changing behaviour
Text description of the diagram
Four boxes, one under the other, with arrows between them.
- Record what the code does: a characterisation test and its golden output.
- Make one change, by clang-tidy --fix or by hand.
- 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.
- 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
--fixapplies 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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- Checked on both compilers. Both compilers build the new code, the sanitizer build runs the test, and the output matches the golden file exactly.
- 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.
- 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.
- 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.
- 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.
Checkpoint
Check: One checked change at a time
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.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
- 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
-
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.
-
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?
-
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?
Key takeaways
- C++17 removed
auto_ptr,random_shuffle,bind1st,registerand exception specifications such asthrow(std::bad_alloc); C++20 removedthrow()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
--fixmake 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
- clang-tidy: the list of checks (modernize-*) (LLVM Project)
- clang-tidy: modernize-loop-convert (LLVM Project)
- clang-tidy: modernize-use-nullptr (LLVM Project)
- N4190: Removing auto_ptr, random_shuffle(), And Old <functional> Stuff (ISO/IEC JTC1/SC22/WG21)
- P0003R5: Removing Deprecated Exception Specifications from C++17 (ISO/IEC JTC1/SC22/WG21)
- P0001R1: Remove Deprecated Use of the register Keyword (ISO/IEC JTC1/SC22/WG21)
- C++ Core Guidelines: ES.47, use nullptr rather than 0 or NULL (Standard C++ Foundation)
- C++ Core Guidelines: C.20, if you can avoid defining default operations, do (Standard C++ Foundation)
- C++ Core Guidelines: C.21, if you define or =delete any copy, move, or destructor function, define or =delete them all (Standard C++ Foundation)
- Working Draft, Programming Languages: C++ (N4950): [support.c.headers.other], [diff.cpp17.except] (ISO/IEC JTC1/SC22/WG21)
- P0619R4: Reviewing Deprecated Facilities of C++17 for C++20 (D.3, throw() removed) (ISO/IEC JTC1/SC22/WG21)
- Options Controlling C++ Dialect: -Wconversion-null, -Wstrict-null-sentinel (GNU Project)
- AddressSanitizer (Clang documentation) (LLVM Project)
- C++11 (cppreference) (cppreference.com)
- C++14 (cppreference) (cppreference.com)
- C++20 (cppreference) (cppreference.com)
- C++23 (cppreference) (cppreference.com)
- std::auto_ptr (cppreference) (cppreference.com)
- std::random_shuffle, std::shuffle (cppreference) (cppreference.com)
- std::bind1st, std::bind2nd (cppreference) (cppreference.com)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress