5.1.14 · D2C Programming

Visual walkthrough — Dynamic memory — malloc, calloc, realloc, free

1,969 words9 min readBack to topic

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 thing v points at" — one int, 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.

  • len marches from upward as we push values.
  • cap only 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:

  1. Extend in place — if the boxes right after your strip happen to be free, it just moves the fence outward. tmp == v (same address).
  2. Relocate — if the neighbours are occupied, it rents a fresh, bigger strip somewhere else, copies your len old 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 realloc returns NULL.
  • We just wrote that NULL into v.
  • 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 return NULL, 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 is NULL, realloc behaves exactly like malloc(n). This is why some code starts with v = NULL and lets the first realloc do the first allocation.
  • First push when cap starts at — then cap *= 2 stays (twice nothing is nothing!). That is why we seed cap = 2 (or special-case cap = 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.