C++ Abstract Classes and Interfaces Pure Virtual Functions Guide
|

C++ Abstract Classes & Interfaces: Complete Guide

Back to C++ RoadmapC++ Programming Course • 65 Lessons

What Are Abstract Classes?

An abstract class is a class that cannot be instantiated. It exists solely as a base class — a contract that derived classes must fulfill. You define abstract classes by declaring one or more pure virtual functions: functions with no implementation that derived classes are required to provide.

Think of an abstract class as a blueprint for a blueprint. A Shape class cannot draw itself — only a Circle or Rectangle knows how. A Database cannot connect — only PostgresDB or SQLiteDB knows the protocol. Abstract classes define what must be done; derived classes define how.

This is how C++ achieves what Java and C# call “interfaces.” C++ does not have a dedicated interface keyword — abstract classes with only pure virtual functions serve the same purpose. Understanding this pattern is essential for writing extensible, testable, professional C++ code.

Pure Virtual Functions

A pure virtual function is declared with = 0 instead of a function body:

#include <iostream>
#include <string>
using namespace std;

class Shape {
public:
    // Pure virtual functions — derived classes MUST implement these
    virtual double area() const = 0;
    virtual double perimeter() const = 0;
    virtual string name() const = 0;

    // Regular virtual function — has a default implementation
    virtual void describe() const {
        cout << name() << ": area=" << area()
             << ", perimeter=" << perimeter() << endl;
    }

    virtual ~Shape() = default;
};

class Circle : public Shape {
    double radius;
public:
    Circle(double r) : radius(r) {}
    double area() const override { return 3.14159265 * radius * radius; }
    double perimeter() const override { return 2 * 3.14159265 * radius; }
    string name() const override { return "Circle"; }
};

class Rectangle : public Shape {
    double w, h;
public:
    Rectangle(double width, double height) : w(width), h(height) {}
    double area() const override { return w * h; }
    double perimeter() const override { return 2 * (w + h); }
    string name() const override { return "Rectangle"; }
};

int main() {
    // Shape s;          // ERROR: cannot instantiate abstract class

    Circle c(5);
    Rectangle r(4, 6);

    c.describe();  // Circle: area=78.5398, perimeter=31.4159
    r.describe();  // Rectangle: area=24, perimeter=20

    // Polymorphic usage
    Shape* shapes[] = {&c, &r};
    for (Shape* s : shapes) {
        s->describe();
    }
    return 0;
}

The = 0 tells the compiler: “this function has no implementation here — any class that inherits from Shape must provide one, or it will also be abstract.” The regular virtual function describe() has a default implementation that calls the pure virtual functions — this works because by the time an object exists, all pure virtuals are implemented.

Rules of Abstract Classes

Cannot be instantiated. You cannot create objects of an abstract class. You can only create objects of concrete (non-abstract) derived classes.

Can have constructors. Even though you cannot create instances directly, the constructor is called when derived classes are constructed:

class Animal {
protected:
    string name;
public:
    Animal(string n) : name(n) {}  // constructor OK
    virtual void speak() const = 0;
    virtual ~Animal() = default;
};

class Dog : public Animal {
public:
    Dog(string n) : Animal(n) {}  // calls Animal constructor
    void speak() const override { cout << name << " says Woof!" << endl; }
};

Can have data members. Abstract classes can hold state just like regular classes.

Derived class must implement ALL pure virtuals to be instantiable. If a derived class does not implement all of them, it is also abstract:

class Shape {
public:
    virtual double area() const = 0;
    virtual double perimeter() const = 0;
    virtual ~Shape() = default;
};

class PartialShape : public Shape {
public:
    double area() const override { return 0; }
    // perimeter() not implemented — PartialShape is ALSO abstract
};

// PartialShape ps;  // ERROR: still abstract

Can be used as pointer/reference types. You cannot create abstract objects, but you can have pointers and references to them — this is how polymorphism works.

The Interface Pattern

An interface is an abstract class with only pure virtual functions and no data. It defines a pure contract with no implementation:

#include <iostream>
#include <string>
using namespace std;

