C03: C++ values, references, ownership, and lifetimes
On this page
- A. Prerequisites and observable goals
- B. The central question: who owns this resource, and how long does it exist?
- C. A small memory picture
- D. RAII makes cleanup follow scope
- E. Copying and moving are distinct contracts
- F. Invalidation and performance choices
- G. Independent practice
- H. Complete solutions: C03
- I. Retention and readiness
A. Prerequisites and observable goals#
C01–C02, basic control flow, and an ability to draw object relationships.
Predict copies and aliases, distinguish an address from an object, use RAII and move-only ownership, and explain why a resource remains valid or becomes invalid.
Level: Degree core; memory safety before clever optimization.
B. The central question: who owns this resource, and how long does it exist?#
C++ makes object lifetime and resource management part of ordinary programming. A resource may be heap storage, a file, a lock, or a socket. An owner is responsible for releasing it. A nonowning reference or pointer lets code observe or modify an object without taking responsibility for its destruction. The important question is not whether a pointer looks intimidating; it is whether the pointed-to object still exists when it is used.
An object has a type, storage, a lifetime, and a value or state appropriate to its type. A value is the information an expression yields or an object represents; it is not the object's address. A declaration int b=a; creates a separate integer initialized from a. A declaration int& r=a; creates an alias to the same integer. Changing r changes a, not b. A pointer variable stores an address-like value and can be reassigned to refer elsewhere. The expression *p accesses the pointed-to object only when the pointer is valid for that access. A null pointer does not designate an object. The address operator &a obtains a pointer to a; the same character in a declaration introduces a reference. Context matters.
C. A small memory picture#
int a = 7;
int b = a;
int& r = a;
int* p = &a;
r = 9;
*p = 12;Schematic: boxes represent objects, not a mandated byte layout or numeric address.
The reference does not create another integer. The pointer does not keep a alive. If a’s lifetime ends, a previously obtained pointer may remain as bits in another object, but dereferencing it is not valid. Returning a pointer or reference to a local automatic variable therefore creates a dangling result. A non-null check does not repair that lifetime defect.
D. RAII makes cleanup follow scope#
RAII—resource acquisition is initialization—associates resource ownership with an object’s lifetime. Construction acquires or establishes the resource; destruction releases it. Local owning objects are destroyed when their scope is left, including ordinary stack unwinding after an exception. The rule is stronger than remembering to call cleanup at the bottom of the happy path. It still has boundaries: abrupt process termination and some forms of abnormal termination are not ordinary scope exit. [CPP]
std::vector<T> owns a dynamic sequence and normally gives the safest starting representation for variable-sized contiguous data. std::unique_ptr<T> owns one object exclusively, and std::unique_ptr<T[]> owns an array. std::shared_ptr<T> expresses shared ownership with a control block, not automatic freedom from design problems; cycles of strong ownership can retain objects forever. Prefer clear single ownership unless shared ownership is actually part of the contract.
Constness constrains operations through an interface. A const Buffer& lets a function inspect an existing buffer without copying or modifying it through that reference. It does not prove that no other alias can modify the object, and it does not itself make concurrent use safe. A const int* points to an integer that cannot be modified through that pointer; int* const is a pointer whose own stored address cannot be changed. Read the declared type rather than treating all const placements as synonyms.
E. Copying and moving are distinct contracts#
Copying should create another value according to the type’s copy semantics. For an owning raw pointer, a naive copy duplicates the pointer but not the resource, producing two putative owners and a potential double deletion. Deleting copy operations prevents accidental duplication of exclusive ownership. Moving transfers ownership and leaves the source in a valid state specified by its type. std::move itself is a cast that permits move-aware operations; the selected constructor or assignment actually performs the transfer. Do not infer that an arbitrary moved-from standard-library object is empty unless its documented contract says so.
Our educational Buffer explicitly promises an empty moved-from state, implements it, and tests it. Its size and owning pointer must agree: a size of 5 with a null pointer would violate the class invariant. A hand-written move operation is justified here because the size is separate bookkeeping. In production, using std::vector<int> often gives a simpler design and avoids maintaining this invariant yourself.
F. Invalidation and performance choices#
An address into a vector is not permanently stable. A growth operation that reallocates storage invalidates pointers, references, and iterators into the old storage. Indexing again after the operation obtains access through the current container. Calling reserve can prevent reallocations within the reserved capacity, but it is not a universal lifetime guarantee across arbitrary modifications or destruction. A stable identifier is often more appropriate than a raw address across engine updates or asynchronous work.
Measure before replacing standard containers with a custom allocator or linked structure. Contiguity can improve locality, but the actual workload matters. Ownership reasoning comes before a benchmark: a faster use-after-free is not an optimization. Sanitizers help reveal selected invalid operations during executed paths; they are not a proof that every lifetime path is safe. [GCC]
G. Independent practice#
F foundation C standard course work T transfer S optional stretch L runnable lab or project. Attempt the complete set before opening its solutions.
In the six-line example above, give the final values of a and b. Explain why r does not own a third integer and why p does not extend a’s lifetime.
Diagnose const int& bad() { int x=5; return x; }. Why is returning 5 by value safe while returning this reference is not? Do not execute the undefined access as an experiment.
A class owns a raw int*, deletes it in its destructor, and uses compiler-generated copy operations. Trace what can happen after copying one instance. Give two sound redesigns with different ownership semantics.
A worker stores &positions[0] from a vector. The main thread appends until reallocation occurs, then the worker reads its pointer. Identify both the lifetime issue and the possible concurrency issue. Propose an immutable-snapshot interface that avoids them.
Implement the move-only Buffer contract in cpp/ownership.hpp: zero-initialized storage, bounds-checked access, const access, deleted copy operations, explicit empty moved-from state, and a one-million-element allocation limit. Build and run ownership_test. Explain why destruction releases storage exactly once.
Optional small hints
C03.03: copy the ownership arrows before thinking about destructor order. C03.05: moving the pointer is not enough if the source still reports a nonzero size.
H. Complete solutions: C03#
Compare the first step where your work differs. A corrected answer is not yet an independent success.
C03.01Show solution
An alias and an owner answer different questions
The final values are a=12 and b=7. Assignment through r and dereferencing p both name a’s storage. b was initialized with a copy of the earlier value 7 and has independent storage. References and raw pointers do not carry ownership merely because they refer to an object. They require an independently valid lifetime.
C03.02Show solution
The result outlives its referent
The local x is destroyed when bad returns. The returned reference then refers to an object outside its lifetime. Reading through that reference is undefined behavior; a particular observed 5 would prove nothing about validity. Returning int by value obtains the result’s value while x is alive and initializes a separate result object. Returning a reference can be correct when the referred-to object has an adequate lifetime and the interface states that dependency, but this local object does not.
C03.03Show solution
A duplicated ownership arrow
The implicit copy duplicates the raw address. If both destructors call delete on it, the first destroys the resource and the second attempts invalid deletion. A deep-copy design allocates a separate array and copies all elements, with exception-safe construction and assignment. An exclusive-ownership design uses unique_ptr and deletes copying, permitting moves. Shared ownership is a third possible design when the semantics genuinely require it, but it changes lifetime and mutation responsibilities rather than simply “fixing pointers.”
C03.04Show solution
Publish a lifetime-stable snapshot
Reallocation ends the validity of the address into the old allocation. Concurrent unsynchronized access to a vector while another thread modifies it can also violate the C++ data-race rules, depending on the operations; addressing lifetime alone is insufficient. Build a new immutable position snapshot privately, publish a shared owning handle through a synchronized mechanism, and have each worker retain that handle for its entire query. Do not mutate the published snapshot. Old snapshots remain alive until their readers release ownership. J05 uses an even simpler bounded version: one immutable index exists until all query workers have joined. This avoids claiming that const alone establishes publication ordering.
C03.05Show solution
Reference implementation
#pragma once
#include <cstddef>
#include <memory>
#include <stdexcept>
#include <utility>
// C03.05: one owner, explicit moves, and checked access.
class Buffer {
std::size_t size_;
std::unique_ptr<int[]> data_;
static std::unique_ptr<int[]> allocate(std::size_t n) {
if (n > 1'000'000) throw std::length_error("teaching size limit");
return n ? std::make_unique<int[]>(n) : nullptr;
}
public:
explicit Buffer(std::size_t n) : size_(n), data_(allocate(n)) {}
Buffer(const Buffer&) = delete;
Buffer& operator=(const Buffer&) = delete;
Buffer(Buffer&& other) noexcept
: size_(std::exchange(other.size_, 0)), data_(std::move(other.data_)) {}
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
data_ = std::move(other.data_);
size_ = std::exchange(other.size_, 0);
}
return *this;
}
[[nodiscard]] std::size_t size() const noexcept { return size_; }
int& at(std::size_t i) {
if (i >= size_) throw std::out_of_range("buffer index");
return data_[i];
}
const int& at(std::size_t i) const {
if (i >= size_) throw std::out_of_range("buffer index");
return data_[i];
}
};Construction either finishes with a pointer and matching size or throws before a usable object exists. The owning smart pointer releases its array during destruction. The copy operations are deleted; the move operations transfer the unique owner and set the old size to zero. Thus ownership is never intentionally duplicated. Bounds checking rejects all access to an empty or moved-from buffer. The const overload permits observation without mutation through that interface. The test also checks noncopyability and nothrow move construction at compile time, not merely at runtime. Passing these tests is evidence for the stated small contract, not a license to use dangling external pointers obtained from any future extension.
Back to C03.05I. Retention and readiness#
Retrieve C01’s alias diagram and explain what changed when ownership became explicit. Advance when you can draw object lifetimes across a function call, distinguish std::move from a move operation, and repair a vector-invalidation design without sprinkling arbitrary copies or locks into it.