Encapsulation and Abstraction
Every property and method in Classes Basics and Inheritance and Polymorphism has been freely readable and writable from anywhere the class's handle is visible — tr.data = 8'hFF; works from any code holding tr. That's fine for a small example, but a class meant to protect an internal invariant (a counter that should only ever increase, an internal state that only the class's own methods should touch) needs a way to say "this property is mine — outside code doesn't get to reach in and change it directly."
local: fully private
class counter;
local int count = 0;
function void increment();
count++;
endfunction
function int get_count();
return count;
endfunction
endclass
counter c = new();
c.increment();
// c.count = 100; // ERROR — count is local, not accessible outside the class
$display(c.get_count()); // fine — get_count() is a public method
local restricts a property or method to code inside the class itself — not even a derived class can touch it directly. Outside code (and derived classes) can only interact with count through the public methods the class chooses to expose (increment(), get_count()) — the class fully controls how its internal state can change, which is the entire point of encapsulation: bugs caused by external code reaching in and setting an invariant-violating value become structurally impossible, not just discouraged by convention.
local is scoped to the class, not to the object
local restricts access by class, not by object instance — a subtlety worth stating precisely, because "private to this object" is the wrong mental model. Any method of counter can reach the count property of a different counter object, not just this one, since both objects are the same class:
class counter;
local int count = 0;
function void increment();
count++;
endfunction
function bit equals(counter rhs);
return (count == rhs.count); // OK — rhs.count is local, but rhs is the same class
endfunction
endclass
rhs.count compiles fine inside equals(), even though count is local and rhs is a completely separate object handle — because the access check local enforces is "is this code inside the class counter," not "is this code operating on this object specifically." This is exactly the pattern a hand-written compare() method (the same kind The Transaction Class writes for fifo_txn) relies on: comparing two objects' internal state field-by-field from inside the class's own code, without either object needing to expose that state publicly.
protected: private, but visible to subclasses
class transaction;
protected bit [7:0] data;
function void set_data(bit [7:0] d);
data = d;
endfunction
endclass
class write_transaction extends transaction;
function void double_data();
data = data * 2; // OK — protected is visible inside a derived class
endfunction
endclass
protected is a middle ground between fully public and local: outside code still can't touch data directly, but a class that extends this one can — useful when a base class wants derived classes to be able to build on its internal state directly, without opening that state to completely unrelated code.
SystemVerilog has no actual public keyword — "public" in the table below just means neither local nor protected was written, which is the default for any property or method that doesn't declare one of those two.
Choosing between public, protected, and local
| Visibility | Outside code | Derived classes | Use for |
|---|---|---|---|
| (default) public | Yes | Yes | The class's actual interface — what it's meant to be used for |
protected | No | Yes | Internal state a subclass legitimately needs to build on |
local | No | No | Internal state nothing outside the class itself should ever touch |
A common, good habit: keep methods (the class's interface) public by default, and make properties (the class's internal state) protected or local unless there's a specific reason external code needs direct access. This mirrors how UVM's own base classes are written — most of uvm_component's internal bookkeeping is protected, and outside code interacts with it only through public methods like get_full_name() or the phase methods, never by reaching into its internals directly.
const: properties that can't change after they're set
Visibility (local/protected) controls who can touch a property. const controls whether it can change at all once set — a second, orthogonal axis of protecting a class's invariants. SystemVerilog supports two flavors:
class packet;
const int max_size = 1500; // global constant — value fixed at declaration
const bit [7:0] id; // instance constant — no initializer here...
function new(bit [7:0] id_val);
id = id_val; // ...assigned exactly once, only inside new()
endfunction
endclass
- A global constant property is initialized right in its declaration (
max_sizeabove), and that value can never change afterward, for any object of the class. Since every instance would share the identical value, it's common practice to also declare itstatic constso the whole class shares one copy instead of each object carrying a redundant copy. - An instance constant property (
idabove) is declaredconstwith no initializer, and may be assigned exactly once — only inside the class's constructor (new()). Different objects can each get their ownidat construction time, but oncenew()returns, that object'sidcan never be reassigned. Attempting to write to either kind ofconstproperty from anywhere else — including from inside another method of the same class — is a compile error.
This pairs naturally with local/protected: visibility decides who can see or touch a property at all, and const decides whether even the class's own code can change it after construction — together they let a class lock down exactly which invariants are structurally impossible to violate.
Abstraction: virtual class
Sometimes a base class exists purely to define a common shape for its subclasses — it should never be instantiated on its own, only extended. SystemVerilog's virtual class (not to be confused with a virtual method from the previous page) marks exactly that:
virtual class shape;
pure virtual function real area();
endclass
class circle extends shape;
real radius;
function new(real r);
radius = r;
endfunction
function real area();
return 3.14159 * radius * radius;
endfunction
endclass
shape s;
// s = new(); // ERROR — virtual class cannot be instantiated directly
circle c = new(2.0);
s = c; // fine — a virtual-class handle can point at a concrete subclass
$display(s.area()); // polymorphism dispatches to circle's area()
virtual class shape cannot be new()'d directly — attempting to do so is a compile error. A pure virtual function (area() above) declares a method's signature with no body at all, forcing every concrete (non-virtual) subclass to provide its own implementation — the compiler rejects a subclass of shape that forgets to implement area(). This is the formal version of a pattern the previous page's transaction class used informally: a base class that exists to be extended, guaranteeing every subclass honors a common method contract, rather than relying on convention alone.
A subclass of a virtual class isn't automatically concrete — it can itself be declared virtual class too, still uninstantiable, while adding more shared structure (properties, non-pure methods, or additional pure virtual methods of its own) for its own subclasses to build on. Only a genuinely concrete subclass — one with no unimplemented pure virtual methods left, and not itself marked virtual class — can actually be new()'d.
What's next
The next page covers the last core class mechanic before moving to randomization: static properties and methods — shared across every instance of a class rather than per-object — and parameterized classes, which let a class's type itself be a compile-time parameter, the way parameter/generate scaled RTL modules back in Verilog.