Part 3 · Constraint Randomization · Intermediate
Randomizing Nested Objects
Recursion into rand class handles, the silent null-handle skip, and the construct-then-randomize pattern for object arrays.
Recursion rules for rand class handles
When a class field is itself a class handle declared rand, randomize() does not randomize the handle (it never re-points it at a different object) — it recurses into the object the handle currently references and adds that object's rand fields and constraints to the one global solve. Constraints in the outer class may reference inner fields and vice versa; the solver sees a single flattened constraint system.
class header;
rand bit [3:0] version;
rand bit [7:0] len;
constraint c_ver { version inside {4, 6}; }
endclass
class packet;
rand header h; // rand handle → solver recurses
rand bit [7:0] data[];
// cross-object constraint: outer references inner field
constraint c_size { data.size() == h.len; }
function new();
h = new(); // construct in new() → never null
endfunction
endclass
packet p = new();
initial begin
assert (p.randomize());
// ONE solve chose: h.version, h.len, data.size(), data contents —
// with data.size() == h.len holding by construction, not by luck
endWhat the solver does: it flattens packet and header into one constraint-satisfaction problem — c_ver and c_size are solved together, so h.len and data.size() are co-determined. This is fundamentally different from randomizing header first and packet second in two calls, where c_size would have to treat h.len as a frozen constant. Constructing h inside packet::new() is the discipline that makes the recursion reliable.
The null-handle skip — a classic silent bug
If a rand class handle is null at randomize() time, the LRM says the solver simply skips it — no error, no warning in most tools, and randomize() still returns 1. The inner object's fields obviously cannot be solved (there is no object), and any outer constraint referencing inner fields through the null handle is the only thing that turns this into a visible failure (null dereference at solve time). When no constraint touches it, the bug is perfectly silent.
class config_c;
rand bit [2:0] qos;
endclass
class txn;
rand config_c cfg; // rand handle — but see new() below
rand bit [7:0] addr;
// NOTE: no constraint references cfg.* → nothing forces a deref
function new();
// BUG: forgot cfg = new();
endfunction
endclass
txn t = new();
initial begin
assert (t.randomize()); // returns 1 — addr randomized
// cfg is STILL NULL. No error anywhere.
// Downstream code reading t.cfg.qos → null dereference crash,
// possibly thousands of cycles later, far from the real bug.
endNULL-HANDLE SKIP — failure timeline
t = new() cfg = null (constructor forgot new())
│
t.randomize() solver: "cfg is null → skip subtree"
│ addr solved fine → returns 1 ◄── looks healthy
│
... 10,000 cycles of simulation ...
│
scoreboard reads t.cfg.qos
│
▼
FATAL: null object access ◄── crash site is nowhere near cause
defenses:
1. construct nested rand objects in new() (prevention)
2. assert (cfg != null) in pre_randomize() (early detection)
3. constraint touching cfg.* makes null loud (side effect)Why the LRM chose skip-not-error: handles may legitimately be null for optional sub-objects (e.g. an optional VLAN tag), and erroring would forbid that pattern. The cost is that the common case — forgot to construct — is indistinguishable from the intentional case. The defensive idiom: construct everything in new(), and where optionality is real, gate it explicitly with a non-rand enable flag plus an if-constraint rather than a null handle.
Arrays of objects: construct-then-randomize
A rand dynamic array of class handles combines both rules: the solver randomizes element FIELDS of every non-null element, but it never constructs elements and never changes the array size of an object-handle array as part of element creation. The required pattern is: size the array procedurally, construct each element, then randomize.
class beat;
rand bit [31:0] data;
rand bit last;
endclass
class burst;
rand beat beats[]; // array of handles
rand bit [3:0] blen;
constraint c_last { // only final beat has last==1
foreach (beats[i])
beats[i].last == (i == beats.size() - 1);
}
// The pattern: pre_randomize is too late to size from rand blen,
// so size/construct in a controlled step BEFORE the solve:
function void build(int n);
beats = new[n];
foreach (beats[i]) beats[i] = new();
endfunction
endclass
burst b = new();
initial begin
b.build(8); // 1. size + construct every element
assert (b.randomize()); // 2. one solve: all beats[i].data/last
// every element non-null → all participate; c_last holds across them
endWhat the solver does: with eight constructed elements, the flattened problem has 8 data fields and 8 last bits plus blen, and c_last's foreach expands to eight equality constraints. Had any beats[i] been null, that element would be skipped — and the foreach constraint dereferencing it would blow up instead. A common refinement is a two-pass idiom: randomize a size first (or pick it with $urandom_range), then build, then randomize — accepting that size is decided outside the main solve.
Two-pass sizing idiom
// When the element COUNT itself should be random:
int n;
assert (std::randomize(n) with { n inside {[1:16]}; }); // pass 1: size
b.build(n); // construct
assert (b.randomize()); // pass 2: fields
// trade-off: constraints cannot couple n with beat contents in
// one solve — acceptable for most stimulus, and far simpler than
// trying to make the solver "create" objects (it never will)Interview angle
The null-skip rule is one of the highest-yield interview facts in all of SystemVerilog — it is asked directly, and it hides inside debugging scenarios (“a testbench crashes with null access long after randomize returned 1 — walk me through it”).
“What happens when you randomize an object whose rand class handle is null?” — the subtree is silently skipped; randomize still returns 1; the handle stays null.
“Does randomize() ever construct objects or change which object a handle points to?” — never; it only writes fields of existing objects.
“How do you randomize an array of 8 transaction objects?” — new[8] the array, new() each element, then one randomize on the container; foreach constraints span elements.
“Can an outer constraint reference an inner object's field?” — yes; nested rand objects join one flattened solve, so cross-object constraints are first-class.
“How would you model an OPTIONAL sub-object?” — non-rand enable bit + if-constraints, not a sometimes-null handle; null should mean 'bug', not 'mode'.
Key takeaways
rand class handles are recursed into, producing ONE flattened solve where cross-object constraints hold by construction.
A null rand handle is silently skipped and randomize() still returns 1 — the highest-yield trap in randomization basics.
Construct nested rand objects in new(); for arrays use size-construct-randomize, since the solver never creates objects.
Model optional sub-objects with an enable flag and if-constraints, not nullable handles.
Common pitfalls
Forgetting cfg = new() in the constructor — randomize passes, downstream null dereference crashes far from the cause.
Expecting randomize() to allocate array elements of an object array — it randomizes fields of existing elements only.
foreach constraints over an object array with null elements — solver-time null dereference errors that look like solver bugs.
Randomizing nested objects in separate calls and assuming cross-object constraints still hold — they only hold in a joint solve.
Using a null handle as an 'optional feature' flag — indistinguishable from the forgot-to-construct bug.