Chapter 25 · Virtual Functions
Exercise · Chapter 25

Chapter 25 — Virtual Functions: MINI-LLVM

You're building MINI-LLVM: a tiny instruction hierarchy that mirrors the real llvm::Instruction class family you'll work with in CS6340 labs. By the end of the exercise you'll have an abstract base class Inst with three concrete derived classes (AddInst, LoadInst, BranchInst), a polymorphic free function describe(), a safe downcast helper asBranch(), and a countOpcode() function that walks a vector of owned instructions — all without importing a single LLVM header.

This exercise resolves the Chapter 24 cliffhanger: in Ch 24 you saw that calling a base-class function through a Base& always invokes the base version, even when the object is actually a derived type (static binding). The describe() function here is the exact same pattern — one function that accepts const Inst& — but now opcodeName() is virtual, so the call dispatches to whichever concrete class is actually there. That is runtime polymorphism, and it is the mechanism LLVM relies on for every pass that iterates over instructions.

The LLVM vocabulary map for this exercise (see also ../../llvm-idioms.md):

This exerciseReal LLVM API
Instllvm::Instruction
Inst::opcodeName()Instruction::getOpcodeName()
AddInstllvm::BinaryOperator (Add opcode)
LoadInstllvm::LoadInst
BranchInstllvm::BranchInst
asBranch(p)dyn_cast<BranchInst>(&I)
asBranch(p) != nullptrisa<BranchInst>(&I) (the boolean-only cousin)
countOpcode(module, "add")a Module → BasicBlock → Instruction walk

dynamic_cast here is standard C++; LLVM's isa<> / cast<> / dyn_cast<> are its own faster, RTTI-free helpers (see ../../llvm-idioms.md), but the reasoning is identical: ask whether the runtime object is a given derived type, then either branch on a bool (isa) or use the downcast pointer (dyn_cast).

Your tasks

  1. Virtual base destructor (~Inst()). Define the body of Inst::~Inst(). It may be empty — the important thing is that it exists and is declared virtual in the header. Without a virtual destructor, deleting a derived object through an Inst* skips the derived destructor entirely.

  2. opcodeName() overrides. Implement all three opcodeName() bodies: AddInst returns "add", LoadInst returns "load", BranchInst returns "br". These three strings make virtual dispatch observable: the same call inst->opcodeName() through an Inst* produces a different string depending on which concrete class lives at the other end of the pointer.

  3. Derived destructors — the vtable proof. Each body does one thing: ++Inst::s_destroyed. When the grader deletes an AddInst through a std::unique_ptr<Inst>, it checks that s_destroyed ticked up — direct proof that the virtual destructor chain ran the derived destructor before the base one.

  4. describe(const Inst& inst). Return "inst: " + inst.opcodeName(). This is the Chapter 24 cliffhanger resolved: the static type of inst is const Inst&, but opcodeName() is virtual, so the call dispatches at runtime to whichever concrete class inst actually is. You write this function once; it works for any present or future Inst subclass.

  5. asBranch(Inst* inst). Use dynamic_cast<BranchInst*>(inst) and return the result directly. Returns a valid BranchInst* when the pointed-at object is a BranchInst; returns nullptr otherwise (including for nullptr input). This is LLVM's dyn_cast<BranchInst>(&I) in miniature. Always use the pointer form — the reference form throws std::bad_cast on failure instead.

  6. countOpcode(...). Iterate over the std::vector<std::unique_ptr<Inst>> with a range-for. For each element, call opcodeName() on the dereferenced pointer and count how many match the requested opcode string. Return the count.

Success criteria

  • opcodeName() called directly on concrete objects (baseline sanity)
  • opcodeName() called through an Inst* (pure virtual dispatch)
  • Virtual destructor proof: s_destroyed increments when a derived object is deleted through a unique_ptr<Inst> base pointer
  • describe() through concrete objects and through base references
  • asBranch() succeeding on a BranchInst, failing (nullptr) on others, and handling nullptr input
  • countOpcode() on empty, single, mixed, and all-same vectors
  • An integrated round-trip: build a 4-instruction basic block, call describe(), countOpcode(), and asBranch() together
