DEV Community

Olivia Craft
Olivia Craft

Posted on

CLAUDE.md for Modern C++: 12 Rules That Stop AI from Writing 1998-Style C++

You ask Claude to "add a Subscription service that calls Stripe" inside your C++ codebase, and you get back something that compiles cleanly and is still wrong:

  • A function returning Subscription* whose ownership convention is "trust me, you delete it."
  • Three new/delete pairs in the happy path because that's what the model saw on Stack Overflow circa 2009.
  • A using namespace std; at the top of a header, leaking into every consumer.
  • A std::mutex locked with m.lock() / m.unlock() by hand — guaranteed to leak on the next thrown exception.
  • A C-style cast (MyType*)ptr that would be static_cast if the model knew you were past 2003.
  • A class with seven uninitialized member variables, default-constructed into UB.

The model isn't lazy. It's been trained on 25 years of C++ code, the median of which is C++98 with <vector>. A CLAUDE.md at the root of your project drags it forward to where you actually live — C++17 with selective C++20.

Here are 12 rules I drop into every Modern C++ project. Each one closes a class of bug AI assistants generate by default.


Rule 1 — No Raw new / delete in Application Code

Why: Every new paired with a hand-written delete is a leak waiting on the next thrown exception. AI tools default to manual allocation because that's what the average training-set C++ does. The fix is structural: ownership lives in a smart pointer, period.

Bad:

class Service {
public:
    Service() : client_(new HttpClient(/*...*/)) {}
    ~Service() { delete client_; }      // and now it's not copy-safe
private:
    HttpClient* client_;
};
Enter fullscreen mode Exit fullscreen mode

Good:

class Service {
public:
    Service() : client_(std::make_unique<HttpClient>(/*...*/)) {}
    // destructor, copy/move semantics: defaulted correctly by the compiler
private:
    std::unique_ptr<HttpClient> client_;
};
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

No raw new / delete / malloc / free in application code.
Use std::make_unique<T>(args...) and std::make_shared<T>(args...).
Owning raw pointers (T* that the caller must delete) are forbidden across API boundaries.
Return std::unique_ptr<T>, return by value, or hand out a non-owning view.
Enter fullscreen mode Exit fullscreen mode

Rule 2 — RAII Everything, No Init() / Cleanup() Pairs

Why: Resources that aren't memory — file handles, sockets, mutexes, GPU buffers, OS handles — are leaked exactly as easily as memory. AI assistants love Init() / Cleanup() patterns because they translate cleanly from imperative pseudocode. The C++ idiom is the opposite: the destructor runs on every exit path, including thrown exceptions.

Bad:

File f;
f.Open("config.toml");
auto data = parse(f.Read());      // throws → f.Close() never called
f.Close();
Enter fullscreen mode Exit fullscreen mode

Good:

class File {
public:
    explicit File(const std::string& path)
        : fd_(::open(path.c_str(), O_RDONLY)) {
        if (fd_ < 0) throw std::system_error(errno, std::generic_category(), path);
    }
    ~File() { if (fd_ >= 0) ::close(fd_); }
    File(const File&) = delete;
    File& operator=(const File&) = delete;
    File(File&& other) noexcept : fd_(std::exchange(other.fd_, -1)) {}
    File& operator=(File&& other) noexcept {
        if (this != &other) { if (fd_ >= 0) ::close(fd_); fd_ = std::exchange(other.fd_, -1); }
        return *this;
    }
    // ...
private:
    int fd_ = -1;
};
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Every resource (file, socket, mutex, GL handle, GPU buffer, OS handle)
is wrapped in an RAII type whose destructor releases it.
No Init() / Cleanup() pairs that callers must remember to invoke.
Wrappers delete copy and explicitly = default the move pair.
Enter fullscreen mode Exit fullscreen mode

Rule 3 — std::mutex Is Always Held by lock_guard / scoped_lock

Why: Manual mutex.lock() / mutex.unlock() is broken the moment any code between them throws — and "any code" in C++ includes most allocations. AI-generated concurrency code is full of this pattern. RAII fixes it for free.

Bad:

