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.
$cast's two calling forms, and a new advanced section.The One Rule
Every polymorphism question reduces to one rule:
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
| Aspect | Virtual Method | Non-Virtual Method |
|---|---|---|
| Keyword | virtual function | function |
| Resolution | Runtime (object type) | Compile time (handle type) |
| Enables polymorphism | Yes | No |
| Through a base handle | Derived override runs | Base 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
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 Feature | The 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 |
| Phasing | uvm_component handles call build_phase/run_phase; your overrides run |
| Sequences | A sequencer runs uvm_sequence_base handles; body() dispatches to each sequence's own |
copy/compare/print | Concrete methods calling virtual do_* hooks — the template-method pattern (see Part 2) |
| Callbacks | Registered 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
$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
| Mistake | Result | Fix |
|---|---|---|
Missing virtual in the base | Base method runs via base handles — silently wrong | virtual in the topmost class; overrides inherit it |
| Signature drift in the override | Some tools: error; others: a separate non-override method | Match return type and arguments exactly |
virtual first appearing mid-hierarchy | Polymorphic only from that level down | Hoist to the root of the hierarchy |
Calling virtual methods inside new() | Dispatches to the derived override before derived fields are initialized | Keep constructors dumb; defer to a post-construction init |
Expecting new() itself to be polymorphic | It isn't — constructors don't dispatch | That's the factory's job: type_id::create |
Ignored $cast function return | Null-handle crash downstream of the real error | Check 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 mustvirtual 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.
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.
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
virtualat the root of the hierarchy. - The failure mode of a missing
virtualis 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 beforesuper.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.
Comments (0)
Leave a Comment