// Interface: only pure virtual functions
class Printable {
public:
    virtual string toString() const = 0;
    virtual ~Printable() = default;
};

class Serializable {
public:
    virtual string serialize() const = 0;
    virtual void deserialize(const string& data) = 0;
    virtual ~Serializable() = default;
};

class Comparable {
public:
    virtual int compareTo(const Comparable& other) const = 0;
    virtual ~Comparable() = default;
};

// A class can implement multiple interfaces
class Student : public Printable, public Serializable, public Comparable {
    string name;
    double gpa;

public:
    Student(string n, double g) : name(n), gpa(g) {}

    // Printable
    string toString() const override {
        return name + " (GPA: " + to_string(gpa) + ")";
    }

    // Serializable
    string serialize() const override {
        return name + "," + to_string(gpa);
    }

    void deserialize(const string& data) override {
        auto pos = data.find(',');
        name = data.substr(0, pos);
        gpa = stod(data.substr(pos + 1));
    }

    // Comparable
    int compareTo(const Comparable& other) const override {
        const Student& s = dynamic_cast<const Student&>(other);
        if (gpa < s.gpa) return -1;
        if (gpa > s.gpa) return 1;
        return 0;
    }
};

void print(const Printable& p) {
    cout << p.toString() << endl;
}

int main() {
    Student alice("Alice", 3.9);
    Student bob("Bob", 3.5);

    print(alice);  // Alice (GPA: 3.900000)
    print(bob);    // Bob (GPA: 3.500000)

    // Serialize and deserialize
    string data = alice.serialize();
    cout << "Serialized: " << data << endl;

    Student clone("", 0);
    clone.deserialize(data);
    print(clone);  // Alice (GPA: 3.900000)

    cout << "Alice vs Bob: " << alice.compareTo(bob) << endl;  // 1 (higher GPA)

    return 0;
}

This is the C++ equivalent of Java’s interface. A class can implement multiple interfaces (unlike single inheritance limitations in some languages). Interfaces define capabilities — Printable means “can be converted to string,” Serializable means “can be saved and loaded.”

Partial Implementation

Abstract classes can provide default implementations for some methods while leaving others pure virtual. This is sometimes called a template method pattern:

#include <iostream>
#include <string>
using namespace std;

class Report {
protected:
    string title;

public:
    Report(string t) : title(t) {}
    virtual ~Report() = default;

    // Template method — defines the algorithm skeleton
    void generate() {
        printHeader();
        printBody();      // pure virtual — subclass decides
        printFooter();
    }

private:
    void printHeader() {
        cout << "========================================" << endl;
        cout << "  " << title << endl;
        cout << "========================================" << endl;
    }

    void printFooter() {
        cout << "----------------------------------------" << endl;
        cout << "  Generated by SudoFlare Report Engine" << endl;
        cout << "========================================" << endl;
    }

protected:
    virtual void printBody() = 0;  // subclass implements this
};

class SalesReport : public Report {
    double revenue, costs;
public:
    SalesReport(double rev, double cost)
        : Report("Sales Report Q2 2026"), revenue(rev), costs(cost) {}

protected:
    void printBody() override {
        cout << "  Revenue:  $" << revenue << endl;
        cout << "  Costs:    $" << costs << endl;
        cout << "  Profit:   $" << (revenue - costs) << endl;
    }
};

class InventoryReport : public Report {
    int items, lowStock;
public:
    InventoryReport(int total, int low)
        : Report("Inventory Report"), items(total), lowStock(low) {}

protected:
    void printBody() override {
        cout << "  Total items: " << items << endl;
        cout << "  Low stock:   " << lowStock << endl;
        cout << "  Status:      " << (lowStock > 10 ? "ALERT" : "OK") << endl;
    }
};

int main() {
    SalesReport sales(150000, 95000);
    sales.generate();

    cout << endl;

    InventoryReport inv(5000, 3);
    inv.generate();

    return 0;
}

The generate() method defines the algorithm structure (header → body → footer). Derived classes customize only the body. This ensures consistent formatting across all report types while allowing flexible content.

Pure Virtual Destructor

