archibate C++ OOP Style

SkillAI & models

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.

Available today. Use it from your connected AI after setup.

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 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 defaultThis skill
Free functions dep1DoX(), dep2DoX()One abstract interface Dep, injected
Concrete class with public mutable fields + methodsAbstract 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 failureoptional<int> parseInt()
enum Mode + switch dispatchinject a strategy / functor, or a state class
pair<bool, It> / tuple<...> returnsnamed result struct
const T& x, const T* pEast 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:

  1. 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"); }
    
  2. 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).
  • Name constructors by intent — use named static factories when variants differ in meaning, not signature (Cake::makeChoco() / Cake::makeMoca(), not Cake(double) vs Cake(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 & 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).

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