void Cache::insert(Key k, Value v) {
    mu_.lock();
    map_[k] = std::move(v);          // bad_alloc here → mutex stays locked
    mu_.unlock();
}
Enter fullscreen mode Exit fullscreen mode

Good:

void Cache::insert(Key k, Value v) {
    std::scoped_lock lock(mu_);      // released on every exit path
    map_[k] = std::move(v);
}
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

std::mutex is held via std::lock_guard / std::scoped_lock — never by hand.
Hold locks for the minimum span. Never call user code (callbacks, virtual methods)
while a lock is held — ordering inversions are silent and brutal.
For multi-mutex critical sections, use std::scoped_lock(m1, m2, ...).
Enter fullscreen mode Exit fullscreen mode

Rule 4 — Move Semantics: noexcept Move, = delete Copy When Owning

Why: A move constructor that isn't noexcept is silently downgraded to a copy by the standard library. std::vector<T>::push_back will copy your "movable" type on every reallocation if T's move isn't noexcept. AI assistants forget noexcept constantly because the language doesn't force them.

Bad:

class Buffer {
public:
    Buffer(Buffer&& other) : data_(other.data_), size_(other.size_) {  // not noexcept!
        other.data_ = nullptr; other.size_ = 0;
    }
    // copy constructor implicitly defined → deep copy happens silently
    char* data_;
    std::size_t size_;
};
Enter fullscreen mode Exit fullscreen mode

Good:

class Buffer {
public:
    Buffer() = default;
    Buffer(const Buffer&) = delete;            // no implicit deep copy
    Buffer& operator=(const Buffer&) = delete;
    Buffer(Buffer&&) noexcept = default;       // explicit, fast move
    Buffer& operator=(Buffer&&) noexcept = default;
    ~Buffer() = default;                       // unique_ptr handles it
private:
    std::unique_ptr<char[]> data_;
    std::size_t size_ = 0;
};
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Move constructor and move-assignment are noexcept — the standard library only uses
moves when they are. A non-noexcept move silently becomes a copy.
Types that own resources delete the copy pair unless deep copy is genuinely cheap.
Default the move pair (= default), don't hand-roll if a member-by-member move works.
Enter fullscreen mode Exit fullscreen mode

Rule 5 — const Correctness Is Not Optional

Why: Every method that doesn't mutate state should say so. AI-generated classes treat const as decoration — a method that "looks read-only" but isn't marked const infects every caller, since const references can't call non-const methods.

Bad:

class Polygon {
public:
    double area() {                // missing const
        return /* ... */;
    }
};

void render(const Polygon& p) {
    auto a = p.area();             // compile error — method isn't const
}
Enter fullscreen mode Exit fullscreen mode

Good:

class Polygon {
public:
    double area() const {          // marked const
        return /* ... */;
    }
};
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Every method that doesn't mutate observable state is marked const.
Every parameter the function won't modify is const T& (or T for trivially copyable).
Member fields that genuinely don't change after construction are const,
or are exposed only via const accessors.
Enter fullscreen mode Exit fullscreen mode

Rule 6 — Sink Parameters: Take by Value, std::move In

Why: The constructor pattern most AI assistants generate — Foo(const std::string& name) : name_(name) {} — costs a copy from every rvalue caller. Take by value and std::move into the member: the compiler picks the right move/copy at the call site, and you have one overload.

Bad:

class User {
public:
    User(const std::string& name, const std::string& email)
        : name_(name), email_(email) {}        // always copies
private:
    std::string name_;
    std::string email_;
};

User u(get_name(), get_email());               // two copies of temporaries
Enter fullscreen mode Exit fullscreen mode

Good:

class User {
public:
    User(std::string name, std::string email)
        : name_(std::move(name)), email_(std::move(email)) {}
private:
    std::string name_;
    std::string email_;
};

User u(get_name(), get_email());               // moves from temporaries
User u2(name_var, email_var);                  // copies once, moves into member
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Sink parameters (functions that store the value) take by value and std::move.
Read-only parameters take const T& (or T for trivially copyable / string_view / span).
Don't write four overloads (const&, &&, etc) — by-value-and-move handles all callers.
Enter fullscreen mode Exit fullscreen mode

