Skip to content

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.

Static Dispatch Trap — Wrong Method Runs Silently
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── 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
Fixed — virtual Enables Dynamic Dispatch
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── 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
endmodule

Polymorphic 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

Polymorphic Container — Mixed Transaction Queue
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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
endmodule

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

virtual task — Polymorphic Driver
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── 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
endmodule

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

Polymorphism in UVM — How run_phase() Gets Called
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// 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 run

Quick Reference

ConceptKey Point
Static dispatchNo virtual — compile-time type of handle decides which method runs. Silent wrong behaviour when child stored in parent handle.
Dynamic dispatchvirtual on parent method — runtime type of object decides. Correct method always runs regardless of handle type.
virtual on functionvirtual function void display(); — same syntax, same behaviour for tasks.
virtual on taskvirtual task drive(BaseTxn t); — essential for polymorphic drivers and monitors.
Polymorphic containerDeclare queue as BaseTxn q[$]. Push any child type. Call methods in a loop — each dispatches to its own implementation.
Golden ruleAny 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
<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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
<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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
<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.

✅ With virtual — runtime dispatch
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
<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>
❌ Without virtual — compile-time dispatch
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
<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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
<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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
<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 typeproves
   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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
<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

1

"My override never runs through the parent handle"

DEBUG
Symptom

A 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().

Buggy Code
Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
<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>
Root Cause

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.

Fix

Mark the parent virtual: virtual function void send();. The child does not need virtual again, but adding it for clarity is conventional.

2

"Stack overflow — virtual override recurses forever"

DEBUG
Symptom

A virtual override calls "the parent" via this and the simulator dies with stack overflow.

Buggy Code
Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
<code>class WriteTxn extends BaseTxn;
  virtual function void display();
    this.display();    // BUG: dispatches back to self
    $display("  wdata=0x%h", wdata);
  endfunction
endclass</code>
Root Cause

this.display() goes through the vtable. For a WriteTxn object, the vtable slot for display points to WriteTxn::display — itself. Infinite recursion.

Fix

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.

3

"$cast succeeds on the wrong type and corrupts my scoreboard"

DEBUG
Symptom

A scoreboard expects only WriteTxn and ReadTxn, but occasionally crashes accessing fields that don't exist on the object it received.

Buggy Code
Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
<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>
Root Cause

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.

Fix

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.

4

"My virtual task in the driver never picks up the test's override"

DEBUG
Symptom

A test extends the base driver and overrides drive_one(), but the base driver's version is what runs during simulation.

Buggy Code
Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
<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>
Root Cause

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.

Fix

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.

5

"Factory override doesn't take effect even though I called set_type_override"

DEBUG
Symptom

A test calls apb_xact::type_id::set_type_override in build_phase, but the driver still produces base apb_xact objects.

Buggy Code
Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
<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>
Root Cause

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.

Fix

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

  1. Declare every overridable method virtual at the parent level. Forgetting virtual silently disables polymorphism through parent handles. Always-virtual is the right default.
  2. Mark virtual on tasks as well as functions. Driver and monitor task overrides depend on it; task without virtual traps you the same way function does.
  3. Repeat virtual on 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.
  4. Always wrap $cast in an if. Log $typename of the actual object on failure. Never let a failed cast produce a downstream null-dereference.
  5. 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.
  6. Construct UVM objects through type_id::create(), not direct new(). The factory is the runtime swap point for polymorphism; new() hard-codes the class and bypasses it.
  7. Inside a virtual override, use super.method() (not this.method()) to reach the parent. this dispatches through the vtable and recurses; super resolves statically.
  8. Do not declare child fields that shadow parent fields. Shadowing turns polymorphism into a confusing mess of "which version of x are we reading?" If you must, document why and use super.x to disambiguate.
  9. Print $typename(this) in every virtual method's first $display during bring-up. The runtime type at the call boundary is your fastest dispatch-debugging signal.
  10. 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.