Pointers Debugging Exercises (10)

← Module home · Work each in the debugging protocol: reproduce → form hypotheses → instrument → fix → reflect. Here the protocol gains one step, always first: draw the diagram of what the code thinks is true.

Format. Each program compiles (unless stated) but misbehaves or crashes. Hint ladders are collapsible — expand one rung at a time. Fix-list summary at the end. ⚠️ These are pencil-and-machine exercises: hand-trace every one before running it, and run the crashy ones knowing exactly why they crash.


D1 — The switcheroo (the two jobs confused)

int main() {
    int a = 1, b = 2;
    int* p = &a;
    *p = &b;        // intent: make p point at b
    std::cout << *p << '\n';
}
// Expected: 2.   Compiler: ERROR (or worse, if it slips through on old compilers).
Hint 1 — which job did the author want, and which did they write?

Intent is Job 1 (re-point): p = &b;. Written is Job 2’s syntax with Job 1’s value: *p = &b tries to write an address into a — a type mismatch (int vs int*), rejected by the compiler.

Hint 2 — the fix and the lesson

p = &b; — no star. The star means “write where it points”; the author wanted “move the arrow.” (Lesson 2 §1)

Reflection. Every pointer statement is one of the two jobs. Say which one you mean out loud, then write it.

D2 — The promise not kept (uninitialized dereference)

int main() {
    int* p;
    int x = 42;
    *p = x;          // "store x somewhere" — where?
    std::cout << *p;
}

Expected: 42. Observed: sometimes 42, sometimes garbage, sometimes a crash — never reliably anything.

Hint 1 — where does the arrow point at the moment of *p = x?

Nowhere sane — p was never initialized; it holds garbage. The write landed in a random box.

Hint 2 — the fix

Initialize at birth (Module rule 1): int* p = &x; — then *p = x; is even redundant; int* p = &x; alone does the job.

Reflection. “It printed 42!” is not evidence — undefined behaviour can imitate correctness. Draw the arrow: if it points at garbage in the diagram, the program is wrong however it behaves.

D3 — The unguarded helper (null accepted, dereferenced anyway)

void setTo(int* target, int value) {
    *target = value;      // crashes when called as setTo(nullptr, 5)
}

int main() {
    int* p = nullptr;     // "no target yet" — a NORMAL pointer state
    setTo(p, 5);
}
Hint 1 — whose job is the null check, caller or callee?

Both can, but the callee must if it accepts pointers: “might be null” is a pointer parameter’s normal case (Lesson 2 §4).

Hint 2 — the fix
void setTo(int* target, int value) {
    if (target == nullptr) return;   // or report the refusal
    *target = value;
}

Or change the contract to a reference (int& target) if “always exists” is the truth — then the compiler enforces it.

Reflection. The signature is a contract. int* promises maybe nothing — so guard. int& promises always there — so don’t. Choose the one that matches reality.

D4 — The leaky loop (re-point without release)

int main() {
    for (int i = 0; i < 1000; i = i + 1) {
        int* p = new int(i);
        if (i % 2 == 0) p = nullptr;   // "clean up" — or so the author thought
    }
    std::cout << "done\n";
}

Expected (by the author): tidy cleanup. Observed: memory use climbs by ~1000 ints; nothing crashes.

Hint 1 — trace ONE loop pass as a diagram. Where does the heap box go when p is set to null? When p is re-created next pass?

Setting p = nullptr doesn’t free the box — it just drops the only arrow. Next pass re-points p at a new box; the old one is orphaned every pass. 1000 leaked boxes, silently.

Hint 2 — the fix
for (int i = 0; i < 1000; i = i + 1) {
    int* p = new int(i);
    // ... use p ...
    delete p;          // release BEFORE the arrow is lost
    p = nullptr;
}

And the honest design answer: this loop never needed the heap at all — int value = i; does the same job with zero risk (Module rule 5).

Reflection. “Setting to null” is cleanup of the pointer, never of the box. delete frees boxes; = nullptr tames arrows. Different objects, different verbs.

D5 — The use-after-free (dangling read)

int main() {
    int* p = new int(42);
    delete p;
    if (*p == 42)                 // "just checking..." — checking WHAT?
        std::cout << "still there\n";
    else
        std::cout << "changed\n";
}

Expected: neither — the program’s behaviour is undefined. Observed: often prints “still there” today — and corrupts or crashes next week after an unrelated edit.

Hint 1 — what does p hold after delete?

The same address — but the box is returned. Reading it is reading memory the system may hand to someone else at any moment. This is Family 2, the delayed-corruption family (Lesson 3 §5).

Hint 2 — the fix

delete p; p = nullptr; on one line — then the “just checking” becomes if (p != nullptr), which is honest: nothing there.

Reflection. The same-line idiom is not ceremony — it converts the worst bug family (silent, delayed) into the best failure mode (loud, immediate).

D6 — The return of the local (dangling by function exit)

int* makeCounter() {
    int count = 0;
    return &count;       // author: "hand the counter up to the caller"
}

int main() {
    int* c = makeCounter();
    *c = *c + 1;         // "increment the counter"
    std::cout << *c << '\n';
}

Expected: 1. Observed: garbage, or a crash, or 1 — with a compiler warning (address of stack memory associated with local variable 'count' returned) that the author ignored.

Hint 1 — draw the stack. What happens to count's box the moment makeCounter returns?

The frame pops — the box evaporates. The caller’s arrow points at a ghost. Family 2, way 2 (Lesson 3 §2).