Rule 7 — std::string_view and std::span<T> for Read-Only Views

Why: Functions that take const std::string& reject const char* literals at the call site without a std::string allocation. std::string_view accepts both for free. The same logic applies to arrays vs std::vector<T> vs std::array<T, N>std::span<T> accepts all of them.

Bad:

bool starts_with(const std::string& s, const std::string& prefix) { /* ... */ }
starts_with("/api/v1/users", "/api/");        // two allocations
Enter fullscreen mode Exit fullscreen mode

Good:

bool starts_with(std::string_view s, std::string_view prefix) {
    return s.size() >= prefix.size() &&
           s.substr(0, prefix.size()) == prefix;
}
starts_with("/api/v1/users", "/api/");        // zero allocations
Enter fullscreen mode Exit fullscreen mode
double sum(std::span<const double> values) {
    return std::accumulate(values.begin(), values.end(), 0.0);
}

std::vector<double> v = {/* ... */};
double arr[] = {1.0, 2.0, 3.0};
sum(v);                                        // works
sum(arr);                                      // works
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Read-only string parameters take std::string_view, not const std::string&.
Read-only array parameters take std::span<const T> (C++20), not const std::vector<T>&.
Never store a string_view or span past the lifetime of its source — they're non-owning.
Enter fullscreen mode Exit fullscreen mode

Rule 8 — No using namespace std; in Headers, Ever

Why: A using namespace std; in a header pollutes every translation unit that includes that header — directly or transitively. Name lookup ambiguities surface in code that doesn't even mention std, and the diagnostic points somewhere unrelated.

Bad:

// renderer.hpp
#include <vector>
#include <string>
using namespace std;                           // poisons every consumer

class Renderer { /* ... */ };
Enter fullscreen mode Exit fullscreen mode

Good:

// renderer.hpp — fully qualify in headers
#include <string>
#include <vector>

class Renderer {
public:
    void draw(std::span<const Mesh> meshes);
};
Enter fullscreen mode Exit fullscreen mode
// renderer.cpp — narrow `using` in implementation files is fine
using std::vector, std::string;
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

No `using namespace std;` at file scope, ever — especially not in headers.
Narrow using-declarations (`using std::vector;`) inside .cpp files are acceptable.
In headers, always fully qualify (std::vector<T>, std::string_view).
Enter fullscreen mode Exit fullscreen mode

Rule 9 — Concepts and if constexpr Replace SFINAE

Why: SFINAE-based templates (std::enable_if, void_t tricks) produce diagnostics that scroll for pages and are unreadable. C++20 concepts produce errors that read like English.

Bad:

template <typename T,
          typename = std::enable_if_t<std::is_integral_v<T>>>
T half(T x) { return x / 2; }
// Error message on a non-integral T: "no matching function for call to half<...>"
//   followed by 40 lines of substitution failure
Enter fullscreen mode Exit fullscreen mode

Good (C++20):

template <std::integral T>
T half(T x) { return x / 2; }

// Error on a non-integral T:
//   "constraints not satisfied for `half<std::string>`:
//    the constraint `std::integral<std::string>` was not satisfied"
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Every template parameter with any requirement is constrained — std::integral,
std::ranges::range, or a custom concept defined in the header.
Replace std::enable_if SFINAE with concepts as you touch the code.
Use if constexpr (C++17) for compile-time branching, not tag dispatch.
Enter fullscreen mode Exit fullscreen mode

Rule 10 — Every Member Is Initialized at Declaration

Why: An uninitialized scalar member is undefined behavior on first read. AI-generated classes leave int count; and bool ready; to "be set in the constructor body" — and then they're not, on the path the model didn't think about.

Bad:

class Counter {
public:
    Counter() {}                   // count_ uninitialized → UB
    void tick() { ++count_; }      // reading uninitialized int
private:
    int count_;
    bool active_;
};
Enter fullscreen mode Exit fullscreen mode

Good:

