Part 2 · OOP for Verification · Intermediate

Polymorphic Containers

Queues of base handles holding mixed derived transactions, iterate-and-dispatch, and factory-style creation functions.

One queue, many types

A container is declared with one element type — but if that type is a base class handle, the container can hold objects of any derived type simultaneously, because each element is just a reference and every derived object IS-A base. A bus_txn q[$] can mix plain bus transactions, AXI bursts, and error-injection transactions in one stream. This is what makes generic testbench plumbing possible: mailboxes, scoreboard queues, and analysis paths are all declared once at the base type and never change as new transaction flavors are added.

diagram
POLYMORPHIC QUEUE — handles in, mixed objects behind

  bus_txn q[$];

  q: [ h0 ][ h1 ][ h2 ][ h3 ]      ← all slots typed bus_txn
       │     │     │     │
       ▼     ▼     ▼     ▼
   bus_txn axi_txn err_txn axi_txn  ← real object types differ
            (qos=3) (bad   (qos=0)
                    parity)

  q[i].print()  — virtual  each object prints ITS OWN format
  q[i].qos      — COMPILE ERROR: base handle exposes base members
                  only; derived fields need $cast first
systemverilog
bus_txn q[$];
axi_txn  a = new();  a.qos = 3;
err_txn  e = new();
bus_txn  b = new();

q.push_back(b);
q.push_back(a);      // implicit upcast on insertion
q.push_back(e);

foreach (q[i])
  q[i].print();      // virtual dispatch: 3 different print bodies run

Iterating: virtual dispatch first, $cast when you must

Consuming a mixed container, you have two tools. Prefer virtual methods : if the per-type behavior lives inside the objects (print, compare, pack, check), the loop body is one virtual call and never mentions concrete types — adding a new transaction flavor requires zero consumer changes. Reach for $cast only when the consumer genuinely needs type-specific fields the base cannot expose — AXI-only coverage sampling qos, for example. A long if/else-if chain of casts is a design smell: behavior that belongs in a virtual method has leaked into the consumer.

systemverilog
// GOOD: behavior lives in the objects — consumer is type-blind
function void check_all(bus_txn q[$]);
  foreach (q[i])
    if (!q[i].check())               // virtual — each type's own rules
      $error("txn %0d failed self-check", i);
endfunction

// ACCEPTABLE: type-specific side path via function-form $cast
function void sample_axi_cov(bus_txn q[$]);
  axi_txn a;
  foreach (q[i])
    if ($cast(a, q[i]))              // only AXI objects taken
      axi_cg.sample(a.qos, a.burst); // derived-only fields
    // non-AXI types skipped by design — mixed stream is normal here
endfunction

Factory-style creation returning a base handle

The creation-side mirror of a polymorphic container is a factory function : it decides which concrete class to construct — from a mode enum, a weighted random pick, or test configuration — and returns the object through a base handle. Generators built this way emit mixed streams without knowing concrete types beyond the creation point; everything downstream rides on virtual dispatch. This is the hand-rolled version of what the UVM factory automates with type overrides, and building it once by hand makes the UVM mechanism obvious.

systemverilog
typedef enum {PLAIN, AXI, ERR} txn_kind_e;

function bus_txn make_txn(txn_kind_e kind);
  case (kind)
    PLAIN: begin bus_txn t = new(); return t; end
    AXI:   begin axi_txn t = new(); return t; end   // upcast on return
    ERR:   begin err_txn t = new(); return t; end
  endcase
endfunction

task generator(mailbox #(bus_txn) mb, int n);
  txn_kind_e kind;
  bus_txn    t;
  repeat (n) begin
    void'(std::randomize(kind) with {
      kind dist {PLAIN := 6, AXI := 3, ERR := 1};
    });
    t = make_txn(kind);          // concrete type chosen HERE only
    void'(t.randomize());        // derived constraints apply
    mb.put(t);                   // mixed stream, one mailbox
  end
endtask

Interview angle

  • 'Can a queue hold different class types?' — yes, via base-class handles; each element references a derived object.

  • 'How do you call derived-only fields from the queue?' — $cast to a derived handle first; base handles expose base members only.

  • 'What breaks if print() isn't virtual?' — every element prints the base format; the mixed stream looks homogeneous in logs.

  • 'Sketch a factory function' — switch on a kind, construct concrete, return base handle; randomize after creation.

Key takeaways

  • Base-handle containers hold mixed derived objects — declare plumbing once at the base type.

  • Consume via virtual methods so per-type behavior stays inside the objects; consumers stay type-blind.

  • Use function-form $cast for genuinely type-specific side paths; cast chains signal misplaced behavior.

  • Factory functions construct concrete types and return base handles — the manual ancestor of the UVM factory.

Common pitfalls

  • Accessing derived fields through the base element type — compile error; the handle's type limits visibility.

  • Non-virtual print/compare in the base — the whole container behaves as the base type, silently.

  • Storing then re-randomizing the same handle — every queue slot aliases one mutating object (see Shallow vs Deep Copy).

  • Growing if/else cast ladders in consumers — move the behavior into a virtual method instead.