Modern C++ skill for Claude Code
C++20/23 with clear owners and sanitizer-clean tests. Claude writes C++ where signatures say who owns what, resources die in destructors, and views never outlive their containers. It checks which C++23 features your actual compilers and standard library support before using them, sets up target-based CMake with presets and strict warnings, and proves changes with AddressSanitizer, UBSan, TSan and clang-tidy instead of hoping.
- name
cpp-modern - size257 lines · ≈2,900 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes and modernizes C++20/23 code with RAII and the rule of zero, ownership expressed through unique_ptr, references and spans, ranges and views without dangling, concepts instead of SFINAE, std::expected or exceptions chosen deliberately, target-based CMake with presets, strict warnings, sanitizers, and clang-tidy. Use when writing or reviewing C++, migrating from C++11/14/17, fixing crashes, leaks or undefined behavior, setting up CMake or vcpkg, or when ASan, UBSan or TSan report errors. Try asking
- ASan reports heap-use-after-free in this parser — find the real cause and fix it
- Modernize this C++11 class: raw new/delete, out-params and enable_if everywhere
- Set up CMake presets for this project with warnings-as-errors and an ASan build
Use it when
- Writing or reviewing C++17/20/23 code
- A crash, leak or sanitizer report needs a root cause
- Migrating legacy C++ to smart pointers, spans, ranges and concepts
- Restructuring a CMake build around targets, presets and vcpkg
Skip it when
- The code is plain C with no C++ compiler — use c-systems
- Compiling C++ to run in the browser or WASI — use webassembly-modules
How to check the result
The build passes with warnings as errors on every CI compiler, ctest is green under the ASan+UBSan preset (and TSan for threaded code), and clang-tidy shows no new findings on changed files.
The common mistake it prevents
Returning a string_view, span or range view into a local or temporary container, which compiles cleanly and reads freed memory at runtime.
Done well means: every resource has exactly one owner and is released by a destructor,
no raw new/delete in application code, function signatures state ownership, the
build uses targets (not global flags), warnings are errors in CI, and the test suite
passes under AddressSanitizer + UndefinedBehaviorSanitizer.
Before changing anything
- Find the real standard and toolchain:
CMAKE_CXX_STANDARD,cxx_std_*features, compiler versions in CI. Do not use a C++23 feature the oldest supported compiler lacks. - Check the library: libstdc++ (GCC), libc++ (Clang/Apple), or MSVC STL. Feature
support differs per library, not per compiler front end. Confirm on the cppreference
“compiler support” page or with feature-test macros (
__cpp_lib_expected,__cpp_lib_print,__cpp_lib_ranges_to_container). - Build once with sanitizers before refactoring so you know the baseline is clean.
Feature baseline (verify against your CI compilers)
| Feature | Std | Rough minimum |
|---|---|---|
Concepts, ranges, std::span, <=>, designated init | 20 | GCC 10+, Clang 13+ (full ranges: GCC 12, Clang 16), MSVC 17.0 |
std::format | 20 | GCC 13, libc++ 17, MSVC 16.10 |
std::expected, std::optional::and_then | 23 | GCC 12, libc++ 16, MSVC 17.3 |
std::print / std::println | 23 | GCC 14, libc++ 18, MSVC 17.7 |
std::ranges::to, views::enumerate, views::zip | 23 | GCC 14 (enumerate 13), libc++ 17-19 varies |
Deducing this | 23 | GCC 14, Clang 18, MSVC 17.2 |
import std; | 23 | Experimental; needs CMake 3.30+ and recent toolchains. Do not adopt in shared libraries yet |
If a row is borderline, guard with the feature-test macro or fall back (fmt library for
std::format/std::print, tl::expected for std::expected).
Ownership in signatures
| Parameter intent | Type |
|---|---|
| Read, never null | const T& (or T if cheap to copy: ints, string_view, span) |
| Read, may be absent | const T* or std::optional<std::reference_wrapper<const T>> |
| Modify caller’s object | T& |
| Read a contiguous range | std::span<const T> |
| Read a string | std::string_view (never store it past the call) |
| Take ownership | std::unique_ptr<T> by value, or T by value then std::move into a member |
| Share ownership (truly) | std::shared_ptr<T> by value; const std::shared_ptr<T>& only if you might copy |
| Return a new object | T by value (RVO); std::unique_ptr<T> for polymorphic types |
class Session {
public:
explicit Session(std::string user, std::unique_ptr<Transport> t)
: user_(std::move(user)), transport_(std::move(t)) {}
void send(std::span<const std::byte> payload); // borrows
private:
std::string user_;
std::unique_ptr<Transport> transport_; // owns
};
auto s = Session{"ada", std::make_unique<TcpTransport>(addr)};
std::make_unique/std::make_sharedalways; nevershared_ptr<T>(new T).shared_ptris a design decision (lifetime genuinely shared across owners), not a default. Cycles needstd::weak_ptron one edge.- Raw
T*means “non-owning, may be null”. Neverdeletethrough it.
RAII and the rule of zero
- Classes that only hold RAII members declare none of the five special members.
- A class that owns a raw handle (fd,
FILE*, C library object) gets a dedicated wrapper, then everything else composes it:
struct FileCloser { void operator()(std::FILE* f) const noexcept { if (f) std::fclose(f); } };
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
FilePtr open_file(const char* path, const char* mode) {
FilePtr f{std::fopen(path, mode)};
if (!f) throw std::system_error(errno, std::generic_category(), path);
return f;
}
- If you must write special members, write all five or
= deletecopy explicitly, and mark move operations and destructorsnoexcept(containers fall back to copying otherwise). - Locks:
std::scoped_lock lk(m1, m2);(deadlock-free for multiple mutexes), never call the mutex’s lock and release members by hand. - Polymorphic base classes:
virtual ~Base() = default;or a protected non-virtual destructor.
Error handling decision
| Situation | Use |
|---|---|
| Expected, frequent failure the caller handles locally (parse, lookup, validation) | std::expected<T, E> with an enum or small struct E |
| Rare failure that unwinds several layers (I/O failure, out of memory, broken invariant in ctor) | Exceptions |
Codebase built with -fno-exceptions (games, embedded) | std::expected everywhere, std::optional for absence |
| Programmer error / violated precondition | assert or a contract check that aborts, not an error value |
enum class ParseError { empty, bad_digit, overflow };
std::expected<int, ParseError> parse_port(std::string_view s) {
if (s.empty()) return std::unexpected(ParseError::empty);
int v{};
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
if (ec == std::errc::result_out_of_range || v > 65535) return std::unexpected(ParseError::overflow);
if (ec != std::errc{} || ptr != s.data() + s.size()) return std::unexpected(ParseError::bad_digit);
return v;
}
Mark functions that cannot throw noexcept; mark results that must be checked
[[nodiscard]].
Ranges and views without dangling
- Views are lazy and non-owning. A view over a temporary container dangles:
auto v = make_vec() | std::views::filter(pred);is caught bystd::ranges::danglingonly for some algorithms. Store the container in a named variable first. - Materialize with
std::ranges::to<std::vector>()(C++23) when the result outlives its source, or crosses an API boundary. views::filtercachesbegin(); do not mutate elements in a way that changes the predicate result while iterating, and do not iterate the same filter view from two threads.- Prefer range algorithms with projections:
std::ranges::sort(users, {}, &User::age);. - Returning
std::string_vieworstd::spaninto a local or a temporary is a use-after-free. Return owned types.
Templates and concepts
template <typename T>
concept Hashable = requires(const T& t) {
{ std::hash<T>{}(t) } -> std::convertible_to<std::size_t>;
};
template <std::ranges::input_range R>
requires Hashable<std::ranges::range_value_t<R>>
void index(R&& items);
- Use standard concepts first (
std::integral,std::invocable,std::ranges::range). - Replace
enable_if/ SFINAE withrequires; error messages become readable. - Put
constexpron functions that can be evaluated at compile time; useconstevalonly when a call must never happen at runtime. if constexprreplaces tag dispatch for small branches.
Build: target-based CMake
cmake_minimum_required(VERSION 3.28)
project(app LANGUAGES CXX)
add_library(core src/core.cpp)
target_compile_features(core PUBLIC cxx_std_23)
set_target_properties(core PROPERTIES CXX_EXTENSIONS OFF)
target_include_directories(core PUBLIC include)
add_library(project_warnings INTERFACE)
if(MSVC)
target_compile_options(project_warnings INTERFACE /W4 /permissive- /utf-8)
else()
target_compile_options(project_warnings INTERFACE
-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion
-Wnon-virtual-dtor -Wold-style-cast -Woverloaded-virtual -Wimplicit-fallthrough)
endif()
target_link_libraries(core PRIVATE project_warnings)
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE core project_warnings)
- Never
include_directories(),add_definitions()orCMAKE_CXX_FLAGS +=for per-project settings; usetarget_*withPRIVATE/PUBLIC/INTERFACE. - Dependencies: vcpkg manifest mode (
vcpkg.json+ toolchain file) or Conan 2; small header-only deps viaFetchContentpinned to a tag or commit hash. - Put configurations in
CMakePresets.json(dev,asan,tsan,release) so CI and humans run identical builds. Exportcompile_commands.json(CMAKE_EXPORT_COMPILE_COMMANDS=ON) for clang-tidy and clangd.
Sanitizers and hardening
cmake --preset asan # or configure manually:
cmake -B build-asan -G Ninja -DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer -g"
cmake --build build-asan && ctest --test-dir build-asan --output-on-failure
# Data races: separate build, TSan cannot combine with ASan
cmake -B build-tsan -DCMAKE_CXX_FLAGS="-fsanitize=thread -g -O1"
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1,ASAN_OPTIONS=detect_leaks=1(LeakSanitizer is unavailable on macOS arm64 Apple clang; use Linux CI orleaks).- Debug/CI library assertions:
-D_GLIBCXX_ASSERTIONS(libstdc++) or-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST(libc++ 18+). These catch out-of-boundsoperator[]and bad iterator use; the FAST mode is cheap enough for production. - Release hardening on GCC/Clang Linux:
-O2 -D_FORTIFY_SOURCE=3 -fstack-protector-strong(GCC 14+ offers-fhardenedas a bundle).
Static analysis
clang-tidy -p build src/*.cpp
run-clang-tidy -p build -j 8 # whole project
Start from .clang-tidy with bugprone-*,cppcoreguidelines-*,modernize-*,performance-*,readability-*
minus noisy checks (modernize-use-trailing-return-type, readability-identifier-length,
cppcoreguidelines-avoid-magic-numbers). Treat new findings as errors only for changed
files (clang-tidy-diff.py) on legacy code.
Undefined behavior: the ones that ship
- Signed integer overflow, shifting by >= width, and out-of-range
float->intcasts. - Reading an object after
std::moveassumes more than “valid but unspecified”. - Iterator, reference or
string_viewinvalidation afterpush_back,insert,erase, or rehash. Reserve or use indices. - Lambdas capturing
[&]orthisstored in callbacks that outlive the scope. - Strict aliasing:
reinterpret_cast<float*>(int_ptr); usestd::bit_castormemcpy. - Uninitialized members: initialize at declaration (
int count_ = 0;). - Data races on non-atomic shared state, including
shared_ptrobject reassignment from two threads (the control block is atomic; the pointer variable is not).
For the full UB catalog with detection method and fix for each case, read references/ub-catalog.md when a sanitizer report or crash does not match the list above.
Modernizing legacy code, in this order
- [ ] 1. Get it building with warnings on and sanitizers clean (fix real bugs first)
- [ ] 2. Raw owning pointers -> unique_ptr / containers; delete manual destructors
- [ ] 3. Out-parameters + bool returns -> return values / std::optional / std::expected
- [ ] 4. Pointer+length pairs -> std::span; const std::string& params that only read -> string_view
- [ ] 5. Hand loops -> range algorithms where it reads better (not everywhere)
- [ ] 6. SFINAE / enable_if -> concepts
- [ ] 7. Run clang-tidy modernize-* with fixes, review diff by hand
Keep each step a separate commit with tests green; never mix a behavior change into a modernization commit.
Verification
- Clean build with the warnings target and
-Werror(or/WX) on every CI compiler. ctestpasses under the ASan+UBSan preset and, for concurrent code, the TSan preset.clang-tidyreports nothing new on changed files.grep -nE '\bnew |delete |malloc\(|free\(' src/returns only documented wrappers.- For performance-motivated changes, benchmark Release builds (Google Benchmark or
hyperfine), never Debug.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/ub-catalog.md
# C++ undefined behavior catalog
Each entry: what triggers it, how it shows up, what detects it, and the fix. Read the
entry that matches the sanitizer report or symptom; do not apply fixes speculatively.
| Tool | Catches |
|---|---|
| ASan (`-fsanitize=address`) | heap/stack/global overflow, use-after-free, use-after-return (with `ASAN_OPTIONS=detect_stack_use_after_return=1`), double free, leaks (Linux) |
| UBSan (`-fsanitize=undefined`) | signed overflow, bad shifts, misaligned access, null deref, invalid enum/bool loads, vptr type confusion (`-fsanitize=vptr`), float-cast overflow (`-fsanitize=float-cast-overflow`) |
| TSan (`-fsanitize=thread`) | data races, lock-order inversions |
| MSan (Clang, Linux only; whole program incl. libc++ must be instrumented) | reads of uninitialized memory |
| `_GLIBCXX_ASSERTIONS` / libc++ hardening | out-of-bounds `operator[]`, `front()` on empty, invalid iterator ranges |
| Valgrind memcheck | uninitialized reads and leaks without recompiling (slow, ~20-50x) |
## Lifetime
### Use after free via dangling reference or view
```cpp
std::string_view name() { std::string s = load(); return s; } // dangles
const auto& first = get_vector()[0]; // temporary destroyed at ;
for (auto x : make_map().at("k")) {} // pre-C++23 range-for temp lifetime bug
```
Detect: ASan `heap-use-after-free` or `stack-use-after-scope`. Clang `-Wdangling`,
`-Wreturn-stack-address`, GCC 13+ `-Wdangling-reference`.
Fix: return owned types; bind temporaries to a named variable before taking references.
C++23 extends temporaries in range-for initializers (P2718, GCC 15/Clang 19 in C++23 mode)
but do not rely on it for code compiled in older modes.
### Iterator / reference invalidation
`push_back`/`emplace_back` on `vector` reallocates; `insert`/`erase` shifts; `unordered_map`
rehash invalidates iterators (not references to elements); `deque` push invalidates iterators.
Detect: ASan, hardening assertions. Fix: `reserve` up front, store indices, or collect
changes and apply after the loop; use `std::erase_if(container, pred)` (C++20).
### Captured references in async callbacks
```cpp
void Server::start() { timer_.on_fire([&] { handle(local_config); }); } // local dies
```
Fix: capture by value (`[cfg = local_config]`), or capture `weak_from_this()` and lock it
inside the callback for objects managed by `shared_ptr`.
### Object used after move
Standard library types are "valid but unspecified" after move: only assign to them or
call functions without preconditions (`clear()`, `empty()`). Your own types promise only
what you document. clang-tidy `bugprone-use-after-move` finds most cases.
## Arithmetic
### Signed overflow
`int` overflow is UB; the optimizer assumes it never happens (loops may become infinite,
checks like `if (a + b < a)` vanish). Detect: UBSan `signed-integer-overflow`.
Fix: widen (`int64_t`), use checked arithmetic (`__builtin_add_overflow` on GCC/Clang,
`ckd_add` from C23 `<stdckdint.h>` where your C++ library exposes it), or unsigned types
when wraparound is intended.
### Shifts
Shift count negative or >= bit width is UB; left-shifting a negative value was UB before
C++20. Fix: mask or check the count; shift unsigned types.
### Float to int
`static_cast<int>(1e20)` is UB. Check range first or use `std::lround` with range checks.
### Division
`INT_MIN / -1` and `x % 0` are UB. Validate divisors at input boundaries.
## Type punning and aliasing
- `reinterpret_cast<float&>(u32)` violates strict aliasing. Use `std::bit_cast<float>(u32)`
(C++20) or `std::memcpy`.
- Reading an inactive `union` member is UB in C++ (unlike C). Use `std::variant` or
`std::bit_cast`.
- Casting a byte buffer to a struct pointer: use `std::memcpy` into a local, or
`std::start_lifetime_as` (C++23, little toolchain support yet).
- Misaligned loads (`*reinterpret_cast<uint32_t*>(buf + 1)`): UBSan `alignment`. Use memcpy.
## Initialization
- Reading an uninitialized scalar member or local. Detect: MSan, Valgrind, GCC
`-Wmaybe-uninitialized` (noisy). Fix: default member initializers; `T x{};`.
`-ftrivial-auto-var-init=zero` (GCC 12+, Clang 16+) is a mitigation, not a fix.
- Static initialization order across translation units: a global constructor reading
another TU's global. Fix: function-local static (`static Registry& r() { static Registry x; return x; }`),
or `constinit` for constant-initialized globals.
## Concurrency
- Data race on any non-atomic object touched by two threads with at least one write.
Includes `bool stop_flag` (use `std::atomic<bool>` or `std::stop_token`).
- `shared_ptr` instance reassigned from two threads: use `std::atomic<std::shared_ptr<T>>`
(C++20) or a mutex.
- Destroying a `std::thread` that is still joinable calls `std::terminate`; prefer
`std::jthread` (auto-joins, supports `stop_token`).
- Lock-order inversion: TSan reports `lock-order-inversion`. Fix: `std::scoped_lock`
with all mutexes at once, or a global lock order.
## Polymorphism
- Deleting a derived object through a base pointer without a virtual destructor.
`-Wnon-virtual-dtor`, `-Wdelete-non-virtual-dtor`.
- Calling a virtual function from a constructor or destructor dispatches to the
current class, not the derived one (not UB, but a frequent bug); pure virtual call there
is UB.
- `static_cast<Derived*>(base)` when the object is not a `Derived`: UBSan `vptr`.
## Standard library preconditions
- `std::sort` with a comparator that is not a strict weak ordering (e.g. `<=`, or NaN
in floats) can read out of bounds. libstdc++ debug mode (`-D_GLIBCXX_DEBUG`) and libc++
debug hardening catch some cases.
- `front()`, `back()`, `pop_back()` on empty containers; `*opt` on empty `optional`;
`std::get` on wrong `variant` alternative throws (fine) but `*std::get_if` on null is UB.
- `std::string_view` built from a `std::string` temporary.
- `std::span` constructed from a container that later reallocates.
## Triage loop
1. Reproduce with the smallest input under ASan+UBSan, `-O1 -g`.
2. Read the first report only; later ones are often consequences.
3. Map the report to an entry above; fix the cause, not the symptom location.
4. Add a regression test that fails under the sanitizer before the fix.
5. Re-run the full suite under the sanitizer preset, then TSan if threads are involved.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.