Step 03 · C++ essentials · B3
Objects, owners and borrowed memory
Follow one object through pointers, references, a borrowed view and an ownership transfer.
Before this page: Bytes, bits and bounded reads. View the learning route.
Before you start
Complete bytes and bounded reads. Read this lesson in four passes: aliases, cleanup, borrowing, then ownership transfer. Draw the small traces before combining them in the complete program. The fragments are excerpts or simplified parts of that program, not standalone executables.
1. A value, its address and an alias
Start with one integer object. &counter obtains its address; int* pointer stores an address of an integer; *pointer accesses the pointed-to integer. In int& alias = counter, the ampersand declares a reference: another name for that same object.
int counter = 3;
int* pointer = &counter;
int& alias = counter;
*pointer = 4;
alias = alias + 1;
Predict: write counter's value after each assignment. How many integer objects are being changed?
Check the alias trace
counter starts at 3, becomes 4 through pointer, then 5 through alias. There is one integer object. pointer is a separate pointer object holding its address; alias names counter. Assigning to alias changes counter rather than rebinding the reference.
A pointer may be null, but accessing an object through a null, dangling or otherwise invalid pointer is not allowed. A reference also requires a live object; it does not keep that object alive.
Two uses of const
const int* p prevents changing the integer through p. int* const p prevents changing p itself. Neither spelling proves ownership or thread safety. In this lesson, read span<const int> as a view that does not permit changing its elements.
2. Decide who performs cleanup
A local object with automatic storage duration is destroyed when its scope ends; this is often called a stack object. Braces introduce the scope in this fragment. A destructor is the function named ~Type(), invoked when an object of that type is destroyed.
{
Buffer buffer;
// Use buffer here.
} // buffer's destructor runs here.
In the complete program below, Buffer owns a vector of integers and its destructor prints a message. The vector also cleans up its own element storage. RAII ties resource cleanup to an owning object's lifetime; it is useful for memory, files and locks.
A dynamically allocated object can outlive the function that created it. std::make_unique<Buffer>() constructs one and returns a std::unique_ptr<Buffer> that owns it. Destroying that owner destroys Buffer and releases its allocation. The handle and its owned object are different objects.
Predict: if a unique_ptr is created inside this scope and its ownership is never transferred, is the allocation left alive when the scope ends?
Check the cleanup rule
No. The local unique_ptr is destroyed at the closing brace, which destroys its Buffer. Allocating dynamically does not itself make the object permanent. The owner controls when cleanup happens.
3. Borrow elements without owning them
std::vector<int> owns a resizable sequence. std::span<const int> borrows elements and their count, without owning them or extending their lifetime. In this fragment, both names reach the same three elements:
std::vector<int> values{10, 20, 30};
std::span<const int> view(values);
values[1] = 25;
// view[1] now reads 25.
Predict: does destroying view destroy values? Does changing an existing integer element require a new span?
Check the borrow
Destroying view leaves values alive. Replacing an existing integer value changes the shared element without reallocating its storage, so this view sees 25.
Destroying the vector invalidates the view. Growing the vector can allocate new storage and invalidate a view even while the vector object remains alive. Do not use an old view after reallocation; create a new one from the current storage. A span provides neither automatic bounds checking nor lifetime enforcement in C++20.
On paper: draw the vector and span pointing to one block of elements. Then draw a larger allocation for the vector. The old span still refers to the old storage; describe why it cannot be read instead of executing that invalid access.
4. Transfer the owner, keep track of the object
Now combine an owner and a borrow. auto deduces a type from its initializer. owner->values accesses the Buffer member through its pointer-like owner.
auto owner = std::make_unique<Buffer>();
std::span<const int> view(owner->values);
auto next_owner = std::move(owner);
std::move(owner) permits a move; construction of next_owner performs the ownership transfer. The original unique_ptr becomes empty. The Buffer and its vector elements stay where they were; this operation does not resize the vector.
Predict: which handle now destroys Buffer? Can view still read element 1 before that destruction?
Check the ownership transfer
next_owner owns and eventually destroys Buffer. owner is empty. view still reaches the original elements, so it remains valid while next_owner keeps Buffer alive and the vector's storage remains valid. Moving arbitrary types has type-specific effects; the empty-owner rule here belongs to unique_ptr.
Optional: shared ownership
std::shared_ptr uses a different policy: several handles share ownership and the last strong owner destroys the managed object. Ownership cycles need additional care. Use the project C++ lesson's runnable example when you reach it; this lesson's program uses a single owning handle at a time.
Trace the complete program
Before running, predict the output order, especially buffer destroyed and scope ended. std::boolalpha prints boolean values as true/false. The final conditional expression counter == 5 ? 0 : 1 returns 0 if the comparison is true and 1 otherwise. Keep the alias trace and the allocation trace separate on paper.
#include <iostream>
#include <memory>
#include <span>
#include <utility>
#include <vector>
struct Buffer {
std::vector<int> values{10, 20, 30};
~Buffer() { std::cout << "buffer destroyed\n"; }
};
int main() {
int counter = 3;
int* pointer = &counter;
int& alias = counter;
*pointer = 4;
alias = alias + 1;
std::cout << "counter=" << counter << '\n';
{
auto owner = std::make_unique<Buffer>();
std::span<const int> view(owner->values);
auto next_owner = std::move(owner);
if (owner || !next_owner || view.size() != 3 || view[1] != 20) return 1;
std::cout << "borrow=" << view[1] << '\n';
std::cout << "old owner empty=" << std::boolalpha << !owner << '\n';
// Do not resize the vector or destroy next_owner while using view.
}
std::cout << "scope ended\n";
return counter == 5 ? 0 : 1;
}
Use the B1 setup once. From this learning repository's root:
cmake --build _Build/curriculum/beginner --target ownership ctest --test-dir _Build/curriculum/beginner -R '^beginner_ownership$' --output-on-failure
To see the output directly in PowerShell, run .\_Build\curriculum\beginner\ownership.exe; in a POSIX shell, run ./_Build/curriculum/beginner/ownership. Tests compare the full output and require a successful exit.
Expected output, with exit status 0:
counter=5 borrow=20 old owner empty=true buffer destroyed scope ended
Exercise
Draw boxes for counter, the two owner handles, the Buffer, its vector elements and the span. Draw arrows after the move. Then change std::span<const int> to std::span<int>, assign 25 to element 1 before moving the owner, and update the expected check/output. Explain why the original const view rejects the assignment.
Hint
The unique_ptr move changes the owning handle, not the allocation. A span points to elements, not to the owner variable.
Worked answer and rubric
The pointer and alias both reach counter, whose final value is 5. next_owner owns Buffer after the move; owner is empty. The view still reaches the original vector elements. The mutable-view edit prints borrow=25; change the internal check accordingly. When the block ends, next_owner destroys Buffer and its vector. Never demonstrate the invalid case by actually dereferencing view after destruction; explain the invalidation instead. You are ready when you can identify who destroys each object and which operation invalidates each borrow.
Read an ownership boundary
When you reach project memory patterns, inspect src/graphics/host_gpu/renderer/renderContext.h and BufferCache::DownloadBufferMemory(). Trace the owner, the borrowed address and the completion condition together. A raw pointer parameter often means a borrow by project convention, but its type alone does not specify the complete contract.
Optional extra practice: pointers and smart pointers and move semantics.
Before moving on
Ticks record your own assessment. Keep your prediction, actual output and explanation; a tick alone is not evidence of understanding.