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

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:
#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.

Write a consumer that knows only the contract
This consumer accepts a base reference and calls only get:
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:
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:
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:
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:
g++ -std=c++17 score_store.cpp && ./a.outBoth 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.

Contract and hierarchy mistakes that break substitutability
Use each failure to sharpen the contract:
Mistake | Consequence | Fix |
|---|---|---|
Derived | Without | Copy the base signature and keep |
Base destructor is not virtual | Destruction through a base pointer is unsafe | Keep |
Missing lookup returns | Absence becomes indistinguishable from a valid zero score | Return |
Base exposes iterators or positions | A caller cannot swap representations | Expose key-level operations |
Base provides protected | 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 becauseScoreStoreis abstract.What if a derived class omits
erase? That derived class remains abstract.Which
getruns through aScoreStore&bound to aHashScoreStore? Virtual dispatch selectsHashScoreStore::get.What is wrong with
std::optional<int> get(const std::string& key) override? It lacks the required trailingconst, sooverrideproduces a compile-time mismatch.What is the final trace after Alice changes from
72to84and Bob is erased? Either implementation givesalice=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.
Keep learning

C++ Virtual Functions and Polymorphism: Trace Dynamic Dispatch Step by Step
Trace a Shape hierarchy line by line to see how static type, dynamic type, final overriders, object slicing, and virtual destructors determine C++ output.

C++ unordered_map and unordered_set: Hashing, Equality and a Custom-Key Frequency Counter
Build a correct mental model for C++ unordered containers, then trace a custom-key counter through a real collision without merging distinct keys.

C++ STL Containers: Choose by Access Pattern, Mutation Cost and Ownership
Choose a C++ STL container from the workload, not from habit. Trace sequence operations, keyed counts, invalidation and object lifetime through exact examples.

C++ STL Algorithms: Sort, Search, Transform and Clean Data in One Pipeline
Trace nine sensor readings through sort, lower_bound, transform, erase-remove and accumulate, with every iterator rule and intermediate value explained.