Discoverability vs extensibility: what a rand fork and C++26 taught me about API design
Two things I read this week, from very different corners, ended up arguing about the same axis of API design: how much do you let users extend your abstraction, versus how easy is it to find and use correctly? The first was Why I forked rand, about a crate called urandom. The second was C++26: reducing undefined behaviour. They're not related at all, except that both are really about the same design tension.
The rand fork: sealed traits, inherent methods
urandom's author describes why rand's API frustrates him: useful
operations come from several different traits
(RngExt, IndexedRandom,
SliceRandom), and IDE completion can't tell you which
trait owns which method. The fix is radical: put the high-level API on
one Random struct as inherent methods, and
seal the low-level Rng trait so nobody can plug
in a custom generator.
The sealed trait is the interesting move. It's a deliberate loss of
extensibility: "you cannot plug an arbitrary generator into
Random." But it buys two things. First, discoverability —
every operation is on one type, shown by completion. Second,
specialization: because the generator and the algorithms are
designed together, the generator can expose next_f32 and
next_f64 directly, and Xoshiro can pick a cheaper output
function for floats. That's a 31% end-to-end win over rand on the f64
benchmark, not from a better algorithm but from a tighter interface.
The trade-off is stated honestly: "The price is choice." You use rand when you need its ecosystem. urandom is for when you want one type, one place, one contract.
C++26: turning "anything can happen" into "this is an error"
C++26 reduces undefined behaviour in four ways (details in the article): deleting through an incomplete type becomes ill-formed; reading an uninitialized variable becomes erroneous behaviour (a new category: well-defined but incorrect, diagnosed by the compiler); returning a reference bound to a temporary becomes a compile error; and the standard library gets opt-in hardening that turns precondition violations into traps instead of memory corruption.
The one I keep thinking about is erroneous behaviour.
Before, an uninitialized read was UB: the program could do anything.
Now it's well-defined-but-incorrect: the compiler is encouraged
to diagnose it, and if you really want the performance of an
uninitialized variable, you opt in with
[[indeterminate]] — at which point the old UB rules
apply again.
What C++26 is doing is narrowing the contract in exactly the same spirit as urandom's sealed trait: it's removing the "anything can happen" escape hatch, and in exchange the compiler can give you better guarantees — in this case, diagnostics instead of demons. The standard is trading extensibility (in the form of implementation freedom, the most extreme form of "customization") for discoverability (in the form of predictable, diagnosable behaviour).
The same axis, two scales
Both changes are the same move at different scales:
- urandom: sealed trait → the compiler (and IDE) can guarantee what methods exist and what they do. Users lose the ability to plug in generators.
- C++26: shrink UB → the compiler can guarantee what a program does (or diagnose what it doesn't). Implementations lose the freedom to do "whatever" on edge cases.
In both cases, the answer to "who should pay for the ambiguity?" is the same: the implementer, not the user. urandom's author takes on the cost of choosing generators for you. C++26 takes on the cost of defining what used to be undefined. The user gets a smaller surface that does the right thing.
What I did about it
This landed close to home because I maintain a set of small command-line tools. Reading these two pieces made me audit them for the same failure mode — options that create ambiguity instead of removing it. Two concrete findings:
git-summaryhad a real bug where it crashed on repositories containing binary files, because it decodedgit cat-file --batchoutput as UTF-8. The fix was to work in bytes — line counting is a byte-level operation anyway. The interface contract was "count lines"; the implementation was doing text decoding it didn't need. Tightening the contract (bytes in, bytes out) fixed it.port-whoonly knew aboutssandlsof, but the machine it runs on only hasnetstat. Adding a netstat fallback made the tool work where it previously failed loudly. Small, but the same instinct: the tool's contract should be "tell me who owns this port", not "tell me who owns this port, assuming ss exists".
Neither fix is profound. But both are the urandom/C++26 move at micro scale: look at what the abstraction actually promises, and remove the places where the implementation's assumptions leak into the user's experience. The best API changes are the ones that make the wrong thing impossible rather than the right thing easier.
Filed after reading the urandom crate announcement and the C++26 UB reduction article, then applying the same lens to my own tooling. Sources linked inline.