Skip to content
academia.sh

Lesson 04 / 15

Null and Dangling Pointers

Why a null pointer is a checkable condition, while a dangling pointer keeps pointing at a freed block and is therefore never run; how both are two faces of the unowned class.

Published Updated

In this lesson

  • Shows with an example how to check a null pointer before dereferencing it
  • Explains that a dangling pointer points at a freed block and why this condition is never run
  • Measures how assigning NULL moves a pointer out of the unowned class by making it checkable
Contents

The previous lesson’s last line assigned NULL to a pointer right after a free call and left it as “the topic of the next lesson.” This lesson measures why that assignment is a mandatory habit: a pointer that keeps pointing at a freed block becomes a dangling pointer, and this condition is one of two observations that fall into the core’s unowned class — no one is obligated to keep this pointer valid, and no one guarantees pulling it to NULL either.

Null Pointer: A Checkable Condition

// null_pointer.c — a null pointer becomes safe once checked before dereferencing
#include <stdio.h>
#include <stdlib.h>

static int *maybe_allocate(int succeed) {
    if (!succeed) {
        return NULL;
    }
    int *p = malloc(sizeof *p);
    if (p != NULL) {
        *p = 9;
    }
    return p;
}

int main(void) {
    int *failed = maybe_allocate(0);
    printf("%s %d\n", "is the failed call null", (int)(failed == NULL));

    int *succeeded = maybe_allocate(1);
    if (succeeded != NULL) {
        printf("%s %d\n", "value from the successful call", *succeeded);
    } else {
        printf("%s\n", "successful call unexpectedly null");
    }

    free(succeeded);
    return 0;
}
is the failed call null 1
value from the successful call 9

PO17. The call maybe_allocate(0) returns NULL, and the comparison failed == NULL gives 1 — this shows a null pointer is an expected and checkable condition. The call maybe_allocate(1) returns a real block; but this block, too, is checked with succeeded != NULL before use, because malloc gives no guarantee of succeeding under every condition. Checking a null pointer before dereferencing it is this measurement’s only rule: *failed was never written, because failed was already known to be null.

Dangling Pointer: Continuing to Point at a Freed Block

After a pointer is freed with free, the value it carries does not change — it still points at the same block, but that block is no longer valid. This condition is called a dangling pointer. Dereferencing a dangling pointer (reading or writing through *p) or handing it to free again (a double free) is undefined behavior: the result is most often a silently wrong value, sometimes a crash, never reliable behavior. That is why this lesson never dereferences or re-frees any dangling pointer — it only describes why this is dangerous and runs its defined counterpart.

// defined_counterpart.c — assigning NULL after freeing makes the dangling condition checkable
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = malloc(sizeof *p);
    if (p == NULL) {
        printf("%s\n", "allocation failed");
        return 1;
    }
    *p = 21;
    printf("%s %d\n", "value before freeing", *p);

    free(p);
    p = NULL;
    printf("%s %d\n", "is it null after freeing", (int)(p == NULL));

    if (p != NULL) {
        printf("%s %d\n", "branch never reached", *p);
    } else {
        printf("%s\n", "dereference skipped thanks to the check");
    }
    return 0;
}
value before freeing 21
is it null after freeing 1
dereference skipped thanks to the check

PO18. p = NULL is assigned right after the free(p) call; no other operation comes between these two lines. The comparison p == NULL gives 1, and the final if block never reaches the *p line because p != NULL is false — the line “dereference skipped thanks to the check” is the proof of this. Had the p = NULL assignment been skipped, p would have become a dangling pointer still pointing at the freed block, and the p != NULL check could not have caught it — because a dangling pointer’s value is not null, only invalid. This is the fundamental distinction between a null pointer and a dangling pointer: one is a checkable value (null), the other is an uncheckable condition (invalid but not appearing null).

A second danger of a dangling pointer is handing it to free again — this condition is called a double free. After the free(p) call, if p still carries the old address, a second free(p) call tries to free the same block twice; this too is undefined behavior and usually corrupts the allocator’s own internal state. The example above never writes this second call, because calling free(NULL) after the p = NULL assignment is harmless — the standard defines freeing a null pointer as doing nothing. This is a second benefit of the NULL assignment: it makes not only dereferencing but also a possible second free call harmless.

