C++ / CAPSTONE PROJECTS
Project: a role-playing inventory with inheritance hierarchies
Build a C++ inventory where an abstract Item base fixes the questions, derived categories answer them, and the report loop never names a concrete type.
What you will learn
- Model the questions the report asks as pure virtual functions on the base
- Store stock as vector<unique_ptr<Item>> so nothing gets sliced on copy
- Declare virtual ~Item() before deleting any derived object through a base pointer
- Turn dynamic_cast type checks into one more virtual function on the base
Understanding Project: a role-playing inventory with inheritance hierarchies
An inventory is the classic case where the data varies but the questions do not. The warehouse holds perishables that mark down as they age, software licences that never spoil, and whatever the business invents next quarter, yet the report always wants the same things: an identifier, a category label, and a current value. Put those questions on a base class Item as pure virtual functions and the report can be written once, against Item, before any category exists. The mental model is that the base class is the contract the report depends on, and each derived class is one answer to it.
Two mechanical details make that contract hold at run time. First, the container must hold pointers, std::vector<std::unique_ptr<Item>>, because a std::vector<Item> needs a fixed element size and would copy only the Item part of a Perishable, throwing away days_left_ together with the markdown rule. Second, Item needs virtual ~Item() = default, since unique_ptr<Item> calls delete on an Item* and without a virtual destructor that call runs ~Item alone and leaks whatever the derived object owned. The expression item->stock_value() is compiled exactly once; the vtable pointer inside each object, not the type of the variable, decides which body it lands in.
Hierarchies pay off in width, not depth. One abstract base with four siblings under it stays readable, whereas Item -> Physical -> Perishable -> Refrigerated forces every new rule through four constructors and blurs which level owns the pricing. Keep the shared fields private in the base and expose them through a protected helper such as base_value(), so a derived class can use them without being able to corrupt them. And when you catch yourself writing if (dynamic_cast<Perishable*>(p)) inside the report, that branch is a virtual function trying to be born: move the question into Item and let each category answer it.
placeholder
<iomanip>
<iostream>
<memory>
<string>
<vector>
// The base class is the list of questions the report is allowed to ask.
class Item {
public:
Item(std::string sku, double unit_price, int quantity)
: sku_(std::move(sku)), unit_price_(unit_price), quantity_(quantity) {}
virtual ~Item() = default;
// No sensible default: every category prices its stock differently.
virtual double stock_value() const = 0;
virtual std::string category() const = 0;
const std::string& sku() const { return sku_; }
protected:
double base_value() const { return unit_price_ * quantity_; }
private:
std::string sku_;
double unit_price_;
int quantity_;
};
class Perishable : public Item {
public:
Perishable(std::string sku, double unit_price, int quantity, int days_left)
: Item(std::move(sku), unit_price, quantity), days_left_(days_left) {}
double stock_value() const override {
return days_left_ < 3 ? base_value() * 0.5 : base_value();
}
std::string category() const override { return "perishable"; }
private:
int days_left_;
};
class Licensed : public Item {
public:
Licensed(std::string sku, double seat_price, int seats)
: Item(std::move(sku), seat_price, seats) {}
double stock_value() const override { return base_value(); } // licences do not spoil
std::string category() const override { return "licensed"; }
};
void print_report(const std::vector<std::unique_ptr<Item>>& stock) {
std::cout << std::fixed << std::setprecision(2);
double total = 0.0;
for (const std::unique_ptr<Item>& item : stock) {
const double value = item->stock_value(); // dispatched on the object, not on Item*
std::cout << std::left << std::setw(8) << item->sku()
<< std::setw(12) << item->category()
<< std::right << std::setw(8) << value << '\n';
total += value;
}
std::cout << "total value: " << total << '\n';
}
int main() {
std::vector<std::unique_ptr<Item>> stock;
stock.push_back(std::make_unique<Perishable>("MILK-1", 2.50, 40, 2));
stock.push_back(std::make_unique<Perishable>("RICE-9", 1.25, 100, 30));
stock.push_back(std::make_unique<Licensed>("IDE-PRO", 89.00, 3));
print_report(stock);
}
Holding base-class pointers and calling virtual functions through them is what lets a new item category join the inventory without editing the code that reports on it.
Worked examples
Why the base destructor must be virtual
Shows the destructor chain that runs when a derived inventory item is deleted through a unique_ptr<Item>.
<iostream>
<memory>
<string>
<vector>
struct Item {
virtual ~Item() { std::cout << "~Item\n"; }
};
struct Crate : Item {
std::vector<std::string> labels{"bolts", "nuts"};
~Crate() override { std::cout << "~Crate frees " << labels.size() << " labels\n"; }
};
int main() {
std::unique_ptr<Item> slot = std::make_unique<Crate>();
slot.reset();
std::cout << "slot empty: " << (slot == nullptr) << '\n';
}
Example explained
Line 1reset() calls delete on an Item*, and because ~Item is virtual the vtable sends that call to ~Crate first.
Line 2Destructors then run outward from the most derived class to the base, the reverse of construction order, so ~Item prints second.
Line 3Delete the word virtual from ~Item and only the ~Item line prints: the labels vector's heap buffer leaks and the program has undefined behaviour with no diagnostic.
Line 4(slot == nullptr) prints 1 rather than true because a bool streams as 1 or 0 unless std::boolalpha is set.
A type test that should have been a virtual function
Uses dynamic_cast to reach a category-only field, and shows why that pattern does not scale as categories are added.
<iostream>
<memory>
<vector>
struct Item {
virtual ~Item() = default;
virtual int units() const = 0;
};
struct Perishable : Item {
int on_hand = 5;
int days_left = 1;
int units() const override { return on_hand; }
};
struct Licensed : Item {
int units() const override { return 999; }
};
int main() {
std::vector<std::unique_ptr<Item>> stock;
stock.push_back(std::make_unique<Perishable>());
stock.push_back(std::make_unique<Licensed>());
for (const std::unique_ptr<Item>& item : stock) {
if (const Perishable* p = dynamic_cast<const Perishable*>(item.get())) {
std::cout << "discard " << p->on_hand << " units, " << p->days_left << " day left\n";
} else {
std::cout << "keep " << item->units() << " seats\n";
}
}
}
Example explained
Line 1dynamic_cast returns a usable pointer only when the object really is a Perishable and yields nullptr for the Licensed row, which is what makes the if behave as a run-time type test.
Line 2It compiles only because Item is polymorphic: the virtual destructor alone is enough to give the class the RTTI that dynamic_cast consults.
Line 3item.get() hands out a borrowed raw pointer, so ownership stays with the vector and nothing is deleted twice.
Line 4Two categories already need two branches, and a third would need a third: that growth is the signal to add a virtual disposition() to Item instead.
Important notes
std::unique_ptr<Item> stores no type information; its default deleter calls delete on an Item*, so the virtual destructor is the only reason ~Perishable runs. std::shared_ptr<Item> captures the concrete deleter at construction, which is why a missing virtual destructor can stay hidden until someone changes smart pointer.
Do not call stock_value() from Item's constructor: while the base is being built the object is still just an Item, so the call runs the base version, and calling a pure virtual there is undefined behaviour.
Common mistakes
Declaring std::vector<Item> stock and pushing copies of derived items: the copy keeps only the Item sub-object, so days_left_ vanishes and the markdown is never applied. With an abstract Item the vector refuses to compile instead, which is the friendlier failure.
Leaving ~Item non-virtual: unique_ptr<Item> deletes through an Item*, only ~Item runs, and every std::string or vector the derived item owned leaks. Because it is undefined behaviour, the build stays silent and the leak only shows up under a sanitizer.
Writing double stock_value() in Perishable without const: that declares a new function rather than an override, Item::stock_value() stays pure, Perishable remains abstract, and make_unique<Perishable> fails with errors about instantiating an abstract class. Adding override collapses that into one clear message.
Try it yourself
Change, predict, then run
Add a Consignment category whose stock_value() returns 0 because the supplier still owns the goods, push one into the same vector, and confirm print_report needs no edits. Then add a virtual int reorder_level() const to Item and print it as a fourth column.
Open the C++ workspaceCheck your understanding
print_report takes const std::vector<std::unique_ptr<Item>>& and never names a derived class, yet each row shows a different pricing rule. Why can a fifth category be added without touching print_report?
- The call item->stock_value() is resolved through the object's vtable at run time, so a new override is reachable from the same already-compiled call site
- The compiler regenerates print_report once per derived class, producing one specialised version per category
- unique_ptr remembers the concrete type it was created from and rewrites the call accordingly
- The vector is passed by const reference, which prevents the items from being sliced
Show answer
The call site is compiled once against Item; each object carries a vtable pointer, so the same instruction lands in Perishable::stock_value or Licensed::stock_value depending on what was actually constructed, and later overrides plug in for free. Option 4 is tempting because a const reference does avoid a copy, but slicing became impossible the moment the elements were pointers, so const-ness has nothing to do with the extensibility. Option 3 is wrong in a useful way: unique_ptr<Item> with the default deleter does not remember make_unique<Perishable>, which is exactly why ~Item has to be virtual.