Pointers in C++ — From Raw Pointers to Smart Pointers (Complete Tutorial)

A unique_ptr is 8 bytes — exactly the same as a raw pointer. Pointers from declaration to ownership, with every claim compiled and measured.

A boxed dot with an arrow pointing to a separate green block, illustrating a C++ pointer holding the address of another object

A std::unique_ptr is 8 bytes on a 64-bit system. So is a raw pointer. They are the same size, the same speed to dereference, and one of them cleans up after itself — which is most of the argument for modern C++ pointer handling in a single measurement.

This guide covers pointers from the declaration syntax through to the point where you should stop writing raw ones: pointer arithmetic, the const-correctness rules that confuse everyone, references versus pointers, nullptr and why it replaced NULL, dangling pointers, and the smart pointer types that make manual delete unnecessary.

Every program below was compiled and run for this article on Ubuntu 24.04 with GCC 13.3 under -std=c++17 with -Wall -Wextra. Sizes come from sizeof at runtime, the const-correctness table was built by compiling each forbidden assignment and recording the actual error, and the use-after-free was confirmed with AddressSanitizer. All output is captured verbatim.

Table of Contents

What Is a Pointer in C++?

A pointer is a variable that stores the memory address of another object. Declaring one uses * (int* p; declares a pointer to int), taking an address uses the address-of operator & (p = &x;), and reading the object at that address uses the dereference operator * (int y = *p;). Pointers are what make dynamic memory, polymorphism, and efficient large-object passing possible in C++ — and they are the source of most of the language’s memory-safety problems, which is why modern C++ wraps them in smart pointers wherever ownership is involved.

The distinction that governs everything below is ownership: does this pointer merely refer to something, or is it responsible for destroying it? Raw pointers cannot express the difference. Smart pointers can, and that is their entire purpose.

For the exact rules on pointer declarations and conversions, cppreference’s pointer declaration page is the reference.

Declaring and Using Pointers

#include <iostream>

int main() {
    int value = 42;

    int* p = &value;        // p holds the address of value
    std::cout << "value    : " << value  << '\n';
    std::cout << "&value   : " << &value << '\n';   // the address itself
    std::cout << "p        : " << p      << '\n';   // same address
    std::cout << "*p       : " << *p     << '\n';   // 42 - dereferenced

    *p = 99;                // writing through the pointer
    std::cout << "value now: " << value  << '\n';   // 99
}

Two style points worth settling early. Write int* p rather than int *p — the * is part of the type, and that reading matches how you will think about const later. And declare one pointer per line: int* a, b; declares a pointer and an int, which is one of the oldest traps in the language.

nullptr, Not NULL

A pointer that points at nothing should be nullptr, introduced in C++11. The older NULL macro is typically defined as 0, which creates genuine ambiguity:

void f(int x)   { std::cout << "  f(int) called with " << x << '\n'; }
void f(char* p) { std::cout << "  f(char*) called with " << (p ? "non-null" : "null") << '\n'; }

f(0);        // picks f(int) - probably not what you meant
f(nullptr);  // picks f(char*) - unambiguous
// f(NULL);  // does not compile
  f(int) called with 0
  f(char*) called with null

That commented-out line is not a stylistic objection. It is a compile error:

error: call of overloaded 'f(NULL)' is ambiguous
note: candidate: 'void f(int)'
note: candidate: 'void f(char*)'

nullptr has its own type (std::nullptr_t) which converts to any pointer type and to nothing else. Use it everywhere; there is no case where NULL or 0 is better.

Pointer Arithmetic

Adding 1 to a pointer does not add 1 byte. It advances by one element — the size of the pointed-to type:

Pointer arithmetic scales by the SIZE OF THE TYPE, not by 1:

  sizeof(int)=4  p+1 advances 4 bytes
  sizeof(double)=8  p+1 advances 8 bytes
  sizeof(Big)=24 p+1 advances 24 bytes

  *p=10  *(p+2)=30  p[2]=30   (p[2] is defined AS *(p+2))
  ints+4 - ints = 4 elements, not bytes

That last line is the key insight: p[2] is defined as *(p + 2). Array subscripting is pointer arithmetic — which is why arr[i] and i[arr] are both legal C++ and mean the same thing (though please never write the second).

Subtracting two pointers gives the number of elements between them, not bytes, and the result type is std::ptrdiff_t.

Note also that all pointers are the same size regardless of what they point at:

  sizeof(int*)=8  sizeof(double*)=8  sizeof(Big*)=8

