Skip to content

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.

std::vector<Animal> by_value;
by_value.push_back(Dog());
by_value[0].speak();
...
woof

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;
base alive: 0
derived alive: 1

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; };
base speaks
derived speaks

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
}
(no output)

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.

int* p = new int(7);
delete p;
std::cout << *p << '\n';
1789884128

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.

Node* p = find(false);      // returns nullptr
std::cout << p->value << '\n';
(no output at all)

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.

void leak_one() { Connection* c = new Connection(); (void)c; }   // never deleted
alive at start: 0
alive after leak_one(): 1

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.

class Slow { public: Slow(Slow&&) { /* no noexcept */ } };
Slow   (move can throw) : copies=7 moves=0
Quick  (move is noexcept): copies=4 moves=3

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::move on a const object copies. const T&& binds to const T&.
  • Declaring a destructor suppresses the implicit moves, so every container operation copies instead of moving. Write = default on 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