Hint 2 — the fixes, best first
  1. Return the value: int makeCounter() { int count = 0; return count; } — copies are cheap for small data.
  2. Out-parameter: void init(int& count) { count = 0; } — caller’s box, borrowed honestly.
  3. Heap: new inside, documented ownership handoff — but only if the data is genuinely big.

Reflection. Heap boxes outlive functions; stack boxes don’t. If you need the heap only to survive a return, first check whether a copy is simpler (Module rule 5).

D7 — The crossed wires (new[] with delete)

int main() {
    int* data = new int[10];
    for (int i = 0; i < 10; i = i + 1) data[i] = i;
    delete data;      // one character off
    data = nullptr;
    std::cout << "ok\n";
}

Expected: clean. Observed: usually “ok” — and the heap’s bookkeeping for arrays is now quietly damaged; crashes appear later, in unrelated allocations.

Hint 1 — which pairs rule does this break?

Module rule 4: new[] ↔ delete[] — the bracket-less delete on an array block is undefined behaviour (Lesson 3 §4).

Hint 2 — the deeper lesson

Count the pairs by form, not just by number. new int → delete; new int[n] → delete[]. Say both halves out loud when you write either.

Reflection. “It ran fine” is exactly how heap corruption behaves — the damage surfaces elsewhere, later. Pairs rules exist because the compiler can’t check this one for you.

D8 — The straying pointer (out-of-bounds arithmetic)

int main() {
    int* data = new int[5];
    for (int i = 0; i < 5; i = i + 1) data[i] = i * i;
    int* p = data;
    for (int i = 0; i <= 5; i = i + 1) {     // spot it...
        std::cout << *p << ' ';
        p = p + 1;
    }
    delete[] data;
    data = nullptr;
}

Expected: 0 1 4 9 16 — five values. Observed: six values — the sixth read from memory past the block.

Hint 1 — how many boxes does the block have, and what does p aim at on the sixth pass?

Five boxes; on pass six p aims one past the end — the [begin,end) convention exists precisely to make “one past the last” a stop mark, never a dereference target (Lesson 3 §1).

Hint 2 — the fix

i < 5 (or the pointer form: while (p != data + 5)). The off-by-one you’ve fought since Unit 05 is the same bug with an arrow instead of an index.

Reflection. Pointer arithmetic has no bounds checking whatsoever — the loop condition is the only fence. Draw the block; count the dereferences.

D9 — The double release (two owners, one box)

void process(int* data, int n) {
    int total = 0;
    for (int i = 0; i < n; i = i + 1) total += data[i];
    std::cout << total << '\n';
    delete[] data;                 // "helpfully" cleaning up
}

int main() {
    int n = 3;
    int* data = new int[n]{1, 2, 3};
    process(data, n);
    delete[] data;                 // main also cleans up — it thinks it's the owner
    data = nullptr;
}

Expected: prints 6, clean exit. Observed: prints 6, then heap corruption or a crash at exit — the same box was returned twice.

Hint 1 — who does the ownership rule say deletes?

Exactly one owner. Here main news the box and process deletes it — two owners, one box (Lesson 3 §6). The second delete corrupts the heap’s records.

Hint 2 — two honest repairs
  1. process is a user, not an owner: take const int* data, int n, drop its delete (the const documents “look only”).
  2. If process should own, transfer visibly: document the contract in the name/comment — but then main must not delete. Pick one; write the contract down.

Reflection. Ownership isn’t a mood — it’s a written contract with one name on it. The const on a parameter is that contract, enforced.

D10 — The silent orphan (ownership handoff dropped)

int* loadValues(int n) {
    int* data = new int[n];
    for (int i = 0; i < n; i = i + 1) data[i] = i * 10;
    return data;                   // hands the handle AND the ownership up
}

int main() {
    loadValues(5);                 // "just load them in" — the return is discarded
    std::cout << "loaded\n";
}

Expected (by the author): values loaded. Observed: prints “loaded”; five heap boxes are leaked — allocated, returned to nobody, unreturnable.

Hint 1 — where did the handle go?

The return value was discarded — the arrow existed for one expression and vanished. The boxes are now unreachable: Family 1, the leak (Lesson 3 §5).

Hint 2 — the fix, and the design question behind it
int* data = loadValues(5);   // catch the handle — accept ownership
// ... use ...
delete[] data;
data = nullptr;

Then ask the design question: does main need a dynamic array at all here? If the caller immediately deletes after one pass, a stack array sized to a known cap (or the course’s usual const int sizes) is simpler — Module rule 5.

Reflection. An ownership handoff requires a receiver. Discarding a returned heap handle is dropping a live wire — catch it, or don’t generate it.


Fix-list summary

D Family / lesson Bug (one line) Fix (one line)
D1 two jobs *p = &b — Job 2 syntax, Job 1 intent p = &b;
D2 invalid access uninitialized dereference initialize at birth
D3 invalid access unguarded null in callee if (target == nullptr) return; — or take a reference
D4 leak re-point/orphan in a loop delete before losing the arrow — or no heap at all
D5 dangling use after delete delete p; p = nullptr; same line
D6 dangling returning a local’s address return by value / out-param / heap with contract
D7 heap corruption delete on a new[] block delete[] — match the forms
D8 invalid access off-by-one pointer walk stop at one-past-the-end; never dereference it
D9 double delete two owners, one box one owner — const documents users
D10 leak returned handle discarded catch it and own it — or don’t allocate