You can declare a destructor as pure virtual — but you must still provide an implementation (outside the class). This makes the class abstract while still having a callable destructor:

#include <iostream>
using namespace std;

class AbstractBase {
public:
    virtual ~AbstractBase() = 0;  // pure virtual destructor
};

// Must provide implementation (called during derived destruction)
AbstractBase::~AbstractBase() {
    cout << "AbstractBase destroyed" << endl;
}

class Concrete : public AbstractBase {
public:
    ~Concrete() { cout << "Concrete destroyed" << endl; }
};

int main() {
    // AbstractBase b;  // ERROR: abstract
    Concrete c;         // OK
    return 0;
}
// Output:
// Concrete destroyed
// AbstractBase destroyed

This is useful when you want a class to be abstract but have no other functions to make pure virtual. The destructor is the only virtual function that must have an implementation even when declared pure, because the base destructor is always called during object destruction.

Dependency Injection

Abstract classes enable dependency injection — passing dependencies as interfaces rather than concrete types. This makes code flexible and testable:

#include <iostream>
#include <string>
#include <memory>
using namespace std;

// Interface
class EmailSender {
public:
    virtual void send(const string& to, const string& subject, const string& body) = 0;
    virtual ~EmailSender() = default;
};

// Production implementation
class SmtpSender : public EmailSender {
public:
    void send(const string& to, const string& subject, const string& body) override {
        cout << "[SMTP] Sending to " << to << ": " << subject << endl;
    }
};

// Test implementation
class MockSender : public EmailSender {
public:
    int sendCount = 0;
    string lastTo, lastSubject;

    void send(const string& to, const string& subject, const string& body) override {
        sendCount++;
        lastTo = to;
        lastSubject = subject;
        cout << "[MOCK] Would send to " << to << ": " << subject << endl;
    }
};

// Business logic depends on the INTERFACE, not the implementation
class UserService {
    EmailSender& mailer;  // reference to interface
public:
    UserService(EmailSender& m) : mailer(m) {}

    void registerUser(const string& email) {
        cout << "User registered: " << email << endl;
        mailer.send(email, "Welcome!", "Thanks for signing up.");
    }
};

int main() {
    // Production: use real SMTP
    SmtpSender smtp;
    UserService prodService(smtp);
    prodService.registerUser("user@example.com");

    cout << endl;

    // Testing: use mock
    MockSender mock;
    UserService testService(mock);
    testService.registerUser("test@example.com");
    cout << "Emails sent: " << mock.sendCount << endl;

    return 0;
}

UserService does not know or care whether it is using SMTP or a mock. It only knows the EmailSender interface. This is the foundation of testable architecture — you can swap implementations without changing business logic.

The Strategy Pattern

The Strategy pattern uses abstract classes to make algorithms interchangeable at runtime:

#include <iostream>
#include <vector>
#include <memory>
#include <algorithm>
using namespace std;

// Strategy interface
class SortStrategy {
public:
    virtual void sort(vector<int>& data) = 0;
    virtual string name() const = 0;
    virtual ~SortStrategy() = default;
};

class BubbleSort : public SortStrategy {
public:
    void sort(vector<int>& data) override {
        for (size_t i = 0; i < data.size(); i++)
            for (size_t j = 0; j < data.size() - 1 - i; j++)
                if (data[j] > data[j + 1])
                    swap(data[j], data[j + 1]);
    }
    string name() const override { return "Bubble Sort"; }
};

class SelectionSort : public SortStrategy {
public:
    void sort(vector<int>& data) override {
        for (size_t i = 0; i < data.size(); i++) {
            size_t minIdx = i;
            for (size_t j = i + 1; j < data.size(); j++)
                if (data[j] < data[minIdx]) minIdx = j;
            swap(data[i], data[minIdx]);
        }
    }
    string name() const override { return "Selection Sort"; }
};

class Sorter {
    unique_ptr<SortStrategy> strategy;
public:
    void setStrategy(unique_ptr<SortStrategy> s) {
        strategy = std::move(s);
    }

    void sort(vector<int>& data) {
        cout << "Using " << strategy->name() << endl;
        strategy->sort(data);
    }
};

