archibate C++ OOP Style
SkillAI & modelsHigh-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.
Available today. Use it from your connected AI after setup.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the archibate C++ OOP Style skill
What this skill tells your AI
The instructions your AI receives, as published by archibate/dotfiles-claude in skills/cpp-oop-style/SKILL.md and read by ahel’s review.
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 hasvirtual ~T() = default;. This is the unit of polymorphism and dependency injection. - Data class — data only. A plain
structwith 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 mathVector3, 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. Useenum 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();, notapi->playDevice("CD")thenapi->stopDevice("CD"). - Sentinel returns —
(size_t)-1,-1, empty string, or null on failure. Useoptional/expectedor a result struct (seereferences/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
…Impllives 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×Msubclasses. - State as class: encode states as classes implementing a
Stateinterface, notenum + switch. Adding a state touches no existing branch. - Component: a
GameObjectholdsvector<unique_ptr<Component>>. Use dynamic composition for behavior, never multiple inheritance. - CRTP: auto-implement boilerplate virtuals (
clone,accept) once in atemplate <class D> struct Impl : Basemixin instead of per subclass. - Visitor / double-dispatch: when behavior depends on two types (or you'd
otherwise write
getType()/isEatable()and switch on it), useaccept/visitso the compiler picks the overload — don't query a type tag. - Closed-set variant: a fixed, known set of types →
std::variant+std::visitinstead of a class hierarchy — value semantics, no heap or vtable. Use avirtualinterface instead when the set is open. (Seereferences/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
Gunwhosevirtual unique_ptr<Bullet> shoot()the callee calls repeatedly — not a single pre-madeBullet. - 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. (Seereferences/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 undersrc/, 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_*; choosePRIVATE,PUBLIC, orINTERFACEfrom 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.jsonso configure, build, and test use short named profiles with separate build trees instead of reconstructed-D...command lines. - Prefer an
OBJECTlibrary for an internal module folded into final products in one build tree. Multiple in-tree apps, tests, or probes do not require an archive. UseSTATICorSHAREDwhen 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_subdirectoryor 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);thenfoo({.name = "x", .age = 24}); - Return a named struct, never
pair/tuple.result.successnotresult.first. optional<T>for nullable returns — never a sentinel like-1or 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 astd::variantor distinct types, not a nullable. enum classfor flags/states — blocks implicitintconversion and argument-order bugs.- Strong types for primitives that should not interconvert. Wrap in a
one-member struct or
enum class FileHandle : int {}soread(fd, …)can't silently take the wrongint. std::span<T>/string_viewfor non-owning buffer/string params — length travels with the data, noptr,lenmismatch.std::chronofor time, never raw integers —time_point + time_pointbecomes a compile error instead of a 54-year sleep.- Plain data is a
structwith public fields, constructed by aggregate initialization —Foo{a, b}or designatedFoo{.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/.yneed nogetX/setX); fields coupled by an invariant hide behind hook methods with mutation banned (avectorexposessize()/resize()and a read-onlydata()because resizing must reallocate). - Name constructors by intent — use named static factories when variants
differ in meaning, not signature (
Cake::makeChoco()/Cake::makeMoca(), notCake(double)vsCake(int)).
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 & memberscamelCase; constantskPascalCase;enum class : uint8_twith 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,
#includein the.cppto 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 theclasskeyword — even for encapsulated types. Open an explicitprivate:/protected:section when you need encapsulation (struct Game { void play(); private: Game(); };). The keyword carries nothing the access labels don't, and defaulting tostructkeeps each type's public surface first and visible. - In headers, share definitions with
inline, neverstatic(which silently duplicates per translation unit).
Use auto wisely in local variables
For an ordinary local value, use one of two declaration forms (adding const
according to the next section):
auto x = rhs; // deduced form: the type is locally evident
Type x{rhs}; // named receiving form: the type adds missing semantics
Use the deduced form when the RHS names the type, the producer and its type are evident within the same function body, or the exact type is lengthy, unnamed, or deliberately an implementation detail:
auto const i = std::size_t{1};
auto worker = std::make_unique<Worker>(config);
auto values = std::vector<int>(3); // `()` intentionally selects the count ctor
Use the named receiving form when a reader cannot recover the type from the current function without inspecting a callee, and the type is important to the meaning of the code:
State const state{machine.state()};
The named form receives an existing expression. When supplying constructor
arguments, put the constructed type on the RHS: auto dog = Dog{"George", 10};.
Do not write Type x = rhs, Type x(rhs), or an uninitialized Type x; those
forms can hide conversions, misuse parentheses, or leave built-in values
indeterminate. Braces make narrowing conversions ill-formed:
std::size_t const count{fetchCount()};
// int count = fetchCount(); // implicit narrowing
// int count(fetchCount()); // implicit narrowing
int const count{static_cast<int>(fetchCount())}; // only after a range check
Do not optimize for silently surviving return-type changes. A named receiving type should make narrowing API drift a compile error instead of an implicit conversion. Braces do not require exact type equality; use strong types when even otherwise-valid conversions must be rejected.
auto deduction does not preserve top-level const or references. In
range-for, use auto const & to read and auto & to modify; bare auto copies.
For maps: for (auto const &[key, value] : map).
The const idiom
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 40
- Forks
- 11
- Last commit
- Sep 2026
Advanced
- Item type
- skill
- Key
cpp-oop-style- Source
- github.com/archibate/dotfiles-claude