22. Modules: the shape of a wolf project
Chapter 18 was about work the compiler does before your program starts. The rest of this part is about work you do before the compiler starts: deciding where code lives, whose code you are willing to build, and what either decision costs later. It begins with the smallest of those questions, which wolf answers with a filesystem.
A module is a directory. Not a file, not a declaration, not a line at the top of the file announcing what the file is called. You make one by making a directory; you import it by naming the directory; and what the directory does with its files inside is nobody’s business but its own.
22.1 Directory = module
Here is a two-module project. The entry file:
use stats
fn main() -> !int {
var widths = List[int]()
(mut widths).push(4)
(mut widths).push(6)
(mut widths).push(8)
print("mean {stats.mean(widths)} of {stats.count(widths)}")
0
}
and the module it imports, which is the directory stats/ and happens
today to hold one file:
/// Summary statistics over a list of column widths.
/// The arithmetic mean, truncated; zero for an empty list.
pub fn mean(xs: List[int]) -> int {
if xs.is_empty() { 0 } else { total(xs) / xs.len }
}
/// How many widths there are.
pub fn count(xs: List[int]) -> int {
xs.len
}
fn total(xs: List[int]) -> int {
var t = 0
for x in xs { t += x }
t
}
$ lupin main.lu
mean 6 of 3
Three things in that pair of files are decisions, and the third is the one this section is about.
use stats names the directory. There is no path, no extension, and no
list of files to keep in step with the disk — the import says which
module, and the module is the folder. A module’s name is where it sits.
pub is the whole visibility vocabulary you need here. mean and
count carry it and are visible to importers; total does not and is
not. Between them sits pub(pkg), which exports an item to the other
modules of your own package and to nobody outside it — the spelling for
a seam that is real internally and not a promise to strangers.
And total is inside the module, which is why the third decision is
invisible until you go looking for it. Move total into its own file:
fn total(xs: List[int]) -> int {
var t = 0
for x in xs { t += x }
t
}
Nothing else changes. Not the entry file, not the import, not mean,
which goes on calling total as if the two were still typed one after
the other:
$ lupin main.lu
mean 6 of 3
That non-event is the design. File boundaries create no scopes, no
namespaces, and no import edges: the module’s namespace is the union of
its files, and a reorganization inside a directory is not an event any
importer can observe. Languages that make the file the unit force you to
choose between a file you can navigate and an interface you can keep
stable. Wolf does not put that choice in front of you, and the price is
that you cannot use the file as a privacy boundary — total is private
to stats, not to summary.lu.
Two consequences follow immediately, and both are errors rather than conventions.
Because sibling files are one namespace, two of them cannot define the same name. It reads as a surprise the first time, because nothing imports anything:
fn describe() -> str { "from extra" }
with a describe already in the entry file, and the objection lands
where the second one sits:
$ lupin main.lu
main.lu: E0302: the name `describe` is defined twice in this module (defined again in `./main.lu`); file boundaries create no scopes (D32) [mod.dup] at 3..11
And because pub is the export, asking for something private gets an
answer that respects your time:
$ lupin main.lu
main.lu: E0304: `total` exists in `vault`, but it is private; only `pub`/`pub(pkg)` items are visible across modules (D32) [mod.vis.private] at 49..54
Read that diagnostic twice. It does not say the name does not exist. A resolver that answered “no such item” would send you hunting a typo that is not there; this one tells you the item is real, the objection is visibility, and which two spellings would change the answer. The difference between those two messages is ten minutes of your life, once per occurrence, forever.
One more error belongs in this section because it surprises people arriving from anywhere else. An import you do not use is not a warning:
$ lupin main.lu
main.lu: E0305: the import `tools` is never used in `./main.lu`; an unused import is a hard error (D32), and deleting the line is machine-applicable [mod.use.unused] at 4..9
Wolf takes Go’s position here, with Go’s justification: an unused import is a dependency edge that slows every build and means nothing, and the fix is a deletion a machine can perform. A warning would be a request.
Exercise 22-1 (fingers · lupin) — Build the two-module project
above from scratch: an entry file and a stats/ directory exporting
mean, with a private total the entry never sees. Run it. Then move
total into a second file inside stats/ and state what changes for the
entry file.
Exercise 22-2 (comprehension · lupin) — vault/keys.lu defines
pub fn count(), pub fn loaded(), and private fn secrets() and
fn total(). The entry calls vault.total(). Predict the diagnostic —
including whether it says the name does not exist — and the exit code.
Exercise 22-3 (comprehension · lupin) — twice/main.lu and its
sibling twice/extra.lu each define fn describe(). Neither file
imports the other. Predict the verdict, and say why “neither imports the
other” is a trap in the question.
Exercise 22-4 (comprehension · lupin) — The entry imports tools
and never mentions it again. Predict: warning or error, and what the
diagnostic offers about the fix.
22.2 No cycles
Two modules, each with a reason to import the other. store writes rows
and asks index to publish them:
/// The row store: writes rows, and asks `index` to publish each one.
use index
/// Store one row and publish it.
pub fn put(n: int) -> int {
index.note(n)
}
/// How many rows the store will accept at once.
pub fn batch() -> int { 64 }
and index publishes rows and asks store whether they are valid:
/// The row index: publishes rows, and asks `store` to validate them.
use store
/// Publish one row.
pub fn note(n: int) -> int {
if n < store.batch() { 0 } else { 1 }
}
/// The index's fan-out.
pub fn width() -> int { 8 }
Neither import is silly. That is how real cycles are born — not from carelessness but from two modules each needing one true thing from the other. Wolf refuses anyway, and draws the loop it refused:
$ lupin main.lu
main.lu: E0303: this import completes a cycle: `store` → `index` → `store` (in `./index/index.lu`); imports between modules must form a DAG (D32) [mod.cycle] at 70..80
The arrows in that message are the whole refactor brief. Look at what
each side wanted: store wanted a publisher, index wanted the batch
rule. Only one of those is shared vocabulary, and it is not a service —
it is a fact about rows. So it moves into a third module that imports
nothing:
/// What `store` and `index` needed from each other, and nothing else.
/// The largest batch either side will accept.
pub fn batch() -> int { 64 }
/// Whether a row number is inside a batch.
pub fn in_batch(n: int) -> bool {
n < batch()
}
index now consumes kinds instead of calling back into store:
/// The row index: publishes rows, and decides validity from `kinds`.
use kinds
/// Publish one row.
pub fn note(n: int) -> int {
if kinds.in_batch(n) { 0 } else { 1 }
}
/// The index's fan-out.
pub fn width() -> int { 8 }
and the arrows form a DAG:
$ lupin main.lu
stored 0
This is interface extraction, and it has one discipline that decides
whether it worked: the extracted module holds what both sides needed from
each other, and nothing else. If kinds starts importing things, the
tangle is reassembling under a new name.
Three things the rule buys, in the order you feel them.
Builds. A DAG gives every module a finish order, so compilation parallelizes and an incremental build has a frontier. A cycle collapses its members into one unit that recompiles together, forever, because nothing in it can be finished before the rest.
Comprehension. In a DAG, “what does this depend on” is a question that terminates. Inside a cycle everything depends on everything, and the forty files you would have to touch to break it were already one file wearing forty names.
Interfaces. E0303 forced us to name the shared vocabulary. kinds
exists because the compiler would not let the dependency stay implicit,
and a named seam is where documentation, tests, and ownership attach.
That is the rule paying for itself: the refactor it demanded is the one a
reviewer would have asked for.
There is a fourth, and it is mechanical. The thing importers depend on is a module’s exported surface, and the compiler will tell you exactly what that surface is and hash it:
$ wolf interface ./summary.lu
module pkg :: (root)
wolfi v0 · toolchain 0.1.0 · edition v1
export_hash 80ef04e41dd9d6880d0dc85db9c3da4ee64a83158c04438747f98f8088f95d22
pkg_hash 80ef04e41dd9d6880d0dc85db9c3da4ee64a83158c04438747f98f8088f95d22
deps: (none)
items:
[0] pub count — fn count(xs: prelude.List[int]) -> int · regions (ρ1) -> -
[1] pub mean — fn mean(xs: prelude.List[int]) -> int · regions (ρ1) -> -
Two pub items, one hash over them, and total nowhere in the listing —
private surface is not part of the interface, so it cannot be part of the
hash. That number is the honest answer to “is this change visible to my
importers”: if it does not move, they cannot tell. Splitting a file does
not move it. Renaming a private helper does not move it. Chapter 25 spends
that fact on semantic versioning, where a hash that did not move is the
difference between a patch and a lie.
Exercise 22-5 (comprehension + extension · lupin) — In tangle/,
store imports index to publish entries and index imports store to
validate them — each import has a reason, which is how real cycles are
born. Predict the diagnostic. Then perform the interface-extraction
refactor in a copy: move the shared vocabulary into a third module
neither imports from, and run the result.
Exercise 22-6 (comprehension · prose) — A library refactor splits
one 900-line module file into four files in the same directory, moves
nothing across module boundaries, and changes no pub markers. List
everything that changes for the library’s importers, then name the
artifact from this section that would prove your answer mechanically.
22.3 No life before main
Wolf has no code that runs before main. No static initializers, no
module-level constructors, no registration hook the linker calls in
whatever order it assembled the objects. A program’s first act is its
first line.
That rule deletes a whole genre of bug and, with it, a whole genre of
pattern. The genre is registration: a plugin system where each module
announces itself into a global table on the way up, so that by the time
main runs, the table is populated by everyone who linked in. It is a
good pattern with one soft spot, which is that it is a set of writes to
shared state in an order nobody chose.
Wolf’s replacement is not a hook. It is a value:
struct Ingest { rows: int }
struct Report { rows: int }
struct Purge { rows: int }
comptime fn handlers(a: type, b: type, c: type) -> str {
"{typeinfo(a).name} {typeinfo(b).name} {typeinfo(c).name}"
}
comptime fn expect_three(a: type, b: type, c: type) -> bool {
assert(handlers(a, b, c).len == 19)
true
}
fn main() -> !int {
const HANDLERS = handlers(Ingest, Report, Purge)
const CHECKED = expect_three(Ingest, Report, Purge)
print("{HANDLERS}")
if CHECKED { 0 } else { 1 }
}
$ wolf run registry.lu
Ingest Report Purge
handlers is chapter 18’s tier doing chapter 18’s job: ordinary wolf,
evaluated during compilation, taking types as arguments and reflecting
their names. What lands in HANDLERS is a finished table. The running
program does not build it, does not lock anything to build it, and cannot
observe a moment when it was half-built, because there was no such
moment — the table was assembled on the compiler’s clock and the binary
carries the answer.
Now look at what happened to the ordering question. It was not answered. It was deleted. There is no “before main” in which two registrations race, no link order to depend on, no initialization sequence to document, and two builds of this source produce the same table byte for byte because comptime cannot read anything that differs between them.
The registry also gets something the init() version never had: a claim
the compiler settles. expect_three asserts the table’s shape at compile
time, so a fourth handler that nobody wired up is a build failure rather
than a mystery at run time. Break the claim and the build stops with the
assertion that failed:
struct Ingest { rows: int }
struct Report { rows: int }
struct Purge { rows: int }
comptime fn handlers(a: type, b: type, c: type) -> str {
"{typeinfo(a).name} {typeinfo(b).name} {typeinfo(c).name}"
}
comptime fn expect_three(a: type, b: type, c: type) -> bool {
assert(handlers(a, b, c).len == 26)
true
}
fn main() -> !int {
const HANDLERS = handlers(Ingest, Report, Purge)
const CHECKED = expect_three(Ingest, Report, Purge)
print("{HANDLERS}")
if CHECKED { 0 } else { 1 }
}
error[E0710]: this comptime assertion failed
--> ./s2.lu:13:5
|
13 | assert(handlers(a, b, c).len == 26)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ evaluated to `false` at compile time
...
19 | const CHECKED = expect_three(Ingest, Report, Purge)
| ----------------------------------- while evaluating `expect_three`, entered here
| ----------------------------------- while evaluating `main`, entered here
|
= note: a failed comptime `assert` stops compilation — it is the witness mechanism for facts the
checker cannot see on its own.
What init() could do that a comptime registry cannot is exactly the set
of things this rule is glad to lose: arbitrary effects, at an unspecified
time, before anyone could have asked for them. Everything else — the
table, the dispatch, the check that the table is complete — is a value,
and values are the thing wolf is good at.
Exercise 22-7 (comprehension · wolf) — The init() idiom this
section retires: a plugin system where each module’s init() registers a
handler into a global table at startup, in whatever order the linker felt
like. Write the comptime replacement for four handlers, with a witness
that fails the build if one goes missing, and say what became of the
ordering question.
Exercise 22-8 (design) — Import cycles are errors (D32). A colleague argues the compiler should permit cycles and merely warn, citing a large codebase where breaking them means touching forty files. Argue wolf’s side using what the rule buys, then concede the strongest point on the other side and answer it.