Pointers & Memory

Part II

2D Arrays  ·  Dynamic Memory  ·  Function Returns

Continuing from Pointers — Part I

What Is a 2D Array?

int a[3][4];   // 3 rows, 4 columns

Think of it as a table:

a[0][0]
a[0][1]
a[0][2]
a[0][3]
a[1][0]
a[1][1]
a[1][2]
a[1][3]
a[2][0]
a[2][1]
a[2][2]
a[2][3]
A 2D array is just a convenient way to address a flat block of memory. There are no rows or columns in memory — only a sequence of integers.

Memory Layout: Row-Major Order

int a[3][4] is stored as 12 consecutive integers:

a[0][0]
200
a[0][1]
204
a[0][2]
208
a[0][3]
212
a[1][0]
216
a[1][1]
220
a[1][2]
224
a[1][3]
228
a[2][0]
232
a[2][1]
236
a[2][2]
240
a[2][3]
244

To find a[i][j], the compiler computes:

address of a[i][j] = base address + (i × cols + j) × sizeof(int)
a[1][2] → 200 + (1×4 + 2)×4 = 200 + 24 = 224

How Indexing Works: Step by Step

Peel apart a[1][2] using what you already know about pointer arithmetic.

int a[3][4];

a          // pointer to row 0           type: int (*)[4]
a + 1      // pointer to row 1           (moves 16 bytes — skips 4 ints)
*(a + 1)   // dereference → row 1        type: int *  (points to a[1][0])
*(a+1) + 2 // move 2 ints forward        points to a[1][2]
*(*(a+1)+2)// dereference → the value    same as a[1][2]
The full equivalence:
a[1][2]  ≡  *(a[1] + 2)  ≡  *(*(a + 1) + 2)
a[i] means "dereference the i-th row pointer" — it gives you an int * pointing to the first element of that row. From there, adding j and dereferencing gives the element.
Same address, different type: a and *a (i.e. a[0]) hold the same numerical address — both point to a[0][0]. But their types differ: a is int (*)[4], so a+1 skips 16 bytes; *a is int *, so (*a)+1 skips 4 bytes. Same value, completely different arithmetic.

Important: a is NOT int **

This is the most common confusion with 2D arrays.

int a[3][4];

int **p  = a;   // ✗ TYPE ERROR — won't work correctly
int (*p)[4] = a; // ✓ CORRECT type for a row pointer

int **p

Expects p to point to an int *.
Dereferencing once gives another pointer.
Not what a 2D array has.

int (*p)[4]

Points to a block of 4 ints.
Dereferencing once gives an int *.
Matches how a 2D array is laid out.

int ** IS correct for a dynamic 2D array (where each row is a separately malloc'd int *). But for a stack 2D array like int a[3][4], the type is int (*)[4].

Passing 2D Arrays to Functions

The column size is mandatory. The compiler needs it to compute row offsets.

// ✗ WRONG — won't compile
void print(int a[][], int rows, int cols) { ... }

// ✓ CORRECT — column size must be specified
void print(int a[][4], int rows) {
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < 4; j++) {
            printf("%d ", a[i][j]);
        }
        printf("\n");
    }
}

int main() {
    int a[3][4] = {{1,2,3,4},{5,6,7,8},{9,10,11,12}};
    print(a, 3);
}
Without the column count, the compiler cannot compute a[i][j] — it does not know how many elements to skip to get to row i.

Pointer to a Row: int (*p)[4]

A pointer can point to an entire row, not just a single element.

int a[3][4];

int (*p)[4] = a;   // p points to row 0 (an array of 4 ints)

p++;               // now p points to row 1 (skips 4 ints = 16 bytes)

(*p)[2];           // element at row 1, column 2  ← same as a[1][2]

Watch the parentheses!

int (*p)[4];  // pointer to array of 4 ints
int *p[4];    // array of 4 int pointers
              // (totally different!)

Step size

int *q = a[0]; // q++ moves 4 bytes
int (*p)[4]=a; // p++ moves 16 bytes

Why Do We Need Dynamic Memory?

Stack limitations

int n;
scanf("%d", &n);
int a[n];       // risky — VLA
                // may fail for large n
                // gone when function returns

Heap to the rescue

int n;
scanf("%d", &n);
int *a = malloc(n * sizeof(int));
// works for any n (within RAM)
// lives until you free it
The heap is a large pool of memory you can request at runtime. Unlike the stack, it does not disappear when a function returns — you control when it goes away.

malloc — Allocate Memory

#include <stdlib.h>

int n = 5;
int *arr = malloc(n * sizeof(int));

if (arr == NULL) {
    // malloc failed (out of memory)
    return 1;
}

arr[0] = 10;   // use it just like a normal array
arr[1] = 20;
// ...

free(arr);     // always free when done
  • malloc(bytes) — asks the OS for a block of bytes
  • Returns a pointer to the start, or NULL if it fails
  • Contents are uninitialized (garbage values)
  • Every malloc must have exactly one matching free

calloc — Allocate and Zero

// malloc — uninitialized (garbage)
int *a = malloc(5 * sizeof(int));

// calloc — zero-initialized
int *b = calloc(5, sizeof(int));
// b[0] == 0, b[1] == 0, ... guaranteed

malloc

Fast. No zeroing. Use when you'll overwrite every element anyway.

calloc

Slightly slower. All bytes are zero. Safer default when you need a clean slate.

calloc(count, size) is exactly malloc(count * size) + zeroing all bytes.

realloc — Resize an Allocation

You started with space for 5 ints, but now need 10. Use realloc.

int *arr = malloc(5 * sizeof(int));
// ... fill arr with data ...