Concepts practiced
  • virtual functions — enable dynamic dispatch through a base pointer or reference (notes 25.2)
  • override — compile-time verification that a derived function matches a base virtual (notes 25.3)
  • final — locks BranchInst::opcodeName() against further overriding (notes 25.3)
  • Pure virtual / abstract base classes= 0 on opcodeName() makes Inst non-instantiable (notes 25.7)
  • Virtual destructors — required whenever a class is deleted through a base pointer; the destroyed-counter proves it fired (notes 25.4)
  • Object slicing — the README demonstrates value-copy slicing (with a concrete base, since abstract Inst cannot be stored by value at all) and why std::vector<std::unique_ptr<Inst>> is the fix for a polymorphic collection (notes 25.9)
  • dynamic_cast (pointer form) — runtime-checked downcast returning nullptr on mismatch, the safe pattern used by LLVM's own dyn_cast<> (notes 25.10)
  • Reused from earlier chapters: std::vector (Ch 16), std::unique_ptr (Ch 22 — provided scaffolding), std::string_view (Ch 5), header/source split (Ch 2)
Constraints
  • Allowed (Ch 25): virtual, override, final, = 0 (pure virtual), virtual destructors, dynamic_cast (pointer form only).
  • Allowed (earlier chapters): std::string, std::string_view, std::vector, std::unique_ptr, std::make_unique, range-for, const references.
  • Forbidden: typeid/type_info RTTI, reinterpret_cast, raw new/delete in learner code (use the provided unique_ptr scaffolding), static_cast down the hierarchy (not safe — use dynamic_cast), virtual inheritance (Ch 25.8 — out of scope for this exercise).
  • Required idioms: override on every derived virtual override; virtual destructor on Inst; pointer form of dynamic_cast in asBranch(); std::vector<std::unique_ptr<Inst>> for polymorphic ownership.
Build & run locally
shell
make              # compile starter/inst.cpp warning-clean (object file only)
make test         # grade your code  ->  RED until the TASK blocks are filled in
make solution     # run the grader against the reference solution
make test-solution  # proof the exercise is solvable: reference MUST be green
make clean        # remove build artifacts

The grader compiles tests/tests.cpp together with your starter/inst.cpp and runs all checks. Turning 32 red lines green is the exercise.

Hints
Task 1 — why the virtual destructor body can be empty

Inst carries no raw resources (no new-allocated memory, no file handles). The body of ~Inst() genuinely does not need to do anything. What matters is:

  1. The declaration in inst.h says virtual ~Inst(); — making it virtual.
  2. There is a definition somewhere (your inst.cpp) — even Inst::~Inst() = default; (which lets the compiler generate an empty body) is fine.

The derived destructors (~AddInst, etc.) do the interesting work in TASK 3.

Task 2 — what "override" actually checks

override tells the compiler:

This function is intended to override a virtual base function.
If it does not match one exactly (return type, name, parameters, const), make it a compile error.

Without override, a typo like std::string opcodeNAME() const silently creates a brand-new function instead of overriding the base virtual. Because Inst::opcodeName() here is pure (= 0, no body to fall back on), the derived class would then leave that pure virtual un-overridden — so it stays abstract and the test's AddInst add {} fails to compile outright ("variable type 'AddInst' is an abstract class"). (For a non-pure base virtual, the typo would instead silently hide the base version and dispatch would keep hitting the base body.) With override, the compiler catches the mismatch at the declaration, before any of that. Always use it on every derived override. (notes 25.3)

Task 3 — the virtual destructor chain in detail

When std::unique_ptr<Inst> goes out of scope holding an AddInst*, this is what happens:

unique_ptr calls: delete (Inst*)(add_ptr)
    |
    v
Virtual dispatch: vtable for AddInst -> slot for ~AddInst
    |
    v
AddInst::~AddInst() runs -> ++Inst::s_destroyed
    |
    v (automatic base-chain)
