C++ Abstract Classes and Interfaces: Build a Usable Storage Contract

Build a C++17 ScoreStore interface, swap vector and hash-backed implementations behind it, and verify both with the same runnable contract test.

KnowledgeGate Team

Exam prep & CS education

Updated 11 Oct 20265 min read

Many first C++ hierarchies fail at the caller. A function says that it accepts a base class, but then checks whether the object uses a vector or a hash table before doing useful work. That is only superficial abstraction. One small C++17 score-storage contract has two different representations, and the same contract test runs against both. The goal is behaviour that callers can trust, a useful principle throughout Coding & DSA courses.

Abstract class versus interface in C++: use the terms precisely

C++ has no interface keyword. A class is abstract when it has at least one pure virtual function, written with = 0. A deliberately narrow abstract class whose public operations describe a contract is commonly called an interface.

An abstract class may hold state, run constructors, provide non-pure virtual functions, and share implementation. An interface-style abstract class normally owns no domain state. It exposes common operations plus a virtual destructor. Not every abstract class is automatically a good interface.

A usable contract also defines insertion, replacement, missing-key, deletion, and size() semantics. The four-pillar object-oriented context is broader; here the focus is a precise C++ contract and runtime substitution.

Design the ScoreStore contract before choosing a container

The ScoreStore contract uses these headers and this base class:

cpp
#include <algorithm>
#include <cassert>
#include <cstddef>
#include <iostream>
#include <memory>
#include <optional>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>

class ScoreStore {
public:
    virtual ~ScoreStore() = default;
    virtual void put(std::string key, int score) = 0;
    virtual std::optional<int> get(const std::string& key) const = 0;
    virtual bool erase(const std::string& key) = 0;
    virtual std::size_t size() const noexcept = 0;
};

put inserts a key or replaces its score. Replacement does not change the number of distinct keys. get returns an engaged optional for a stored score and nullopt for absence, so score 0 remains valid. erase is true only when it removes one key. size counts distinct keys.

The virtual destructor matters because a caller may own a concrete object through std::unique_ptr<ScoreStore>. Destruction through that base must be defined. Each implementation still owns its keys and scores.

Class diagram of the ScoreStore base and its five operations above VectorScoreStore and HashScoreStore, both ending at alice=84, size=1.

Write a consumer that knows only the contract

This consumer accepts a base reference and calls only get:

cpp
std::string format_score(const ScoreStore& store, const std::string& key) {
    const auto score = store.get(key);
    return key + "=" + (score ? std::to_string(*score) : "missing");
}

Later, format_score(store, "alice") produces alice=84, while format_score(store, "carol") produces carol=missing. It has no dynamic_cast, type tag, container operation, or concrete-store name.

At the call site, the reference has static type const ScoreStore&. The concrete object determines which get override runs through virtual dispatch. The trailing const is part of the function signature and permits lookup through this const reference.

C++ Virtual Functions and Polymorphism owns the call-by-call treatment of static type, dynamic type, slicing, qualified calls, and destruction order. ScoreStore uses that mechanism to solve a different problem: explicit operation semantics, hidden representations, and one reusable test for both implementations.

A factory could return std::unique_ptr<ScoreStore>. Its caller can then use store->put(...) without knowing the concrete class. The stable operation set is the point, not a factory framework.

Implement the contract twice without leaking representation

The vector version searches pairs. Erase-remove reports whether the sequence became smaller:

cpp
class VectorScoreStore final : public ScoreStore {
public:
    void put(std::string key, int score) override {
        auto it = std::find_if(items_.begin(), items_.end(),
            [&](const auto& item) { return item.first == key; });
        if (it != items_.end()) { it->second = score; return; }
        items_.emplace_back(std::move(key), score);
    }

    std::optional<int> get(const std::string& key) const override {
        auto it = std::find_if(items_.begin(), items_.end(),
            [&](const auto& item) { return item.first == key; });
        return it == items_.end() ? std::nullopt
                                  : std::optional<int>{it->second};
    }

    bool erase(const std::string& key) override {
        const auto old_size = items_.size();
        items_.erase(std::remove_if(items_.begin(), items_.end(),
            [&](const auto& item) { return item.first == key; }), items_.end());
        return items_.size() != old_size;
    }