int main() {
    vector<int> data = {64, 25, 12, 22, 11};

    Sorter sorter;
    sorter.setStrategy(make_unique<BubbleSort>());
    sorter.sort(data);

    for (int x : data) cout << x << " ";
    cout << endl;  // 11 12 22 25 64

    return 0;
}

The Observer Pattern

The Observer pattern uses interfaces to create loosely coupled event systems:

#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
using namespace std;

// Observer interface
class Observer {
public:
    virtual void onEvent(const string& event, const string& data) = 0;
    virtual ~Observer() = default;
};

// Subject that observers watch
class EventEmitter {
    vector<Observer*> observers;
public:
    void subscribe(Observer* obs) {
        observers.push_back(obs);
    }

    void unsubscribe(Observer* obs) {
        observers.erase(
            remove(observers.begin(), observers.end(), obs),
            observers.end());
    }

protected:
    void emit(const string& event, const string& data) {
        for (Observer* obs : observers) {
            obs->onEvent(event, data);
        }
    }
};

// Concrete subject
class Shop : public EventEmitter {
public:
    void sell(const string& item, double price) {
        cout << "SOLD: " << item << " for $" << price << endl;
        emit("sale", item + " $" + to_string(price));
    }

    void restock(const string& item, int qty) {
        cout << "RESTOCKED: " << qty << "x " << item << endl;
        emit("restock", item);
    }
};

// Concrete observers
class InventoryTracker : public Observer {
public:
    void onEvent(const string& event, const string& data) override {
        if (event == "sale") cout << "  [Inventory] Decrease stock: " << data << endl;
        else if (event == "restock") cout << "  [Inventory] Increase stock: " << data << endl;
    }
};

class Analytics : public Observer {
public:
    int saleCount = 0;
    void onEvent(const string& event, const string& data) override {
        if (event == "sale") {
            saleCount++;
            cout << "  [Analytics] Sale #" << saleCount << ": " << data << endl;
        }
    }
};

int main() {
    Shop shop;
    InventoryTracker inventory;
    Analytics analytics;

    shop.subscribe(&inventory);
    shop.subscribe(&analytics);

    shop.sell("Laptop", 999.99);
    shop.sell("Mouse", 29.99);
    shop.restock("Keyboards", 50);

    return 0;
}

The Shop does not know about InventoryTracker or Analytics. It only knows the Observer interface. You can add new observers (email notifications, logging, webhooks) without modifying the shop.

Mock Objects for Testing

Abstract classes make testing easy. Instead of connecting to real databases or APIs, you create mock implementations:

#include <iostream>
#include <string>
#include <map>
using namespace std;

// Interface
class Database {
public:
    virtual bool save(const string& key, const string& value) = 0;
    virtual string load(const string& key) = 0;
    virtual bool exists(const string& key) = 0;
    virtual ~Database() = default;
};

// Mock for testing — stores data in memory
class MockDatabase : public Database {
    map<string, string> storage;
public:
    int saveCount = 0, loadCount = 0;

    bool save(const string& key, const string& value) override {
        saveCount++;
        storage[key] = value;
        return true;
    }

    string load(const string& key) override {
        loadCount++;
        auto it = storage.find(key);
        return it != storage.end() ? it->second : "";
    }

    bool exists(const string& key) override {
        return storage.count(key) > 0;
    }
};

// Business logic using the interface
class UserRepo {
    Database& db;
public:
    UserRepo(Database& d) : db(d) {}

    bool createUser(const string& username, const string& email) {
        if (db.exists("user:" + username)) return false;
        db.save("user:" + username, email);
        return true;
    }

    string getEmail(const string& username) {
        return db.load("user:" + username);
    }
};

int main() {
    MockDatabase mockDb;
    UserRepo repo(mockDb);

    cout << boolalpha;
    cout << "Create alice: " << repo.createUser("alice", "alice@test.com") << endl;  // true
    cout << "Create alice: " << repo.createUser("alice", "alice2@test.com") << endl; // false (exists)
    cout << "Alice email: " << repo.getEmail("alice") << endl;  // alice@test.com

    cout << "DB saves: " << mockDb.saveCount << endl;   // 1
    cout << "DB loads: " << mockDb.loadCount << endl;   // 1

    return 0;
}

