| name | cpp-oop-style |
| description | High-quality C++ OOP coding style (archibate / parallel101 lineage) that overrides sloppy AI-default C++. Use this skill WHENEVER writing, editing, refactoring, or reviewing C++ code (.cpp / .h / .hpp / .cc / .cxx), designing C++ classes, interfaces, APIs, or libraries, or when the user mentions C++ design, OOP, design patterns, dependency injection, RAII, or "clean / modern C++". Also use it for CMake-first C++ project layout, module targets, usage requirements, third-party dependency acquisition and integration, binary ABI compatibility, installation, packaging, and application deployment. Apply it even when the user does not explicitly ask for a style: the default way models write C++ leans on free functions, public mutable state, raw new/delete, sentinel return codes, and long loose parameter lists — this skill replaces all of that with abstract-class-or-data-class design, dependency injection, type-rich APIs, value-based error handling, and RAII ownership. |
archibate C++ OOP Style
A skill that makes you write C++ the way a senior systems engineer who loves
design patterns writes it — not the way an autocompleter does. When this skill
is loaded, it overrides your default C++ instincts.
The one rule
"Abstract class, data class, or value type. Nothing else."
Every type you introduce is one of these three kinds — never the muddy middle:
- Abstract class — behavior only. A pure-virtual interface with no data
members of its own; any injected collaborators live in its concrete
…Impl,
not in the interface. Always has virtual ~T() = default;. This is the unit of
polymorphism and dependency injection.
- Data class — data only. A plain
struct with public fields, built with
designated initializers. No business logic, no getters/setters wrapping plain
fields. This is the unit of value passing and configuration.
- Value / resource type — a concrete, value-semantic type that either owns a
resource (an RAII wrapper) or enforces one invariant (a strong type:
Money,
EmailAddress, a math Vector3, a C-handle wrapper). It has a small, total
interface and behaves like a built-in — the Regular type. This is the one
concrete-class-with-methods that earns its keep.
Reject the muddy middle that models reach for by default: a concrete class that
mixes private fields, a grab-bag of public methods, and a scatter of free
helper functions — neither a clean interface, nor plain data, nor a focused value
type. That shape is the single biggest tell of AI-slop C++.
So a concrete class with methods is allowed only when it is (a) the
implementation of an abstract class (struct FooImpl final : Foo, defined
in a .cpp, never a header), or (b) a value/resource type as above.
Everything else is behavior behind an interface, or data in a struct.
What you are overriding
| AI-slop default | This skill |
|---|
Free functions dep1DoX(), dep2DoX() | One abstract interface Dep, injected |
| Concrete class with public mutable fields + methods | Abstract class (behavior) or data struct (data) |
Dog dog; dog.doThing(globalThing); | Inject the collaborator: dog.doThing(dep) |
void f(string n, int a, int p, int addr) | void f(FooConfig const &cfg) (designated init) |
new T / delete / new T[] | make_unique / make_shared / vector<T> |
int parseInt() returning -1 on failure | optional<int> parseInt() |
enum Mode + switch dispatch | inject a strategy / functor, or a state class |
pair<bool, It> / tuple<...> returns | named result struct |
const T& x, const T* p | East const: T const &x, T const *p |
Named anti-patterns (real smells this overrides)
These are the concrete shapes that mark sloppy or dated C++ — name them and
refuse them:
- God-base interface — one abstract class fusing data and behavior, with a
pile of public mutable members (e.g. a node base every node both reads state
from and overrides). Split it: behavior → interface, state → data struct.
- Global object + free functions — a global instance poked by a scatter of
free helpers. Make it a class with a clear owner and inject it.
- Stringly-typed API —
setParam("mode", "fast"), sockets/params keyed by
string. Use enum class, strong types, and named fields so the compiler
checks them. Relatedly, fetch an abstract handle once rather than re-passing
a string key on every call: auto *dev = api->getDevice("CD"); dev->play();,
not api->playDevice("CD") then api->stopDevice("CD").
- Sentinel returns —
(size_t)-1, -1, empty string, or null on failure.
Use optional / expected or a result struct (see references/error-handling.md).
The canonical shape
// Dep.h — interface only. Pure virtual. Lives in a small header.
struct MethodConfig {
Point position{};
float size{};
};
struct Dep {
virtual ~Dep() = default;
virtual std::string someQuery() const = 0;
virtual void someMethod(MethodConfig const &config) = 0;
};
// Animal.h
struct Animal {
virtual ~Animal() = default;
virtual void someInterface(Dep *dep) = 0; // collaborator injected, not owned
};
// Dog.h — concrete impl, declared minimally, defined in .cpp
struct Dog final : Animal {
void someInterface(Dep *dep) override;
private:
int somePrivate{};
};
// Dog.cpp
void Dog::someInterface(Dep *dep) {
auto answer = dep->someQuery(); // reuse, don't reimplement per concrete dep
// ...
}
// callSite.cpp — the composition root wires concrete to abstract
auto dog = Dog{};
auto dep1 = std::make_unique<Dep1>(someOptions);
dog.someInterface(dep1.get());
Class design
Virtual functions are a backbone of this style — reach for them. They do
two distinct jobs, and both are worth an interface:
-
Dispatch / dependency injection — one shared caller works across subtypes
it doesn't know. This is what replaces branching on a type tag
(switch (getType()), if (type == Dog)) to pick behavior: let the vtable
dispatch, so adding a subtype touches no existing branch. Without it, every new
subtype copy-pastes the shared logic and one requirement change means editing N
files. The payoff is open for extension, closed for modification —
a new subtype, even one written later by another module or plugin, slots in
behind the interface without reopening any caller.
void feed(Animal *a) { puts("feeding"); a->speak(); puts("done"); }
-
Implementation hiding — the interface lives in the header, the concrete
…Impl lives in the .cpp. This is worthwhile even with a single
implementation: a compile firewall (member types and heavy/third-party
headers stay out of your public header, callers don't recompile when the impl
changes), a clean ABI boundary, and a ready test seam. (See
"single-implementation interface" below.)
The only thing to avoid is the empty interface — a virtual that delivers
neither job: you already hold the concrete type, there is exactly one
implementation, and you gain no hiding, seam, or ABI benefit. That is pure
overhead. Everywhere a real seam exists — polymorphism or build/ABI/test —
prefer the interface.
Escalate abstraction only as far as the duplication demands. Lift a repeated
value to a variable, repeated logic to a function, a clump of arguments to a
struct, shared state-plus-behavior to a class, a fixed set of variants to an
enum, a fixed set of types to a std::variant, and an open set of behaviors
to a virtual interface — in that order. Don't jump to the interface when a
function would do. The real cost of copy-paste is not the typing — it is the typo
you later make in one rarely-run branch. When the type set is closed and known at
compile time, resolve it at compile time — a variant or concept-constrained
overloads (not an if constexpr type-switch; see
references/generics-compile-time.md).
One interface, one responsibility. Never mix concerns (e.g. IO and
computation) in one abstract class — it forces an N×M subclass explosion. Split
into independent interfaces and let a high-level function combine them:
struct Inputer { virtual ~Inputer() = default; virtual std::optional<int> fetch() = 0; };
struct Reducer { virtual ~Reducer() = default; virtual int init() = 0; virtual int add(int, int) = 0; };
int reduce(Inputer *in, Reducer *r); // 2+2 classes, unlimited combinations
Template Method — public non-virtual wrapper, protected virtual do_xxx.
The public method owns the contract and supplies ergonomic overloads; subclasses
override only the raw do_xxx. (As in std::pmr::memory_resource.)
struct Converter {
void process(std::string_view sv) { do_process(sv.data(), sv.size()); }
void process(char const *s) { do_process(s, std::strlen(s)); }
protected:
virtual void do_process(char const *s, size_t n) = 0;
};
Strategy vs Template Method — which to pick. Many independent behaviors on one
object → Strategy: hold pointers to injected strategy interfaces (a Character
with separate move and attack strategies). A single behavior that needs the
object's own members → Template Method: the base is the strategy, the
virtual reads its own fields (a Weapon whose attack uses its damage /
range). One axis of variation that owns no state → functor; several axes, or
state-carrying behavior → strategy objects.
Thin virtual core, fat non-virtual API. Put only primitives behind virtual
(do_read, do_write, do_seek); build the rich convenience API
(getline, flush) as non-virtual methods on top. Few virtuals, much reuse.
Compose, don't multiply subclasses.
- Adapter: wrap an interface, return the same interface, add one capability.
Adapters compose orthogonally instead of
N×M subclasses.
- State as class: encode states as classes implementing a
State interface,
not enum + switch. Adding a state touches no existing branch.
- Component: a
GameObject holds vector<unique_ptr<Component>>. Use dynamic
composition for behavior, never multiple inheritance.
- CRTP: auto-implement boilerplate virtuals (
clone, accept) once in a
template <class D> struct Impl : Base mixin instead of per subclass.
- Visitor / double-dispatch: when behavior depends on two types (or you'd
otherwise write
getType() / isEatable() and switch on it), use
accept/visit so the compiler picks the overload — don't query a type tag.
- Closed-set variant: a fixed, known set of types →
std::variant + std::visit
instead of a class hierarchy — value semantics, no heap or vtable. Use a
virtual interface instead when the set is open. (See
references/generics-compile-time.md.)
- Flyweight: when many objects share identical heavy data (a texture, a lookup
table), hoist it into a separate type held by a
shared_ptr; keep only the
per-instance data (position, velocity) local. 1000 bullets, one shared sprite —
not 1000 texture copies. The owner's method just forwards to the shared object
(sprite->draw(position)) — that delegation is the proxy idiom.
Interface/implementation split (header hygiene). Put the pure-virtual
interface in a small header; keep the concrete …Impl final entirely in the
.cpp. Hand back the interface through a factory, so callers never see — or
#include — the concrete type:
// Foo.h
struct Foo { virtual ~Foo() = default; virtual void run() = 0; };
std::unique_ptr<Foo> createFoo(FooConfig const &cfg); // factory returns the interface
This is also how you select backends: define the factory once per backend
directory and let the build system link exactly one. Swapping an implementation
(real vendor SDK ↔ a fake for tests/replay) becomes a build-variable change, not
a code change — the test double is just another implementation behind the seam.
A single-implementation interface is justified — for hiding, not dispatch.
Even when only one …Impl will ever exist, the compile-firewall / ABI / test-seam
payoff of point 2 still earns the interface — the deliberate exception to "don't
over-abstract." The public header carries only the interface and a factory; the
sole …Impl and its heavy headers stay in the .cpp:
// Widget.h — interface + factory are the whole public surface
struct Widget {
virtual ~Widget() = default;
virtual void draw() = 0;
};
std::unique_ptr<Widget> makeWidget(WidgetConfig const &cfg);
// Widget.cpp — the lone impl and its <heavy/thirdparty.h> are hidden here
struct WidgetImpl final : Widget {
heavy::thirdparty::Object object;
explicit WidgetImpl(WidgetConfig const &cfg) { /* ... */ }
void draw() override { /* ... */ }
};
std::unique_ptr<Widget> makeWidget(WidgetConfig const &cfg) {
return std::make_unique<WidgetImpl>(cfg);
}
Prefer this over classic value-semantic PIMPL since it allows a test fake or a
second backend later; plain PIMPL gives only the compile firewall, no seam.
Command/callback pairs (Api / Spi). For a subsystem with inversion of
control, split the two directions into two interfaces: an Api (the
application programming interface — commands you call into the subsystem) and
an Spi (the service provider interface — events the subsystem calls back
out to you). The owner implements the Spi and holds the Api; wire the two with
api->setSpi(this).
struct PlayerSpi { // you implement — called back on events
virtual ~PlayerSpi() = default;
virtual void onTrackEnded() = 0;
};
struct PlayerApi { // you call in — commands
virtual ~PlayerApi() = default;
virtual void setSpi(PlayerSpi *spi) = 0;
virtual void play(Track const &t) = 0;
};
struct App final : PlayerSpi { // owner: implements Spi, holds Api
explicit App(PlayerApi *api) : api(api) { api->setSpi(this); }
void onTrackEnded() override { api->play(next()); } // reacts to the callback
PlayerApi *api;
};
Singleton — encapsulate the one instance, never a bare global. For a genuinely
process-wide subsystem, hide the constructor, delete copy/move, and hand out the
instance through one accessor — define it in the .cpp like any other method:
// Game.h
struct Game {
void update();
static Game &instance(); // the sole accessor
Game(Game &&) = delete;
private:
Game();
};
// Game.cpp
Game &Game::instance() { static Game inst; return inst; } // lazy, thread-safe (C++11)
A header form — a header-only util, or the generic
template <class T> T &singleton() { static T inst; return inst; } — must be
inline, not static, and gets a separate copy per Windows DLL. A singleton is
still global state: prefer injection through the composition root, and reserve it
for subsystems that are truly one-per-process.
Dependency injection
- Inject abstractions into high-level functions, never concrete types. The
caller chooses the implementation; the callee depends only on the interface.
- Inject a factory, not a product, when the callee must create many. Give a
Gun whose virtual unique_ptr<Bullet> shoot() the callee calls repeatedly —
not a single pre-made Bullet.
- A single composition root does all the wiring. One
main.cpp (or one
setup function) calls the factories and injects via constructor args or
setters. No globals reach across modules; production vs test differ only by
which factories the root calls.
- Collaborators are borrowed, not owned. Pass dependencies as raw interface
pointers (
Dep *) or references; the injectee never owns its collaborators.
Ownership lives in the composition root. (See references/ownership-lifetime.md.)
CMake-first project structure
- Make the target graph mirror the module graph. Give each architectural
module its own directory,
CMakeLists.txt, and library target; let executable
targets be composition roots that link those modules.
- Keep public structure explicit. Put exported headers under
include/<module>/, implementations under src/, include them as
<module/Foo.h>, and use the module name as the C++ namespace.
- Attach requirements to the target that owns them. Sources, include paths,
definitions, options, and dependencies use
target_*; choose PRIVATE,
PUBLIC, or INTERFACE from whether consumers need the requirement.
- Name repeated configuration profiles with CMake Presets. When developers
repeatedly choose among several options, build types, or toolchains, commit a
CMakePresets.json so configure, build, and test use short named profiles with
separate build trees instead of reconstructed -D... command lines.
- Prefer an
OBJECT library for an internal module folded into final products
in one build tree. Multiple in-tree apps, tests, or probes do not require an
archive. Use STATIC or SHARED when the library is itself a deliberate
archive, runtime, ABI, installation, or deployment boundary.
- Normalize each third-party dependency to one CMake target. Prefer an
upstream target whether its source is vendored with
add_subdirectory or
discovered as an installed package. Wrap header-only trees, pkg-config data,
legacy variables, and raw binary SDKs behind a target instead of scattering
include paths and flags across consumers.
- Give each logical dependency one provider and version in the final graph.
Resolve diamonds at the composition root; do not let two parents silently
embed incompatible copies of the same library.
- Choose the delivery contract before adding packaging machinery. Public
libraries and geek-oriented CLI tools get a textbook install target and source
archive; end-user applications get a dedicated artifact pipeline; pybind11
extensions are installed into a Python wheel staging tree.
Read references/cmake-first-projects.md before creating or restructuring a
CMake C++ project, changing module targets, or deciding dependency visibility.
For acquiring, building, finding, vendoring, or wrapping a third-party library,
or for diagnosing a binary dependency ABI mismatch, read
references/dependencies/router.md first.
For any install, package, release archive, portable bundle, AppImage, native
installer, or Python-extension distribution task, read
references/deployment/router.md first. It routes further by deliverable type
and distribution scope.
Type-rich data classes
Make illegal states unrepresentable and make call sites self-documenting. The
compiler is your reviewer.
- Bundle ≥3 related params into a named struct with designated init. Names
beat positions; adding a defaulted field breaks zero callers.
void foo(FooConfig const &cfg); then foo({.name = "x", .age = 24});
- Return a named struct, never
pair/tuple. result.success not
result.first.
optional<T> for nullable returns — never a sentinel like -1 or a
nullable raw pointer. (Error handling: references/error-handling.md.)
- Don't reflexively wrap fields in
optional<T> — reserve it for genuinely
sometimes-absent data; on an always-present field it just sprays null-checks. A
real either/or is a std::variant or distinct types, not a nullable.
enum class for flags/states — blocks implicit int conversion and
argument-order bugs.
- Strong types for primitives that should not interconvert. Wrap in a
one-member struct or
enum class FileHandle : int {} so read(fd, …) can't
silently take the wrong int.
std::span<T> / string_view for non-owning buffer/string params —
length travels with the data, no ptr,len mismatch.
std::chrono for time, never raw integers — time_point + time_point
becomes a compile error instead of a 54-year sleep.
- Plain data is a
struct with public fields, constructed by aggregate
initialization — Foo{a, b} or designated Foo{.x = a, .y = b} — with no
hand-written constructor and no encapsulation ceremony.
- Getters/setters earn their place only to guard an invariant — inside a
value/resource type. Independent fields stay public (a
Point's .x/.y need
no getX/setX); fields coupled by an invariant hide behind hook methods with
mutation banned (a vector exposes size()/resize() and a read-only data()
because resizing must reallocate).
Naming & layout
- No
m_ prefix, no trailing-underscore on members. Members are bare names.
- Trailing underscore only on a ctor/setter param that shadows a member:
void setX(double x_) { x = x_; }.
- Types
PascalCase; methods & members camelCase; constants kPascalCase;
enum class : uint8_t with explicit underlying type.
- Predicate methods read as intent:
shouldRetry(), canFlush().
- One concept per header, kept small.
#pragma once, never include guards.
- Forward-declare in headers,
#include in the .cpp to cut compile
coupling.
- East const everywhere:
T const &, T const * — const binds to what
precedes it, which reads consistently right-to-left.
- Always
struct, never the class keyword — even for encapsulated types.
Open an explicit private: / protected: section when you need encapsulation
(struct Game { void play(); private: Game(); };). The keyword carries nothing
the access labels don't, and defaulting to struct keeps each type's public
surface first and visible.
- In headers, share definitions with
inline, never static (which silently
duplicates per translation unit).
The auto idiom (AAA)
- Almost Always Auto:
auto x = Type{...}, never Type x(...). Forces
initialization and survives return-type changes.
- Explicit cast over implicit:
auto i = size_t{3}; not auto i = 3;.
- In range-for:
auto const & to read, auto & to modify. Never bare
auto — it copies. For maps: for (auto const &[k, v] : m).
- C++20
auto parameters are implicit templates: auto square(auto const &x).
- Dispatch on type with
concept-constrained overloads, the static twin of
virtual dispatch — never an if constexpr (is_same_v<…>) chain, which is the
compile-time form of the getType() / enum-switch anti-pattern. Reserve
if constexpr for capability gating (requires { … }) and variadic recursion.
(See references/generics-compile-time.md.)
The const idiom
- Almost Always Const: write
auto const value = makeValue(); unless the
binding must later be reassigned or moved from. Mutation should be deliberate
and visible at the declaration.
- Declare every pass-by-value parameter
const: spell it T const,
std::span<T> const, or T const *const. Top-level const freezes the local
parameter binding; element or pointee constness remains a separate choice.
- Prefer new
const variables over of reuse: declare new local variables
for logically different variable instead of re-assigning existing ones. Only
reuse when a loop or iteration involves iterative update of a same variable.
- Mark every observation-only member function
const. A query may not
mutate the object's observable value; require the same qualifier on interface
declarations and overrides.
- Expose read-only access with a const view:
T const &, T const *,
std::span<T const>, or std::string_view. Return mutable access only when
mutation is an explicit part of the API contract.
- Leave a local non-const when ownership must move from it.
const blocks
moving from move-only values and may turn an intended move into a copy; never
return T const by value for the same reason.
- Reserve
mutable only for logical constness, such as a cache or mutex that
does not change the observable value. Never for hiding ordinary state changes.
Signature clarity
Function signatures should be self-explained. An interface should convey its expected behavior from its declaration. A function should explain its purpose solely from name and types without ambiguity.
Why: when writing code, reading the header can explains the expected behavior for most trivial functions.
Use a name telling full story e.g. Process::isRunning() instead of Process::check(), unless the class already locks the context e.g. OutOfOrderChecker::check().
Avoid ambiguious function and class names, rename them immediately once you flag one.
Why: a confusing interface name may confuse future agents to risk misuse them.
When there are ambiguity of generic type in argument, define and use type-rich classes sleep(Duration const &), findByName(Name const &).
When there are multiple argument whose order and meaning are ambiguious, use fill(Rect const &) and invoke with Rect{...}.
Why: saves future agent from drifting type semantics during refactor.
Duty class
Keep class interface small and orthogonal. Alert god-class tendency. When a class is piling too many methods and can be classified into orthogonal categories, consider breakdown heavy duty cluster into duty class.
E.g. std::unique_ptr<Painter> Canvas::getPainter() + Painter::fill(Path const &, Brush const &) + Painter::stroke(Path const &, Pen const &). Here Painter can be another abstract class, and Canvas implements Paintable which requires Paintable::getPainter(). Canvas.cpp can implement that as CanvasPainter privately using anonymous namespace (a typical implementation can holds a Canvas * pointer). This keeps the Canvas interface stay focused, also reserve for future Paintable implementations.
Why: programmers and LLMs works better when knowledge is progressively disclosed. Reading a god-class floods context by side-cars unrelevant to the goal. So keep interface small and hierarchy to avoid dilution.
Don't repeat yourself
When there are more than 2~3 paths sharing common pattern or concept: extract into abstracted class. E.g. Path for Line, Arc, Bezier; Brush for Color, Gradient; saves fill() from combination hell.
Two approaches to abstraction:
- Potentially vast expansion in future -> Dynamic polymorphism:
Path as abstract class; pointer semantics; pass as Path const & (or Path * if mutable); return and store as std::unique_ptr<Path>.
- Fixed types, likely won't expand -> Static polymorphism:
Brush as a data-class wrapped std::variant; value semantics; pass as Brush const &; return and store as Brush.
Avoid using function overload and templates for polymorphism unless the context is metaprogramming or performance.
Boolean expression style
Prefer the C++ alternative operator tokens not, and, and or in
human-written boolean expressions. They are core-language keywords with exactly
the same semantics and precedence as !, &&, and ||, but they are harder to
miss while scanning:
if (not isReady() or (isExpired() and canRetry())) {
return false;
}
- Parenthesize mixed
and / or expressions even when precedence already gives
the intended result.
- Prefer a positive named predicate over a dense negation; introduce
isUnavailable() when it communicates a recurring domain concept better than
not isAvailable().
- Keep
!= and bitwise operators symbolic. Do not generalize this rule to
uncommon spellings such as not_eq, bitand, or xor.
- This rule is for boolean expressions, not rvalue references (
T &&) or
declarations such as operator&&. Match third-party and generated code rather
than rewriting it solely for house style.
Prefer brace initialization
Prefer direct-list initialization ({}) over direct initialization (()) when
the two forms select the same constructor:
struct Dog {
explicit Dog(std::string name, std::int32_t age);
};
auto dog = Dog{"George", 10}; // NEVER: Dog dog("George", 10);
This is list initialization, not an "aggregate constructor." Aggregate
initialization is only the constructor-free data-class case such as
Point{.x = 1, .y = 2}. Dog above has a user-declared constructor and is not
an aggregate.
Use () when braces intentionally select an initializer_list overload with
different semantics. std::vector is the canonical example:
auto oneValue = std::vector<std::int32_t>{3}; // one element: {3}
auto threeZeros = std::vector<std::int32_t>(3); // three elements: {0, 0, 0}
auto twoValues = std::vector<std::int32_t>{3, 42}; // {3, 42}
auto threeValues = std::vector<std::int32_t>(3, 42); // {42, 42, 42}
An implicit constructor permits copy-list initialization at a call site. This is
not aggregate initialization either:
struct Dog {
Dog(std::string const &name, int age);
};
void showDog(Dog const &dog);
showDog({"George", 10});
auto dogs = std::vector<Dog>();
dogs.push_back({"George", 10});
When to use explicit constructor
- Default to
explicit for every converting constructor, including
multi-argument constructors used through {...}.
- Allow implicit conversion only when the source and destination are genuinely
substitutable values and the conversion is unsurprising and lossless, such as
a UTF-8 string literal becoming an owning
std::string.
- Different semantics require
explicit: a count is not a container, a raw
handle is not an owning resource, and an integer is not an age merely because
their representation matches.
- When construction modes differ by intent, use named factories rather than
constructor overloads:
Angle::fromDegrees(x) and Angle::fromRadians(x).
struct BigInt {
BigInt(std::int32_t value); // exact, lossless value-domain extension
};
struct Dog {
explicit Dog(std::string const &name);
};
void sendMsg(std::string const &msg);
void showBigInt(BigInt const &big);
void showDog(Dog const &dog);
void usage() {
sendMsg("hello");
showBigInt(42);
showDog(Dog{"George"});
}
C++ cast ladder
Pick casts by the semantic conversion being requested. For arithmetic values,
"up-cast" and "down-cast" are misleading: signedness, range, precision, and the
runtime value all matter.
-
Known-safe constant → braces. List initialization rejects narrowing at
compile time: auto channel = std::uint8_t{42}; is valid while
std::uint8_t{300} is ill-formed.
-
Runtime integral conversion → check, then static_cast. In C++20 use
std::in_range; in C++17 compare against numeric_limits with signedness
handled explicitly:
std::optional<std::size_t> toSize(std::int32_t value) {
if (not std::in_range<std::size_t>(value)) return std::nullopt;
return static_cast<std::size_t>(value);
}
-
Floating-point → integer → define the policy first. Reject non-finite and
out-of-range values, then choose truncation, floor, ceil, or rounding before
the final static_cast. A naked cast silently bakes in truncation and is
undefined when the finite result is outside the destination range.
-
Representation conversion → std::bit_cast only between equally sized,
trivially-copyable types. In C++17 use std::memcpy with the same static
assertions. This is not numeric conversion.
For a polymorphic Dog : Animal hierarchy:
- Derived-to-base pointer/reference conversion is implicit. Returning
unique_ptr<Animal> from a factory deliberately hides Dog.
- Prefer virtual dispatch over recovering the concrete type. When a boundary
genuinely requires checked base-to-derived conversion,
dynamic_cast<Dog *>(p)
returns nullptr on mismatch; dynamic_cast<Dog &>(r) throws std::bad_cast.
- Use
static_cast<Dog *>(p) only when a nearby invariant proves the dynamic
type. Assert that invariant where it is established; a wrong unchecked
downcast has undefined behavior.
Avoid reinterpret_cast. Its legitimate uses are narrow low-level boundaries,
such as the implementation-required pointer/uintptr_t round trip. Converting
an object pointer to void * is implicit; converting a byte buffer to a packed
struct is not a safe zero-copy parser because alignment, lifetime, and aliasing
still apply. Copy bytes with memcpy/bit_cast, then validate the fields.
Avoid const_cast. It is tolerable only when adapting a legacy API whose
signature incorrectly omits const and which is known not to write. Modifying an
object that was originally defined const is undefined behavior.
Ban C-style casts (T)x: they can silently combine static_cast,
const_cast, and reinterpret_cast. Use braces, a named C++ cast, or a
domain-specific conversion function that makes validation visible.
C++ arithmetic types
Choose an integer type from the value's meaning, not from a blanket ban:
- Use
std::int8_t / std::uint32_t and friends when an exact width is part of
a wire format, file layout, ABI, SIMD lane, or hardware register. Exact-width
typedefs are optional on platforms that cannot provide that width.
- Use
int for ordinary small signed arithmetic when no exact width is part of
the contract. Do not serialize it or expose its layout as an ABI promise.
- Use a container's
size_type (usually std::size_t) for sizes and indices
that must interoperate with that container. Use std::ptrdiff_t for signed
distances and subtraction. Do not mix signed and unsigned values casually.
- Use
std::uintptr_t only when the implementation provides it and an integer
must round-trip an object pointer. It is not a generic "native integer."
- Avoid bare
long in portable layouts: it differs between LP64 and LLP64.
Use float, double, or long double according to the required precision,
range, ABI, and measured performance. Append f to a floating literal intended
to be float, such as 3.14f; do not rely on an implicit double conversion.
Add assert when you made assumption
Use static_assert for compile-time properties and assert for internal runtime
invariants. Put the check next to the assumption it protects:
auto const b = someInt();
auto const a = someInt();
auto const diff = b - a;
// Keep a future return-type change from making `diff < 0` always false.
static_assert(std::is_signed_v<decltype(diff)>);
if (diff < 0) {
return false;
}
auto const v = internalAlgorithm();
assert(not v.empty());
return v.back() - v.front();
assert disappears when NDEBUG is defined. Never use it to validate external
input or report a recoverable failure:
auto const v = fetchFromInternet();
if (v.empty()) return std::nullopt;
return v.back() - v.front();
Construction as validation: put one invariant in a value type so downstream
code cannot receive an invalid value. Encapsulation earns its place by making
the illegal state unrepresentable.
struct Age {
static std::optional<Age> fromYears(std::int32_t value_) noexcept {
if (value_ < 0 or value_ > 130) return std::nullopt;
return Age{value_};
}
std::int32_t value() const noexcept { return raw; }
private:
explicit Age(std::int32_t raw_) noexcept : raw(raw_) {}
std::int32_t raw;
};
struct UserConfig {
Email email;
UserName name;
Age age;
};
auto const age = Age::fromYears(inputAge);
if (not age) return false;
registry->registerUser(UserConfig{.email = email, .name = name, .age = *age});
return true;
Return C++23 std::expected when the caller needs an error reason. In C++20/17,
use the project's expected backport, a named result struct, or optional when
no error detail is needed.
UserConfig remains an aggregate data class because each field is independently
valid. If validity depends on a relationship among several fields, replace it
with one composite value type and a validating named factory; a struct with a
validating constructor is no longer the skill's "data only" data class.
Function size discipline
Function discipline. Decompose programs into named, single-responsibility
functions — don't pile logic into main, and don't fuse unrelated jobs (a sum
that also prints; let the caller decide what to do with the result). Prefer
early-return guard clauses over deep nesting, and keep each function within a
screenful — Linus's rule of thumb: ≤3 levels of nesting, ≤24 lines, ≤80 columns.
Pragmatics — when to dial it back
This is a style for code that must live and change. Don't weaponize it:
- Don't pre-abstract. A one-off internal helper does not need an interface.
Add the dispatch seam when a second implementation actually appears (or is
imminent). This is about polymorphism only — a single-implementation interface
for a compile firewall, ABI boundary, or test seam is still justified (see
"single-implementation interface" under Class design).
- Hot paths prefer a template
Func over std::function/virtual for
zero-overhead dispatch. (See references/functors-callbacks.md.) In a measured
inner loop it is even fine to drop OOP entirely — raw intrinsics, free
functions, value-semantic SIMD wrappers — provided every such kernel is paired
with a reference-checked test and a benchmark. Performance you can't measure is
not a reason to abandon the style. (See $cpp-hpc-optimization.)
shared_ptr vs unique_ptr: prefer a single clear owner (unique_ptr,
or a process-lifetime raw owning pointer for singletons); reach for shared_ptr
only when ownership is genuinely shared.
Exemplar libraries — good API to imitate
When unsure what a well-designed API looks like, study these. Each is a clean
demonstration of one principle:
| Library | Principle it demonstrates |
|---|
fmt / std::format | type-rich, compile-time-checked format API; no unsafe varargs |
ranges-v3 / std::ranges | composable lazy adaptors over concrete containers |
| magic_enum | type-safe enum reflection without macros or codegen |
| nlohmann-json | RAII ownership and type-deduced get<T>() |
tl::expected / std::expected | value-based error propagation |
| structopt | struct-as-API — a plain data class drives the interface |
What not to imitate
Fine to use; wrong to copy the style of:
- poco — raw
new/delete throughout, Java-style OOP, no value semantics.
- rapidjson / jsoncpp — SAX template maze / weakly-typed
Value tree.
- tinyxml2, legacy OpenCV C API, stb — raw-pointer, pre-RAII style.
Qt is a different case: it is excellent classic OOP — object-tree ownership,
signals/slots, QObject parenting. Its m_ members, raw new, and
parent-owns-child idioms are deliberate and correct for that paradigm. Keep
them inside Qt code; just don't carry them into value-semantic modern C++, where
this skill's conventions apply.
Compiler hygiene
Let the compiler enforce the style — most rules above become hard errors instead
of review comments. Build with:
-Wall -Wextra -Weffc++
-Werror=return-type -Werror=uninitialized
-Werror=suggest-override # every override marked `override`
-Wzero-as-null-pointer-constant # `nullptr`, never `0` / `NULL`
-Wold-style-cast # named casts only, never `(T)x`
-Werror=vla # `std::vector` / `std::array`, never VLAs
-Wnon-virtual-dtor -Wdelete-non-virtual-dtor
-Wconversion -Wsign-compare # no silent narrowing
-Werror=unused-result # don't ignore a [[nodiscard]] result
Add -D_GLIBCXX_DEBUG in development builds to catch iterator/bounds misuse at
runtime (every linked translation unit must match).
References
You MUST proactively load these when the task touches their area:
references/decoupled-modules.md — definite computation vs tacit I/O or GUI
boundaries, interface seams, agent-operable harnesses, and integration gates.
Load me before decomposing a new C++ project or multi-module architecture.
references/cmake-first-projects.md — course-derived CMake-first directory
layout, target kinds, usage requirements, named configuration presets, source
discovery, third-party dependencies, and embeddable subprojects. Load me
before creating or restructuring CMake C++ targets or dependency wiring.
references/dependencies/router.md — third-party dependency router by source
control, package metadata, graph ownership, and ABI risk. Load me first for
vendoring, package discovery, manual library integration, binary SDKs,
dependency diamonds, or C++ ABI mismatches; then follow the matching leaf.
references/deployment/router.md — deployment router by deliverable type,
audience, and portability scope. Load me first for installation, packaging,
release archives, application artifacts, native installers, or Python wheels;
then follow only the matching child routes.
references/debug-instrumentation.md — discriminating state capture, mature
logging backends, stable instrument keys, JSONL, and bounded hot-path probes.
Load me before adding or structuring temporary diagnostic logs.
references/debug-harnesses.md — minimal harnesses, application or GDB REPLs,
durable developer surfaces, customer diagnostics, live calibration, and fast
diagnostic builds. Load me when debugging needs controllable execution or when
designing persistent development and support controls.
references/ownership-lifetime.md — no raw new, smart pointers vs vector,
references vs pointers, RAII for C resources, the rule of five, dangling
temporaries. Load me before smart pointers, or resource management design.
references/functors-callbacks.md — template Func vs std::function,
lambdas over std::bind, capture lifetime, closures as structs. Load me on
function-programming context.
references/error-handling.md — recoverable vs unrecoverable, optional /
expected, [[noreturn]], the result-struct / error-sink / bool+log fallbacks.
Load me before I/O interface, business logic, error handling, or third-party error
code wrapper.
references/wrapping-c-resources.md — RAII wrappers for opaque C handles:
move-only handle template, error_category, check-on-assign with
, builders, scope-guard binds. Load me before integrating
third-party libraries (e.g. OpenGL, CUDA) or manage OS resources with C handles.