    std::size_t size() const noexcept override { return items_.size(); }

private:
    std::vector<std::pair<std::string, int>> items_;
};

The hash version uses key-based operations while preserving the same rules:

cpp
class HashScoreStore final : public ScoreStore {
public:
    void put(std::string key, int score) override {
        items_.insert_or_assign(std::move(key), score);
    }

    std::optional<int> get(const std::string& key) const override {
        auto it = items_.find(key);
        return it == items_.end() ? std::nullopt
                                  : std::optional<int>{it->second};
    }

    bool erase(const std::string& key) override {
        return items_.erase(key) == 1;
    }

    std::size_t size() const noexcept override { return items_.size(); }

private:
    std::unordered_map<std::string, int> items_;
};

Neither class exposes storage. Vector positions, map iterators, reserve, or bucket counts in ScoreStore would tie the contract to one representation.

Run one contract test against both implementations

The exact sequence begins with a fresh store and checks each promised rule:

cpp
void run_contract_test(ScoreStore& store) {
    assert(store.size()==0);
    store.put("alice", 72);
    store.put("bob", 91);
    assert(store.size()==2);
    assert(store.get("alice")==std::optional<int>{72});

    store.put("alice", 84);
    assert(store.size()==2);
    assert(store.get("alice")==std::optional<int>{84});
    assert(!store.get("carol").has_value());
    assert(store.erase("bob"));
    assert(!store.erase("bob"));
    assert(store.size()==1);

    std::cout << format_score(store, "alice") << ", "
              << format_score(store, "carol") << ", size="
              << store.size() << '\n';
}

int main() {
    VectorScoreStore vector_store;
    HashScoreStore hash_store;
    run_contract_test(vector_store);
    run_contract_test(hash_store);
}

Compile score_store.cpp and run it:

bash
g++ -std=c++17 score_store.cpp && ./a.out

Both lines are exactly alice=84, carol=missing, size=1. The test checks the shared semantics for this sequence, not equal algorithms, memory use, or performance.

Trace of run_contract_test through both stores, from an empty store to alice=84, each printing alice=84, carol=missing, size=1.

Contract and hierarchy mistakes that break substitutability

Use each failure to sharpen the contract:

Mistake

Consequence

Fix

Derived get omits trailing const

Without override, the class stays abstract; with it, the compiler reports the mismatch

Copy the base signature and keep override

Base destructor is not virtual

Destruction through a base pointer is unsafe

Keep virtual ~ScoreStore() = default

Missing lookup returns 0

Absence becomes indistinguishable from a valid zero score

Return std::optional<int>

Base exposes iterators or positions

A caller cannot swap representations

Expose key-level operations

Base provides protected items_

Both implementations are forced into one storage model

Keep representation private

Add shared validation or a small default operation only when it becomes genuinely common. Speculative reuse does not justify a hierarchy.

How exams and interviews can test abstract classes and interfaces

The questions test ScoreStore's abstractness, exact overrides, and virtual dispatch.

  • What happens for ScoreStore store;? It is ill-formed because ScoreStore is abstract.

  • What if a derived class omits erase? That derived class remains abstract.

  • Which get runs through a ScoreStore& bound to a HashScoreStore? Virtual dispatch selects HashScoreStore::get.

  • What is wrong with std::optional<int> get(const std::string& key) override? It lacks the required trailing const, so override produces a compile-time mismatch.

  • What is the final trace after Alice changes from 72 to 84 and Bob is erased? Either implementation gives alice=84, carol=missing, size=1.

These questions distinguish an abstract declaration, an exact override, and runtime dispatch.

Choose an interface only when runtime substitution earns its cost

Use one concrete class when there is one stable implementation and no substitution need. Use an abstract interface when runtime choice, a test double, or independently evolving implementations matter. Consider templates or concepts when the concrete type is known at compile time. Prefer a consumer that contains or receives a ScoreStore over one that inherits from it.

The short version: define observable behaviour first, hide representation, and test every implementation through the base. Continue with the C++ Tutorial learning path. For structured work across C, C++, Java, Python, and competitive coding, use Coding for Placements next.