Common OOP Bugs¶
The dangerous bugs are the ones the compiler accepts. Everything on this page builds cleanly with
-Wall -Wextra -Werror and then does the wrong thing — which is why this page exists separately
from Common Compiler Errors.
Every output quoted below was captured by running the code shown on the course toolchain.
Pairs with Module 16 — Advanced OOP Capstone
1. Object slicing¶
Symptom: the base behaviour runs where you expected the derived behaviour. No warning.
The first line is the sliced Animal: push_back copied only the base subobject. The second is
the same object reached through a pointer, which dispatches correctly.
Detect it: the behaviour is right through a reference and wrong through a value. Fix: hold
polymorphic objects by pointer — std::vector<std::unique_ptr<Animal>> — or pass by reference.
2. Missing virtual destructor¶
Symptom: a leak with no error, and the derived destructor never runs.
class Base { public: ~Base(); }; // not virtual
class Derived : public Base { public: ~Derived(); };
Base* p = new Derived();
delete p;
The derived object is still "alive" after being deleted. Detect it: a construction/destruction
counter that never reaches zero. Fix: virtual ~Base(); — one word, and the rule is unconditional
for any class with a virtual function.
3. An override that never overrides¶
Symptom: the base version always runs. Silent, because a non-matching signature is a new
function, not an error — unless you say override.
class Base { public: int value() const; }; // const
class Derived : public Base { public: int value(); }; // no const: a different function
Detect it: the compiler only tells you if you annotate:
error: 'int Derived::value()' marked 'override', but does not override
error: 'virtual int Base::value() const' was hidden [-Werror=overloaded-virtual=]
Fix: write override on every overriding function — then this bug cannot happen. The related
classic is a base function that was never virtual at all:
class Base { public: void speak() const; }; // not virtual
class Derived : public Base { public: void speak() const; };
Same name, same signature, no polymorphic behaviour: ref.speak() through a Base& is bound at
compile time. Fix: add virtual to the base, and override to the derived.
4. Double free — two owners of one allocation¶
Symptom: a crash at the end of a program, or inside the destructor of an object you never suspected.
class Buffer
{
public:
explicit Buffer(int n) : data_(new int[n]) {}
~Buffer() { delete[] data_; }
private:
int* data_; // owns — and the compiler-generated copy does not know
};
int main()
{
Buffer a(4);
Buffer b = a; // both now own the same allocation
std::cout << "two Buffers constructed\n";
return 0; // both destructors run
}
Even the cout line is missing — standard output is buffered, and the abort lost the buffer.
That is worth remembering while debugging: absence of output does not prove the program never got
there. Add std::flush (std::endl flushes too) after any print you are using as a marker.
Fix: Rule of Three — or better, own with a std::vector, and
declare nothing.
Detect it: a live-object counter that goes negative, or a crash in a destructor with no apparent cause.
5. Dangling pointer and null dereference¶
Symptom: nonsense values, or a crash, often nowhere near the delete.
The value is arbitrary. The program may print the old value, print rubbish, print nothing, or crash — all of those are permitted, because this is undefined behaviour. "It printed 7 for me" is not evidence that the code is correct.
The related form is a pointer or reference into a container that later grew:
const std::string& first = names[0];
names.push_back("new"); // reallocation: "first" now refers to freed memory
Detect it: keep the lifetime question explicit — for every raw pointer, name its owner and its last use. Fix: hold a value, an index, or an iterator you re-obtain after mutating; or a smart pointer whose lifetime covers the use.
The related case: a null dereference¶
On this shell the run reported exit status 139 — the shell's way of saying killed by a segmentation fault. On Windows it surfaces as an access violation. The program printed nothing because the crash lost the buffered output.
This is the good kind of crash. A null dereference fails immediately, at the line that caused
it, and it fails the same way every time. Compare it with the dangling-pointer case above, which
printed 1789884128 and might equally have printed the right answer — undefined behaviour can look like success,
and that is what makes it expensive.
Detect it: put a flushed marker before the dereference; if the process dies with no output at
all, you have found a crash rather than a wrong value. Fix: establish the invariant this pointer
is never null — return a reference instead of a pointer, throw from the lookup, or use
std::optional. Do not sprinkle if (p) checks; that hides the design question.
6. Memory leak¶
Symptom: memory grows; nothing looks wrong. On a short-lived program, nothing looks wrong at all.
A live-object counter is the portable way to see a leak without a sanitizer: increment in the constructor, decrement in the destructor, print the count. If the count is not zero at the end, the objects were never destroyed, and the counter points at which ones.
Fix: every new has one owner, and that owner is a value member, a unique_ptr or a
shared_ptr — Rule of Zero. If a sanitizer is available on your
toolchain, it finds this automatically; on a toolchain where it cannot link, the counter does not
lie.
7. Ownership mistakes beyond double free¶
| Symptom | Cause |
|---|---|
| Nothing is ever destroyed (leak, no crash) | Everything is a shared_ptr, and something holds a cycle |
| Destroyed too early (crash mid-run) | An observer outlived its owner: a raw pointer, a reference, or a weak_ptr never checked |
| Destroyed twice | Two containers own the same object: one holds a unique_ptr, another a raw pointer it also deletes |
| Destroyed in the wrong order | A parent destroyed before its children, in a design where children refer back to it |
The design question in every row is the same: who owns it, and what ends first? Answer it in writing before you code, and the whole table disappears.
8. Copy-cost and move bugs¶
Symptom: correct output, worse performance than expected — usually discovered as "why is this slow?" long after the design is fixed.
std::vector only moves during reallocation when the move cannot throw; otherwise it copies, to
keep its strong guarantee. Fix: noexcept on the move constructor and move assignment.
Two more in the same family:
std::moveon aconstobject copies.const T&&binds toconst T&.- Declaring a destructor suppresses the implicit moves, so every container operation copies
instead of moving. Write
= defaulton the moves, or own nothing directly.
9. Exception mistakes¶
| Symptom | Cause | Fix |
|---|---|---|
terminate called after throwing an instance of 'std::runtime_error' with no handler |
An exception escaped a noexcept function or a destructor |
Do not throw from either; report from an explicit close() |
| The handler runs, but the derived data is gone | catch (std::runtime_error e) — caught by value |
catch (const std::exception& e) |
The try/catch around a call does not catch |
The function is noexcept, so std::terminate happens first |
Remove noexcept, or handle it inside |
| State half-updated after a failure | The operation was not designed to be transactional | Validate first, or build-then-swap (strong guarantee) |
terminate called ... vector::_M_range_check |
An uncaught std::out_of_range from .at() |
Handle the range, or use .at() deliberately as a checked lookup |
terminate called after throwing an instance of 'std::out_of_range'
what(): vector::_M_range_check: __n (which is 5) >= this->size() (which is 2)
The what() message told you the index and the size. Read it before guessing.
10. Invariant bugs — the quiet ones¶
No crash, no message, wrong state.
| Symptom | Cause |
|---|---|
| Two fields disagree ("available" and "on loan") | The same fact is stored twice |
| A count is right until an object is copied | The count is a member, but the fact is global |
| A total drifts after edits | Recomputed from stale data instead of derived |
| A negative balance appears | A mutating path bypassed validation |
Detect them by writing the invariant as a function and asserting it after every operation in a
test — assert(balance() >= 0);. Fix by making the wrong state unrepresentable: derive one
field from the other, or move the fact into a single owner.
Quick diagnosis table¶
| What you see | Look first at |
|---|---|
| Wrong behaviour through a value, right through a pointer | Slicing (§1) |
| A destructor that never runs | Missing virtual destructor (§2) |
| The base version always runs | Missing virtual or override (§3) |
| Crash in a destructor or at exit | Double free / ownership (§4, §7) |
| Crash immediately, no output, exit 139 | Null dereference (§5) |
| Nonsense values, no crash | Dangling pointer, UB (§5) |
| Memory grows | Leak, cycle, missing ownership (§6) |
| Correct but slow | Missing noexcept, copies, const move (§8) |
terminate called |
Exception from a noexcept function or destructor (§9) |
| State that is simply wrong | Invariant stored twice (§10) |
Related: Polymorphism · Rule of Zero, Three and Five · Smart Pointers · Debugging Checklist · Common Compiler Errors