1. OOP in SystemVerilog - Polymorphism

The scoreboard had been passing for a month. It held a queue of base_txn handles, called expected.compare(actual) on each, and never once complained. Then a lab escape traced back to a corrupted packet the testbench had seen, compared, and blessed. The diff was one word: the base class declared function bit compare(...) — no virtual. Every derived transaction had lovingly overridden it; none of those overrides had ever executed. Through a base handle, the non-virtual call went to the base implementation — a stub that returned 1. The testbench wasn't checking packets. It was checking that 1 equals 1, forty thousand times a night.

Polymorphism — a base-class handle dispatching to derived-class behavior — is the single OOP mechanism the whole UVM edifice stands on, and the virtual keyword is its ignition switch. This post builds the mechanics from the ground up, shows why UVM is unusable without it, and ends with a ladder into the deep end: dispatch internals, the missing covariant returns, and double dispatch for heterogeneous scoreboards.

Note Originally published in 2016; rewritten in 2026 with a verification-first framing, a corrected treatment of $cast's two calling forms, and a new advanced section.
~15 min read · Intermediate body, Advanced tail · Part 1 of 2 — continue with Part 2: Abstract Classes.

The One Rule

Every polymorphism question reduces to one rule:

Key A virtual method is resolved by the object's type at runtime. A non-virtual method is resolved by the handle's type at compile time. Declare virtual in the topmost base class, and every override downstream inherits it — forget it there, and no amount of virtual in derived classes fully repairs the dispatch.
flowchart LR
    subgraph VIRTUAL["Virtual Method"]
        V1["Base handle"] --> V2["Looks at OBJECT type"]
        V2 --> V3["Calls derived override"]
    end

    subgraph NONVIRTUAL["Non-Virtual Method"]
        N1["Base handle"] --> N2["Looks at HANDLE type"]
        N2 --> N3["Calls base method"]
    end

    style V3 fill:#d1fae5,stroke:#10b981
    style N3 fill:#fee2e2,stroke:#ef4444
AspectVirtual MethodNon-Virtual Method
Keywordvirtual functionfunction
ResolutionRuntime (object type)Compile time (handle type)
Enables polymorphismYesNo
Through a base handleDerived override runsBase method runs — silently

Read that last row again with the cold open in mind. The failure mode isn't an error message; it's the wrong method running successfully. That's what makes a missing virtual one of the most expensive single-keyword bugs in verification.

The Classic Demonstration

The zoo example survives every rewrite of this post because it isolates the mechanism perfectly — one virtual method, one non-virtual, same handle:

class Animal;
  string name;
  function new(string name); this.name = name; endfunction

  virtual function void speak();          // polymorphic
    $display("%s makes a sound", name);
  endfunction

  function void info();                   // NOT polymorphic
    $display("Animal: %s", name);
  endfunction
endclass

class Dog extends Animal;
  function new(string name); super.new(name); endfunction
  virtual function void speak(); $display("%s says: Woof!", name); endfunction
  function void info();          $display("Dog: %s", name);       endfunction
endclass

class Cat extends Animal;
  function new(string name); super.new(name); endfunction
  virtual function void speak(); $display("%s says: Meow!", name); endfunction
  function void info();          $display("Cat: %s", name);        endfunction
endclass
module tb;
  initial begin
    Animal animal;
    Dog dog = new("Buddy");
    Cat cat = new("Whiskers");

    animal = dog;      // upcast — always legal, no cast needed
    animal.speak();    // "Buddy says: Woof!"   — virtual: object wins
    animal.info();     // "Animal: Buddy"       — non-virtual: handle wins

    animal = cat;
    animal.speak();    // "Whiskers says: Meow!"
    animal.info();     // "Animal: Whiskers"
  end
endmodule

Same handle, same two calls, and the split tells the whole story: speak() follows the object, info() follows the handle. Heterogeneous collections are where this becomes a superpower:

Animal zoo[3];
zoo[0] = new("Generic");
zoo[1] = Dog::new("Rex");     // scoped constructor — see tip below
zoo[2] = Cat::new("Tom");

foreach (zoo[i]) zoo[i].speak();   // each object answers as itself
Tip Dog::new("Rex") is a scoped constructor call (SV-2012+): it constructs a Dog and assigns it straight to a base-class slot, no temporary derived handle needed. Handy in initialization code — and a common "what does this syntax even mean" stumble when reading modern testbenches.

Now Swap the Nouns: This Is Your Scoreboard

Replace Animal with base_txn, speak() with compare(), and the zoo array with an analysis-port queue, and the toy example becomes the cold open's production bug:

class base_txn;
  rand bit [31:0] addr, data;

  // virtual, or the scoreboard checks nothing — see below
  virtual function bit compare(base_txn rhs);
    return (addr == rhs.addr) && (data == rhs.data);
  endfunction
