Chapter 23

Modules and Files

Until now, most examples have fit in one file or one REPL session. Real programs do not. They are split into multiple classes and files, with clear boundaries between parts of the system.

In Nex, the main tool for bringing code from another Nex file into the current program is intern.

Why Split a Program

Splitting a program across files is not mainly about size; it is about design. Put code in a separate file when:

  • it represents a distinct concept
  • it can be understood independently
  • it should be reused elsewhere
  • keeping it separate makes the boundary clearer

A Bank_Account class and a Report_Printer class should not live in one file merely because both belong to the same application. They are different concepts with different reasons to change.

The intern Statement

Nex loads classes from other files with intern:

intern math/Calculator

This means: find the Nex file for Calculator under the math path and make that class available in the current program.

An alias may also be given:

intern math/Calculator as Calc

That allows the imported class to be referred to locally as Calc.

How intern Resolves Files

intern searches in this order:

  1. the directory of the currently loaded script, if the code came from a file
  2. the REPL or process working directory
  3. ~/.nex/deps

For a path-qualified intern such as:

intern net/Tcp_Socket

Nex tries these local layouts under each search root:


lib/net/Tcp_Socket.nex
lib/net/tcp_socket.nex
lib/net/src/Tcp_Socket.nex
lib/net/src/tcp_socket.nex

Then it tries the same path forms under:

~/.nex/deps

So all of these are valid examples:

./lib/net/tcp_socket.nex
/some/project/lib/net/Tcp_Socket.nex
~/.nex/deps/net/tcp_socket.nex
~/.nex/deps/net/src/Tcp_Socket.nex

Exact-case filenames are checked first. If they are not found, Nex falls back to a lowercase filename such as tcp_socket.nex.

A Simple Two-File Example

Suppose lib/math/Counter.nex contains:

class Counter
create
  make() do
    count := 0
  end
feature
  count: Integer
  increment() do
    count := count + 1
  end
  value(): Integer do
    result := count
  end
end

Another file may use it with:

intern math/Counter

class Main
create
  make() do
    let c := create Counter.make
    c.increment
    c.increment
    print(c.value)
  end
end

The point is that Counter now has its own module boundary. It can be read, tested, and reused on its own.

Namespaces and Ambiguous References

The path in intern path/Class is not only a way to find a file. It also becomes that class's namespace. Two libraries can each define a class named Account without conflict, right up until a single program interns both of them:

intern finance/Account
intern billing/Account

let a := create Account.make(1)

Nex does not guess which Account is meant here, and it does not let whichever library happened to load first silently win. It rejects the program at compile time:

Ambiguous reference to 'Account': interned from billing.Account and finance.Account.
Reference one directly by its qualified name (e.g. billing.Account), or rename it
on the way in with a path-qualified `intern ... as`.

The fix is to write the class's full name — the same path/Class form used on the intern line — anywhere the class name would otherwise appear: in a type annotation, in create, in inherit, or in a match clause.

intern finance/Account
intern billing/Account

let a: finance/Account := create finance/Account.make(1)
let b: billing/Account := create billing/Account.make(2)
print(a.balance)
print(b.id)

Both classes are now usable in the same program, each referred to unambiguously by its own qualified name. Two things are worth noting about how this behaves:

  • A class declared directly in the current file always wins over an interned one of the same name. Ambiguity is only ever between two interned classes — an unrelated library update can never silently break code that already worked by introducing a name clash with something you defined yourself.
  • If two different intern paths happen to lead to the same file — two libraries that both depend on a third and each intern it — that is not a collision. It is one class, reachable two ways.

A bare, unqualified name — no path at all, like a plain intern Logger — never collides with anything else brought in the same way, so this only comes up once a program starts interning path-qualified libraries.

Aliases

Aliases are useful when:

  • the imported name is long
  • two imported classes would otherwise collide
  • a local short name improves readability

Example:

intern geometry/Long_Polygon_Name as Polygon

let p := create Polygon.with_sides(5)

Use aliases sparingly. The goal is clarity, not abbreviation for its own sake.

Aliasing to resolve a collision

A path-qualified alias is also a second way out of the ambiguity in the previous section — rename one side of a collision (or both) on the way in, and each alias refers unambiguously to its own class from then on:

intern finance/Account
intern billing/Account as Billing_Account

let b := create Billing_Account.make(2)
print(b.id)

Billing_Account reaches billing's class directly, without ever touching the name it collides on. The bare name Account is unchanged, though: it is still ambiguous, so it must not be used while both classes are interned. To refer to both classes by short names, alias both sides:

intern finance/Account as Finance_Account
intern billing/Account as Billing_Account

let a := create Finance_Account.make(1)
let b := create Billing_Account.make(2)

Aliasing only helps this way when the intern being renamed is path-qualified; aliasing a bare, unpathed intern is purely for shortening or readability, since there is no path to disambiguate with.

Free Functions and intern

intern brings in a unit's free functions exactly as it brings in its classes — the whole file, not only the one name written after the path. A function sharing a file with an interned class arrives alongside it, under its own bare name, whether or not you ever use it.

A function's qualified name is the directory path of the intern followed by the function's own name — not the file's name. Interning trade/shipping, whose file defines ship, makes the function callable as trade.ship.

But a function has none of the escape hatches above: as only ever renames a class. Given two warehouses that each define a ship function, rounding a shipment quantity a different way:

-- trade/ship.nex
function ship(qty: Integer): Integer do result := qty + 1 end

