SystemVerilog Basics

Chapter 11 of 16

OOP Continued: Inheritance, Polymorphism, and Virtual Methods

Build inheritance relationships with extends, understand why polymorphism doesn't work the way you'd expect without the virtual keyword, learn to safely convert a base-class handle back to a subclass handle with $cast, and see how this sets up the factory mechanism in the next track, UVM.

Inheritance and polymorphism are probably already familiar from another language. This chapter maps them directly onto SystemVerilog's syntax, and focuses on one thing that trips up nearly every newcomer: methods in SystemVerilog aren't virtual by default — without the virtual keyword, polymorphism doesn't work the way you'd expect.

Inheritance: extends

class packet;
  int addr;
  int data;
 
  function new(int addr = 0, int data = 0);
    this.addr = addr;
    this.data = data;
  endfunction
 
  function void print();
    $display("packet: addr=%0h data=%0h", addr, data);
  endfunction
endclass
 
class error_packet extends packet;
  string error_msg;
 
  function new(int addr = 0, int data = 0, string error_msg = "");
    super.new(addr, data);   // call the base class's constructor
    this.error_msg = error_msg;
  endfunction
 
  function void print();
    super.print();           // reuse the base class's existing behavior
    $display("  error: %s", error_msg);
  endfunction
endclass