// Need more space — resize to 10
int *tmp = realloc(arr, 10 * sizeof(int));
if (tmp == NULL) {
    // realloc failed — arr is still valid
    free(arr);
    return 1;
}
arr = tmp;   // now arr points to the bigger block
Never write arr = realloc(arr, ...) directly. If realloc fails and returns NULL, you lose your only pointer to the original memory — a memory leak.

free — Two Rules

Rule 1: Never free twice

int *p = malloc(sizeof(int));
free(p);
free(p);   // ✗ double-free
           // undefined behavior

Rule 2: Never use after free

int *p = malloc(sizeof(int));
*p = 42;
free(p);
printf("%d", *p);  // ✗ use-after-free
                   // undefined behavior
Good habit: Set the pointer to NULL right after freeing. Dereferencing NULL crashes visibly — which is better than silent corruption.
free(p);
p = NULL;

Dynamic Array — Full Example

#include <stdio.h>
#include <stdlib.h>

int main() {
    int n;
    printf("How many numbers? ");
    scanf("%d", &n);

    int *arr = malloc(n * sizeof(int));
    if (!arr) { return 1; }

    for (int i = 0; i < n; i++) {
        printf("Enter number %d: ", i + 1);
        scanf("%d", &arr[i]);
    }

    int sum = 0;
    for (int i = 0; i < n; i++) sum += arr[i];
    printf("Sum = %d\n", sum);

    free(arr);
    return 0;
}
The array size is decided at runtime. This is not possible on the stack (portably).

Dynamic 2D Array

Allocate an array of pointers, then allocate each row separately.

int rows = 3, cols = 4;

// Step 1: array of row pointers
int **a = malloc(rows * sizeof(int *));

// Step 2: allocate each row
for (int i = 0; i < rows; i++) {
    a[i] = malloc(cols * sizeof(int));
}

// Use exactly like a static 2D array
a[1][2] = 42;

// Free in reverse order
for (int i = 0; i < rows; i++) {
    free(a[i]);       // free each row first
}
free(a);              // then free the pointer array

Two Ways — Very Different Memory

Stack: int a[3][4]

r0c0
r0c1
r0c2
r0c3
r1c0
r1c1
r1c2
r1c3
r2c0
r2c1
r2c2
r2c3

12 ints — one contiguous block

Dynamic: int **a

a[0] 4 ints (row 0)
a[1] 4 ints (row 1)
a[2] 4 ints (row 2)

Rows may be scattered in heap

With int **, each row is a separate malloc — rows are not contiguous. a[i][j] syntax works, but the memory is scattered. There is a third approach that gives you dynamic sizing AND contiguous memory.

A Third Way: Flat Dynamic Array

One single malloc for all elements — row-major, just like a stack 2D array.

int rows = 3, cols = 4;

// ONE allocation for everything
int *flat = malloc(rows * cols * sizeof(int));

// Access: compute the offset manually
flat[1 * cols + 2] = 42;    // same as a[1][2]
flat[i * cols + j] = value; // general form

// Free: just one call
free(flat);
r0c0
r0c1
r0c2
r0c3
r1c0
r1c1
r1c2
r1c3
r2c0
r2c1
r2c2
r2c3
Fully contiguous — same memory layout as int a[3][4], but the size is decided at runtime and you manage the lifetime manually.

Three Approaches — Side by Side

int a[3][4] int **a int *flat
Memory layout One block, row-major Scattered rows One block, row-major
Size at runtime? No Yes Yes
Access syntax a[i][j] a[i][j] flat[i*cols+j]
Rows same length? Yes (fixed) No — can vary Yes (fixed)
Free Automatic Loop + free(a) One free
Use int ** when rows can have different lengths (jagged arrays). Use int *flat when you need dynamic sizing with contiguous memory. Use int a[N][M] when the size is fixed at compile time.

Returning a Pointer from a Function

✗ Classic mistake

int* make_array() {
    int arr[5] = {1,2,3,4,5};
    return arr;  // DANGER!
    // arr lives on the stack
    // destroyed when function returns
}

The caller gets a dangling pointer.

✓ Correct way

int* make_array(int n) {
    int *arr = malloc(n * sizeof(int));
    for (int i = 0; i < n; i++)
        arr[i] = i + 1;
    return arr;  // heap — persists!
}

int *p = make_array(5);
// use p...
free(p);  // caller must free
If a function creates data that must outlive the function, allocate it with malloc. The caller is then responsible for calling free.

sizeof Proves They Are Different

The clearest way to see the difference between a 2D array and int **:

int a[3][4];        // stack 2D array
int **p;            // pointer to pointer

sizeof(a)           // 48  ← 3 × 4 × sizeof(int) = full array
sizeof(p)           // 8   ← just one pointer (on 64-bit system)

sizeof(a[0])        // 16  ← one row = 4 ints
sizeof(p[0])        // 8   ← one int* pointer
sizeof on a true 2D array gives you the full size of all the data. sizeof on a pointer gives you the size of the pointer — not what it points to. This is why functions receiving arrays cannot use sizeof to find the length; the array decays to a pointer when passed.
Bottom line: int a[3][4] and int **p both support a[i][j] syntax, but they are fundamentally different in memory layout, type, and size.

Mental Model Summary

Syntax / Concept What it means
int a[3][4] 12 contiguous ints, addressed as 3 rows × 4 cols
a[i][j] Element at row i, col j — offset = i×cols + j
int a[][4] in a function Column size required so compiler can compute row offsets
int (*p)[4] Pointer to a row of 4 ints — p++ skips 16 bytes
malloc(n * sizeof(T)) Allocate n items on the heap, uninitialized
calloc(n, sizeof(T)) Same as malloc but zero-initialized
realloc(p, new_size) Resize a heap allocation — always use a temp pointer
free(p); p = NULL; Return heap memory; nullify to prevent accidental reuse
return malloc(...) Safe — heap outlives the function; caller must free