Install
openclaw skills install @iliaal/compound-eng-cpp-systemsModern C++ patterns: RAII and ownership, rule of zero/five, exceptions and error handling, API and ABI boundaries, templates, and CMake tooling. Use when writing, reviewing, refactoring, or debugging C++, working with smart pointers, move semantics, memory leaks, template errors, or gtest. For plain C, see ia-c-systems.
openclaw skills install @iliaal/compound-eng-cpp-systemsCovers 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.
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 |
| Tool | Purpose |
|---|---|
cmake | Build system; CMAKE_EXPORT_COMPILE_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.
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.T* and T& mean non-owning observation, and are correct in that role. A parameter taking unique_ptr by value is announcing that it consumes ownership; one taking T* is 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, since const 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 by default on locals, member functions, and reference parameters.std::string, std::vector) when the function stores the argument; pass by const& when it only reads. Do not pass by const& and then copy inside.std::string_view for read-only string parameters, with one rule attached: never store one unless the backing buffer is guaranteed to outlive the view. A string_view member is a dangling reference waiting for a temporary.const and noexcept where true. noexcept on move operations is what lets std::vector move rather than copy on reallocation.Pick one model per module and hold it at the boundary.
std::exception, throw by value, catch by const&. 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".[[nodiscard]] on every returning function so an ignored failure is a warning.noexcept, and mean it: an escaping exception calls std::terminate.init() construction produces objects with an invalid state that every method must then check. Prefer a static factory returning optional/expected.The decisions that break callers, learned the expensive way:
explicit on 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 or static_assert instead). Rationale and the full evolution rules are in the reference below.For extern "C" boundaries, exception containment, PIMPL, and ABI-stable headers, load api-and-abi.md.
Use a template when at least three concrete instantiations exist or are certain. Before that, a concrete type is clearer and compiles faster.
static_assert plus type traits otherwise. An unconstrained template fails deep inside instantiation with an error nobody can read.if constexpr over tag dispatch and SFINAE where the standard allows it.T&& plus std::forward) only in genuinely forwarding code. A forwarding reference in a constructor hijacks the copy constructor and produces baffling overload resolution.<algorithm> and ranges over hand-written loops; a named algorithm states intent that an index loop hides.std::vector unless measurement says otherwise. reserve() when the final size is known.std::move only 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_ptr is null, future is invalid), and relying on those is fine.std::move(local): it defeats copy elision.TEST(Suite, Case), TEST_F for fixtures). One test file per public surface.EXPECT_* to continue after failure, ASSERT_* where continuing would crash or cascade.EXPECT_THROW/EXPECT_NO_THROW for the exception contract; assert on the exception's type and message, not merely that something threw.For generic test discipline (anti-patterns, real assertions, rationalization resistance), see the ia-writing-tests skill.
For CMake target design, sanitizer and warning presets, clang-tidy configuration, and dependency handling, load cmake-and-tooling.md.
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.
#include what the file uses; do not rely on transitive includes from another header.using namespace at namespace scope in a header. Fully qualify instead, or scope the using to a function body.-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Werrorclang-tidy reports no new findings on the diff-fsanitize=address,undefined with zero reportsclang-format --dry-run --Werror produces no diffnew/delete, no new shared_ptr where unique_ptr suffices