Code That Says Exactly What It Means
Peter C
peterc at gmail.com
Sun Oct 26 06:33:11 UTC 2025
Among D developers, the idea of adding a new visibility attribute
like scopeprivate often meets with scepticism. After all, D
already has private for module-level encapsulation and package
for sharing across sibling modules. And if you really want to
lock a class down, you can always put it in its own module. So
why bother?
The problem is that these existing tools force you into coarse
choices. If helpers and tests live in the same module, they see
everything. If you isolate the class in its own module, they see
nothing. And while package is useful for widening access across a
package hierarchy, it doesn't let you narrow visibility inside a
module. Rather, package widens visibility, across a whole package
hierarchy. That's not the same as narrowing it down. In practice,
this means you can't express a very common design intent:
"helpers in the same module should see some internals, but not
the most sensitive ones."
That's where a 'scopeprivate' comes in. It introduces a missing
rung on the visibility ladder: type-only access. With it, you
could mark fields like Account.id or Account.owner as private or
package, so persistence and logging helpers can use them, while
marking fields like Account.balance or Account.authToken as
scopeprivate, so only the class itself can touch them. The
compiler enforces this boundary, preventing accidental leaks in
logs, persistence, or tests. You no longer need to split every
class into its own module just to achieve stricter encapsulation,
and you no longer need to rely on convention or code review to
stop sensitive data from slipping out.
In other words, scopeprivate doesn't make the impossible possible
- you can already hack around with module boundaries and package.
What it does is make disciplined program design, explicit and
enforceable. It lets you express intent directly at the
declaration site, keeps related code together without sacrificing
safety, and ensures that invariants remain protected by
construction.
For developers who care about clarity, maintainability, and
correctness, that's not redundant - it's a meaningful step toward
code that says exactly what it means.
module exampleOnly;
@safe:
private:
//import std;
import std.stdio : writeln;
import std.exception : enforce;
public class Account
{
private
{
int id;
string owner;
}
scopeprivate
{
double balance;
string authToken;
}
public this(int id, string owner, double openingBalance, string
token)
{
this.id = id;
this.owner = owner;
this.balance = openingBalance;
this.authToken = token;
}
public void deposit(double amount)
{
enforce(amount > 0, "Deposit must be positive");
balance += amount;
}
public void withdraw(double amount)
{
enforce(amount > 0 && amount <= balance, "Invalid
withdrawal");
balance -= amount;
}
public double getBalance() const { return balance; }
}
// Can see IDs and owners (enough to write to DB), but not
balances or tokens.
public void saveToDatabase(Account a)
{
writeln("[DB] INSERT INTO accounts (id, owner) VALUES (", a.id,
", '", a.owner, "')");
// Example compiler message if you were to uncomment the lines
below:
// writeln("Balance: %s", a.balance);
// Error: no property `balance` for `a` of type
`finance.Account` [`balance` is not accessible here]
// writeln("AuthToken: %s", a.authToken);
// Error: no property `authToken` for `a` of type
`finance.Account` [`authToken` is not accessible here]
}
// Logs can include IDs and owners for traceability, but cannot
leak sensitive state.
public void logTransactionStart(Account a, string action)
{
writeln("[LOG] Account id=", a.id, " owner=", a.owner, "
starting action=", action);
// Example compiler message if you were to uncomment the lines
below:
//writeln("[LOG] Account balance = %s", a.balance);
// Error: no property `balance` for `a` of type
`finance.Account` [`balance` is not accessible here]
//writeln("[LOG] Account authToken = %s", a.authToken);
// Error: no property `authToken` for `a` of type
`finance.Account` [`authToken` is not accessible here]
}
public void logTransactionEnd(Account a, string action, bool
success)
{
writeln("[LOG] Account id=", a.id, " owner=", a.owner, "
finished action=", action, " status=", success ? "OK" : "FAILED");
}
// Unit tests are also subject to scopeprivate's visibility
restriction.
// Compile-time visibility tests
unittest
{
auto acc = new Account(5, "Eve", 400.0, "tok5");
// Allowed:
static assert(__traits(compiles, acc.getBalance()));
// Forbidden (scopeprivate):
static assert(!__traits(compiles, acc.balance));
static assert(!__traits(compiles, acc.authToken));
}
// Constructor tests
unittest
{
auto acc = new Account(1, "Alice", 100.0, "tok");
assert(acc.getBalance() == 100.0);
}
// Deposit tests
unittest
{
import std.exception : assertThrown;
auto acc = new Account(2, "Bob", 50.0, "tok2");
acc.deposit(25.0);
assert(acc.getBalance() == 75.0);
assertThrown!Exception(acc.deposit(0));
assertThrown!Exception(acc.deposit(-10));
}
// Withdraw tests
unittest
{
import std.exception : assertThrown;
auto acc = new Account(3, "Charlie", 200.0, "tok3");
acc.withdraw(50.0);
assert(acc.getBalance() == 150.0);
assertThrown!Exception(acc.withdraw(0));
assertThrown!Exception(acc.withdraw(-5));
assertThrown!Exception(acc.withdraw(500)); // too much
}
// Logging and DB output tests
@system unittest
{
// helper to capture writeln output
string captureOutput(void delegate() dg)
{
import std.array : appender;
import std.stdio : stdout, File;
import core.stdc.stdio : fflush;
import std.string : strip;
auto buf = appender!string();
auto old = stdout;
auto f = File.tmpfile();
scope(exit) f.close();
stdout = f;
dg();
fflush(f.getFP());
f.rewind();
foreach (line; f.byLine)
buf.put(line.idup);
stdout = old;
return buf.data.strip;
}
auto acc = new Account(4, "Dana", 300.0, "tok4");
auto dbOut = captureOutput({ saveToDatabase(acc); });
assert(dbOut == "[DB] INSERT INTO accounts (id, owner) VALUES
(4, 'Dana')");
auto logStart = captureOutput({ logTransactionStart(acc,
"deposit"); });
assert(logStart == "[LOG] Account id=4 owner=Dana starting
action=deposit");
auto logEndOk = captureOutput({ logTransactionEnd(acc,
"deposit", true); });
assert(logEndOk == "[LOG] Account id=4 owner=Dana finished
action=deposit status=OK");
auto logEndFail = captureOutput({ logTransactionEnd(acc,
"withdraw", false); });
assert(logEndFail == "[LOG] Account id=4 owner=Dana finished
action=withdraw status=FAILED");
}
More information about the Digitalmars-d
mailing list