<< Home

Chapter 8: Pointers, References, and Arrays

Pointers, references, and arrays form the low level backbone of C++. They let you work with memory directly, bind names to objects, and manage sequences of elements. Although modern C++ offers safer abstractions, understanding these primitives explains how data moves and how storage behaves under the hood.

Raw pointers and pointer arithmetic

A raw pointer holds the address of an object. You can dereference it to access that object, and you can move it through memory using arithmetic. This style of traversal is common in low level code where direct control is required. Keep pointer lifetimes and ownership clear, because raw pointers do not express who manages the underlying object.

Declaring and using pointers

You create a pointer by storing the address of an existing object. Dereferencing accesses the pointed value. Always check validity before use.

int x = 7;
int* p = &x;
std::cout << *p;

Pointer arithmetic on contiguous sequences

Incrementing or decrementing a pointer moves it by whole elements, not bytes. Arithmetic is only defined within arrays or one past the end. Anywhere else leads to undefined behavior.

int arr[3] = {1,2,3};
int* q = arr;
++q;  // now points to arr[1]
std::cout << *q;
⚠️ Never perform arithmetic on a pointer unless you are sure the target is part of a valid contiguous block of elements.

References: lvalue and rvalue

A reference binds a name directly to an object. An lvalue reference binds to a persistent object, while an rvalue reference binds to a temporary or a movable resource. Use references to avoid unnecessary copies and to clarify ownership expectations.

Lvalue references

An lvalue reference must bind to an existing object with a stable address. It behaves like an alias and cannot be reseated. Use it when a function needs to read or modify the caller's data.

void inc(int& n) { ++n; }
int v = 10;
inc(v);

Rvalue references

An rvalue reference binds to temporaries and expresses move semantics. It allows efficient transfer of resources by stealing state from objects that will not be used again.

void take(std::string&& s);
take(std::string("hi"));
💡 Use std::move to convert an lvalue to an rvalue reference when you intend to move from it.

C style arrays and decay rules

C style arrays are fixed size blocks of contiguous elements. Their size is known at compile time when declared with a constant bound. However, they decay to pointers in many contexts, which hides size information and complicates interfaces.

Declaring arrays

An array groups elements of the same type. Access uses the familiar bracket form, and traversal uses indices or pointers.

int a[4] = {1,2,3,4};
std::cout << a[2];

Array to pointer decay

When passed to a function or used in most expressions, an array converts to a pointer to its first element. This means the size is lost unless passed separately, which encourages mistakes if not tracked carefully.

void print_all(const int* p, std::size_t n);
int b[3] = {4,5,6};
print_all(b, 3);  // b decays to int*
⚠️ Because decay hides array bounds, prefer safer alternatives such as std::array or std::span when you need size aware sequences.

std::array and std::span

std::array is a fixed size container that preserves size information and behaves like an STL type with iterators. std::span is a lightweight view that refers to an existing range without owning it. These forms let you keep C style layout when necessary while avoiding the pitfalls of raw arrays.

std::array as a safer fixed block

std::array<T,N> stores elements inline just like a C style array, but its size is part of the type. It supports standard algorithms and communicates its bounds clearly.

#include <array>
std::array<int,3> a = {7,8,9};
for (int x : a) std::cout << x;

std::span as a non owning view

std::span wraps a pointer and a size into a single object. It does not allocate or own memory; it simply views a range. This turns old pointer plus length conventions into a clean and self describing type.

#include <span>
void show(std::span<int> s) {
  for (int x : s) std::cout << x;
}
int c[4] = {1,2,3,4};
show(c);
💡 Use std::span to pass ranges without forcing callers to wrap them; it adapts to arrays, vectors, and many other containers.

Nullability and not null guidelines

A raw pointer can be null, which introduces the risk of dereferencing an invalid address. Clarify intent by using non null contracts, references, smart pointers, or helper types that express whether null is allowed. When null is meaningful, check for it early and fail clearly.

Checking for null before use

Always verify that a pointer is valid before dereferencing. Prefer early returns or guard expressions that prevent deeper logic from running with an invalid pointer.

void use(const widget* p) {
  if (!p) return;
  p->run();
}

Expressing not null intent

When a function requires a valid object, prefer a reference or a smart pointer that guarantees presence. Custom wrappers or third party not_null types can also encode the rule that null is forbidden.

void operate(widget& w);                 // cannot be null
void manage(std::shared_ptr<widget> p);  // may be null
⚠️ Avoid silent null handling in deep code paths. Make nullability explicit so callers know what guarantees they must provide.