Inst::~Inst() runs -> (empty body)

If Inst::~Inst() were not virtual, the vtable lookup never happens and the compiler calls Inst::~Inst() directly — AddInst::~AddInst() is skipped entirely, s_destroyed stays 0, and any AddInst-specific resources would be leaked. That is the bug the virtual destructor rule prevents. (notes 25.4)

Task 4 — seeing the dispatch in action
C++
std::string describe(const Inst& inst)
{
    return "inst: " + inst.opcodeName();
    //                ^^^^^^^^^^^^^^^^^
    //  static type:  const Inst&
    //  dynamic type: AddInst, LoadInst, or BranchInst (at runtime)
    //
    //  Because opcodeName() is virtual, the call goes through the vtable
    //  of the ACTUAL object, not the static type. This is late binding.
}

Chapter 24 had this same pattern with a NON-virtual function: every call through const Base& hit Base::identify() regardless of the actual object. Adding virtual is the one-word fix.

Task 5 — dynamic_cast pointer vs reference form

Pointer form (use this):

C++
BranchInst* b = dynamic_cast<BranchInst*>(inst);
// b == nullptr if inst does not point at a BranchInst
// b is valid   if inst does

Reference form (avoid in asBranch):

C++
BranchInst& b = dynamic_cast<BranchInst&>(*inst);
// throws std::bad_cast if *inst is not a BranchInst
// need a try/catch to handle failure — more code, harder to use

LLVM's own dyn_cast<T>(ptr) uses the pointer form and returns nullptr on failure — exactly the pattern you're implementing here. (notes 25.10, llvm-idioms.md)

Task 6 — the range-for loop shape
C++
int count { 0 };
for (const auto& ptr : module)   // ptr is const unique_ptr<Inst>&
{
    // ptr->opcodeName() dereferences unique_ptr and calls virtual opcodeName()
    if (ptr->opcodeName() == opcode)
        ++count;
}
return count;

ptr->opcodeName() is the same as (*ptr).opcodeName(). The -> operator on unique_ptr forwards to the underlying raw pointer. Virtual dispatch still works — the vtable pointer lives in the concrete object on the heap, not in the unique_ptr wrapper.

Stuck on a build error instead of a test failure?
  • "allocating an object of abstract class type 'Inst'" — you cannot create an Inst object directly; only derived concrete classes. If a test tries Inst x {}, that is the bug — but the provided tests never do this.
  • "override" compile error — the signature in your derived definition does not exactly match the base virtual declaration. Check const, parameter types, and return type carefully. The override keyword is catching a real mismatch.
  • "undefined reference to Inst::s_destroyed" — the static member needs a definition in exactly one .cpp. It is already provided in the starter; do not remove or duplicate it.
  • dynamic_cast returns garbage instead of nullptrdynamic_cast requires the class to be polymorphic (have at least one virtual function). Inst qualifies; just make sure you are using the pointer form, not a C-style cast.
Stretch goals
  • Add a RetInst class that overrides opcodeName() to return "ret" and marks both the override and the class itself as final — experiment with what final allows and forbids.
  • Add a virtual void print(std::ostream&) const to Inst and a non-member operator<< that calls it — the polymorphic printing pattern from notes 25.11.
  • Make describe() return the result of a second virtual method, say virtual std::string operands() const, that each derived class implements differently — explore how deep the dispatch chain can go.
  • Replace countOpcode with a findFirst(module, opcode) -> Inst* that returns a pointer to the first matching instruction, or nullptr if none — combine range-for with dynamic_cast to also downcast the result.
  • Add exception handling (Ch 27 preview): wrap the reference form of dynamic_cast in a try/catch(std::bad_cast&) and compare the error path against the nullable pointer form.
