Inheritance and Polymorphism
Classes Basics built one transaction class. Real testbenches need families of related classes — a base transaction, and specialized variants (a read transaction, a write transaction) that share most of the base's structure but differ in specifics. Writing each variant from scratch, duplicating every shared property and method, is exactly the kind of repetition object-oriented programming exists to eliminate.
extends: building a class from another
class transaction;
rand bit [7:0] data;
bit valid;
function new();
valid = 1;
endfunction
function void display();
$display("data=%0h", data);
endfunction
endclass
class write_transaction extends transaction;
rand bit [7:0] address;
function void display();
$display("WRITE addr=%0h data=%0h", address, data);
endfunction
endclass
write_transaction inherits every property and method transaction declared (data, valid, new()) and adds its own (address), plus overrides display() with its own version. The base class (transaction) is unchanged and still usable on its own; the derived class (write_transaction) is a strict superset, guaranteed to have everything the base has plus whatever it adds.
super: reaching back to the parent
class write_transaction extends transaction;
rand bit [7:0] address;
function new();
super.new(); // run the parent's constructor first
address = 0;
endfunction
function void display();
super.display(); // run the parent's display() first
$display(" address=%0h", address);
endfunction
endclass
super.method_name() calls the parent's version of a method from inside an overriding method in the child — used here so write_transaction's display() doesn't have to re-implement printing data, it just adds its own line after delegating to the parent's version. super.new() does the same for constructors, and is required whenever the parent's new() takes arguments the child needs to supply.
Polymorphism: one handle, many actual behaviors
transaction tr; // a base-class handle
write_transaction wtr;
wtr = new();
wtr.address = 8'h10;
wtr.data = 8'hFF;
tr = wtr; // a base handle can point at a derived object
tr.display(); // calls write_transaction's display(), not transaction's
This is polymorphism: tr is declared as the base type, but because it's actually holding a write_transaction object, calling tr.display() runs the derived class's override, not the base class's original. The class of the actual object, not the declared type of the handle, decides which method body runs. This only works because display() is declared virtual in practice (see below) — without it, SystemVerilog would use the handle's declared type to pick the method instead, defeating the whole point.
virtual methods: making polymorphism actually work
class transaction;
virtual function void display();
$display("base transaction");
endfunction
endclass
Declaring a method virtual in the base class is what makes the override-and-dispatch-by-actual-type behavior in the previous example happen. Without virtual, SystemVerilog resolves tr.display() using tr's declared type (transaction) at compile time, always calling the base version regardless of what tr actually points to at runtime — silently defeating the override. Declaring every method meant to be overridden as virtual is a default good habit, not an occasional special case — a non-virtual method that a derived class overrides "works" only by accident, as long as nothing ever calls it through a base-class handle, which is exactly the situation polymorphism is meant to support.
Downcasting: going back from base to derived with $cast
A base handle can point at a derived object (tr = wtr; above), but the reverse direction isn't automatic. If a piece of code only has a transaction tr handle and needs to reach address — a property that only exists on write_transaction, not on the base — it has to explicitly convert tr back to a write_transaction handle first:
transaction tr;
write_transaction wtr2;
tr = wtr; // upcast: always legal, happens implicitly
if ($cast(wtr2, tr))
$display("address=%0h", wtr2.address);
else
$display("ERROR: tr is not actually a write_transaction");
$cast(dest, src) is a downcast: it checks, at runtime, whether src actually refers to an object of dest's type (or something further derived from it) — not just whether the compiler can statically justify it. If it does, $cast performs the assignment and returns 1; if tr were actually holding some other transaction subtype (a read_transaction, say), the types wouldn't match, $cast would return 0, and wtr2 would be left unchanged. This is why $cast's return value should always be checked (as above), the same discipline randomize()'s return value already needed — an unchecked, failed $cast silently leaves wtr2 holding whatever it held before, not the object anyone actually wanted.
Note that $cast still needs some class-hierarchy relationship between dest and src's declared types to even compile — trying to $cast between two classes that share no common ancestor at all (neither extends the other, directly or indirectly) is a compile-time error, not a runtime 0. The runtime check only decides between "is this particular object actually the derived type or not" among types that could plausibly relate; it doesn't make an unrelated-type cast legal.
Only one parent: no multiple inheritance
write_transaction extends transaction — and that's the only class it can extend. SystemVerilog supports single inheritance only: a class may extend exactly one parent, never two. There's no class c extends a, b; form the way some other object-oriented languages allow multiple base classes. When a design genuinely needs to combine behavior from more than one unrelated source, the usual SystemVerilog answer is composition instead — giving the class a property that holds an instance of the other behavior, rather than trying to inherit from it directly.
Why this matters for testbenches generally
A scoreboard, driver, or any piece of testbench infrastructure written to operate on a base transaction handle works unmodified on every derived transaction type (write_transaction, read_transaction, or any future variant) without being rewritten — new transaction types slot into existing infrastructure by extending the base class and overriding a handful of virtual methods, rather than every piece of infrastructure needing a special case per transaction type. This exact pattern — write against a base class, let polymorphism dispatch to the right derived behavior — is the backbone of how UVM's driver/sequencer/factory machinery lets a single testbench structure work across many different transaction and sequence types.
What's next
The next page covers encapsulation — controlling which properties and methods outside code is allowed to touch directly — and abstraction via virtual class, for base classes that are only ever meant to be extended, never instantiated on their own.