Part 2 · OOP for Verification · Intermediate
Virtual Methods & Dynamic Dispatch
Declared-type vs object-type call resolution, the dispatch-table mental model, and why TB base classes mark methods virtual.
Two types in every call
Every method call through a handle involves two types: the declared type of the handle (known at compile time) and the object type of what it currently points at (known only at runtime). The virtual keyword decides which one resolves the call. Non-virtual : the compiler binds the call to the declared type's version — static dispatch, decided before simulation starts. Virtual : the simulator looks at the actual object at call time and runs the most-derived override — dynamic dispatch. Virtualness is sticky: once a method is virtual in a base class, it is virtual in every descendant whether or not they repeat the keyword.
class base;
function void who_nv(); // NON-virtual
$display("base::who_nv");
endfunction
virtual function void who_v(); // virtual
$display("base::who_v");
endfunction
endclass
class child extends base;
function void who_nv(); // HIDES base version
$display("child::who_nv");
endfunction
virtual function void who_v(); // OVERRIDES base version
$display("child::who_v");
endfunction
endclass
base b;
child c = new();
b = c; // base handle → child object
b.who_nv(); // "base::who_nv" ← declared type wins (static)
b.who_v(); // "child::who_v" ← object type wins (dynamic)
c.who_nv(); // "child::who_nv" ← declared type is child hereThe dispatch table mental model
A useful way to reason about dynamic dispatch: imagine every class carries a method table — one slot per virtual method, each slot holding the most-derived implementation for that class. Every object knows its class's table. A virtual call through any handle reads the slot from the object's table, so the handle's declared type is irrelevant to which body runs (it only limits which methods are visible to call). Non-virtual calls bypass the table entirely — the compiler hard-codes the target from the handle type.
DISPATCH TABLE MENTAL MODEL
class base class child extends base
┌─────────────────────┐ ┌─────────────────────────┐
│ vtable │ │ vtable │
│ who_v → base::who_v│ │ who_v → child::who_v │ ◄─ slot
└─────────────────────┘ └─────────────────────────┘ overridden
base b = some_child_object;
b.who_v() — VIRTUAL:
follow b ──► OBJECT ──► object's class is child
└─► child vtable[who_v]
└─► child::who_v runs
b.who_nv() — NON-VIRTUAL:
compiler sees 'b is declared base'
└─► base::who_nv hard-wired at compile time
(object's real type never consulted)
Handle type = which methods you MAY call (compile-time view)
Object type = which BODY runs, for virtual methods onlyWhy testbench base classes mark methods virtual
Reusable verification code is written once against base types and driven forever through base handles: the env calls comp.run(), the scoreboard calls txn.compare(rhs), the logger calls txn.print(). For a derived test or derived transaction to change that behavior, the call must dispatch on the object type — which means the base must declare those methods virtual. Forgetting virtual is the classic silent failure: the derived override compiles cleanly, the simulator simply never calls it through base handles. This is exactly why UVM declares essentially every hook (build_phase, run_phase, do_compare, do_print) virtual.
class base_driver;
// hooks meant for extension — MUST be virtual
virtual task pre_drive(bus_txn t); endtask
virtual task drive(bus_txn t);
// protocol-correct default
endtask
task run(mailbox #(bus_txn) mb);
bus_txn t;
forever begin
mb.get(t);
pre_drive(t); // dispatches to the override at runtime
drive(t);
end
endtask
endclass
class err_driver extends base_driver;
virtual task pre_drive(bus_txn t);
if ($urandom_range(0, 9) == 0) t.addr[1:0] = 2'b01; // misalign
endtask
endclass
// env code holds base_driver d; pointing at err_driver —
// run() is untouched, yet error injection happens. Polymorphism.Interview angle
'b.f() where b is base handle to child object — what runs?' — child::f if virtual, base::f if not. State both halves.
'Is virtual inherited?' — yes; once virtual, always virtual down the hierarchy.
'Why does UVM make everything virtual?' — framework code calls hooks through base handles; without virtual, user overrides would never execute.
'Any cost to virtual?' — one indirection per call; negligible in TB code, which is why the default is to make extension hooks virtual.
Key takeaways
Non-virtual calls bind to the handle's declared type at compile time; virtual calls bind to the object's type at runtime.
Mental model: each object carries its class's method table; virtual calls read the slot from the object, not the handle.
Once virtual in the base, a method is virtual everywhere below — overrides need matching signatures.
Mark every intended extension hook virtual in base classes — otherwise derived overrides are silently bypassed.
Common pitfalls
Forgetting virtual on a base method — derived override compiles but never runs through base handles.
Assuming the handle type picks the method body for virtual calls — it only restricts what is visible to call.
Mismatched override signature — creates a different method instead of overriding; some tools only warn.
Calling a virtual method from a constructor — dispatches to the derived body before the derived part is initialized.