Visual walkthrough — Dynamic memory — malloc, calloc, realloc, free
Everything here assumes only that you have seen a pointer (a variable that holds an address) and the heap (a big pool of memory you rent at runtime). We build the rest.
Step 1 — What a pointer to heap bytes even is
WHAT. We rent a slab of bytes from the heap and hold its starting address in a pointer v.
int *v = malloc(cap * sizeof(*v));Let us name every symbol right where it appears:
sizeof(*v)reads as "the size of the thingvpoints at" — oneint, typically bytes (see sizeof operator).cap(capacity) is how many int-sized boxes we asked for.
WHY this form. malloc speaks only in bytes, but we think in elements. The multiplication is the translator between the two languages.
PICTURE. v is an arrow. It does not point at "the array" — it points at the first byte of a fenced-off strip of heap. The strip is cap boxes wide.
Step 2 — Why we need TWO counters, not one
WHAT. We keep len (used) and cap (rented) as separate variables.
lenmarches from upward as we push values.caponly changes when we re-rent.
WHY. If we tracked only one number we could not tell "the strip is full" apart from "the strip is half empty." The gap cap - len is exactly the free room left.
PICTURE. Two rulers under the same strip: a solid amber bar of length len (filled), a cyan outline of length cap (rented). The white gap between the bar's tip and the fence is the room we can still write into.
Recall What does
cap - len measure?
The number of empty boxes still available before we must grow. When it hits , the strip is full.
Step 3 — The "am I full?" test
WHAT. Before every push we check one condition:
if (len == cap) { /* grow */ }
WHY the == and not >=? Because our invariant from Step 2 guarantees len can never jump past cap — it rises one step at a time. So the only way to be "full" is exact equality. (>= would also work and is safer if you ever break the invariant; == documents the invariant.)
PICTURE. The amber "used" bar has grown until its tip touches the fence. No white gap remains. That touching moment is the trigger.
Step 4 — Why double the capacity (the heart of the derivation)
WHAT. When full, we set:
cap *= 2;
WHY doubling and not "add one"? This is the whole point, so let us see the cost.
Every time we grow, realloc may have to copy all len existing integers to a new spot. Copying k items costs k units of work.
-
Grow-by-1 strategy: we copy on every push. Total copies to reach items: Each term is "the array we had to copy at that push." That parabola of work is brutal.
-
Doubling strategy: we only copy at sizes Total copies: A geometric series that sums to less than twice
n. The work is a flat line.
The word for "expensive rarely, cheap usually, averaging out to cheap" is amortized per push.
PICTURE. Two curves on the same axes: the grow-by-1 parabola climbing steeply versus the doubling line staying low. The gap between them is the reason we double.
Step 5 — What realloc physically does
WHAT. We ask for a bigger strip:
int *tmp = realloc(v, cap * sizeof(*v));
realloc has two possible moves, and it chooses for you:
- Extend in place — if the boxes right after your strip happen to be free, it just moves the fence outward.
tmp == v(same address). - Relocate — if the neighbours are occupied, it rents a fresh, bigger strip somewhere else, copies your
lenold integers into it, frees the old strip, and returns the new address.tmp != v.
WHY return into tmp and not v? Preview of Step 6 — because move (2) can fail, and if we overwrote v we would lose the address of the still-valid old strip. See Memory Leaks and Valgrind.
PICTURE. Two panels side by side. Left: the fence slides right, same address (cyan). Right: a whole new strip appears elsewhere, old bytes flow into it via an amber copy-arrow, and the old strip greys out (freed).
Step 6 — The degenerate case: realloc FAILS
WHAT. The heap can be exhausted. On failure realloc returns NULL and leaves the old block untouched and valid. That is why the safe pattern is:
int *tmp = realloc(v, cap * sizeof(*v));
if (!tmp) { free(v); return 1; } // old block still ours → free it, bail out
v = tmp; // success → commit the new address
WHY the temporary is not optional. Trace the broken version v = realloc(v, ...):
- On failure
reallocreturnsNULL. - We just wrote that
NULLintov. - The old strip is still rented — but its only address is gone. Unreclaimable. That is a memory leak.
PICTURE. The broken path: realloc returns NULL, the arrow v is bent to point at nothing (NULL), and the old amber strip floats away untethered — leaked. Beside it, the correct path keeps v aimed at the old strip until tmp is proven good.
Step 7 — The write, and the zero-length edge
WHAT. With room guaranteed, we finally store the value and advance:
v[len++] = x;
len++ is post-increment: use the old len as the index, then bump it. So we write into the first empty box, then mark it as used.
Edge cases you must never trip on:
realloc(v, 0)— asking for zero bytes. The standard permits it to either free and returnNULL, or return a tiny non-NULL"empty" block. Never rely on which — that is implementation-defined behaviour. Don't grow to .realloc(NULL, n)— if the first argument isNULL,reallocbehaves exactly likemalloc(n). This is why some code starts withv = NULLand lets the firstreallocdo the first allocation.- First push when
capstarts at — thencap *= 2stays (twice nothing is nothing!). That is why we seedcap = 2(or special-casecap = cap ? cap*2 : 1). A pure "double" is helpless against a zero start.
PICTURE. The box at index len lights up amber as x drops in; a small arrow shows len stepping one box to the right afterward. Inset: the 0 * 2 = 0 trap crossed out in amber.
The one-picture summary
The entire life of a growable buffer: rent small → fill → detect full → double & (maybe) relocate → commit tmp into v → keep filling → free at the end. One arrow, one strip, doubling at the powers of two.
Recall Feynman: tell the whole story to a 12-year-old
You're collecting stickers but you don't know how many you'll get. You buy a tiny 2-slot album (cap = 2, len = 0). Each sticker goes in the next empty slot and you tick your "used" counter (len++). The moment your used-count equals the album size (len == cap), the album is full. So you buy a new album twice as big (cap *= 2), and the shop carefully copies your old stickers into it for you (that's realloc). Sometimes the shop just glues extra pages onto your current album (extend in place); sometimes it hands you a brand-new album and moves everything (relocate) — you don't care which, as long as you follow the new album's address (v = tmp). One rule you must never break: hold the old album until the new one is safely in your hands. If you throw away the old album's location before the new one is confirmed and the shop is out of albums, your stickers are lost forever (a leak). Why double instead of buying one-page-bigger albums each time? Because re-copying every sticker on every single new sticker would drown you in copying (an mountain); doubling means you only re-copy at sizes 2, 4, 8, 16… — total work stays a gentle straight line. When you're done collecting, you return the album to the shop (free) so someone else can use the shelf space.
Active Recall
Recall Why keep
len and cap as separate variables?
cap is boxes rented, len is boxes filled; their difference cap - len is the free room. One number alone can't tell "full" from "half empty."
Recall Why does doubling give amortized
pushes but grow-by-1 gives total? Doubling copies only at sizes , summing to copies (). Grow-by-1 copies on every push: .
Recall Why assign
realloc's result to tmp first?
On failure realloc returns NULL but the old block stays valid. v = realloc(v,...) would overwrite the only pointer to that block → leak. tmp lets you keep v until success.
Recall What does
realloc(NULL, n) do? And realloc(v, 0)?
realloc(NULL, n) acts like malloc(n). realloc(v, 0) is implementation-defined (may free and return NULL, or return an empty block) — never rely on it.