endclass

class ecc_txn extends base_txn;
  rand bit [6:0] ecc;
  virtual function bit compare(base_txn rhs);
    ecc_txn r;
    if (!$cast(r, rhs)) return 0;
    return super.compare(rhs) && (ecc == r.ecc);   // extend, don't replace
  endfunction
endclass

class scoreboard;
  base_txn expected_q[$];

  function void check(base_txn actual);
    base_txn exp = expected_q.pop_front();
    if (!exp.compare(actual))                      // dispatches by OBJECT type
      $error("Mismatch: %s", actual.convert2string());
  endfunction
endclass

The scoreboard never knows ecc_txn exists — it manipulates base_txn handles, and dispatch routes each compare() to the right override. Delete that one virtual and every ECC check in the environment evaporates without a single warning. This is why "generic code + specific behavior" is the design idiom of every reusable verification component: the infrastructure is written once against base handles; the meaning arrives with the objects.

Polymorphism Is UVM

UVM isn't a framework that happens to use polymorphism; it's polymorphism industrialized. Strip it away and every pillar falls:

UVM FeatureThe polymorphism underneath
Factory (type_id::create)Creation site asks for a base type; the returned object may be any registered override — polymorphism at construction time
Phasinguvm_component handles call build_phase/run_phase; your overrides run
SequencesA sequencer runs uvm_sequence_base handles; body() dispatches to each sequence's own
copy/compare/printConcrete methods calling virtual do_* hooks — the template-method pattern (see Part 2)
CallbacksRegistered objects invoked through virtual hook methods (see the Callbacks guide)
// The factory: polymorphic creation in action
class error_test extends base_test;
  `uvm_component_utils(error_test)

  virtual function void build_phase(uvm_phase phase);
    // From this test on, every my_seq_item the factory creates
    // is actually an error_seq_item — no env code changes.
    my_seq_item::type_id::set_type_override(error_seq_item::get_type());
    super.build_phase(phase);   // env builds AFTER the override is in place
  endfunction
endclass

Note the ordering: the override is registered before super.build_phase() builds the environment. Overrides only affect creations that happen after them — registering an override after the env has built its sequencers is the most common "my factory override does nothing" bug.

$cast: Coming Back Down the Hierarchy

Upcasting (derived → base) is free. Downcasting — recovering the specific type from a base handle — must be checked at runtime, because the handle might point at anything:

Animal a = Dog::new("Buddy");
Dog d;

// Function form: returns 0 on failure — for when mismatch is EXPECTED
if ($cast(d, a))  d.fetch();
else              $display("not a Dog — fine, moving on");

// Task/statement form: runtime FATAL on failure — for when mismatch is a BUG
$cast(d, a);      // if a weren't a Dog, simulation errors here
Tip $cast has two personalities and choosing between them is a design statement. Use the checked function form when multiple types legitimately flow past (capability probing in a monitor). Use the statement form when the type is guaranteed by construction — then a failure is a real bug and deserves a loud, immediate death, not an if that someone forgot the else for. The worst option is the third one you see in old code: function form with the return value ignored, which converts type errors into null-handle crashes three lines later.

Common Mistakes

MistakeResultFix
Missing virtual in the baseBase method runs via base handles — silently wrongvirtual in the topmost class; overrides inherit it
Signature drift in the overrideSome tools: error; others: a separate non-override methodMatch return type and arguments exactly
virtual first appearing mid-hierarchyPolymorphic only from that level downHoist to the root of the hierarchy
Calling virtual methods inside new()Dispatches to the derived override before derived fields are initializedKeep constructors dumb; defer to a post-construction init
Expecting new() itself to be polymorphicIt isn't — constructors don't dispatchThat's the factory's job: type_id::create
Ignored $cast function returnNull-handle crash downstream of the real errorCheck it, or use the statement form deliberately

Interview Corner

Q: A base handle calls a method that both base and derived implement. What runs?

A: If the method is virtual — declared so at the base — the object's type decides, so the derived override runs. If it's non-virtual, the handle's type decides, so the base version runs even though a derived object sits behind the handle. The dangerous case is the second one, because it succeeds silently with the wrong behavior.

Q: Why must virtual appear in the base class rather than the derived class?

A: Dispatch is decided at the call site, and the call site sees the handle's type. If the base declares the method non-virtual, a base-handle call is bound to the base implementation at compile time — the derived class marking its own copy virtual only starts polymorphism from that level down, which a base handle never reaches.

Q: What's the difference between the factory and polymorphism?

A: Two ends of an object's life. Polymorphism picks which method body runs through a base handle after the object exists; the factory picks which type gets constructed when someone asks for a base type. UVM needs both: set_type_override is useless without virtual methods to route behavior to the substituted object, and virtual methods are pointless if every creation site hardcodes new() of a specific type.

Q: What does virtual cost at runtime?

A: One extra indirection per call — the simulator looks up the method through the object's type rather than jumping to a fixed address, and the call can't be inlined. In a simulation dominated by scheduler events and signal updates, this is noise; no real testbench has ever been rescued by de-virtualizing methods. The honest cost of virtual is design discipline, not nanoseconds — the honest cost of omitting it is the cold open of this post.

Beyond the Basics: Advanced → Expert

Level 1 — The dispatch machinery in your head

A workable mental model (simulators vary in implementation, not in semantics): every class with virtual methods has a method table — an array of function pointers, one slot per virtual method, filled with the most-derived override for that class. Every object carries a reference to its class's table. A virtual call is: follow the object → follow its table → jump through the slot. Non-virtual calls skip all three steps — the address is baked in at compile time. All of polymorphism's behavior falls out of this picture: why the object wins (the table belongs to the object's class), why signatures must match (same slot, same shape), and why virtual must start at the base (the slot must exist in the base's table for base-handle calls to have anything to index).

Level 2 — No covariant returns: the clone() tax

C++ lets an override narrow its return type (Base* clone() overridden as Derived* clone()). SystemVerilog does not — override signatures must match exactly. This single omission explains one of UVM's most-typed idioms: uvm_object::clone() returns uvm_object, forever, no matter what it actually constructs, so every clone site pays the cast tax: $cast(my_txn_copy, txn.clone());. Knowing why the tax exists changes how you design your own APIs: return the base type, document the guaranteed dynamic type, and consider wrapping the cast in a static helper (static function my_txn cast_clone(uvm_object o)) so the idiom lives in one place instead of five hundred.

Level 3 — Parameterized classes: where polymorphism stops

Static (parametric) and dynamic (virtual) polymorphism compose worse than people expect. Each specialization — uvm_driver#(apb_txn), uvm_driver#(ahb_txn) — is a distinct, unrelated class with its own method tables; there is no common parameterized base to dispatch through. Virtual dispatch works freely within one specialization's inheritance chain and never across specializations. Frameworks bridge the gap with an unparameterized anchor class above the parameterized layer (uvm_sequence_base above uvm_sequence#() — the type-erasure ladder from Part 2). When your own generic component needs heterogeneous handling, copy that shape; when you instead see if (kind == APB) ... else if (kind == AHB) chains growing in a "generic" class, you're watching parametric polymorphism fail to do dynamic polymorphism's job.

Level 4 — Double dispatch: when one object isn't enough

Virtual dispatch selects behavior by one object's type. A heterogeneous scoreboard needs behavior selected by two — comparing an apb_txn against an ahb_txn mapped through a bridge is a different operation than APB-vs-APB. The classic solution is double dispatch: the first virtual call (expected.compare_to(actual)) resolves the first type; inside each override, a capability probe ($cast) or a second virtual call resolves the second. The visitor pattern is this idea systematized — see the design-patterns series for the full treatment. Recognizing "I need dispatch on two types" stops you from building the usual alternative: a god-method of nested type tests that every new transaction type must be manually threaded through.

Level 5 — Auditing dispatch integrity in a real codebase

The expert skill is defensive: on a legacy bench, how do you find the missing-virtual bugs before the lab does? Three sweeps that pay for themselves: (1) grep every base class handed out as a handle type for non-virtual methods that derived classes also define — most simulators and linters can warn on method hiding; turn that warning on and treat it as an error. (2) Hunt $cast calls whose return value vanishes — each is a latent null-dereference wearing a type bug's clothes. (3) For the scoreboard specifically, run a mutation test: corrupt one field of one in-flight transaction on purpose and confirm the regression actually fails — the direct antidote to the cold open, and the cheapest checker-of-the-checkers you will ever write (the Refactoring post's "tests that can't fail" smell, made executable).

Key Takeaways

  • One rule underneath everything: virtual → object type decides; non-virtual → handle type decides. Declare virtual at the root of the hierarchy.
  • The failure mode of a missing virtual is silent success — wrong method, green regression. Mutation-test your scoreboard so it can't happen to you.
  • UVM is polymorphism industrialized: factory (construction-time), phasing and sequences (dispatch-time), do_* hooks (template method). Register factory overrides before super.build_phase().
  • $cast's two forms are a design statement: checked function form for expected variety, statement form for guaranteed types. Never ignore the function form's return.
  • The deep end: no covariant returns (hence the clone-cast idiom), no dispatch across parameterizations (hence type-erasure anchors), and double dispatch when behavior depends on two types.
Author
Mayur Kubavat
DV engineer working on SoC verification. Writes here about UVM, PCIe, SystemVerilog, and the everyday craft of getting designs to tape-out.

Comments (0)

Leave a Comment