Dynamic Memory
Storage regions, allocation, resizing, release, dynamic arrays and records, ownership, object lifetime, memory faults, and cleanup patterns.
Some object sizes are known only while a program runs, and some objects must outlive the block that creates them. C provides dynamically allocated storage for these cases. The language gives direct control, not automatic ownership: every successful allocation needs a clear owner, every access needs a live object, and every release must happen exactly once after the final use.
Storage Regions
Programmers often say “stack,” “data segment,” and “heap,” but the C standard describes storage duration, not a required machine layout.
Automatic Storage
Ordinary block-local objects usually have automatic storage duration:
void calculate(void) {
int temporary = 0;
/* temporary's lifetime belongs to this block execution */
}
Their lifetimes begin when execution enters the relevant block and end when it leaves. Returning their addresses creates dangling pointers.
Static Storage
File-scope objects and objects declared with static have static storage duration. Their lifetimes cover the entire program execution, and they are initialized before program startup.
static unsigned long call_count;
Static duration does not imply global name visibility: a block-scope static object has a narrow name scope but a program-long lifetime.
Allocated Storage
Allocation functions create storage whose lifetime is controlled explicitly:
int *value = malloc(sizeof *value);
If successful, the allocated object remains available until passed to free or affected by a successful resizing operation. Leaving the allocating function does not release it.
Use “dynamic storage” or “allocated storage” for the language-level concept. A typical implementation obtains it from a heap, but portable source should not depend on a particular memory-map model.
Memory Allocation
malloc
malloc from <stdlib.h> requests a number of bytes:
#include <stdlib.h>
int *number = malloc(sizeof *number);
if (number == NULL) {
/* allocation failed */
}
On success, the returned pointer is suitably aligned for ordinary object types and designates storage with indeterminate contents. Initialize before reading:
*number = 42;
In C, do not cast malloc’s void * result:
int *number = malloc(sizeof *number); /* idiomatic C */
Using sizeof *number ties the allocation size to the pointed-to type. A later type change does not leave a stale repeated type name.
Zero-Size Requests
malloc(0) may return a null pointer or a unique pointer value that may later be passed to free; the result must not be dereferenced. Avoid using zero-size behaviour as program logic. Treat an empty collection explicitly and allocate only when the count is positive.
calloc
calloc(count, element_size) allocates space for an array and sets all bytes to zero:
int *counts = calloc(item_count, sizeof *counts);
if (counts == NULL && item_count != 0) {
/* allocation failed */
}
For integer types, all-bits-zero represents zero. Do not generalise byte-zeroing into a guarantee for every possible pointer or floating representation. calloc establishes zero bytes; interpret them according to the target type’s guarantees.
calloc can detect multiplication overflow internally when computing total bytes, whereas malloc(count * size) receives only the already-computed, possibly wrapped product. Still validate application-specific maximum counts.
Allocation Size
Before malloc(count * sizeof *items), prove that multiplication fits size_t:
#include <stdint.h>
#include <stdlib.h>
if (count > SIZE_MAX / sizeof *items) {
/* byte count is not representable */
}
items = malloc(count * sizeof *items);
SIZE_MAX is available from <stdint.h> when provided as required by the implementation headers. count == 0 deserves a separate policy.
An allocation can fail even when the size arithmetic fits. Always check the result before dereferencing.
Dynamic Arrays
A runtime-sized array can be allocated with an explicit owner:
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
bool allocate_readings(size_t count, double **result) {
if (result == NULL) {
return false;
}
*result = NULL;
if (count == 0) {
return true;
}
if (count > SIZE_MAX / sizeof **result) {
return false;
}
double *readings = malloc(count * sizeof *readings);
if (readings == NULL) {
return false;
}
*result = readings;
return true;
}
The caller owns the returned allocation after success:
double *readings = NULL;
if (!allocate_readings(count, &readings)) {
fputs("could not allocate readings\n", stderr);
return EXIT_FAILURE;
}
/* initialize and use count elements */
free(readings);
readings = NULL;
The pointer carries no length. Keep count alongside it. Data Structures later develops abstractions that package pointer, logical size, and capacity.
Dynamic Records
Allocate one structure using the pointer expression:
struct Student *student = malloc(sizeof *student);
if (student == NULL) {
return false;
}
student->id = id;
student->average = 0.0;
If a record owns additional allocations, construction must handle partial failure.
struct Person {
char *name;
unsigned int age;
};
static char *duplicate_string(const char *source) {
size_t length = strlen(source);
if (length == SIZE_MAX) {
return NULL;
}
char *copy = malloc(length + 1);
if (copy != NULL) {
memcpy(copy, source, length + 1);
}
return copy;
}
struct Person *person_create(const char *name, unsigned int age) {
if (name == NULL) {
return NULL;
}
struct Person *person = malloc(sizeof *person);
if (person == NULL) {
return NULL;
}
person->name = duplicate_string(name);
if (person->name == NULL) {
free(person);
return NULL;
}
person->age = age;
return person;
}
If the name allocation fails, the already allocated structure is released. Construction publishes a fully valid object or returns failure; it never returns a half-initialized owner.
Memory Resizing
realloc
realloc(pointer, new_size) attempts to resize an existing allocation. On success, it returns a pointer to storage of the new size, preserving the initial bytes up to the smaller old/new size. The allocation may move, invalidating every pointer into the old block.
On failure for a nonzero request, it returns NULL and leaves the original allocation unchanged. Therefore this is wrong:
items = realloc(items, new_bytes); /* original pointer lost on failure */
Use a temporary:
void *resized = realloc(items, new_bytes);
if (resized == NULL) {
/* items still owns the original allocation */
} else {
items = resized;
}
With a typed pointer:
if (new_count == 0) {
free(items);
items = NULL;
} else {
if (new_count > SIZE_MAX / sizeof *items) {
/* reject without changing items */
} else {
size_t new_bytes = new_count * sizeof *items;
int *resized = realloc(items, new_bytes);
if (resized == NULL) {
/* original items remains valid */
} else {
items = resized;
}
}
}
Avoid realloc(pointer, 0) in portable ownership logic because historical and standard-version details make its result awkward. Handle a requested zero count by calling free and setting the owner to NULL explicitly.
Interior Pointers
int *third = &items[2];
int *resized = realloc(items, larger_size);
If resizing succeeds, third is invalid even if the new allocation happens to receive the same address. Recompute interior pointers from the new base after success.
New Bytes
When an allocation grows, bytes in the newly added region are indeterminate. Initialize new elements before reading them.
Memory Release
free
free(pointer) ends the lifetime of an allocated object:
free(person->name);
free(person);
Order follows ownership: release owned children before the owner that stores their addresses.
Passing NULL to free is safe and does nothing. Passing any other value not returned by a compatible allocation function, passing an interior pointer, or freeing the same allocation twice causes undefined behaviour.
free(items + 1); /* invalid: not the allocation's base pointer */
Setting one pointer to NULL after free can prevent accidental reuse through that variable:
free(items);
items = NULL;
It does not repair aliases. Any other pointer to the allocation still dangles.
Ownership
Ownership answers who is responsible for releasing an allocation. Every interface should use language such as:
- owned: caller must eventually release it;
- borrowed: caller may use it for a specified lifetime but must not free it;
- transferred: responsibility moves from one component to another;
- shared: multiple users require an explicit management policy.
Example contract:
/* Returns an owned null-terminated copy, or NULL on failure.
The caller releases the result with free. */
char *string_duplicate(const char *source);
Contrast with:
/* Returns a borrowed pointer into source, or NULL if absent.
The result remains valid only while source remains live and unchanged. */
const char *find_separator(const char *source);
A pointer type alone does not encode either contract.
Move-Like Transfer
C has no built-in move operation, but code can transfer ownership explicitly:
destination->name = source->name;
source->name = NULL;
Afterward, only destination owns the allocation. Clearing the old owner prevents two cleanup paths from freeing the same storage. This state transition must be atomic from the caller’s perspective or carefully guarded on failure.
Object Lifetime
An allocated object’s lifetime begins after successful allocation when storage is used to hold the relevant object and ends at free or a successful realloc affecting that block. Access outside the lifetime is undefined.
Pointer value and object lifetime are separate:
int *alias = items;
free(items);
/* alias is non-null but dangling */
Comparing or dereferencing dangling values can itself be problematic under modern pointer models; the useful rule is simple: once an allocation is released, stop using every derived pointer.
Memory Leaks
A leak occurs when live allocated storage is no longer reachable by a pointer the program can use to release it:
items = malloc(100 * sizeof *items);
items = malloc(200 * sizeof *items); /* first allocation leaked */
Leaks waste resources and can become availability failures in long-running programs. A short-lived process does not justify abandoning ownership discipline; cleanup code is part of testing and future reuse.
Common leak sources include:
- overwriting the only owner pointer;
- returning early after partial construction;
- forgetting elements that each own storage;
- mishandling
reallocfailure; - unclear transfer between modules.
Dangling Pointers
A pointer dangles when the designated object’s lifetime has ended:
int *pointer = malloc(sizeof *pointer);
int *alias = pointer;
free(pointer);
pointer = NULL;
/* alias still dangles */
Use-after-free can corrupt data silently because the allocator may reuse the same storage for another object. Lifetime reasoning must include every alias, not only the apparent owner.
Double Free
Releasing one allocation twice is undefined:
free(pointer);
free(pointer); /* invalid unless pointer was set to NULL */
The deeper solution is single, explicit ownership. Setting an owner to null after release is a useful local defence, but multiple aliases or owners still require a sound design.
Cleanup Patterns
Single Exit Cleanup
When several resources are acquired, one cleanup region can release whatever succeeded:
bool process(const char *first_path, const char *second_path) {
bool success = false;
FILE *first = NULL;
FILE *second = NULL;
char *buffer = NULL;
first = fopen(first_path, "r");
if (first == NULL) {
goto cleanup;
}
second = fopen(second_path, "w");
if (second == NULL) {
goto cleanup;
}
buffer = malloc(4096);
if (buffer == NULL) {
goto cleanup;
}
/* perform work, checking every operation */
success = true;
cleanup:
free(buffer);
if (second != NULL && fclose(second) == EOF) {
success = false;
}
if (first != NULL) {
fclose(first);
}
return success;
}
This disciplined forward goto avoids duplicating cleanup across many error paths. It does not create arbitrary control flow: acquisition proceeds downward and cleanup happens in reverse order.
Destructor Functions
Pair a constructor with one cleanup function:
void person_destroy(struct Person *person) {
if (person == NULL) {
return;
}
free(person->name);
free(person);
}
Callers do not need to know internal ownership details. The function can evolve when the structure gains another owned member.
Initialize Owners Early
Set owner pointers to NULL before acquisition. Cleanup can then call free safely on any path. Publish new ownership only after full construction succeeds.
Heap State Trace
Trace a successful resize followed by cleanup:
| Moment | owner items | aliases | live allocation |
|---|---|---|---|
after malloc | points to block A | none | A, 4 elements |
before realloc | points to A | third points inside A | A |
successful realloc | temporary points to B | third invalid | B, 8 elements |
| assign temporary | points to B | recompute if needed | B |
after free | set to NULL | none may be used | none |
The successful resize ends the old allocation’s lifetime even if the allocator reuses its numeric address.
Growing Buffer
A small growable buffer brings allocation, size arithmetic, ownership, and failure guarantees into one example. The buffer owns exactly one allocation. size counts initialized values; capacity counts available elements.
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int *data;
size_t size;
size_t capacity;
} IntBuffer;
static bool buffer_valid(const IntBuffer *buffer) {
if (buffer == NULL || buffer->size > buffer->capacity) {
return false;
}
if (buffer->capacity == 0) {
return buffer->data == NULL;
}
return buffer->data != NULL;
}
static void buffer_init(IntBuffer *buffer) {
buffer->data = NULL;
buffer->size = 0;
buffer->capacity = 0;
}
static void buffer_destroy(IntBuffer *buffer) {
free(buffer->data);
buffer_init(buffer);
}
static bool buffer_reserve(IntBuffer *buffer, size_t requested) {
assert(buffer_valid(buffer));
if (requested <= buffer->capacity) {
return true;
}
if (requested > SIZE_MAX / sizeof *buffer->data) {
return false;
}
int *replacement = realloc(
buffer->data, requested * sizeof *buffer->data);
if (replacement == NULL) {
return false;
}
buffer->data = replacement;
buffer->capacity = requested;
assert(buffer_valid(buffer));
return true;
}
static bool buffer_append(IntBuffer *buffer, int value) {
assert(buffer_valid(buffer));
if (buffer->size == buffer->capacity) {
size_t next = buffer->capacity == 0 ? 4 : buffer->capacity * 2;
if (next < buffer->capacity || !buffer_reserve(buffer, next)) {
return false;
}
}
buffer->data[buffer->size] = value;
buffer->size++;
assert(buffer_valid(buffer));
return true;
}
static void buffer_print(const IntBuffer *buffer) {
putchar('[');
for (size_t i = 0; i < buffer->size; i++) {
printf("%s%d", i == 0 ? "" : ", ", buffer->data[i]);
}
printf("] size=%zu capacity=%zu\n",
buffer->size, buffer->capacity);
}
int main(void) {
IntBuffer values;
buffer_init(&values);
for (int value = 10; value <= 60; value += 10) {
if (!buffer_append(&values, value)) {
fputs("buffer growth failed\n", stderr);
buffer_destroy(&values);
return EXIT_FAILURE;
}
buffer_print(&values);
}
buffer_destroy(&values);
return EXIT_SUCCESS;
}
The representation invariant is:
0 <= size <= capacity
capacity == 0 => data == NULL
capacity > 0 => data designates capacity int objects
data[0..size) are initialized values
data[size..capacity) are spare, not logical elements
buffer_append restores that invariant on every return. If growth fails, neither size nor any existing value changes. buffer_reserve may leave the allocation at the same numeric address or move it; callers are not allowed to retain element pointers across a call that may grow.
Growth Trace
Appending six values with the chosen policy produces:
| Append | size before | capacity before | allocation action | state after |
|---|---|---|---|---|
| 10 | 0 | 0 | allocate 4 elements | size 1, capacity 4 |
| 20 | 1 | 4 | none | size 2, capacity 4 |
| 30 | 2 | 4 | none | size 3, capacity 4 |
| 40 | 3 | 4 | none | size 4, capacity 4 |
| 50 | 4 | 4 | resize to 8 | size 5, capacity 8 |
| 60 | 5 | 8 | none | size 6, capacity 8 |
Capacity is not the number of meaningful elements. Reading data[6] after the final append is invalid at the program level even though that slot lies inside allocated storage, because it has not been initialized as part of the logical sequence.
Geometric growth leaves spare capacity but avoids reallocating for every append. Growing by exactly one element would copy a growing prefix repeatedly. The policy is an engineering choice: a fixed buffer has predictable memory and failure at a documented limit; a geometric buffer supports unknown counts but has occasional linear-time growth and allocation failure.
Failure Injection
Real machines rarely fail a tiny allocation during a classroom run, so ordinary testing may never execute cleanup paths. Replace allocation calls behind a narrow wrapper during tests and configure the wrapper to fail on allocation number one, two, and so on. For each injected point, verify:
- the function reports failure;
- every previously owned allocation remains reachable or is released according to contract;
- published sizes and tags still describe valid state;
- caller outputs promised unchanged on failure are unchanged;
- destruction after the failure is safe exactly once.
For buffer_append, a failed first growth must leave the canonical empty state. A failed later growth must leave the previous pointer, size, capacity, and elements intact. Testing only eventual successful output cannot distinguish a strong failure guarantee from accidental survival.
Allocation wrappers should not be scattered through domain code. Keep the production operation injectable through a module boundary or compile-time test configuration. The purpose is to make rare states deterministic, not to permanently complicate every call site.
Ownership Table
At a function boundary, write ownership as a state table:
| Operation | Before success | After success | After failure |
|---|---|---|---|
| create | caller owns no object | caller owns new object | caller owns none |
| clone | source remains owned by caller | caller also owns independent clone | source unchanged, no clone |
| borrow | owner remains elsewhere | temporary view, no release duty | no view |
| transfer | source owns object | destination owns, source cleared | source still owns |
| destroy | caller owns object | no live object, owner reset | cleanup should not partially fail for memory alone |
This table exposes ambiguous APIs. A function named set_name(person, text) could borrow text, copy it, or take ownership of it. Those behaviours have identical pointer parameter syntax and completely different lifetime consequences.
Zero-size allocation results are implementation-dependent in ways that make them poor ownership sentinels. Normalize empty containers to data == NULL, avoid relying on whether malloc(0) returns null or a unique value, and handle zero counts before arithmetic or element access. A canonical empty state makes cleanup and invariant checks simpler.
Transactional Construction
A constructor for an object with several owned members should keep the result private until every required acquisition succeeds:
allocate outer record
initialize every owner member to NULL
allocate first member
allocate second member
validate completed invariant
publish pointer to caller
At each failure, destroy the partial private object using the same cleanup routine that handles a complete object. The destructor must tolerate initialized-but-null members. The caller receives either one fully valid owner or no owner; it never receives a half-constructed record and a list of fields that might exist.
This is a strong failure guarantee for construction. Other operations may reasonably offer weaker guarantees, but they must say what remains valid. For example, a stream reader may append several complete records before a later allocation fails and return a partial-but-valid collection plus status. Strong rollback may require extra memory or copying, so select the guarantee from caller needs rather than applying one slogan everywhere.
Reallocation Decisions
realloc combines several possibilities under one call:
failure for nonzero request -> old allocation remains live and unchanged
success at same address -> old lifetime rules still transition through resize
success at new address -> bytes preserved up to the smaller size
growth -> new trailing bytes are uninitialized
shrink -> bytes beyond new size cease to exist
zero request -> special rules are awkward; handle separately
Code should not inspect whether the numeric address changed to decide whether old interior pointers survive. After successful realloc, derive every pointer again from the returned base. This uniform rule remains correct even when the allocator grows in place.
Shrinking is optional when logical size decreases. Retaining capacity avoids repeated allocation when usage oscillates. An explicit trim operation can let callers trade spare memory for a fallible resize at a controlled time. Shrink failure is often harmless because the old larger allocation still represents a valid object.
Avoid realloc(pointer, 0) as a shortcut for free in portable interfaces. C17’s zero-size allocation behaviour and returned value rules are easy to misuse, and later standards revised related wording. Handle zero as:
free(pointer);
pointer = NULL;
when the operation truly means destruction, or retain a canonical empty owner according to the type’s contract.
Clone Discipline
Cloning an owning object means duplicating every resource that should be independent. For a string owner:
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
char *duplicate_text(const char *source) {
if (source == NULL) {
return NULL;
}
size_t length = strlen(source);
if (length == SIZE_MAX) {
return NULL;
}
char *copy = malloc(length + 1);
if (copy == NULL) {
return NULL;
}
memcpy(copy, source, length + 1);
return copy;
}
The length == SIZE_MAX check is mathematically complete for length + 1, though a real null-terminated string of that length cannot be traversed within an ordinary practical process. It documents the arithmetic boundary. The result is either null or a separately owned terminated copy released with free.
For a record with multiple owned strings, clone each into a fresh partial record; if any clone fails, destroy those already acquired. Do not assign the source record first and then overwrite pointer members: a cleanup after intermediate failure could release source-owned allocations through the shallow copies.
After a successful deep clone, modifying or destroying either object must not affect the other. Tests should mutate both directions and inject failure at every member allocation.
Lifetime Failures
- Reading
mallocstorage before initialization: contents are indeterminate. - Multiplying allocation sizes without an overflow check: a wrapped small allocation permits later overflow.
- Assigning
reallocdirectly to the owner: failure loses the original allocation. - Using old interior pointers after
realloc: recompute them from the new base. - Freeing an interior, automatic, or static address: only compatible allocation results may be freed.
- Freeing twice or using after free: both are undefined behaviour.
- Assuming zero bytes create every type’s zero value:
callocpromises byte initialization. - Leaving ownership undocumented: copies and error paths become ambiguous.
Ownership Reasoning
- C defines storage durations rather than requiring literal stack and heap regions.
mallocreturns uninitialized allocated storage;callocsupplies zeroed bytes.- Allocation-size arithmetic and allocation success both require checks.
realloccan move storage; use a temporary and invalidate old interior pointers after success.freeends an allocated object’s lifetime and must receive the allocation’s base pointer exactly once.- Pointer values do not communicate ownership, bounds, or lifetime by themselves.
- Constructors should return fully valid owners or clean up and fail.
- Centralized reverse-order cleanup and destructor functions make every exit path auditable.
Memory Problems
State the Ownership
- Distinguish automatic, static, and allocated storage duration.
- What happens to the original allocation when a nonzero
reallocrequest fails? - Why is setting one pointer to
NULLinsufficient when aliases exist? - Distinguish owned, borrowed, transferred, and shared pointers.
Follow the Heap
- Draw ownership after a shallow structure copy where one pointer member owns an allocation.
- Trace all live resources through three different failure points in the
processcleanup example. - Trace a successful moving
reallocwhile two interior pointers exist.
Repair the Lifetime
- Repair a direct owner assignment from
realloc. - Find the integer-overflow risk in
malloc(count * sizeof *items)and add a guard. - Correct a constructor that leaks its structure when allocating a member fails.
- Diagnose a double free caused by copying an owning structure by assignment.
Manage an Allocation
- Implement a dynamic copy of an integer array. Return status separately from the owned result and handle an empty source deliberately.
- Implement
person_create,person_duplicate, andperson_destroywith deep-copy semantics. - Read an unknown number of characters into a dynamically growing buffer. Preserve the old allocation on resize failure and guarantee termination.
Specify Failure
- Annotate every pointer in a small program as owner, borrowed view, or non-owning optional pointer. Identify any ambiguous transfers.
- Design a partial-construction policy for a structure that owns three allocations. Show cleanup after each possible acquisition failure.
- Compare a fixed-capacity local buffer with a dynamic buffer for command input. Discuss maximum size, failure handling, lifetime, and cleanup.