CodeRigor

FREE LESSON

Values, references and const

Choose deliberately between copying, aliasing and pointing; write const-correct APIs; never let a reference outlive its object.

C++ gives you three ways to name an object: by value (your own copy), by reference (another name for an existing object) and by pointer (an object holding an address). Most performance surprises and many crashes come from being unclear about which one you have. This lesson makes the choice deliberate: when you copy, when you alias, what const promises, and how a reference can outlive the thing it refers to.

Before the examples: source files and definitions

C++ is compiled. For each .cpp, the preprocessor expands macros and includes header contents, producing a translation unit (TU). Each TU is compiled separately; the linker combines the resulting object files into a program.

You are responsible for following the One Definition Rule (ODR). Within one TU, a class or function may be defined only once. Across a program, an ordinary non-inline function with external linkage needs one definition when it is used. Put its declaration in a header and its body in one .cpp.

A class definition can appear in several TUs through one shared header, and inline functions and templates can too, under matching-definition rules. So “exactly one definition of every class in the entire program” would be too strict. Guard headers against repeated inclusion within a TU; keep their definitions consistent across TUs, including after macro expansion.

One header, separate translation units
Each .cpp sees its own expansion of the header. A guard prevents a second copy in that TU; it does not stop another .cpp from including the header.
One header, separate translation units
Each .cpp sees its own expansion of the header. A guard prevents a second copy in that TU; it does not stop another .cpp from including the header.

The runnable examples below are complete, separate programs, each with its own main; do not paste their definitions together. The project-building lesson develops headers, linkage, inline and the macro pitfalls with a real multi-file lab.

1. Values are independent

Initialising or assigning a value copies it. Afterwards the two objects have nothing to do with each other:

C++
#include <print>
#include <string>

int main() {
    std::string a = "hello";
    std::string b = a;      // b is a copy
    b += ", world";
    std::println("a = {}, b = {}", a, b);   // a is unchanged
}

That's the default for every type — int, std::string, std::vector, your own structs. Value semantics make code easy to reason about: nothing else can change your copy behind your back. The price is the copy itself.

A copy is a second object
After b = a, the two strings own separate buffers; appending to b can't touch a.
A copy is a second object
After b = a, the two strings own separate buffers; appending to b can't touch a.

2. References are aliases

A reference is another name for an object that already exists. It must be initialised, and it can never be re-seated to refer to something else:

C++
#include <print>

int main() {
    int x = 1, y = 2;
    int& r = x;     // r *is* x from now on
    r = y;          // NOT "make r refer to y": assigns y's value to x
    r += 10;
    std::println("x = {}, y = {}", x, y);   // x = 12, y = 2
}

r = y is the classic surprise: assignment through a reference assigns to the referred-to object. There's no syntax to rebind a reference — if you need that, you want a pointer (or std::reference_wrapper).

Three ways to name an int
x and y are objects with their own storage. r is just another name for x — usually no storage at all. p is an object of its own, holding x's address.
Three ways to name an int
x and y are objects with their own storage. r is just another name for x — usually no storage at all. p is an object of its own, holding x's address.
C++
int main() {
    int& r;   // error: a reference must be bound when it's declared
}

3. Pointers: nullable, re-seatable, and two kinds of const

A pointer is a value that holds an address. It can be null and can be pointed elsewhere. const can apply to the pointer, to what it points at, or both — read the declaration right to left:

C++
const int* p;        // pointer to const int: can't change *p, can change p
int const* p;        // same thing, different spelling
int* const p = &x;   // const pointer to int: can change *p, can't change p
const int* const p = &x;   // neither
C++
int main() {
    int x = 1, y = 2;
    const int* p = &x;
    *p = 5;            // error: *p is const
    int* const q = &x;
    q = &y;            // error: q itself is const
}

Use a reference when "there is always exactly one object here", a pointer when "there may be none" (nullptr) or "it may change which object".

Read right to left: what can you change?
const before the * protects the pointee; const after it protects the pointer itself.
Read right to left: what can you change?
const before the * protects the pointee; const after it protects the pointer itself.

4. Passing parameters

The parameter type is a statement about what the function does with the argument:

