1. Why an abstract type is useful
When a program grows beyond a couple of files and a couple of functions, it suddenly becomes a luxury to “know the concrete class.” Sometimes client code just wants “something that can print a task” or “something that can show a message,” and it doesn’t care at all how that is implemented. A similar situation in real life: it’s not important to you which courier delivers the pizza — what matters is that the pizza arrives.
Imagine we have a simple console app called Tasky (a toy task tracker). Previously we could print tasks directly via std::cout. But now we want two output modes: “plain text” and “nicely boxed.” We really don’t want to rewrite the whole codebase for two variants and sprinkle ifs all over the project.
Let’s make a small task model (we had it earlier, but this gives context):
#include <string>
struct Task {
int id{};
std::string title;
bool done{};
};
Now the natural question: how to write a “print task” function so it works with different printing styles — without knowing which concrete style is chosen?
2. Pure virtual and abstractness
Pure virtual: = 0 — it is not “assign zero”
The syntax = 0 in a method declaration looks like we are trying to “assign zero to a function” (which, of course, sounds like dark C++ magic). Actually this is a special notation: a pure virtual function — “a method without an implementation in the base class, required to be overridden.”
Standards documents use a separate term pure-specifier for this part of the declaration. So it’s not a “random compiler trick,” but an official language construct.
The simplest “interface” for printing a task might look like this:
#include <iostream>
struct ITaskPrinter {
virtual ~ITaskPrinter() = default;
virtual void print(const Task& t) const = 0; // pure virtual
};
Key points here are the following.
We wrote virtual void print(...) const = 0; — this means: the base type promises that all descendants will have a print method, but it does not say how to print. And yes, = 0 is not a value or a “null pointer,” it is part of the virtual function declaration syntax.
Why you cannot instantiate an abstract class
When a class contains at least one pure virtual function, the class becomes abstract. That means: you cannot create an object of that class directly. This makes perfect sense: if the type has a “required method without implementation,” what should an object do when that method is called? Shrug? Print “404: implementation not found”? The compiler takes a radical stance: “we won’t create it.”
Here is a demonstration “by feeling”:
int main() {
// ITaskPrinter p; // Compilation error: abstract class
}
However, you can use an abstract class via a reference or pointer to the base type — exactly like we used polymorphism before. So “you cannot have an object of the base type,” but “a reference/pointer to the base type” is fine, because there will actually be a derived object underneath.
By the way, details and subtleties of checking abstract class types are not just a classroom topic: these things are discussed and specified at the WG21 (the C++ committee) level.
3. Implementing the interface in Tasky
Implementation: a descendant must override = 0
Now the practical part: let's make two task printers. One prints “plain,” the other prints “in a box.” Start with the simple one:
#include <iostream>
struct PlainTaskPrinter : ITaskPrinter {
void print(const Task& t) const override {
std::cout << "#" << t.id << ": " << t.title << "\n";
}
};
The second one — the “box” (a mini version, without huge ASCII art):
#include <iostream>
struct BoxTaskPrinter : ITaskPrinter {
void print(const Task& t) const override {
std::cout << "[#" << t.id << "] " << t.title << (t.done ? " (done)\n" : "\n");
}
};
Note that we used override. This is our safety belt: the compiler will confirm that we actually overrode a base-interface method and didn't accidentally declare a “similar” method (a classic problem is forgetting const or mixing up parameters).
Now let's write a function that prints all tasks while knowing nothing about the specific printer:
#include <vector>
void print_all(const std::vector<Task>& tasks, const ITaskPrinter& printer) {
for (const auto& t : tasks) {
printer.print(t);
}
}
And assemble a mini-demo:
#include <vector>
int main() {
std::vector<Task> tasks = {
{1, "Write lecture about abstract classes", false},
{2, "Drink tea (compulsory)", true},
};
PlainTaskPrinter plain;
BoxTaskPrinter box;
print_all(tasks, plain);
// #1: Write lecture about abstract classes
// #2: Drink tea (compulsory)
print_all(tasks, box);
// [#1] Write lecture about abstract classes
// [#2] Drink tea (compulsory) (done)
}
This is the main point of an interface/abstract class: client code depends on capabilities (print), not on the concrete class PlainTaskPrinter or BoxTaskPrinter.
Abstractness is “inherited”
A very common situation for beginners: “I derived… but it’s still abstract… the compiler complains… life hurts.” This is not a bug, it’s logic.
If a descendant did not implement all pure virtual methods, it also becomes abstract. For example:
struct BrokenPrinter : ITaskPrinter {
// void print(...) is not implemented => the class is still abstract
};
And again:
int main() {
// BrokenPrinter bp; // Compilation error: still abstract
}
This behavior is actually looking out for your future. The compiler is basically saying: “You promised to implement the contract. If you didn't — I won't let you create an object that would blow up on first call.”
override: insurance against a “almost the same method”
In practice, the most painful mistake with virtual methods is when you think you overridden a method, but actually you wrote a different signature. Then the virtual call goes to the base version (or fails to compile if the base was pure virtual), and you sit and stare at the screen in disbelief.
A typical example: you forgot const on the method, and that is already a different method.
struct AlmostPrinter : ITaskPrinter {
void print(const Task& t) override { // <-- missing const, override will cause an error
std::cout << t.title << "\n";
}
};
Here override is our hero. Without override the compiler might silently accept your new print method that does not override the interface. With override it will honestly tell you: “friend, you didn't override anything.”
Practical rule: every method you think you are overriding should have override. It's like wearing a seatbelt: you can skip it, but statistics and physics are against you.
Virtual destructor in the interface
In abstract classes used polymorphically (via Base* / Base&), it is crucial that the base class destructor is virtual. Otherwise deleting a derived object through a base pointer may result in the derived destructor not being called, and resources leaking or breaking.
We already used the correct form:
struct ITaskPrinter {
virtual ~ITaskPrinter() = default;
virtual void print(const Task& t) const = 0;
};
Why is = default good here? Because we don't need custom logic; we just want “virtuality” and correct behavior. So this is not a “special implementation,” but the “right class setup.”
Even if you currently don't delete objects manually with delete (which is great), the rule still holds: a polymorphic base — a virtual destructor. It's part of basic C++ hygiene.
4. The “interface” in C++ as a style
Some languages have a dedicated keyword interface. C++ has no separate keyword, and the role of an interface is usually played by an abstract class, often without data members, with a virtual destructor and a set of pure virtual methods.
It is important to understand a subtle point: an abstract class can have fields and even methods with implementations. But when we say “interface” in the teaching sense, we typically mean “a type about behavior only.” So keeping a std::vector<Task> inside an interface “just in case” is like carrying a pot in your pocket: you can do it, but people will ask questions.
Useful mini-table to keep in mind:
| Concept | How it looks in code | What it means |
|---|---|---|
| Virtual function | |
The base type has an implementation; a descendant may override |
| Pure virtual | |
No implementation in the base; a descendant must override |
| Abstract class | has at least one = 0 | Cannot create an object of this type directly |
| “Interface” (style) | abstract class without data | The type describes “what it can do,” not “what it stores” |
And one more diagram to avoid confusion about who is who:
classDiagram
class ITaskPrinter {
<<abstract>>
+print(Task) const = 0
+~ITaskPrinter()
}
class PlainTaskPrinter {
+print(Task) const
}
class BoxTaskPrinter {
+print(Task) const
}
ITaskPrinter <|-- PlainTaskPrinter
ITaskPrinter <|-- BoxTaskPrinter
5. Common mistakes
Mistake #1: treating = 0 as “there is zero stored” or “a default value.”
This usually comes from the visual similarity to assignment. But a pure virtual has no “value”; it's a marker: “no implementation in this class.” If you keep that in mind, reading declarations becomes easier: = 0 — not about data, but about the descendant's obligation.
Mistake #2: trying to create an interface object directly.
A beginner writes ITaskPrinter p; and wonders about the compiler error. An abstract class is a “type for interaction,” not a “ready-made thing.” You need to create a concrete derived class and work via ITaskPrinter& / ITaskPrinter*.
Mistake #3: forgetting to implement one pure virtual method and getting “class is still abstract.”
This is especially tricky when the interface has grown to several methods and you implemented two out of three. The code looks “almost ready,” but you cannot instantiate the object. A good habit is to add interface methods carefully and immediately fix all implementations, otherwise you create a mini-build-apocalypse for yourself.
Mistake #4: not using override and accidentally not overriding a method.
The most common variant is a mismatch in const, parameter type, or even a typo in the name. Without override the compiler may stay silent, and you will get strange behavior (or be unable to instantiate an object if the base was pure virtual). With override you get a clear error right at the problem — immediately, while you’re still in the hot spot.
Mistake #5: forgetting the virtual destructor in a polymorphic base.
Sometimes this seems “unimportant”: “it's an interface, I don't store anything.” But the rule about a virtual destructor is not about whether you store data, it's about how you destroy an object through a base type. Therefore virtual ~I() = default; — it is not decoration, but insurance against very unpleasant surprises.
GO TO FULL VERSION