// safe_second_call.c — free(NULL) is defined as harmless by the standard
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = malloc(sizeof *p);
    if (p == NULL) {
        printf("%s\n", "allocation failed");
        return 1;
    }
    *p = 4;
    printf("%s %d\n", "value before freeing", *p);

    free(p);
    p = NULL;

    free(p);
    printf("%s\n", "second free ran on null");
    return 0;
}
value before freeing 4
second free ran on null

PO19. This code calls free(p) twice, but a p = NULL line sits between the two calls. The first call frees the real block; the second call operates on NULL, and because the standard explicitly defines this as “doing nothing,” the program reaches the line “second free ran on null” without crashing. Had the p = NULL assignment on the line between them been missing, this same code’s second free(p) call would have tried to free the same block twice — the double-free error defined above — and that is why it is not attempted here.

Both members of the unowned class in this lesson trace back to the same root: the overwritten pointer observation in the first lesson’s core left a block unreachable by overwriting the single pointer holding its address without freeing it first — a leak. A dangling pointer is the opposite-direction error: the block has been freed, but the pointer keeps pointing at it. Both mean “no one guaranteed this condition,” which is why both fall into the unowned class.

Multiple Copy Pointers: Why a NULL Assignment Is Not Enough

Both examples so far worked through a single pointer. In real code, multiple pointers can point at the same block — one performs the free call, but the others stay unaware of the p = NULL line.

// copy_pointer.c — of two pointers pointing at the same block, only one is pulled to NULL
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = malloc(sizeof *p);
    if (p == NULL) {
        printf("%s\n", "allocation failed");
        return 1;
    }
    *p = 7;

    int *q = p;
    printf("%s %d\n", "do p and q show the same value", (int)(*p == *q));
    printf("%s %d\n", "do p and q carry the same address", (int)(p == q));

    free(p);
    p = NULL;
    printf("%s %d\n", "is p null after freeing", (int)(p == NULL));
    return 0;
}
do p and q show the same value 1
do p and q carry the same address 1
is p null after freeing 1

PO20. After the line int *q = p;, p and q carry the same address — both the *p == *q and p == q comparisons give 1. When free(p) runs and then p = NULL, only the p variable itself is affected; q never appears in these two lines, so it still carries the old address of the block that was freed with free — q is now a dangling pointer. This code neither dereferences q after this point nor compares it against NULL, because the value q carries is now invalid and trying to read it (even a comparison) is undefined behavior. What this is meant to show is not visible while the code runs — which is exactly the bound itself: p = NULL only changes the condition of the variable named p, not the condition of q.

Dangling Pointers Across the Ownership Axis: Three Different Sources

Both examples up to this point produced the dangling condition from a single source: freeing a malloc block with free. But a dangling pointer can arise a different way in each of the course’s four ownership classes (language, caller, callee, unowned); none of this code is run here because each leads to undefined behavior, but all three tie back to the same root this lesson measures.

In the language class, if a function returns the address of a local variable (something like return &local;), the returned pointer becomes dangling the instant the function returns — because it is the language itself that manages local‘s lifetime, and that lifetime is bound to the function’s body. There is no free call here at all; the block disappears not through a malloc, but through the call frame itself going away. The result is still the same: the caller is left holding a pointer to an invalid address.

In the caller class, the dangling condition is the form seen in both this lesson’s examples: if the side that allocated the memory and carries the obligation to call free forgets the NULL assignment after freeing (or carries a second copy, as in copy_pointer.c), that pointer stays dangling.

In the callee class, the dangling condition comes from the opposite direction: if a function frees a block it holds itself (think of the data a core like owner.c owns) internally with free, but does not pull back a pointer it previously handed to the caller for that block, the caller’s own copy is now a dangling pointer — the caller has no way of knowing this, because the callee never reported it in any way. This is why a function in the callee class must have an explicit contract: documenting when it frees a block, and that this freeing invalidates the caller’s copies, is the callee side’s responsibility.

All three arrive at the same observation: a dangling pointer is a question about whether the address a pointer carries still points at a valid block, and regardless of which class it comes from, this question is never answered by the language itself — the answer always falls to the programmer to track. The difference between the three sources is when the question becomes noticeable: the error in the language class usually surfaces the instant the function returns, on first use; the error in the caller class can be delayed by a forgotten NULL assignment, as in this lesson; the error in the callee class is the latest to be noticed, because nothing changes in the caller’s own code at all — only the internal behavior of the function it calls has changed.

The Difference from Rust’s Borrow Checker

The question of who watches over freed memory in C sits in a different place from Rust’s borrow checker, covered in the Ownership and the Memory Model course (M10/K02): in Rust, a value’s lifetime is determined at compile time, and a reference whose lifetime has ended is rejected by the compiler; in C, no mechanism checks whether a pointer still points at a valid block, aside from a habit like p = NULL. In Rust, something resembling the copy_pointer.c example above would not compile at all — the instant p is lent to q, the borrow checker would prevent p from being freed while q is still alive. C has no such check; knowing that q is dangling is left entirely to the kind of tracking a programmer does, as done here in this lesson.

A Tool Catches the Dangling Pointer, Not the Compiler

Every check used in this lesson (== NULL, != NULL) is hand-written, added by the programmer themself — the compiler itself does not know whether a pointer is dangling and never flags it automatically. In real projects, this gap is closed with runtime tools: a dynamic analyzer tracks every malloc/free pair while a program runs, and reports it if a block is accessed (dereferenced, double-freed) after being freed. These tools work not at compile time but at runtime, and only for paths actually triggered in that particular run — unlike Rust’s compile-time borrow checker, a dangling-pointer error must actually run at least once before it is caught. This sums up the nature of defense in C: the language itself gives no guarantee, but discipline (the NULL-assignment habit seen in this lesson) and tools (runtime analyzers) together make up for that missing guarantee.

The Bounding Measurement

The p = NULL assignment does not eliminate the dangling-pointer problem — it only makes it visible for that one pointer. The copy_pointer.c example above showed this concretely: q, despite carrying the same address as p, was never affected by the p = NULL line at all. This lesson measured the single-pointer and two-pointer cases separately; more complex sharing patterns — three or more pointers sharing the same block, or a pointer stored in a struct field — are the topic of the course’s Dynamic Memory topic, in the Deallocation Discipline lesson. The core question stays the same there: who holds the authority to free a block, and who informs the other copies that it has been freed.

Summary

  • A null pointer (NULL) is a checkable value; it can be tested with == NULL before dereferencing, and this lesson applied that after every allocation call.
  • A dangling pointer is a pointer that keeps pointing at a freed block; this lesson never dereferenced or re-freed one — it only described it.
  • Assigning NULL to a pointer right after a free call converts the dangling condition into a checkable null condition.
  • Null and dangling pointers are two different faces of the core’s unowned class: one is an unfreed leak, the other is a freed but still-pointed-at block. p = NULL applies only to that one pointer; if another copy points at the same block, as copy_pointer.c showed, that copy is still dangling.
  • The dangling condition can arise not only from a caller forgetting a NULL assignment, but also from a local variable’s address being returned in the language class, or a callee freeing a block it holds without informing the caller — all three arrive at the same question (is the address still valid) by a different route.
  • A double free (calling free on the same block twice) is also a consequence of a dangling pointer; the standard defining free(NULL) as harmless makes the p = NULL habit protective against this second danger as well — safe_second_call.c showed this concretely.

Next Step

The four lessons up to this point used pointers’ concrete types, like int * and double * — the type always said what value was being pointed at. The next lesson, void and Opaque Pointers, measures conditions where a pointer’s type is deliberately hidden: the core’s opaque record observation shows how it moves ownership to the callee class while fully concealing the Record struct’s contents. The dangling pointer danger seen in this lesson still applies there — because the caller holding an opaque record’s address has the obligation to free it belonging to the callee, it can only learn when the record becomes invalid from the callee’s contract; because the record’s contents are hidden, that contract comes not from the type definition itself, but only from documentation.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close