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.

systemverilog
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 here

The 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.

diagram
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 only

Why 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.

systemverilog
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.