A pointer holds an address; addresses are a fixed width on a given platform. This is covered further in the guide to data types in C, where the same figure changes between 32-bit and 64-bit builds.

Pointer arithmetic is only defined within a single array (plus one past the end). Computing a pointer beyond that range is undefined behaviour even if you never dereference it.

const and Pointers: The Four Combinations

This is where most C++ learners stall, and the confusion is entirely about what the const binds to.

const and pointers: which part is frozen?Read the declaration right to left. Errors below are real g++ output.declarationthe pointerthe pointeemeaningint *pnon-constnon-constboth allowedconst int *pnon-constconstcannot change the valueint *const pconstnon-constcannot repointconst int *const pconstconstneither allowedWhat g++ actually says*p = 99;on const int *perror: assignment of read-only location ‘* p’p = &b;on int *const perror: assignment of read-only variable ‘p’
int a = 10, b = 20;

int*             p1 = &a;   // neither const
const int*       p2 = &a;   // pointee is const
int* const       p3 = &a;   // pointer is const
const int* const p4 = &a;   // both const

*p1 = 99;  p1 = &b;         // both allowed
/* *p2 = 99; */ p2 = &b;    // cannot change value, can repoint
*p3 = 99;  /* p3 = &b; */   // can change value, cannot repoint
/* *p4 = 99;   p4 = &b; */  // neither

Uncommenting each forbidden line produces a distinct compiler error — these are the real messages, not paraphrases:

AttemptCompiler error
*p2 = 99;assignment of read-only location * p2
p3 = &b;assignment of read-only variable p3
*p4 = 99;assignment of read-only location *(const int*)p4
p4 = &b;assignment of read-only variable p4

The reading rule: const binds to whatever is on its left, unless there is nothing on its left, in which case it binds right. So int* const p is “p is a const pointer to int”, and const int* p is “p is a pointer to const int”. Read declarations right to left and they stop being ambiguous.

In practice, const int* — pointer to const — is what you want for function parameters that only read. It documents intent and lets the compiler catch accidental writes.

References vs Pointers

A reference is an alias for an existing object. It looks like a pointer’s simpler cousin, and choosing between them is a question people ask constantly:

PropertyPointerReference
Can be nullYesNo — must bind to an object
Can be reassignedYesNo — binds once, permanently
Needs dereferencingYes (*p)No — used like the object itself
Pointer arithmeticYesNo
Can point to nothing meaningfulYesNo
int x = 10, y = 20;

int* p = &x;   p = &y;    // fine - p now refers to y
int& r = x;    r = y;     // does NOT rebind - this ASSIGNS y's value to x

That second line catches people: r = y does not make r refer to y. It copies y into x, because r is x.

Choose a reference when the thing must exist and will never change, which is most function parameters. Choose a pointer when it might legitimately be absent (use nullptr) or when it must be redirected. A function taking const std::string& cannot be handed “nothing”; one taking const std::string* can.

Dangling Pointers and Use-After-Free

delete frees the memory. It does not change the pointer:

before delete: 42
pointer value after delete is unchanged: non-null
after p = nullptr:  null
guarded dereference skipped safely

After delete p, the variable p still holds the old address. Testing if (p) passes. Dereferencing it is undefined behaviour, and AddressSanitizer confirms exactly what it is:

ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010
READ of size 4 at 0x502000000010 thread T0
freed by thread T0 here:

The immediate fix is p = nullptr; after every delete. The real fix is not to write delete at all.

Smart Pointers: The Modern Answer

C++11 introduced pointer types that own what they point at and destroy it automatically. The measured cost of that safety:

sizeof(raw pointer)   = 8 bytes
sizeof(unique_ptr)    = 8 bytes  (no overhead)
sizeof(shared_ptr)    = 16 bytes  (pointer + control block ptr)

std::unique_ptr is exactly the same size as a raw pointer. There is no space penalty and, with optimisation on, no meaningful time penalty either. The “smart pointers are heavyweight” objection is measurably wrong for the type you should reach for first.

unique_ptr — one owner

{
    auto u = std::make_unique<Res>(1);
}   // destructor runs here, automatically
  acquired 1
  released 1

No delete. No leak on an early return. No leak if an exception is thrown. The resource is released when u goes out of scope, whatever route execution takes to get there.

shared_ptr — shared ownership, reference counted

  acquired 2
  use_count after 1 owner : 1
  use_count after 2 owners: 2
  use_count after inner scope ends: 1
  released 2