Parameter Use when Cost
T small/cheap types (int, double, std::string_view, std::span), or a sink: you keep your own copy anyway a copy — or a move if the caller passes an rvalue
const T& read-only access to something expensive to copy none
T& the function modifies the caller's object (in-out) none
T&& rare in everyday APIs: overloads that steal from temporaries none

Worked example: counting copies

A type that counts its copies makes the costs visible:

C++
#include <print>
#include <string>
#include <utility>

struct Tracked {
    static inline int copies = 0;
    std::string data = std::string(100, 'x');
    Tracked() = default;
    Tracked(const Tracked& o) : data(o.data) { ++copies; }
    Tracked(Tracked&&) noexcept = default;
    Tracked& operator=(const Tracked& o) { data = o.data; ++copies; return *this; }
    Tracked& operator=(Tracked&&) noexcept = default;
};

std::size_t by_value(Tracked t) { return t.data.size(); }
std::size_t by_cref(const Tracked& t) { return t.data.size(); }

struct Holder {
    Tracked kept;
    void set(Tracked t) { kept = std::move(t); }   // sink: take by value, then move
};

int main() {
    Tracked t;
    by_value(t);   std::println("by value:         {} copies", std::exchange(Tracked::copies, 0));
    by_cref(t);    std::println("by const&:        {} copies", std::exchange(Tracked::copies, 0));

    Holder h;
    h.set(t);            std::println("sink, lvalue:     {} copies", std::exchange(Tracked::copies, 0));
    h.set(Tracked{});    std::println("sink, temporary:  {} copies", std::exchange(Tracked::copies, 0));
}

Read-only access through const Tracked& costs nothing. The sink set(Tracked t) costs exactly one copy for an lvalue (you needed one anyway — kept must own its data) and zero for a temporary, because the parameter is move-constructed and then moved into kept. One function handles both cases optimally; that's why "take sinks by value, then std::move" is the default.

Faded example — your turn