Real-World Example: File System Interface

#include <iostream>
#include <string>
#include <vector>
#include <map>
#include <memory>
using namespace std;

// Abstract file system interface
class FileSystem {
public:
    virtual bool writeFile(const string& path, const string& content) = 0;
    virtual string readFile(const string& path) = 0;
    virtual bool deleteFile(const string& path) = 0;
    virtual bool fileExists(const string& path) = 0;
    virtual vector<string> listFiles(const string& directory) = 0;
    virtual ~FileSystem() = default;
};

// In-memory implementation (for testing)
class MemoryFileSystem : public FileSystem {
    map<string, string> files;
public:
    bool writeFile(const string& path, const string& content) override {
        files[path] = content;
        cout << "[MEM] Written: " << path << " (" << content.size() << " bytes)" << endl;
        return true;
    }

    string readFile(const string& path) override {
        auto it = files.find(path);
        if (it == files.end()) throw runtime_error("File not found: " + path);
        return it->second;
    }

    bool deleteFile(const string& path) override {
        return files.erase(path) > 0;
    }

    bool fileExists(const string& path) override {
        return files.count(path) > 0;
    }

    vector<string> listFiles(const string& dir) override {
        vector<string> result;
        for (const auto& [path, _] : files) {
            if (path.find(dir) == 0) result.push_back(path);
        }
        return result;
    }
};

// Application that uses the file system interface
class ConfigManager {
    FileSystem& fs;
    string configPath;

public:
    ConfigManager(FileSystem& filesystem, const string& path)
        : fs(filesystem), configPath(path) {}

    void save(const string& key, const string& value) {
        fs.writeFile(configPath + "/" + key, value);
    }

    string load(const string& key) {
        string path = configPath + "/" + key;
        if (!fs.fileExists(path)) return "";
        return fs.readFile(path);
    }

    vector<string> listKeys() {
        return fs.listFiles(configPath);
    }
};

int main() {
    MemoryFileSystem memFS;
    ConfigManager config(memFS, "/etc/myapp");

    config.save("database_host", "localhost");
    config.save("database_port", "5432");
    config.save("log_level", "info");

    cout << "Host: " << config.load("database_host") << endl;
    cout << "Port: " << config.load("database_port") << endl;

    cout << "\nAll config keys:" << endl;
    for (const string& key : config.listKeys()) {
        cout << "  " << key << endl;
    }

    return 0;
}

In production, you would swap MemoryFileSystem for DiskFileSystem or S3FileSystem. The ConfigManager works identically with any implementation — that is the power of abstract interfaces.

Practice Exercises

Exercise 1: Create a PaymentProcessor interface with processPayment(double amount) and refund(double amount). Implement CreditCardProcessor, PayPalProcessor, and CryptoProcessor. Write a Checkout class that accepts any PaymentProcessor.

Exercise 2: Implement the template method pattern with an ETLPipeline abstract class (Extract-Transform-Load). The base class defines the pipeline: extract()transform()load(). Create CSVPipeline and JSONPipeline with different implementations.

Exercise 3: Create a Renderer interface and implement ConsoleRenderer (outputs ASCII art) and HTMLRenderer (outputs HTML tags). Write a Document class that uses a Renderer to output headings, paragraphs, and lists in the chosen format.

Exercise 4: Build a Cache interface with get(), set(), has(), remove(). Implement MemoryCache (in-memory map) and LRUCache (evicts least recently used when full). Write tests using the interface.

Summary

Abstract classes define contracts through pure virtual functions (= 0). They cannot be instantiated — only concrete derived classes can. The interface pattern (abstract class with only pure virtuals) is how C++ achieves Java/C#-style interfaces and enables multiple interface inheritance. Abstract classes are the foundation of design patterns like Strategy, Observer, and Template Method. They enable dependency injection and mock testing — essential for writing professional, testable code. In the next lesson, you will learn about multiple inheritance and the diamond problem — what happens when a class inherits from more than one base.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *