C++ Clean Code Basics: Express Intent with const and noexcept

Introduction: Encode intent in the code

Series 31 covered syntax and patterns; series 38 is about how you arrange and connect them. The first step is making intent explicit in interfaces.
const, noexcept, and [[nodiscard]] tell the compiler—and the next reader—what a function does not do and what it guarantees. That catches ignored return values and exception-safety mistakes at compile time.

The three differ in how strictly they are enforced, and that affects how you should use them. const is checked by the compiler everywhere: a const member function that modifies a member does not compile. [[nodiscard]] produces a warning, not an error, unless you build with -Werror (or /WX), so its value depends on a warnings-clean build. noexcept is not checked at compile time at all — the compiler will happily let a noexcept function call something that throws — and is enforced only at run time, by calling std::terminate if an exception tries to escape. So const is a guarantee, [[nodiscard]] a strong hint, and noexcept a promise that you, not the compiler, must keep.


Problem scenarios: When interfaces are vague

Scenario 1: A Config passed without const gets mutated

Problem: You called process(const Config& cfg) but an internal call mutates cfg, causing subtle bugs. Without const, there is no guarantee of read-only use, so callers cannot safely pass by reference. Fix: Take const Config& and make every callee a const member function so the compiler blocks mutation attempts.

Scenario 2: vector::resize copies instead of moving

Problem: A custom type in std::vector triggers copies on resize/push_back because the move constructor is not noexcept. The standard library may prefer copy when move could throw. Fix: Mark move constructor, move assignment, and destructor noexcept where appropriate so std::vector can use moves.

Scenario 3: Ignoring init() hides initialization failure

Problem: bool init() returns false on failure, but callers write init(); and never check—production runs with failed initialization. Fix: Declare [[nodiscard]] bool init() so ignoring the return value triggers a warning or error.

Scenario 4: Ignoring create()’s result silently throws the work away

Problem: create(); on a function returning std::unique_ptr<Resource> does not leak — the temporary unique_ptr is destroyed at the end of the statement and frees the resource — but the resource is created and destroyed for nothing, and the caller almost certainly meant to keep it. With a raw-pointer factory (Resource* create()), the same line would leak. Fix: [[nodiscard]] std::unique_ptr<Resource> create();


Const correctness

Marking read-only intent

  • A const member function promises not to change the logical state of the object. The compiler rejects non-const calls on const objects; it also hints thread-safety when discussing read-only access.
  • const references/pointers mean this function does not modify the argument.
flowchart TD
    subgraph const_usage[Where const helps]
        A[const member functions] --> A1["No object state change"]
        B[const& parameters] --> B1["No argument mutation"]
        C[const returns] --> C1["Non-modifiable result"]
    end
    A1 --> D[Compiler-enforced]
    B1 --> D
    C1 --> D

If get is const, process can call get on const Config& but not set.

class Config {
public:
    std::string get(const std::string& key) const;
    void set(const std::string& key, std::string value);
};
void process(const Config& cfg) {
    auto v = cfg.get("timeout");
    // cfg.set("x", "y");  // error: non-const call on const object
}

Const correctness spreads through a codebase in one direction. Once process takes const Config&, every member function it calls must be const, and every function those call with the config must take it by const& too. Retrofitting that onto a large codebase is painful precisely because one missing const on a getter deep in the call chain blocks all callers above it — the error passing 'const Config' as 'this' argument discards qualifiers shows up far from the real cause. That is why the cheapest time to add const is when a function is first written: mark every member function const unless it needs to modify the object, and every reference parameter const& unless the function’s job is to modify it.

The rest of const, briefly

The other const tools matter for API design mostly as a checklist, and each has more to it than fits here:

  • const overloads (char& operator[](size_t) and const char& operator[](size_t) const) let one name give write access to non-const objects and read access to const ones.
  • mutable is for members that change without changing the object’s logical state: caches, a mutex, statistics. Because the standard library assumes concurrent calls to const member functions are safe, a const function that writes a mutable member must synchronize, which is why the mutex itself is usually mutable too.
  • const is shallow: a const member function cannot reseat a pointer member but can modify what it points to, so a pimpl class has to keep logical constness by hand.
  • Return types: const T& avoids a copy but ties the caller to the object’s lifetime; const on a by-value return (const std::string name() const;) protects nothing and blocks moves.

Reading const in declarations, shallow const, thread-safe mutable, const_cast with legacy APIs, and the places where const hurts are covered in Const Correctness in C++. This article stays with what const, noexcept and [[nodiscard]] say together about an interface.


noexcept

Contract: this function does not throw

  • noexcept states this function will not throw. For move operations, noexcept helps standard containers prefer move over copy when reallocating.
  • Omitted or noexcept(false) means exceptions are possible. Destructors and move operations should usually be noexcept-friendly.
flowchart LR
    subgraph no_noexcept[Without noexcept]
        A1["vector resize"] --> A2{move ctor}
        A2 -->|may throw| A3[copy chosen]
    end
    subgraph with_noexcept[With noexcept]
        B1["vector resize"] --> B2{move ctor}
        B2 -->|noexcept| B3[move chosen]
    end
class Buffer {
public:
    Buffer(size_t size) : data_(new char[size]), size_(size) {}
    Buffer(Buffer&& other) noexcept
        : data_(std::exchange(other.data_, nullptr)),
          size_(std::exchange(other.size_, 0)) {}
    ~Buffer() noexcept { delete[] data_; }
private:
    char* data_;
    size_t size_;
};

Why noexcept destructor? Throwing from destructors during stack unwinding can call std::terminate. Design destructors not to throw and document with noexcept. Since C++11, destructors are implicitly noexcept anyway, so writing it is documentation rather than a change in behavior.

The move constructor uses std::exchange to take the pointer and leave nullptr behind in one expression. Leaving the source in a valid, empty state is the important part: the moved-from Buffer will still be destroyed, and its destructor must not delete the same memory again. Forgetting to null out the source is the classic double-free bug in hand-written move constructors. Note that this class declares a move constructor but no copy constructor, which implicitly deletes copying — usually the right call for a raw owning pointer, since a defaulted copy would copy the pointer and double-free. A move assignment operator is still missing here; a complete class would add one (also noexcept), or better, hold the buffer in a std::unique_ptr<char[]> and let the compiler generate all of it.

swap and basic operations

swap typically does not throw when swapping handles/pointers—mark it noexcept to help generic algorithms.

Conditional noexcept: noexcept(expr)

template<typename T>
class Optional {
    T value_;
    bool has_value_;
public:
    Optional(Optional&& other) noexcept(std::is_nothrow_move_constructible_v<T>)
        : value_(std::move(other.value_))
        , has_value_(other.has_value_) {
        other.has_value_ = false;
    }
};

The conditional form lets a wrapper be exactly as noexcept as what it wraps: Optional<std::string> gets a noexcept move, while Optional<T> for a type with a throwing move does not falsely promise it.

noexcept and std::vector

On reallocation, if the move constructor is noexcept, vector uses move; otherwise it may copy to preserve the strong exception guarantee.

The reasoning behind that rule: if the vector moved elements one by one into the new buffer and the fifth move threw, the first four would already be moved-from in the old buffer, and there would be no reliable way back. Copying leaves the old buffer untouched until everything succeeded. The library therefore uses std::move_if_noexcept, which moves only when the move constructor is noexcept (or the type cannot be copied at all). This is a silent performance problem rather than a bug — the program behaves correctly, just with a full deep copy on every reallocation — so it tends to be found in a profiler. A quick check is static_assert(std::is_nothrow_move_constructible_v<Buffer>); next to the class, which fails the build if a later change (for example adding a member whose move can throw) removes the property.

The flip side is to not add noexcept where it is not true. When a noexcept function lets an exception escape, the program terminates immediately, with no chance for callers to handle it and no guarantee that the stack is unwound. A function that allocates memory, for instance, can throw std::bad_alloc, so marking it noexcept converts an out-of-memory condition into a crash. Keep it for operations that genuinely cannot fail: moves of handles, swaps, destructors, simple getters.


[[nodiscard]]

Prevent ignored return values

Functions marked [[nodiscard]] trigger diagnostics if the return value is unused—ideal for error codes, new resources, and computed results.

flowchart TD
    A["[[nodiscard]] call"] --> B{Return value used?}
    B -->|Yes| C[OK]
    B -->|No| D[Warning or error]
    D --> E[Catch bugs early]
[[nodiscard]] bool init();
[[nodiscard]] std::unique_ptr<Resource> create();

When the value is ignored, GCC and Clang warn with ignoring return value of 'bool init()', declared with attribute 'nodiscard' and MSVC with warning C4834. If discarding is sometimes intentional, cast to void — (void)init(); or static_cast<void>(init()); — which silences the warning and documents that the decision was deliberate. Be selective: marking every getter [[nodiscard]] produces warning noise that teams learn to ignore, which defeats the purpose. It pays off on functions where ignoring the result is almost certainly a bug — error codes, newly acquired resources, and pure functions whose only effect is the return value (calling v.empty() when v.clear() was meant is the standard library’s own example, and empty() is [[nodiscard]] since C++20).

[[nodiscard]] on types, and reasons in C++20

Since C++17 you can put [[nodiscard]] on a class or enumeration type, so all functions returning that type by value inherit the attribute—useful for error enums and result types. C++20 added a reason string, [[nodiscard("check the error code")]], which appears in the diagnostic, and allowed the attribute on constructors, so that Lock{m}; (a temporary that locks and immediately unlocks) can be flagged.


Common errors and fixes

Error 1: calling non-const members on const X&

Symptom: passing 'const X' as 'this' argument discards qualifiers
Fix: add const to getters and other non-mutating members. This and nine other const diagnostics, with the message text GCC prints, are walked through in Ten C++ const Errors and Their Fixes.

Error 2: mutating members inside const members

Fix: use mutable for caches/locks that do not affect logical constness.

Error 3: vector chooses copy because move isn’t noexcept

Fix: Buffer(Buffer&&) noexcept with std::exchange for pointer members.

Error 4: ignoring init() / factory returns

Fix: [[nodiscard]] on bool and std::unique_ptr factories.


Production patterns

RAII handles with noexcept destructor/moves; factories marked [[nodiscard]]; read-only repositories use const methods and const& parameters; swap is noexcept.

On a legacy codebase, adding these annotations in one sweep usually stalls: const propagates through call chains and a single commit touches hundreds of files, while [[nodiscard]] on widely used functions produces a flood of warnings at once. What works better is annotating leaf types first (value classes, small utilities), then working outward, and adding [[nodiscard]] to one error-returning API at a time, fixing each warning as it appears — every warning that turns up is a place where an error was being ignored.


Complete example

#include <memory>
#include <string>
#include <expected>
enum class DbError { NotConnected, QueryFailed, Timeout };
class Database {
    std::unique_ptr<Connection> conn_;
public:
    [[nodiscard]] std::expected<QueryResult, DbError>
    query(const std::string& sql) const noexcept {
        if (!conn_) return std::unexpected(DbError::NotConnected);
        return conn_->execute(sql);
    }
    void close() noexcept {
        if (conn_) conn_->disconnect();
    }
};

This combines all three: [[nodiscard]] because ignoring a query result — especially an error — is a bug; const because running a query does not change which connection the object holds (the connection itself is changed through the pointer, an example of shallow const); and noexcept because failures are reported through the std::expected return value rather than exceptions. That last promise is only true if conn_->execute and the construction of QueryResult cannot throw. If execute allocates or calls a driver that throws, an exception reaching this noexcept boundary terminates the program, so a real implementation would either catch inside query and convert to DbError, or drop noexcept. std::expected requires C++23; Connection and QueryResult are placeholders here. close() is noexcept because it is the kind of function destructors call.


Performance notes

PriorityTargetWhy
1Destructor noexceptException-safety baseline
2Move noexceptvector reallocation
3[[nodiscard]] on errors/resourcesPrevent silent failures

Summary

ToolRole
constRead-only intent
noexceptNo-throw contract for moves/RAII
[[nodiscard]]Force callers to handle returns

References



FAQ

Q. Does const affect performance?

A. const itself is free; const& avoids copies for large objects.

Q. Next steps?

A. Polymorphism & variant (#38-2) — Series index