Dynamic Memory Allocation
Heap allocation lets a C program create storage whose size and lifetime are decided at runtime. That flexibility is powerful precisely because the language does not track the owner, capacity, or lifetime for you. Treat allocation as a contract: acquire one object, establish its valid state, transfer or retain ownership deliberately, then release it exactly once.
Storage duration explains lifetime before allocation syntax
int count; exists for one block invocation and is released when that block ends.static local exist for the whole program run.malloc reserves storage until its owner calls free.A pointer variable is not the allocation. int *scores might live automatically on the stack, while the block it points to lives dynamically on the heap. When the pointer's scope ends, the heap block does not disappear; losing the only owning pointer leaks the allocation.
int *make_default_scores(void) {
int *scores = malloc(4 * sizeof *scores);
if (scores == NULL) return NULL;
for (int index = 0; index < 4; index++) {
scores[index] = 0;
}
return scores; /* Caller now owns this allocation. */
}
/* int *scores = make_default_scores(); ... free(scores); */malloc reserves bytes; it does not create a valid value
malloc(bytes) returns suitably aligned uninitialized storage, or NULL when it cannot satisfy the request. The bytes have indeterminate values: reading an int from them before writing a valid int is not initialization.
#include <stdint.h>
#include <stdlib.h>
int *allocate_scores(size_t count) {
if (count == 0) return NULL; /* This API chooses no buffer for empty. */
if (count > SIZE_MAX / sizeof(int)) return NULL;
int *scores = malloc(count * sizeof *scores);
if (scores == NULL) return NULL;
return scores;
}Prefer sizeof *scores over spelling a separate type in the byte count. If scores later changes to point to double or a structure, the calculation stays coherent. In C, do not cast malloc's return value: a missing <stdlib.h> declaration should remain a compiler diagnostic instead of being hidden by a cast.
calloc gives zeroed bytes, not a universal default constructor
calloc(count, size) reserves space for an array and initializes all its bytes to zero. It is useful for counters, flags, and byte buffers, and it can detect a count-by-size overflow. Still check for NULL, and do not assume all-bits-zero is a portable representation for every possible C object type or semantic default.
size_t count = 32;
unsigned char *seen = calloc(count, sizeof *seen);
if (seen == NULL) {
return 1;
}
/* seen[index] is zero until this program marks it. */
seen[5] = 1;
free(seen);
seen = NULL;Write ownership into the API contract
Every allocation needs one clear owner at a time. A function can borrow a pointer for the duration of a call, retain a pointer whose lifetime the caller must guarantee, or transfer ownership. Name this in documentation and reflect it in types and behavior.
print_scores(const int *values, size_t count)
Reads caller-owned memory and never frees or stores the pointer.
int *read_scores(...)
Returns a fresh allocation. A non-NULL return gives the caller one future free duty.
void vector_destroy(Vector *vector)
Releases storage owned by the vector and resets it to a harmless empty state.
“This function frees the pointer” and “this function might retain the pointer” are not implementation trivia; they determine whether every caller is correct. Prefer an API where a single object such as a vector owns its internal buffer rather than exposing a raw pointer plus a separate undocumented cleanup rule.
free ends the allocation's lifetime, not every copy of its address
int *scores = malloc(8 * sizeof *scores);
if (scores == NULL) return 1;
int *alias = scores;
scores[0] = 91;
free(scores);
scores = NULL;
/* alias is now dangling too. Do not read, write, or free(alias). */Calling free(NULL) is safe and does nothing, which makes cleanup code simpler. Calling free twice on the same allocation, reading through a dangling pointer, or passing a non-allocator pointer to free is undefined behavior. Clearing the one owning field after free is helpful, but it does not repair aliases held elsewhere.
realloc must be a transaction, not a gamble
realloc(pointer, bytes) may extend the existing allocation or move its contents to a new location. On failure it returns NULL and leaves the original allocation valid. Therefore never overwrite the only owning pointer until the call succeeds.
int *grow_scores(int *scores, size_t old_count, size_t new_count) {
if (new_count > SIZE_MAX / sizeof *scores) return NULL;
int *grown = realloc(scores, new_count * sizeof *scores);
if (grown == NULL && new_count != 0) {
return NULL; /* scores still belongs to the caller. */
}
for (size_t index = old_count; index < new_count; index++) {
grown[index] = 0;
}
return grown;
}Choose and document an empty-buffer policy. realloc(pointer, 0) has implementation-defined behavior across C versions and libraries; a beginner-friendly vector can instead call free explicitly when capacity becomes zero. After a successful moving reallocation, every old alias is dangling even though the newly returned pointer is valid.
Worked implementation: a capacity-aware integer vector
A dynamic vector makes the lifecycle concrete. Its invariants are: 0 <= length <= capacity; items == NULL when capacity is zero; and only elements below length hold initialized values.
struct IntVector {
int *items;
size_t length;
size_t capacity;
};
int vector_push(struct IntVector *vector, int value) {
if (vector == NULL) return 0;
if (vector->length == vector->capacity) {
size_t next = vector->capacity == 0 ? 8 : vector->capacity * 2;
if (next < vector->capacity || next > SIZE_MAX / sizeof *vector->items) {
return 0;
}
int *grown = realloc(vector->items, next * sizeof *grown);
if (grown == NULL) return 0;
vector->items = grown;
vector->capacity = next;
}
vector->items[vector->length] = value;
vector->length += 1;
return 1;
}
void vector_destroy(struct IntVector *vector) {
if (vector == NULL) return;
free(vector->items);
vector->items = NULL;
vector->length = 0;
vector->capacity = 0;
}vector_push changes the structure only after its allocation succeeds. That preserves the old vector when memory is exhausted. vector_destroy is idempotent for a correctly initialized vector because free(NULL) is safe and all fields return to the empty invariant.
Design failure paths before the happy path
Reject impossible counts, multiplication overflow, and invalid pointers before allocating.
Do not modify a live object until a requested allocation succeeds.
Write valid values before exposing the pointer through the public object.
Transfer ownership deliberately; every acquired allocation has one cleanup route.
struct IntVector values = {0};
int status = 1;
for (int score = 0; score < 5; score++) {
if (!vector_push(&values, score * 10)) {
fprintf(stderr, "out of memory\n");
goto cleanup;
}
}
status = 0;
cleanup:
vector_destroy(&values);
return status;Make memory bugs observable with tools
Warnings and tests are the first line of defense, but many memory faults depend on a particular execution path. Build with strong warnings and an address/undefined-behavior sanitizer when your compiler supports it, then exercise error and boundary cases.
# GCC or Clang, when available:
cc -std=c17 -Wall -Wextra -Wpedantic -g \
-fsanitize=address,undefined vector.c -o vector
./vector
# Run memory-oriented tests under your platform's dynamic analysis tool
# when available, and treat a reported leak or invalid access as a failing test.Independent lab: parse an unknown number of scores
Extend the previous file lesson. Build score_list.c that reads one integer score per line from a filename, appends each valid score to an IntVector, and prints count, average, minimum, and maximum. Do not guess a maximum row count.
struct IntVector scores = 0; never call realloc through an uninitialized pointer.vector_push preserve the old vector on allocation failure.vector_destroy after parse failure, I/O failure, and success.Lesson review
- Dynamic allocation is for runtime-sized or independently-lived storage; a pointer variable and the allocation it references have different lifetimes.
- Check
NULLfrommalloc/callocbefore access and check byte-count overflow before multiplying. - Use
sizeof *pointerto couple allocation size to the pointed-to type. - Every allocation has one owner; document whether APIs borrow, transfer out, or transfer in ownership.
- Call
freeexactly once, never use a pointer after free, and reset owning fields to their empty invariant. - Assign
reallocto a temporary pointer first; commit only when the new allocation succeeds. - Use sanitizers and dynamic analysis with tests to find leaks, invalid accesses, and lifecycle errors.