The object is destroyed when the last shared_ptr to it goes away. That costs 16 bytes and an atomic counter — real overhead, worth paying only when ownership genuinely is shared.

And the raw pointer, for comparison

  acquired 3
  (nothing released - that is the leak)

Same scope, same object, no destructor call. That is a leak, and nothing in the language will tell you about it.

A tool will, though. Compiling the raw-pointer version with AddressSanitizer and giving the function an early return between the new and the delete:

==584==ERROR: LeakSanitizer: detected memory leaks
SUMMARY: AddressSanitizer: 4 byte(s) leaked in 1 allocation(s).

The unique_ptr version, with identical control flow — same early return, same object — produces no sanitizer output at all:

  Widget 1 built
  Widget 1 destroyed

That is the argument in two blocks of output: the leak is not hypothetical, it is not caught by the compiler, and the fix required deleting a line rather than adding one. Build with -fsanitize=address during development and this class of bug stops being invisible.

The decision rule:

SituationUse
Single owner (the default)std::unique_ptr
Genuinely shared ownershipstd::shared_ptr
Observing without owningraw pointer, or std::weak_ptr to break cycles
Passing to a function that only readsreference, or raw pointer if it may be null
An array that growsstd::vector, not a pointer at all

The full treatment of ownership semantics, custom deleters and weak_ptr cycles is in the dedicated guide to smart pointers in modern C++.

Pointers to Pointers

A pointer variable has an address of its own, so you can point at it:

int   value = 42;
int*  p     = &value;
int** pp    = &p;        // pp points to p, which points to value

std::cout << **pp;       // 42 - two dereferences

The common real use is a function that needs to modify the caller’s pointer, not just the pointed-to value — the classic C idiom for allocation functions. In C++ this is usually better expressed by returning a unique_ptr or taking a reference-to-pointer (int*&). The C-side treatment is covered in double pointer to pointer in C.

Common Pitfalls

  • Uninitialised pointers. int* p; holds garbage, not null. Always initialise — to nullptr at minimum.
  • int* a, b; declares one pointer and one int. One declaration per line avoids it entirely.
  • Using NULL or 0. Ambiguous in overload resolution, as demonstrated above. Use nullptr.
  • Forgetting the pointer survives delete. Set it to nullptr, or use a smart pointer and never face the question.
  • Mismatching new/delete forms. new[] requires delete[]. Using the wrong one is undefined behaviour — another problem smart pointers and std::vector remove.
  • Returning a pointer to a local. The object dies at the end of the function; the pointer outlives it.
  • Assuming pointer arithmetic works in bytes. It works in elements. Cast to char* if you genuinely need byte offsets.
  • Reaching for a pointer when a container will do. std::vector handles dynamic arrays better than any hand-managed pointer, as covered in the std::vector guide.

Key Takeaways

  • unique_ptr is 8 bytes — identical to a raw pointer — so the safety is free. shared_ptr is 16 and adds an atomic counter, worth paying only for genuinely shared ownership.
  • nullptr is not a style preference. f(NULL) with overloads on int and char* is a compile error; nullptr resolves unambiguously.
  • const binds left unless nothing is on its left. int* const is a const pointer; const int* is a pointer to const. Each of the four combinations produces a distinct compiler error when violated.
  • Pointer arithmetic scales by type size: p+1 advanced 4, 8 and 24 bytes for int, double and a 24-byte struct. p[2] is defined as *(p+2).
  • delete does not null the pointer. The dangling pointer still tests non-null; AddressSanitizer reports the dereference as heap-use-after-free.
  • References cannot be null or rebound. Use them when the object must exist; use pointers when absence or redirection is meaningful.

Frequently Asked Questions

Conclusion

Pointers have a reputation as the hard part of C++, and that reputation is slightly misplaced. The mechanics — address-of, dereference, arithmetic — are a couple of hours of work. What is genuinely hard is ownership: knowing who is responsible for destroying an object, and making sure that responsibility is discharged exactly once along every path execution might take.

Raw pointers cannot express that. A function returning Widget* tells you nothing about whether you must delete it, and no amount of care in the caller compensates for a contract the type system does not enforce. That is the problem unique_ptr and shared_ptr solve, and — as the measurements above show — they solve it for free in the common case. Write raw pointers to observe; use smart pointers to own; reach for std::vector before you reach for new[]. The rest of the C++ section covers the containers and idioms that make manual memory management rare in modern code.

Scroll to Top