-- other/ship.nex
function ship(qty: Integer): Integer do result := qty - 1 end
intern trade/ship
intern other/ship

print(ship(10))

a bare call is rejected the same way a bare class reference is:

Ambiguous reference to 'ship': interned from other.ship and trade.ship.
Call one directly by its qualified name, e.g. other.ship(...).

The fix looks similar to a class's qualified name, but is spelled differently — with . rather than /:

intern trade/ship
intern other/ship

print(trade.ship(10))  -- 11
print(other.ship(10))  -- 9

The different spelling is a matter of syntax, not style. A class's path/Class could only appear in a type annotation, a create, an inherit, or a match clause — never in the middle of an ordinary expression, so / can be used there without being mistaken for division. A function call is an ordinary expression, and / already means division inside one, so a qualified call instead reuses . — the same character an ordinary method call already uses.

trade.ship(…) parses exactly like a method call on a variable named trade. Nex reads it as a qualified call unless trade is bound where the call appears — a local, parameter, or field of that name in the enclosing routine or class, or a global or class of that name. A local only shadows the module inside the routine that declares it:

intern trade/ship

function use(trade: Integer): Integer do
  result := trade + 2
end

print(trade.ship(10))  -- 11

The parameter trade belongs to use alone, so the top-level call still reaches the module. Inside use, however, trade.ship(…) would be an ordinary member access on the parameter — avoid reusing an intern path segment as a local name in a routine that also calls that module.

A multi-segment path is written the same way, one more .:

intern trade/core/ship

print(trade.core.ship(10))

As with classes, none of this matters until two interned functions actually collide. Most of the time a function is simply called by its bare name, exactly as before.

Designing Module Boundaries

A good file boundary usually matches a good class boundary. As a rough rule:

  • one main class per file
  • helper classes in their own files when they have an identity of their own
  • unrelated utility code should not be stuffed into a random “misc” file

Ask of every file:

  • what concept does this file define?
  • what other files should know about it?
  • what can remain private to this file’s class or classes?

Files are design documents as much as storage containers.

What Belongs Together

These belong together:

  • a Stack class and helper routines whose only purpose is to support the stack

These do not:

  • Stack, Customer, and Image_Loader in the same file

If you feel tempted to group code by when it was written rather than by what it means, stop and redesign.

The best modules reduce mental load. A reader opening a file should have a good guess what they are about to find.

Intern and Contracts

Contracts become even more important once code is split across files. When a class is used from another module, the reader of the calling code should not have to open the original file to learn its basic obligations and guarantees; good preconditions, postconditions, and invariants are what make module boundaries trustworthy.

In a multi-file program, the contract is often the first and most important documentation of a class.

A Worked Example: Splitting a Small Ledger

Imagine a program with these concepts:

  • Transaction
  • Account
  • Ledger_Report

A clean structure would be:

finance/Transaction.nex

class Transaction
create
  make(d: String, a: Real) do
    description := d
    amount := a
  end
feature
  description: String
  amount: Real
end

finance/Account.nex

intern finance/Transaction

class Account
create
  make(name: String) do
    owner := name
    entries := []
  end
feature
  owner: String
  entries: Array[Transaction]
  add_entry(t: Transaction) do
    entries.add(t)
  end
  balance(): Real do
    result := 0.0
    across entries as entry do
      result := result + entry.amount
    end
  end
end

finance/Ledger_Report.nex

intern finance/Account

class Ledger_Report
feature
  print_balance(a: Account) do
    print(a.owner + ": " + a.balance.to_string)
  end
end

Each file has one job. The design is visible in the file structure itself.

Summary

  • Split programs into files to express design boundaries, not merely to reduce length
  • Use intern path/Class_Name to load Nex classes from other files
  • The path in intern path/Class is also the class's namespace: two interned classes with the same bare name are a compile-time ambiguous reference error, never a silent guess
  • Resolve a collision by writing the class's qualified name (path/Class) directly, or by giving one side a path-qualified alias with as
  • A class declared in the current file always wins over an interned one of the same name; only two interned classes can collide
  • Use as when a local alias improves clarity, even without a collision
  • intern brings in a unit's free functions the same way it brings in its classes, but a function cannot be renamed with as; a colliding bare call is an ambiguous reference too, resolved instead with a qualified call — path.name(…), spelled with . rather than / because a function call, unlike a class reference, is an ordinary expression where / already means division
  • A qualified call is read that way unless its leading name is bound where the call appears — a local, parameter, or field in the enclosing routine or class, or a global or class of that name; a local shadows the module only inside the routine that declares it
  • Good file boundaries usually follow good class boundaries
  • Contracts make multi-file code easier to trust and reuse
  • A clean module layout reduces coupling and mental overhead

Exercises

1. Split a simple earlier example into two files: one defining a class and one using it with intern.

2. Take a class that currently does too much and divide it into two classes in two files. Explain why the new boundary is better.

3. Write a short example using intern ... as ... and show why the alias is helpful.

4. Sketch a directory layout for a small address-book program with classes Contact, Address_Book, and Csv_Exporter.

5. Write two small libraries that each define a class with the same name, then intern both into one program. Show two different ways to use both classes: a qualified reference, and an alias.

6. Write two small libraries that each define a free function with the same name, then intern both into one program. Show the ambiguous-reference error a bare call produces, then fix it with a qualified call to each.

7.* Choose a chapter 26-sized program idea of your own. Before writing any code, propose the file structure and justify each file in one sentence.