SystemVerilog · Module 9
Polymorphism & Virtual Methods
Static vs dynamic dispatch, the vtable model, polymorphic containers, the UVM factory pattern, and the canonical 'override never runs' debugging trap.
Module 9 · Page 9.10
What Polymorphism Actually Means
You are writing a scoreboard. It receives transactions from the DUT. Some are write transactions. Some are reads. Some are errors. They all come in through the same analysis port as BaseTxn handles.
When the scoreboard calls t.display() on each one, it does not check the type, it does not cast, it does not have a giant if (type == WRITE) block. It just calls display() and the correct version runs automatically — WriteTxn.display() for write objects, ReadTxn.display() for read objects.
That is polymorphism. One call. The right behaviour. The method that runs is determined at runtime based on the actual type of the object, not the declared type of the handle.
Static Dispatch vs Dynamic Dispatch
This is the concept that trips up almost every engineer new to OOP in SystemVerilog. Read this section carefully.
When you call a method through a handle, the simulator needs to decide which version of the method to run. It has two possible strategies: ❌ Static Dispatch (no virtual) Method version is chosen at compile time based on the declared type of the handle — not the actual object. BaseTxn t = new WriteTxn(); t.display(); // calls BaseTxn.display() // WriteTxn.display() is IGNORED Result: Wrong method runs. Silent incorrect behaviour. ✅ Dynamic Dispatch (with virtual) Method version is chosen at runtime based on the actual type of the object in memory. BaseTxn t = new WriteTxn(); t.display(); // calls WriteTxn.display() // correct — object is a WriteTxn Result: Correct method runs. Polymorphism works.
The virtual keyword on the parent method is what switches from static to dynamic dispatch. One keyword. Completely different behaviour.
The Static Dispatch Trap — Most Dangerous OOP Bug
This bug does not crash. It does not throw a warning. The wrong method runs silently and your test passes with completely wrong behaviour. Let me show you exactly what happens.
// ── WITHOUT virtual — static dispatch ────────────────────────
class BaseTxn;
int txn_id;
// No 'virtual' keyword ← this is the problem
function void display();
$display("[BASE] id=%0d", txn_id);
endfunction
endclass
class WriteTxn extends BaseTxn;
rand bit [31:0] wdata;
function void display(); // override exists but parent is not virtual
$display("[WRITE] id=%0d data=0x%08h", txn_id, wdata);
endfunction
endclass
module tb_trap;
BaseTxn base_h; // parent-type handle
WriteTxn child_obj = new(); // actual WriteTxn object
initial begin
child_obj.txn_id = 1;
void'(child_obj.randomize());
base_h = child_obj; // store child in parent handle
child_obj.display(); // [WRITE] id=1 data=0x... ← correct
base_h.display(); // [BASE] id=1 ← WRONG! wdata not shown
// The handle type (BaseTxn) determined the method, not the object
end
endmodule// ── WITH virtual — dynamic dispatch ──────────────────────────
class BaseTxn;
int txn_id;
virtual function void display(); // ← 'virtual' added
$display("[BASE] id=%0d", txn_id);
endfunction
endclass
class WriteTxn extends BaseTxn;
rand bit [31:0] wdata;
virtual function void display(); // also mark child version virtual
$display("[WRITE] id=%0d data=0x%08h", txn_id, wdata);
endfunction
endclass
module tb_fixed;
BaseTxn base_h;
WriteTxn child_obj = new();
initial begin
child_obj.txn_id = 1;
void'(child_obj.randomize());
base_h = child_obj;
base_h.display();
// [WRITE] id=1 data=0x... ← correct! runtime type wins
end
endmodulePolymorphic Containers — Mixed Objects, One Queue
Because a child object is always a valid parent, you can store objects of different child types in a single parent-typed queue. Then call the same method on all of them in a loop — each one executes its own version automatically. BaseTxn q[$] — Mixed objects, one queueBaseTxn handle→WriteTxn objectReadTxn objectErrorTxn objectWriteTxn object q.foreach: t.display() → each calls its OWN display() automatically
class BaseTxn;
int txn_id;
string kind;
virtual function void display();
$display("[%s #%0d]", kind, txn_id);
endfunction
virtual function bit is_error();
return 0;
endfunction
endclass
class WriteTxn extends BaseTxn;
rand bit [31:0] addr, wdata;
function new(int id); kind="WR"; txn_id=id; endfunction
virtual function void display();
$display("[WR #%0d] addr=0x%08h data=0x%08h", txn_id, addr, wdata);
endfunction
endclass
class ReadTxn extends BaseTxn;
rand bit [31:0] addr;
bit [31:0] rdata;
function new(int id); kind="RD"; txn_id=id; endfunction
virtual function void display();
$display("[RD #%0d] addr=0x%08h rdata=0x%08h", txn_id, addr, rdata);
endfunction
endclass
class ErrorTxn extends BaseTxn;
rand bit [1:0] err_code;
function new(int id); kind="ERR"; txn_id=id; endfunction
virtual function void display();
$display("[ERR #%0d] code=%02b", txn_id, err_code);
endfunction
virtual function bit is_error(); return 1; endfunction
endclass
module tb;
BaseTxn q[$]; // ONE queue holds all transaction types
initial begin
// Push different types into the same queue
WriteTxn wt = new(1); void'(wt.randomize()); q.push_back(wt);
ReadTxn rd = new(2); void'(rd.randomize()); q.push_back(rd);
ErrorTxn er = new(3); void'(er.randomize()); q.push_back(er);
WriteTxn wt2= new(4); void'(wt2.randomize()); q.push_back(wt2);
// ONE loop — each object calls its OWN display()
foreach (q[i]) begin
q[i].display(); // polymorphic dispatch
if (q[i].is_error()) // also polymorphic
$display(" ^ Error transaction detected!");
end
// Output:
// [WR #1] addr=0x... data=0x...
// [RD #2] addr=0x... rdata=0x...
// [ERR #3] code=...
// ^ Error transaction detected!
// [WR #4] addr=0x... data=0x...
end
endmoduleWithout polymorphism you would need a type-check and cast at every point in the scoreboard, environment, and coverage collector. With polymorphism, one loop line handles all cases — and adding a new transaction type in the future requires zero changes to the scoreboard code.
virtual Works on Tasks Too
Everything about virtual applies equally to tasks inside a class, not just functions. A base driver can declare a virtual task drive(), and each protocol-specific subclass overrides it with its own bus-driving logic. The environment only ever calls the base version — runtime dispatch routes to the correct implementation.
// ── Base driver — environment works with this type ────────────
class BaseDriver;
string name;
function new(string n); name = n; endfunction
// virtual task — subclasses provide protocol-specific implementation
virtual task drive(BaseTxn t);
$display("[%s] BaseDriver.drive() — should be overridden", name);
endtask
virtual task reset_bus();
$display("[%s] reset_bus — should be overridden", name);
endtask
endclass
// ── APB driver ────────────────────────────────────────────────
class ApbDriver extends BaseDriver;
function new(); super.new("ApbDrv"); endfunction
virtual task drive(BaseTxn t);
$display("[ApbDrv] Driving APB: addr=0x%08h", t.txn_id);
// #10; @(posedge clk); etc.
endtask
virtual task reset_bus();
$display("[ApbDrv] APB reset: psel=0 penable=0");
endtask
endclass
// ── AXI driver ────────────────────────────────────────────────
class AxiDriver extends BaseDriver;
function new(); super.new("AxiDrv"); endfunction
virtual task drive(BaseTxn t);
$display("[AxiDrv] Driving AXI4: addr=0x%08h", t.txn_id);
endtask
virtual task reset_bus();
$display("[AxiDrv] AXI reset: awvalid=0 wvalid=0 arvalid=0");
endtask
endclass
// ── Environment that works with ANY driver ────────────────────
class Env;
BaseDriver drv; // parent-type handle
function new(BaseDriver d); drv = d; endfunction
task run(BaseTxn t);
drv.reset_bus(); // polymorphic — right reset() called
drv.drive(t); // polymorphic — right drive() called
endtask
endclass
module tb;
BaseTxn t = new(); t.txn_id = 42;
initial begin
Env apb_env = new(new ApbDriver());
apb_env.run(t);
// [ApbDrv] APB reset: psel=0 penable=0
// [ApbDrv] Driving APB: addr=0x0000002a
Env axi_env = new(new AxiDriver());
axi_env.run(t);
// [AxiDrv] AXI reset: awvalid=0 wvalid=0 arvalid=0
// [AxiDrv] Driving AXI4: addr=0x0000002a
end
endmoduleThe Env class never changes. Swap the driver type and the environment automatically behaves differently. This is exactly how UVM environments work — and it is all built on virtual tasks.
Why UVM Is Built on virtual
Every method you override in UVM — build_phase(), connect_phase(), run_phase(), do_copy(), do_compare(), do_print() — is declared virtual in the UVM base classes. Your driver extends uvm_driver and overrides run_phase(). The phasing mechanism holds a uvm_component handle, calls run_phase() on it, and dynamic dispatch routes the call to your driver's implementation — not the base class's empty one.
// What UVM does internally (simplified)
class uvm_component;
virtual task run_phase(uvm_phase phase);
// empty default — does nothing
endtask
endclass
// Your driver overrides it
class my_driver extends uvm_driver #(my_txn);
`uvm_component_utils(my_driver)
virtual task run_phase(uvm_phase phase);
// YOUR logic runs here
forever begin
seq_item_port.get_next_item(req);
// drive signals...
seq_item_port.item_done();
end
endtask
endclass
// UVM phase runner — holds uvm_component handles
// foreach component: component.run_phase(phase)
// Dynamic dispatch routes to my_driver.run_phase()
// Without 'virtual' in uvm_component, your run_phase would never runQuick Reference
| Concept | Key Point |
|---|---|
| Static dispatch | No virtual — compile-time type of handle decides which method runs. Silent wrong behaviour when child stored in parent handle. |
| Dynamic dispatch | virtual on parent method — runtime type of object decides. Correct method always runs regardless of handle type. |
| virtual on function | virtual function void display(); — same syntax, same behaviour for tasks. |
| virtual on task | virtual task drive(BaseTxn t); — essential for polymorphic drivers and monitors. |
| Polymorphic container | Declare queue as BaseTxn q[$]. Push any child type. Call methods in a loop — each dispatches to its own implementation. |
| Golden rule | Any method in a base class that might be overridden should be declared virtual. Add it now — retrofitting later is painful. |
Verification Usage — Where Polymorphism Pays For Itself in UVM
Polymorphism is not just a textbook OOP feature — it is the load-bearing wall of every modern UVM testbench. Three patterns dominate; learning to recognise them turns reading unfamiliar UVM code from a chore into a routine.
The factory override (compile-once, swap-at-runtime)
UVM's factory exists entirely because of polymorphism. A driver creates transactions through type_id::create(), which returns a base-class handle. At test time, a one-line set_type_override_by_type swaps the concrete class without recompiling — error-injection tests get an error_xact, smoke tests get a plain base_xact, all through the same driver code.
<code>// In env: driver creates transactions through the factory
apb_xact tx = apb_xact::type_id::create("tx");
// In test: swap the actual class without touching the driver
function void error_test::build_phase(uvm_phase phase);
super.build_phase(phase);
apb_xact::type_id::set_type_override(apb_error_xact::get_type());
endfunction
// Now every "apb_xact" the driver creates is actually an apb_error_xact.</code>The single-port scoreboard
A scoreboard receives transactions through one analysis FIFO declared as the base type. It then uses $cast + virtual compare()/convert2string() to handle any subclass uniformly. One scoreboard instead of one per transaction kind.
<code>class apb_scoreboard extends uvm_scoreboard;
uvm_analysis_imp #(apb_base_xact, apb_scoreboard) ap;
virtual function void write(apb_base_xact t);
`uvm_info("SB", $sformatf("[%s] %s",
$typename(t), t.convert2string()), UVM_MEDIUM)
if (!compare_with_ref(t))
`uvm_error("SB", $sformatf("mismatch: %s", t.convert2string()))
endfunction
endclass
// One scoreboard handles writes, reads, errors, bursts — all polymorphically.</code>Virtual sequencer + virtual sequence
A virtual sequence orchestrates multiple sub-sequences across protocol interfaces. The base sequence declares virtual task body();; each specific scenario overrides body() with its own logic. The test instantiates a base sequence handle and starts whichever subclass is configured for the scenario.
Simulation Behavior — What the Simulator Builds for Polymorphism
The vtable — one per class
For every class that has at least one virtual method, the compiler emits a single virtual method table (vtable) — a per-class array of function pointers, one slot per virtual method, in the declaration order of the topmost class that introduced each method. Every object carries a hidden pointer (sometimes called the "vptr") to its class's vtable. The vtable is built once at elaboration and never modified.
A virtual call in 3 cycles
The instruction sequence the simulator generates for h.foo() when foo is virtual is approximately:
<code>load vptr = *(h + 0) // fetch vtable pointer from object header
load func = *(vptr + slot) // index into vtable to get function pointer
indirect call func(this=h) // jump to the resolved function</code>Override changes the slot, not the call site
When a child overrides a virtual method, the child class gets its own vtable in which the same slot now points to the child's function. The call site at h.foo() is unchanged — it still fetches "slot N from vptr" — but because h points to a child object, the vptr now points to the child's vtable, and slot N now contains the child's function pointer. The runtime indirection is what makes polymorphism work.
Cost of dynamic dispatch
Two memory loads and one indirect call per virtual invocation. On a modern simulator running a UVM testbench, this is utterly invisible — the per-transaction cost of constraint solving, randomisation, and analysis port fan-out dwarfs vtable lookup by orders of magnitude.
<code>class Base;
virtual function void foo();
$display("Base");
endfunction
endclass
class Child extends Base;
virtual function void foo();
$display("Child");
endfunction
endclass
Base h = Child::new();
h.foo(); // prints "Child" via vtable</code><code>class Base;
function void foo();
$display("Base");
endfunction
endclass
class Child extends Base;
function void foo(); // hides, doesn't override
$display("Child");
endfunction
endclass
Base h = Child::new();
h.foo(); // prints "Base" — type of h decides</code>Waveform Analysis — Tracking Polymorphic Calls in the Dump
Polymorphism happens entirely inside the simulator's process state — no signals fan out, no events fire, nothing appears in the waveform viewer naturally. To make polymorphic behaviour visible, log the dynamic type at every virtual call boundary.
The trace pattern
<code>virtual function void Base::handle();
$display("[%0t] %s::handle this=%s", $time, "Base", $typename(this));
endfunction
virtual function void WriteTxn::handle();
super.handle(); // base line first
$display("[%0t] WriteTxn::handle this=%s data=0x%h",
$time, $typename(this), wdata);
endfunction</code>ASCII view — mixed polymorphic stream
<code> 100ns Base::handle this=WriteTxn
100ns WriteTxn::handle this=WriteTxn data=0xDEADBEEF
200ns Base::handle this=ReadTxn
200ns ReadTxn::handle this=ReadTxn
300ns Base::handle this=ErrorTxn
300ns ErrorTxn::handle this=ErrorTxn etype=CRC
↑ ↑
call-site method runtime type — proves
that ran polymorphism dispatched correctly</code>Mirroring transaction-type to a waveform signal
Encode the dynamic type as a small enum and mirror it to a module-scope signal so the entire polymorphic stream appears as a single trace next to the protocol signals.
<code>typedef enum bit [2:0] {
T_WRITE=1, T_READ=2, T_ERROR=3, T_BURST=4
} txn_type_e;
txn_type_e wave_last_type;
function void monitor::write(apb_base_xact t);
WriteTxn wt; ReadTxn rt; ErrorTxn et; BurstWriteTxn bwt;
if ($cast(bwt, t)) wave_last_type = T_BURST;
else if ($cast(wt, t)) wave_last_type = T_WRITE;
else if ($cast(rt, t)) wave_last_type = T_READ;
else if ($cast(et, t)) wave_last_type = T_ERROR;
endfunction</code>Industry Insights — Hard-Won Lessons About Polymorphism
Debugging Academy — Five Polymorphism Bugs You Will Meet
"My override never runs through the parent handle"
DEBUGA driver receives transactions and calls tx.send() through a base-class handle. The base class's send() always runs, even when the actual object is a WriteTxn with its own send().
<code>class BaseTxn;
function void send(); // BUG: not virtual
$display("base send");
endfunction
endclass
class WriteTxn extends BaseTxn;
function void send(); // tries to override
$display("write send");
endfunction
endclass
BaseTxn h = WriteTxn::new();
h.send(); // prints "base send" — static dispatch by handle type</code>Without virtual on the parent method, h.send() resolves at compile time using h's declared type (BaseTxn). The child's body is dead code for any call through a parent handle.
Mark the parent virtual: virtual function void send();. The child does not need virtual again, but adding it for clarity is conventional.
"Stack overflow — virtual override recurses forever"
DEBUGA virtual override calls "the parent" via this and the simulator dies with stack overflow.
<code>class WriteTxn extends BaseTxn;
virtual function void display();
this.display(); // BUG: dispatches back to self
$display(" wdata=0x%h", wdata);
endfunction
endclass</code>this.display() goes through the vtable. For a WriteTxn object, the vtable slot for display points to WriteTxn::display — itself. Infinite recursion.
Use super.display() to reach the parent statically: super.display(); $display(" wdata=0x%h", wdata);. super bypasses the vtable and resolves to the parent's named version at compile time.
"$cast succeeds on the wrong type and corrupts my scoreboard"
DEBUGA scoreboard expects only WriteTxn and ReadTxn, but occasionally crashes accessing fields that don't exist on the object it received.
<code>function void scoreboard::write(BaseTxn t);
WriteTxn wt;
$cast(wt, t); // BUG: return value ignored
process_write(wt); // crashes when t is a ReadTxn
endfunction</code>When $cast fails (because t is actually a ReadTxn), it leaves wt as null. The next access then dereferences null and crashes — but several lines later, making the root cause hard to find.
Always wrap $cast in an if and log the failure: if (!$cast(wt, t)) begin uvm_error("SB", $sformatf("expected WriteTxn, got %s", $typename(t))); return; end`. Belt-and-braces: fail fast and log the actual runtime type for forensics.
"My virtual task in the driver never picks up the test's override"
DEBUGA test extends the base driver and overrides drive_one(), but the base driver's version is what runs during simulation.
<code>class apb_driver extends uvm_driver #(apb_xact);
task drive_one(apb_xact t); // BUG: not virtual
// ... base implementation ...
endtask
task run_phase(uvm_phase phase);
forever begin
seq_item_port.get_next_item(req);
drive_one(req); // static call to base version
seq_item_port.item_done();
end
endtask
endclass
class smart_driver extends apb_driver;
task drive_one(apb_xact t);
// ... custom implementation never invoked ...
endtask
endclass</code>Without virtual task, the call drive_one(req) inside run_phase always resolves to apb_driver::drive_one at compile time, regardless of which subclass the factory actually produced.
Mark drive_one as virtual task in the base. Then the call drive_one(req) dispatches through the vtable to the runtime subclass's version. virtual works on tasks exactly the same way it works on functions.
"Factory override doesn't take effect even though I called set_type_override"
DEBUGA test calls apb_xact::type_id::set_type_override in build_phase, but the driver still produces base apb_xact objects.
<code>class my_driver extends uvm_driver #(apb_xact);
task run_phase(uvm_phase phase);
apb_xact tx = new(); // BUG: bypasses factory
forever begin
seq_item_port.get_next_item(req);
drive(req);
seq_item_port.item_done();
end
endtask
endclass</code>The factory only swaps types for objects created through type_id::create(). A direct new() bypasses the factory entirely — there is no polymorphism happening at construction time.
Always create UVM objects through the factory: apb_xact tx = apb_xact::type_id::create("tx");. Now any registered set_type_override takes effect transparently — the driver code is unchanged, but the actual class produced is the overriding subclass.
Interview Q&A — Twelve Questions You Will Be Asked
The ability for one call site to invoke different concrete implementations depending on the runtime type of the object behind a base-class handle. The mechanism is virtual methods plus the implicit vtable the simulator builds for every class with at least one virtual method. The same line of code h.display() can run WriteTxn::display, ReadTxn::display, or ErrorTxn::display depending on what object h currently points to.
Best Practices — Ten Rules for Polymorphism in Production
- Declare every overridable method
virtualat the parent level. Forgettingvirtualsilently disables polymorphism through parent handles. Always-virtual is the right default. - Mark
virtualon tasks as well as functions. Driver and monitor task overrides depend on it;taskwithoutvirtualtraps you the same wayfunctiondoes. - Repeat
virtualon each child override for readability. Optional semantically, but a reviewer scanning the override site sees the dispatch behaviour immediately without having to chase up the hierarchy. - Always wrap
$castin anif. Log$typenameof the actual object on failure. Never let a failed cast produce a downstream null-dereference. - Use polymorphic containers (queues, FIFOs of base type) instead of one container per subclass. One scoreboard, one analysis port, many transaction kinds — that is the UVM idiom.
- Construct UVM objects through
type_id::create(), not directnew(). The factory is the runtime swap point for polymorphism;new()hard-codes the class and bypasses it. - Inside a virtual override, use
super.method()(notthis.method()) to reach the parent.thisdispatches through the vtable and recurses;superresolves statically. - Do not declare child fields that shadow parent fields. Shadowing turns polymorphism into a confusing mess of "which version of
xare we reading?" If you must, document why and usesuper.xto disambiguate. - Print
$typename(this)in every virtual method's first$displayduring bring-up. The runtime type at the call boundary is your fastest dispatch-debugging signal. - Don't try to "optimise" by making methods non-virtual. The runtime cost of vtable dispatch is invisible; the cost of losing override capability is enormous when subclasses arrive later.