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.
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:
#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.
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:
#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).
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.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:
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; // neitherint 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".
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:
#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.)
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&:
#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.
const std::string& name() {
std::string n = "local";
return n; // n is destroyed here; the caller gets a dangling reference
}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:
#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:
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 behaviourRules 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_viewandstd::spanare reference-like too: the same rules apply.- C++23 fixed one notorious case: temporaries in a range-
forinitialiser (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:
#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:
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.#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:
#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
constdeclarations right to left:const int*vsint* const. - Parameters: cheap or sink →
T(thenstd::move); read-only →const T&; in-out →T&. - Mark read-only member functions
const; provideconst/non-const overload pairs for accessors. - A reference never extends a lifetime except when a temporary binds directly to a local reference.
autocopies; writeauto&/const auto&(also in range-for and structured bindings) to alias.- C++26
_names things you intend to ignore.