starter/inst.cpp C++
// Chapter 25 — Virtual Functions · MINI-LLVM: inst.cpp   (STARTER)
// ─────────────────────────────────────────────────────────────────────────────
// Fill in the six TASK blocks below.  Each maps 1:1 to a task in the README
// and to a declaration in ../inst.h.  The bodies currently return WRONG
// PLACEHOLDERS so the file compiles immediately — that is why `make test` is
// RED right now.  Your job is to turn it GREEN.
//
//     make build            compile your code (should already work)
//     make test             grade it          (RED until you fill these in)
//     make solution         run the grader against the reference if stuck
//
// KEY VOCABULARY (notes 25.2, 25.3, 25.7):
//   virtual    — enables dynamic dispatch through a base pointer/reference
//   override   — verifies the function actually overrides a base virtual
//   final      — prevents further overriding (on a function) or inheritance
//   = 0        — pure virtual; makes the class ABSTRACT (non-instantiable)
//   virtual ~  — virtual destructor; required when deleting through base ptr
// ─────────────────────────────────────────────────────────────────────────────

#include "../inst.h"
#include <string>

// ── Provided: definition of the static counter (do not change this) ──────────
// This counter lets the grader verify that deleting through a base pointer
// really calls the DERIVED destructor.  Each derived ~Dtor should ++s_destroyed.
int Inst::s_destroyed = 0;

// ─────────────────────────────────────────────────────────────────────────────
// TASK 1: Virtual base-class destructor
// ─────────────────────────────────────────────────────────────────────────────
// Define the body of Inst::~Inst().  It does not need to increment s_destroyed
// (the DERIVED destructors do that in TASK 3).  The body may be empty; what
// matters is that it EXISTS and is VIRTUAL (already declared virtual in inst.h).
//
// WHY: If ~Inst() were not virtual, 'delete base_ptr' would skip the derived
// destructor, leaving derived-only resources leaked and s_destroyed un-bumped.
// The virtual destructor is the FIRST thing to add to any polymorphic base.
// (notes 25.4)
//
//   >>> YOUR CODE HERE <<<
//
Inst::~Inst()
{
    // placeholder — body intentionally empty (correct behavior: do nothing extra)
}
// ─────────────────────────────────────────────────────────────────────────────


// ─────────────────────────────────────────────────────────────────────────────
// TASK 2: Override opcodeName() in each derived class
// ─────────────────────────────────────────────────────────────────────────────
// Each derived class must return its own opcode string so that virtual dispatch
// through an Inst& or Inst* picks the RIGHT concrete name at runtime.
//
// AddInst::opcodeName() must return "add"    (notes 25.2 — opcode dispatch)
// LoadInst::opcodeName() must return "load"
// BranchInst::opcodeName() must return "br"
//
// The 'override' specifier (already on the declarations in inst.h) makes a
// signature mismatch a compile error — your best friend against typos. (25.3)
//
//   >>> YOUR CODE HERE <<<
//
std::string AddInst::opcodeName() const
{
    return "???";   // placeholder — wrong; replace with the real opcode string
}

std::string LoadInst::opcodeName() const
{
    return "???";   // placeholder
}

std::string BranchInst::opcodeName() const
{
    return "???";   // placeholder
}
// ─────────────────────────────────────────────────────────────────────────────


// ─────────────────────────────────────────────────────────────────────────────
// TASK 3: Derived destructors — count to prove the virtual dtor fires
// ─────────────────────────────────────────────────────────────────────────────
// Define ~AddInst(), ~LoadInst(), and ~BranchInst().  Each body must execute:
//
//     ++Inst::s_destroyed;
//
// That is the ONLY thing needed in each body.  When the grader deletes an
// AddInst through a std::unique_ptr<Inst> (a base pointer), it checks that
// s_destroyed ticked upward — proof that the virtual destructor chain ran
// AddInst::~AddInst() before Inst::~Inst().  Without 'virtual ~Inst()', only
// Inst::~Inst() would run and the counter would stay at zero. (notes 25.4)
//
//   >>> YOUR CODE HERE <<<
//
AddInst::~AddInst()
{
    // placeholder — does NOT increment s_destroyed (tests will fail)
}

