Every class member is public by default: any code with a handle can read it, write it or call it. The local qualifier restricts a member to the declaring class, and protected admits the declaring class plus its subclasses.
The compiler enforces the boundary at compile time. Violations are errors, not warnings.
Member qualifier | Same class | Subclass | Outside code |
|---|---|---|---|
None (public) | yes | yes | yes |
protected | yes | yes | no |
local | yes | no | no |
Encapsulation is a contract: public members are the supported interface and the rest stays free to change. Hidden state also keeps invariants enforceable, since only the class methods can disturb them.
In a VIP base class, protected marks the intended extension surface: handles and hooks that subclasses are expected to use. Local marks internals that even subclasses must reach through methods.
SystemVerilog has no friend declaration, so privileged access always goes through an explicit method. A debug accessor with a policy argument replaces backdoor field access.
A base class with a protected setting its subclasses may tune and a local count only its own methods may change:
class link_base;
protected int unsigned timeout = 100; // subclasses may tune this
local int unsigned in_flight; // only link_base's own methods change it
function void start();
in_flight++;
endfunction
function void finish();
if (in_flight > 0) in_flight--;
endfunction
function int unsigned pending();
return in_flight;
endfunction
endclass
class slow_link extends link_base;
function new();
timeout = 500; // OK: protected is visible to subclasses
endfunction
function void send_one();
start(); // OK: the base's public method
// in_flight++; // COMPILE ERROR: local to link_base
finish();
endfunction
function int unsigned get_timeout();
return timeout;
endfunction
endclass
module tb_visibility;
slow_link l;
int errors = 0;
initial begin
l = new();
l.start();
if (l.pending() != 1) errors++;
l.send_one();
if (l.pending() != 1) errors++; // send_one started and finished its own
l.finish();
if (l.pending() != 0) errors++;
if (l.get_timeout() != 500) errors++;
// l.timeout = 0; // COMPILE ERROR: protected, outside code
if (errors == 0) $display("PASS: the subclass tuned the protected timeout and moved the local count only through methods");
else $fatal(1, "FAIL: %0d visibility checks failed", errors);
$finish;
end
endmodulePASS: the subclass tuned the protected timeout and moved the local count only through methods1 and 2. g0 matches; g1 is a mismatch; the last packet has nothing to compare against, which the checker also counts as a miss.
A hidden queue and hidden tallies that only move through methods, with getters as the read path.
class mini_txn;
local int id; // local: only mini_txn code may touch these
local int unsigned data;
function new(int i, int unsigned d);
id = i;
data = d;
endfunction
// Same-class access: this method may read rhs's locals, another handle of mini_txn.
function bit compare(mini_txn rhs);
return (id == rhs.id) && (data == rhs.data);
endfunction
endclass
class in_order_checker;
// Hidden: only this class touches the queue and tallies,
// so hits plus misses always equals the number of checks.
local mini_txn expected_q[$];
local int hits;
local int misses;
function void add_expected(mini_txn t);
expected_q.push_back(t);
endfunction
function void check_actual(mini_txn t);
mini_txn exp;
if (expected_q.size() == 0) begin
misses++;
return;
end
exp = expected_q.pop_front();
if (exp.compare(t)) hits++;
else misses++;
endfunction
// Read-only view of hidden state; no setter is part of this API.
function int get_hits();
return hits;
endfunction
function int get_miss_count();
return misses;
endfunction
endclass
module top;
in_order_checker chk;
mini_txn e0;
mini_txn e1;
mini_txn g0;
mini_txn g1;
mini_txn surprise;
int errors = 0;
initial begin
chk = new();
e0 = new(0, 32'hA1);
e1 = new(1, 32'hB2);
g0 = new(0, 32'hA1); // matches e0
g1 = new(1, 32'h00); // wrong data for e1
surprise = new(7, 32'hFF);
chk.add_expected(e0);
chk.add_expected(e1);
chk.check_actual(g0);
chk.check_actual(g1);
chk.check_actual(surprise); // queue is empty: counts as a miss
// chk.hits = 0; // COMPILE ERROR: hits is local to in_order_checker
if (chk.get_hits() !== 1) errors++;
if (chk.get_miss_count() !== 2) errors++;
if (chk.get_hits() + chk.get_miss_count() !== 3) errors++;
if (errors == 0) $display("PASS: the checker tallied 1 hit and 2 misses over 3 checks");
else $fatal(1, "FAIL: hit and miss tallies differ from 1 and 2");
$finish;
end
endmodulePASS: the checker tallied 1 hit and 2 misses over 3 checksLeaving everything public turns every field into API and freezes refactoring. Writing setters without validation keeps the name of encapsulation with none of its effect.
Local in a base class blocks subclasses that genuinely need the state. Prefer protected for the extension surface and local for invariant-carrying internals.
Compilation fails. Local members are visible only inside the declaring class, so the subclass must call a base method instead.
A getter such as get_miss_count() with no matching setter. Reads stay possible while writes stay impossible.
One way to write it:
typedef enum bit [1:0] {DATA = 2'd0, CTRL = 2'd1, ERR = 2'd2} kind_e;
class pkt_txn;
kind_e kind;
bit [1:0] dest;
bit [3:0] len;
bit [7:0] payload[$];
function new(kind_e k, bit [1:0] d, bit [7:0] p[$]);
kind = k; dest = d; payload = p; len = 4'(p.size());
endfunction
function bit compare(pkt_txn other);
if (kind != other.kind || dest != other.dest || len != other.len) return 0;
foreach (payload[i]) if (payload[i] != other.payload[i]) return 0;
return 1;
endfunction
endclass
// PacketFIFO's checker: the expected queue and the tallies are hidden.
class pkt_checker;
local pkt_txn expected_q[$];
local int matched, mismatched;
function void wrote(pkt_txn p); // a packet went into the FIFO
expected_q.push_back(p);
endfunction
function void read(pkt_txn p); // a packet came out
pkt_txn exp;
if (expected_q.size() == 0) begin
mismatched++; // nothing was expected
return;
end
exp = expected_q.pop_front();
if (exp.compare(p)) matched++;
else mismatched++;
endfunction
function int get_matched(); return matched; endfunction
function int get_mismatched(); return mismatched; endfunction
function int get_pending(); return expected_q.size(); endfunction
endclass
module tb_pkt_checker;
pkt_checker chk;
pkt_txn p;
int errors = 0;
initial begin
chk = new();
p = new(DATA, 2, '{8'hA5, 8'h3C}); chk.wrote(p);
p = new(CTRL, 1, '{8'h07}); chk.wrote(p);
p = new(DATA, 2, '{8'hA5, 8'h3C}); chk.read(p); // matches
p = new(CTRL, 1, '{8'h08}); chk.read(p); // one payload byte wrong
p = new(DATA, 0, '{8'h11}); chk.wrote(p); // still inside at the end
// chk.expected_q.delete(); // COMPILE ERROR: local
$display("matched=%0d wrong=%0d pending=%0d", chk.get_matched(), chk.get_mismatched(), chk.get_pending());
if (chk.get_matched() != 1 || chk.get_mismatched() != 1 || chk.get_pending() != 1) errors++;
if (errors == 0) $display("PASS: pkt_checker counted 1 match, 1 wrong packet and 1 packet still expected");
else $fatal(1, "FAIL: checker tallies wrong");
$finish;
end
endmodulematched=1 wrong=1 pending=1
PASS: pkt_checker counted 1 match, 1 wrong packet and 1 packet still expectedRun with Verilator 5.048 (--binary --timing) on 2026-10-18; every bench printed the PASS line shown, with no warnings. These class benches were run on Verilator only.