Library::add stores a title. Pick the parameter type and fill in the body so that add(title) copies once and add(std::string(...)) copies zero times. (Hint: section 4's table, and what must happen to the parameter inside.)

C++
class Library {
    std::vector<std::string> titles_;
public:
    void add(/* ??? */ title) { titles_.push_back(/* ??? */); }
};
Answer

void add(std::string title) { titles_.push_back(std::move(title)); }. Taking const std::string& would force a copy even for temporaries; taking const std::string (by value but const) would also block the move.

5. const member functions and const overloads

A member function marked const promises not to modify the object, and it is the only kind you can call on a const object or through a const&:

C++
#include <cstddef>
#include <print>
#include <utility>
#include <vector>

class Grid {
    std::vector<int> cells_;
public:
    explicit Grid(std::size_t n) : cells_(n) {}
    std::size_t size() const { return cells_.size(); }        // read-only: const
    int& at(std::size_t i) { return cells_.at(i); }           // mutable access
    const int& at(std::size_t i) const { return cells_.at(i); }  // read-only access
};

int sum(const Grid& g) {          // only const members are callable here
    int s = 0;
    for (std::size_t i = 0; i < g.size(); ++i) s += g.at(i);   // picks the const overload
    return s;
}

int main() {
    Grid g(3);
    g.at(1) = 5;                                   // non-const overload: returns int&
    std::println("sum = {}", sum(g));
    std::println("{}", std::as_const(g).at(1));    // force the const overload
}

The pair of at overloads is the standard pattern: a non-const object hands out int&, a const one const int&. The compiler picks by the constness of the object. std::as_const(x) gives you a const& to call the const version explicitly. (C++23's deducing this — auto& at(this auto& self, …) — can write both overloads as one function template.)

6. Dangling references

A reference doesn't keep its object alive. If the object dies first, the reference dangles, and using it is undefined behaviour.

C++
const std::string& name() {
    std::string n = "local";
    return n;              // n is destroyed here; the caller gets a dangling reference
}
Returning a reference to a local
start / 1
The reference outlives the frame it points into. The stack slot is reused by the next call — reading it is undefined behaviour.
Returning a reference to a local
The reference outlives the frame it points into. The stack slot is reused by the next call — reading it is undefined behaviour.

Lifetime extension, and where it stops

Binding a temporary directly to a const& (or &&) local extends the temporary's life to the reference's scope:

C++
#include <print>
#include <string>

int main() {
    const std::string& r = std::string(40, 'x');   // temporary lives as long as r
    std::println("{}", r.size());
}

The extension does not pass through a function call. Here the temporary is bound to the parameter s, and it dies at the end of the full expression — r dangles:

C++
const std::string& first(const std::string& s) { return s; }

const std::string& r = first(std::string(40, 'x'));   // dangling after this line
std::println("{}", r.size());                          // undefined behaviour

Rules of thumb:

  • Never return a reference or pointer to a local variable or parameter taken by value.
  • Returning a reference to a member is fine — as long as the object outlives the reference. get_vector()[0] on a temporary is not.
  • std::string_view and std::span are reference-like too: the same rules apply.
  • C++23 fixed one notorious case: temporaries in a range-for initialiser (for (auto x : make().items())) now live until the end of the loop.

7. auto drops references and top-level const

auto deduces a value type: it strips references and top-level const. Ask for a reference explicitly when you want one:

C++
#include <print>
#include <vector>

int main() {
    std::vector<int> v{1, 2, 3};
    const int& cr = v[0];

    auto a = cr;          // int        — a copy, and not const
    auto& b = v[1];       // int&       — alias
    const auto& c = v[2]; // const int& — read-only alias
    a = 10; b = 20;
    std::println("{} {} {} (a={}, c={})", v[0], v[1], v[2], a, c);

    for (auto x : v) x *= 2;   // modifies copies: v unchanged
    for (auto& x : v) x *= 2;  // modifies v
    std::println("{} {} {}", v[0], v[1], v[2]);
}

decltype(auto) keeps exactly the declared type, references included — useful in generic code that forwards a return value, rarely needed elsewhere.

8. Structured bindings

Structured bindings name the parts of a pair, tuple, array or struct. The auto in front applies to a hidden whole object, so auto [a, b] = p; copies p and auto& [a, b] = p; aliases it:

The binding applies to a hidden object
auto [a, b] = p copies p into a hidden e and names its members; auto& [a, b] = p makes e a reference, so a and b name p's own members.
The binding applies to a hidden object
auto [a, b] = p copies p into a hidden e and names its members; auto& [a, b] = p makes e a reference, so a and b name p's own members.
C++
#include <map>
#include <print>
#include <string>

int main() {
    std::map<std::string, int> stock{{"apple", 3}, {"pear", 5}};
    for (auto& [name, qty] : stock) qty += 1;              // modifies the map
    for (const auto& [name, qty] : stock) std::println("{}: {}", name, qty);
}

C++26: the _ placeholder

When you must name something you'll never use, C++26 lets you call it _ — and unlike an ordinary name, several _ can coexist in one scope without "unused" or "redeclaration" complaints:

C++
#include <map>
#include <print>
#include <string>

int main() {
    std::map<std::string, int> stock{{"apple", 3}, {"pear", 5}};
    int total = 0;
    for (const auto& [_, qty] : stock) total += qty;   // key deliberately unused
    auto [_, inserted] = stock.insert({"fig", 1});
    std::println("total {}, inserted fig: {}", total, inserted);
}

Summary

  • Follow the ODR: one definition per TU, and consistent definitions across TUs where the language permits them.
  • Values copy; references alias and can't be re-seated; pointers can be null and re-pointed.
  • Read const declarations right to left: const int* vs int* const.
  • Parameters: cheap or sink → T (then std::move); read-only → const T&; in-out → T&.
  • Mark read-only member functions const; provide const/non-const overload pairs for accessors.
  • A reference never extends a lifetime except when a temporary binds directly to a local reference.
  • auto copies; write auto& / const auto& (also in range-for and structured bindings) to alias.
  • C++26 _ names things you intend to ignore.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Const as a contract between caller and callee: top-level vs low-level const, const member functions and why const-correct APIs compose.

What lvalue/rvalue actually mean and why a reference can only bind to some expressions — the vocabulary behind every binding rule in this lesson.

Why copying by value is the default worth defending, and when aliasing (references/pointers) is the right exception.

Pointers vs references, object lifetime and dangling — the failure modes that 'never let a reference outlive its object' prevents.

Default, value, aggregate and list initialisation untangled; useful when a copy you thought you made turns out to be something else.

What a const member function promises (and doesn't): const-correct getters and why they matter for const references to your types.

The flip side of const-correctness: const data members and const return/by-value types that block moves.