SKILL.md
C++ Systems & Libraries
Covers C++17 as the baseline, with C++20 features called out where a project's standard allows them. For plain C (manual lifetimes, status enums, native extensions), see the ia-c-systems skill.
Repo conventions outrank this skill
Check CMakeLists.txt for CXX_STANDARD, read .clang-format and .clang-tidy, and read two adjacent translation units before writing. Where they conflict with the rules below, they win.
The conflicts that actually happen:
| Local constraint | Consequence |
|---|---|
-fno-exceptions |
Error handling is codes or expected-alikes. Constructors cannot report recoverable failure, so use a fallible factory or construct a valid fallback state. new (std::nothrow) only where the project's OOM policy is to observe null and recover; plain new is fine where the policy is termination |
| Standard pinned below C++17 | No std::optional/string_view/structured bindings/if constexpr; check before using any |
| Public header is ABI-stable | No layout changes, no inline-function changes, no added virtuals: load the ABI reference |
| Embedded or freestanding target | No RTTI, no dynamic allocation in hot paths, possibly no STL containers |
Tooling
| Tool | Purpose |
|---|---|
cmake |
Build system; CMAKEEXPORTCOMPILE_COMMANDS=ON feeds every other tool |
clang-format |
Formatter, driven by the repo's .clang-format |
clang-tidy |
Lint (bugprone-, performance-, modernize-, cppcoreguidelines-) |
| ASan + UBSan | -fsanitize=address,undefined; TSan separately for threaded code |
gtest / catch2 |
Unit tests |
include-what-you-use |
Cuts transitive-include creep that slows builds and hides dependencies |
ccache |
Compile cache; the single biggest iteration-speed win on a C++ tree |
Build with -Wall -Wextra -Wpedantic -Wshadow -Wconversion and treat warnings as errors in CI.
Ownership and RAII
Every resource has exactly one owner, and that owner is an object whose destructor releases it. A raw new or delete in application code is a defect.
std::unique_ptr<T>for sole ownership. It is the default; it costs nothing over a raw pointer.std::shared_ptr<T>only where lifetime is genuinely shared and cannot be expressed as "the owner outlives the users". Reach for it third, not first.std::weak_ptr<T>to break ownership cycles. LeakSanitizer does report a cycle that is unreachable from any root, but not one still reachable from a global or other registered root, and detection varies by platform and configuration. Do not rely on the sanitizer to find these.- Raw
TandT&mean non-owning observation, and are correct in that role. A parameter takingunique_ptrby value is announcing that it consumes ownership; one takingTis announcing it does not. std::span<T>(C++20) or a pointer-plus-length pair for a borrowed contiguous range in a new public API, sinceconst std::vector<T>&there refuses every other container. On a C++17 baseline, or for internal code whose callers all hold vectors anyway,const std::vector<T>&is fine and simpler.
Rule of zero: a class that owns nothing declares no destructor, no copy, and no move. Composing members that manage themselves gets all five special members correct for free. Rule of five: declaring any one of destructor, copy constructor, copy assignment, move constructor, or move assignment obliges the author to reason about all five. A user-declared destructor suppresses the implicit move operations, so a class that gained a destructor silently started deep-copying where it used to move.
Const correctness and value semantics
constby default on locals, member functions, and reference parameters.- Pass by value for types that are cheap to move (
std::string,std::vector) when the function stores the argument; pass byconst&when it only reads. Do not pass byconst&and then copy inside. std::stringviewfor read-only string parameters, with one rule attached: never store one unless the backing buffer is guaranteed to outlive the view. Astringviewmember is a dangling reference waiting for a temporary.- Mark member functions
constandnoexceptwhere true.noexcepton move operations is what letsstd::vectormove rather than copy on reallocation.
Error handling
Pick one model per module and hold it at the boundary.
- Exceptions where the project allows them: throw types deriving from
std::exception, throw by value, catch byconst&. Use them for genuinely exceptional conditions, not for control flow. std::optional<T>for "absent is normal".std::expected<T, E>(C++23) or a project equivalent for "failed with a reason".- Error codes in exception-free builds, with
[[nodiscard]]on every returning function so an ignored failure is a warning. - Mark anything that must not throw
noexcept, and mean it: an escaping exception callsstd::terminate. - A constructor that can fail either throws or does not exist. Two-phase
init()construction produces objects with an invalid state that every method must then check. Prefer a static factory returningoptional/expected.
API design
The decisions that break callers, learned the expensive way:
- Three decisions break callers when they go wrong:
expliciton a single-argument constructor (decide at introduction, since adding it later is source-breaking), removing an overload (keep the narrow one and delegate), and an overload that silently ignores part of its argument (delete it orstatic_assertinstead). Rationale and the full evolution rules are in the reference below. - Prefer free functions over members where they do not need private access; they extend without touching the class.
- Return by value and let the compiler elide. Out-parameters exist for multiple returns and for reuse of a caller's buffer, not as an optimization.
For extern "C" boundaries, exception containment, PIMPL, and ABI-stable headers, load [api-and-abi.md](./references/api-and-abi.md).
Templates and generic code
Use a template when at least three concrete instantiations exist or are certain. Before that, a concrete type is clearer and compiles faster.
- Constrain with C++20 concepts where available,
static_assertplus type traits otherwise. An unconstrained template fails deep inside instantiation with an error nobody can read. if constexprover tag dispatch and SFINAE where the standard allows it.- Keep template definitions out of widely-included headers when the instantiation cost is real; explicit instantiation in one translation unit is often the right trade.
- Perfect forwarding (
T&&plusstd::forward) only in genuinely forwarding code. A forwarding reference in a constructor hijacks the copy constructor and produces baffling overload resolution.
Standard library
- Prefer
<algorithm>and ranges over hand-written loops; a named algorithm states intent that an index loop hides. std::vectorunless measurement says otherwise.reserve()when the final size is known.- Structured bindings for pair and tuple returns; a named struct for anything a caller will read twice.
- Use
std::moveonly where the source is genuinely dead afterwards. Never depend on an unspecified post-move value; destroy, reassign, or invoke only operations whose post-move contract is documented. Some types do specify one (unique_ptris null,futureis invalid), and relying on those is fine. - Never return
std::move(local): it defeats copy elision. - Never call unknown code -- a user callback, a virtual, a comparator, a visitor -- from inside a loop over a container the callee can reach. Insertion invalidates iterators and pointers into the storage, and a callback that mutates the container being walked leaves the loop reading freed memory whose bytes usually still look plausible, so the first symptom is wrong output rather than a crash. Iterate a copy, or index by position and re-check
size()after every reentrant call, and where elements are owned indirectly, take a strong reference on each one before the pass that can drop the last owner. - An empty
std::string_viewmay havedata() == nullptr, which the standard permits, andmemcpydeclares its source non-null regardless of the size argument. Every libc no-ops a zero-length copy in practice, so the defect surfaces only as a UBSan diagnostic on each empty append. Guard the copy with a size check rather than suppressing the check.
Concurrency
- Lock through an RAII guard, never a bare
lock()/unlock()pair -- an early return or a throw between them leaves the mutex held.std::lockguardfor a plain scope,std::uniquelockwhen the lock must be deferred, moved, or handed to a condition variable,std::shared_lockfor reader access. - Name the guard.
std::lockguard<std::mutex>{m};is a temporary that locks and unlocks before the next statement runs, leaving everything after it unguarded, and it compiles silently under-Wall -Wextra -Wshadow.std::lockguard<std::mutex>(m);is worse-looking but harmless -- it parses as a declaration of a variable namedmand fails to compile. Onlystd::lock_guard<std::mutex> guard{m};locks for the scope. - Take multiple mutexes with one
std::scoped_lock(a, b)(C++17), which applies a deadlock-avoidance algorithm. Two sequential guards impose a lock order that a second call site can invert. - Always pass a predicate:
cv.wait(lock, [&]{ return ready; }). A barewaitreturns on spurious wakeup and on a notify that raced ahead of the waiter. - Never call unknown code -- a user callback, a virtual, an observer notification -- while holding a lock. The callee may take another lock, re-enter, or block, and none of that is visible at the call site.
- A
mutexmember makes a class non-copyable and non-movable; decide whether the type is meant to be either before adding one.
Compile-time signal for these is near zero, so run anything threaded under TSan (see Testing) rather than trusting the warning bundle.
Testing
- gtest is the default (
TEST(Suite, Case),TEST_Ffor fixtures). One test file per public surface. EXPECTto continue after failure,ASSERTwhere continuing would crash or cascade.EXPECTTHROW/EXPECTNO_THROWfor the exception contract; assert on the exception's type and message, not merely that something threw.- Run the suite under ASan and UBSan in CI, and under TSan separately for anything threaded.
For generic test discipline (anti-patterns, real assertions, rationalization resistance), see the ia-writing-tests skill.
Build and analysis
For CMake target design, sanitizer and warning presets, clang-tidy configuration (including which checks are fatal versus warn-only), and dependency handling, load [cmake-and-tooling.md](./references/cmake-and-tooling.md).
Legibility
Function decomposition, naming as a greppability contract, the name test that stops over-decomposition, contract comments, and a worked refactor with its change-cost proof: load [legibility-standard.md](./references/legibility-standard.md).
Discipline
- Preserve behavior and API compatibility unless a break was requested. A public header change is a decision, not a cleanup.
- Do not introduce a template, an inheritance hierarchy, or a policy parameter for a single call site.
#includewhat the file uses; do not rely on transitive includes from another header.- No
using namespaceat namespace scope in a header. Fully qualify instead, or scope theusingto a function body. - When a constraint forces a deviation, comment at the deviation site and state the constraint.
Verify
- Build clean with
-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Werror clang-tidyreports no new findings on the diff- Tests pass under
-fsanitize=address,undefinedwith zero reports - Any threaded code touched by the change exercised under TSan with zero reports -- the warning bundle above does not catch lock misuse
clang-format --dry-run --Werrorproduces no diff- No new raw
new/delete, no newsharedptrwhereuniqueptrsuffices - Any class that gained a destructor has its move operations reviewed