LoadInst::~LoadInst()
{
    // placeholder — does NOT increment s_destroyed (tests will fail)
}

BranchInst::~BranchInst()
{
    // placeholder — does NOT increment s_destroyed (tests will fail)
}
// ─────────────────────────────────────────────────────────────────────────────


// ─────────────────────────────────────────────────────────────────────────────
// TASK 4: describe() — the Ch24 cliffhanger RESOLVED
// ─────────────────────────────────────────────────────────────────────────────
// Return a string of the form "inst: <opcodeName>", e.g. "inst: add".
//
// The call inst.opcodeName() inside this function uses VIRTUAL LATE BINDING:
// even though the parameter type is 'const Inst&' (a base reference), the call
// dispatches to the most-derived override at runtime.  This is the solution to
// the Chapter 24 problem: in Ch24 all calls through a Base& hit the base
// version; adding 'virtual' sends each call to the right derived version.
// (notes 25.2)
//
// Hint: "inst: " + inst.opcodeName() gives you the right string.
//
//   >>> YOUR CODE HERE <<<
//
std::string describe(const Inst& /*inst*/)
{
    return "inst: ???";   // placeholder — real result uses virtual dispatch
}
// ─────────────────────────────────────────────────────────────────────────────


// ─────────────────────────────────────────────────────────────────────────────
// TASK 5: asBranch() — safe dynamic downcast (LLVM's dyn_cast<> in miniature)
// ─────────────────────────────────────────────────────────────────────────────
// Use dynamic_cast<BranchInst*>(inst) to attempt a downcast.
//   • If 'inst' actually points at a BranchInst, the cast succeeds and you get
//     a valid BranchInst*.
//   • If 'inst' points at something else (AddInst, LoadInst, nullptr), the cast
//     returns nullptr — safe, no exception.
//
// IMPORTANT: always use the POINTER form of dynamic_cast here (not the
// reference form); the reference form throws std::bad_cast on failure, which is
// harder to handle and NOT what LLVM's dyn_cast does.  (notes 25.10)
//
// In real LLVM code you would write:
//     if (auto *BI = dyn_cast<BranchInst>(&I)) { ... }
// This function is that pattern packaged as a helper.  (llvm-idioms.md)
//
//   >>> YOUR CODE HERE <<<
//
BranchInst* asBranch(Inst* /*inst*/)
{
    return nullptr;   // placeholder — always returns null (even for real BranchInsts)
}
// ─────────────────────────────────────────────────────────────────────────────


// ─────────────────────────────────────────────────────────────────────────────
// TASK 6: countOpcode() — walk a polymorphic instruction list
// ─────────────────────────────────────────────────────────────────────────────
// Iterate over every element of 'module' (a vector of unique_ptr<Inst>).
// For each element, dereference the unique_ptr to get an Inst&, then call
// opcodeName() on it.  Compare the result to 'opcode' (std::string_view
// compares naturally with ==).  Count and return how many match.
//
// Key insight — WHY unique_ptr<Inst> instead of a plain Inst value?
// If you stored Inst objects BY VALUE (std::vector<Inst>) and pushed an
// AddInst, the derived part would be SLICED OFF, turning it into a plain Inst
// with no override.  Storing unique_ptr<Inst> keeps the full derived object
// alive; the pointer knows nothing about the concrete type but the vtable
// still works.  This is the polymorphic-ownership idiom from notes 25.9.
//
// In real LLVM, this looks like:
//     for (auto &I : BB) { if (I.getOpcodeName() == "add") ++count; }
// (llvm-idioms.md "Walk every Instruction in a BasicBlock")
//
//   >>> YOUR CODE HERE <<<
//
int countOpcode(const std::vector<std::unique_ptr<Inst>>& /*module*/,
                std::string_view /*opcode*/)
{
    return 0;   // placeholder — always returns 0 (tests will fail for non-zero counts)
}
// ─────────────────────────────────────────────────────────────────────────────
Run
Submit
Run in your browser — coming soon For now: copy or download the files and use make test locally (see “Build & run locally” above).