error_packet extends packet makes error_packet inherit all of packet's properties and methods, and adds its own error_msg field. A few things worth noting:

  • super.new(...): a subclass constructor must call the base class's constructor (usually as its first statement), which handles initializing the inherited portion of the properties.
  • super.methodName(...): calls the base class's original implementation of a method from inside the subclass's version, avoiding duplicating the base class's logic.
  • The subclass redefines print() — this is called method overriding; the subclass's version replaces the base class's (at least on the surface — the next section shows it's not quite that simple).

Polymorphism: operating on a subclass object through a base-class handle

Since error_packet is a packet, a packet-typed handle can point to an error_packet object:

packet p;
error_packet ep = new(32'h1000, 32'hDEAD, "parity error");
 
p = ep;      // legal: a base-class handle can point to a subclass object
p.print();   // which print() gets called here?

If you're coming from Java, you might reasonably assume p.print() calls error_packet's print(), since that's what p actually points to — but in SystemVerilog, the code above actually calls packet's print(), printing only packet: addr=1000 data=dead, with no sign of error_msg.

That's because SystemVerilog (like C++) decides which method to call based on the handle's declared type (packet here) by default, not the object's actual type (error_packet) — completely different from Java, where every method is virtual by default. This is one of the easiest mistakes to make when coming from another OOP language.

virtual methods: making polymorphism actually work

To make the intuitive "call whichever method matches the actual object's type" behavior really happen, both the base class's and the subclass's methods need to be declared virtual:

class packet;
  int addr;
  int data;
 
  function new(int addr = 0, int data = 0);
    this.addr = addr;
    this.data = data;
  endfunction
 
  virtual function void print();
    $display("packet: addr=%0h data=%0h", addr, data);
  endfunction
endclass
 
class error_packet extends packet;
  string error_msg;
 
  function new(int addr = 0, int data = 0, string error_msg = "");
    super.new(addr, data);
    this.error_msg = error_msg;
  endfunction
 
  virtual function void print();
    super.print();
    $display("  error: %s", error_msg);
  endfunction
endclass

With virtual added, the same call site now behaves differently:

packet p;
error_packet ep = new(32'h1000, 32'hDEAD, "parity error");
 
p = ep;
p.print();
// packet: addr=1000 data=dead
//   error: parity error

This time p.print() correctly calls error_packet's print() — because a virtual method call is resolved at runtime based on the object p actually points to, not the type p was declared with. Rule of thumb: any method that might be overridden by a subclass should be explicitly declared virtual — the same "explicit beats an implicit default" advice as chapter 9's "almost always add automatic."

Why this matters so much for verification code

Imagine writing generic verification code that operates on a packet-typed handle — but at runtime, that handle might actually point to a specialized subclass (an error-injecting error_packet, or a packet for some specific protocol). virtual methods let the same generic code automatically call each subclass's specialized behavior, with no need to know in advance which subclasses exist.

This is exactly the foundation the next track's core mechanism — UVM's factory — is built on: the factory lets you swap a component for a subclass implementation without touching the testbench's structural code, and the reason "swapping it changes the behavior too" works at all is fundamentally the virtual-method polymorphism covered in this section.

Putting it together: a polymorphic queue

Combine chapter 6's queue with this chapter's inheritance and polymorphism — a packet-typed queue holding different subclass objects, correctly calling each one's own print() while iterating:

packet pkts[$];
packet       p;
error_packet ep;
 
p = new(32'h1000, 32'hAAAA);
pkts.push_back(p);
 
ep = new(32'h2000, 32'hBBBB, "timeout");
pkts.push_back(ep);
 
foreach (pkts[i]) begin
  pkts[i].print();  // polymorphism: each element calls its own actual type's print()
end
// packet: addr=1000 data=aaaa
// packet: addr=2000 data=bbbb
//   error: timeout

The queue itself only knows "this holds a bunch of packets," but because print() is virtual, what actually gets printed depends on each element's real object type.

$cast: converting a base-class handle back to a subclass handle

The earlier p = ep; — assigning a subclass object to a base-class handle — is called upcasting: since error_packet is a packet, this assignment is always safe, and SystemVerilog does it implicitly. The reverse, downcasting — converting a base-class handle back to a specific subclass handle — isn't so straightforward: a handle declared as packet might or might not actually point to an error_packet object, and the compiler has no way to know at compile time, so a direct assignment is a compile error:

error_packet ep2;
ep2 = p;   // compile error: can't directly assign a packet handle to an error_packet handle

This is what $cast is for: a system task that checks at runtime whether the conversion is actually safe, completing it and returning 1 on success, or leaving the destination handle untouched and returning 0 on failure (instead of crashing):

packet p;
error_packet ep, ep2;
 
ep = new(32'h1000, 32'hDEAD, "parity error");
p  = ep;   // upcast: always safe, done implicitly
 
if ($cast(ep2, p)) begin
  $display("cast succeeded, ep2 now points to the same error_packet object");
  ep2.print();
end else begin
  $error("cast failed: p does not actually point to an error_packet object");
end

$cast(ep2, p) attempts to convert the object p actually points to into the type ep2 is declared as: if p really does point to an error_packet (or a subclass of it), the cast succeeds, ep2 points to that same object, and it returns 1; if p actually points to something else (say, a plain packet with no error info), the cast fails, ep2 is left unchanged, and it returns 0 — wrapping it in an if lets you check success safely instead of letting the program error out.

$cast shows up nearly everywhere in UVM: the factory returns base-class handles when it creates components, and test code frequently needs to cast that handle back to the specific subclass it actually cares about, in order to reach that subclass's added properties and methods — the other half of the mechanism this chapter opened with in "why this matters so much for verification code": upcasting lets generic code operate on any subclass object, and $cast is how you safely convert a handle back to a concrete type when you need to.

Summary

  • extends builds an inheritance relationship; a subclass constructor needs super.new(...) to call the base class's constructor; super.methodName(...) reuses a base class method's implementation.
  • Methods aren't virtual by default: without virtual, calling a method through a base-class handle runs the version matching the handle's declared type, not the object's actual type — unlike Java's default behavior.
  • Adding virtual to a method is what actually makes "calling through a base-class handle runs the subclass's overridden version" work.
  • Rule of thumb: any method that might be overridden by a subclass should be explicitly declared virtual.
  • This is exactly the foundation UVM's factory mechanism relies on — generic code operates on a base-class handle, and the actual subclass behavior runs automatically at runtime.
  • Assigning a subclass object to a base-class handle (upcasting) is always safe and implicit; converting a base-class handle back to a subclass handle (downcasting) needs $cast, which checks at runtime whether the conversion is safe, returning 1 on success and 0 on failure instead of corrupting the handle.

After packet p = ep; (ep is an error_packet object, and print() is not declared virtual), which version runs when you call p.print()?

After declaring print() virtual in both packet and error_packet, which version runs for packet p = ep; p.print();?

Inside a subclass's constructor, which keyword calls the base class's constructor (e.g. xxx.new(...))? (lowercase)

Why doesn't error_packet ep2 = p; (p is a packet-typed handle) compile directly, requiring $cast(ep2, p) instead?