class Counter {
public:
    Counter() = default;
    void tick() { ++count_; }
private:
    int count_ = 0;                // default member initializers
    bool active_ = false;
    std::string name_{};
    std::vector<int> events_{};
};
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Every member variable has a default initializer at its declaration.
Constructor bodies should be empty or near-empty — initialize in the
member-initializer list, not by assignment in the body.
Uninitialized scalar reads are UB and silent. Brace-init defaults are free.
Enter fullscreen mode Exit fullscreen mode

Rule 11 — C-Style Casts Are Banned; Use the Verbose Forms

Why: A C-style cast (T)x silently does whatever it takes — static_cast, const_cast, reinterpret_cast, or all three at once. The verbose forms are searchable, intent-revealing, and rejected by the compiler when they're wrong.

Bad:

void* raw = some_api();
MyType* p = (MyType*)raw;          // is this a static_cast or reinterpret_cast?
const auto& s = (std::string&)other; // const_cast hidden in plain sight
Enter fullscreen mode Exit fullscreen mode

Good:

auto* p = static_cast<MyType*>(raw);                   // safe pointer conversion
const auto* derived = dynamic_cast<const Derived*>(b); // checked downcast
auto* bytes = reinterpret_cast<std::byte*>(buffer);    // intent-flagged
auto& mutable_s = const_cast<std::string&>(other);     // const_cast is loud — use sparingly
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

C-style casts (T)x are forbidden.
Use static_cast for safe conversions, dynamic_cast for checked downcasts,
const_cast only at well-documented boundaries, reinterpret_cast for
intentional bit-level reinterpretation. Each one is searchable in review.
Enter fullscreen mode Exit fullscreen mode

Rule 12 — CMake: target_*, Out-of-Source, No include_directories

Why: include_directories and link_libraries are directory-scoped and global — they leak into every target defined in or below that CMakeLists.txt. AI-generated CMake is full of them because they look like "the simple version." The target_* family is per-target, scoped (PRIVATE / PUBLIC / INTERFACE), and exported correctly by install.

Bad:

# CMakeLists.txt — global, leaks everywhere
include_directories(${CMAKE_SOURCE_DIR}/include)
link_libraries(fmt::fmt)

add_library(engine src/engine.cpp)
add_library(physics src/physics.cpp)   # silently links fmt::fmt too
Enter fullscreen mode Exit fullscreen mode

Good:

add_library(engine src/engine.cpp)
target_include_directories(engine
    PUBLIC  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
            $<INSTALL_INTERFACE:include>
    PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_link_libraries(engine PUBLIC fmt::fmt)
target_compile_features(engine PUBLIC cxx_std_20)
target_compile_options(engine PRIVATE -Wall -Wextra -Wpedantic -Werror)
Enter fullscreen mode Exit fullscreen mode

Rule for CLAUDE.md:

Use target_include_directories, target_link_libraries, target_compile_options —
never include_directories / link_libraries (directory-scoped, leak everywhere).
PRIVATE for impl, PUBLIC for API, INTERFACE for header-only deps.
Out-of-source builds only (build/). cmake_minimum_required(VERSION 3.21) at minimum.
Sanitizers wired via option(ENABLE_ASAN "..." OFF) — Debug builds default ON in dev.
Enter fullscreen mode Exit fullscreen mode

Why This Is Worth Doing Once

Every rule above traces to a real production bug from an AI-generated PR. A delete that ran twice on a hot path because move semantics weren't thought through. A std::mutex that stayed locked for the rest of the process when an allocation threw inside a critical section. An uninitialized bool active_ whose value depended on whatever was on the stack — green in debug, red in release. A (MyType*)voidptr that reinterpret_cast-ed into a different type because the layouts coincidentally matched on x86_64 and didn't on ARM.

You can keep catching these in review forever. Or you can write a CLAUDE.md, drop it at the repo root, and stop seeing 80% of them.

The 12 rules above are a starting point — the full pack has 50+ production-tested rules covering Modern C++, Rust, Go, TypeScript, React, Vue, Django, FastAPI, Postgres, Kubernetes, Docker, and more.

Free C++ gist with all 3 rules → https://gist.github.com/oliviacraft/1f74c314f1f2f7b47f2bddf236977dcb

Full CLAUDE.md Rules Packhttps://oliviacraftlat.gumroad.com/l/skdgt

Top comments (0)