Chapter 8: Interfaces and Generics
Freestanding code keeps Loup's interfaces and generics. A UART, a framebuffer console and a debug ring buffer can all implement one SerialOut interface; a table or a ring buffer can be written once and used for several element types. Both features are resolved at compile time, so neither needs a runtime (Language Reference §3-3.4.3):
- a call through an interface-typed value is a static type-id dispatch: the compiler knows every type that implements the interface and branches to the right method — no heap-allocated vtable, no runtime registration;
- generic code is monomorphized: each instantiation becomes its own concrete code.
This chapter covers interface declarations and dispatch, generic functions and types, bounds, explicit type arguments, lifetime parameters, and the parts of the hosted feature set that freestanding leaves out.
Declaring and implementing an interface
An interface lists method signatures, each ending in ;. A struct or class names the interfaces it implements with implements in its header and defines the methods in its body:
@mode(freestanding)
interface SerialOut {
func writeByte(b: uint8): void;
func name(): string;
}
# The PC's COM1 port: an x86 I/O port, written with `out`.
struct PortUart implements SerialOut {
var port: uint16;
func writeByte(b: uint8): void {
asm { "out dx, al" in: "dx" = this.port, "al" = b clobbers: [] }
}
func name(): string {
return "com1"
}
}
# A ring buffer in memory, for when no UART is wired up.
struct MemoryLog implements SerialOut {
var written: uint32;
func writeByte(b: uint8): void {
this.written = this.written + 1u32
}
func name(): string {
return "memlog"
}
}
Listing 8-1: An interface with two implementations
There is no separate impl block. Writing the implementation the Rust or Swift way fails at the type name:
@mode(freestanding)
interface SerialOut {
func writeByte(b: uint8): void;
}
struct Uart {
var port: uint16;
}
impl Uart: SerialOut {
func writeByte(b: uint8): void { }
}
error[E0003]: expected `;`
--> src/main.lpfree:11:6
|
11 | impl Uart: SerialOut {
| ^^^^
|
error[E0001]: expected expression
--> src/main.lpfree:13:1
|
13 | }
| ^
|
Move the methods into the struct and add implements SerialOut to its header, as in Listing 8-1.
The Language Reference requires every interface method to be implemented. The 1.25 checker does not report a missing method yet, so a struct that declares implements SerialOut without a name() method passes loup check; review implementations against their interface.
Programming against an interface
A parameter, local or field of interface type accepts any implementation; assigning a struct to an interface-typed binding is the upcast:
func writeStr(out: SerialOut, s: string): void {
for (c in s) {
out.writeByte(c as uint8)
}
}
func banner(useUart: bool): void {
if (useUart) {
let uart: SerialOut = PortUart(port = 0x3F8u16)
writeStr(uart, "Loup kernel booting\n")
} else {
writeStr(MemoryLog(written = 0u32), "Loup kernel booting\n")
}
}
Listing 8-2: Dispatching through an interface-typed value
out.writeByte(...) compiles to a branch on the receiver's type id between PortUart.writeByte and MemoryLog.writeByte. Adding a third implementation adds a third branch; nothing is registered at run time.
No default methods in freestanding
In the hosted modes an interface method can have a default body that every implementation inherits. Freestanding leaves default bodies out (§3-3.4.3), and a call that relies on one fails:
@mode(freestanding)
interface SerialOut {
func writeByte(b: uint8): void;
# A default method, written once for every implementation.
func writeStr(s: string): void {
for (c in s) {
this.writeByte(c as uint8)
}
}
}
struct DebugPort implements SerialOut {
var port: uint16;
func writeByte(b: uint8): void {
asm { "out dx, al" in: "dx" = this.port, "al" = b clobbers: [] }
}
}
func banner(out: DebugPort): void {
out.writeStr("boot\n")
}
error[E0001]: type 'DebugPort' has no method 'writeStr'
--> src/main.lpfree:22:5
|
22 | out.writeStr("boot\n")
| ^^^^^^^^^^^^^^^^^^^^^^
|
Write the shared behaviour as a free function over the interface, as writeStr(out: SerialOut, s: string) in Listing 8-2 does. It is just as reusable and makes the dispatch visible.
Generic functions and types
Type parameters go in square brackets after the name. Calls usually infer them; you can always write them:
struct Pair[T] {
var first: T;
var second: T;
}
func swapPair[T](p: Pair[T]): Pair[T] {
return Pair(first = p.second, second = p.first)
}
enum Either[L, R] {
Left(value: L),
Right(value: R),
}
func isLeft[L, R](e: Either[L, R]): bool {
return match e {
Either.Left(v) => true,
Either.Right(v) => false,
}
}
func demo(): uint32 {
let p = swapPair[uint32](Pair(first = 1u32, second = 2u32)) # explicit
let q = swapPair(Pair(first = 3u16, second = 4u16)) # inferred
return p.first + (q.first as uint32)
}
Listing 8-3: A generic struct, function and enum
Each distinct instantiation — swapPair[uint32], swapPair[uint16] — is compiled separately, so generic code costs the same as hand-written code for each type.
Bounds
A bound restricts a type parameter to types that implement an interface, which is what lets the body call the interface's methods. The Language Reference allows the bound inline, [L: Logger], or in a where clause after the return type. In 1.25 the checker only uses a where bound when it looks up a method:
@mode(freestanding)
interface Logger {
func log(level: uint8, msg: string): void;
}
func warn[L: Logger](logger: L, msg: string): void {
logger.log(2u8, msg)
}
error[E0001]: type 'T0' has no method 'log'
--> src/main.lpfree:8:5
|
8 | logger.log(2u8, msg)
| ^^^^^^^^^^^^^^^^^^^^
|
T0 is the checker's internal name for the type parameter L: with the inline bound, it does not know that L has a log method. Write the bound as a where clause:
interface Logger {
func log(level: uint8, msg: string): void;
}
struct CountingLogger implements Logger {
var lines: uint32;
func log(level: uint8, msg: string): void {
this.lines = this.lines + 1u32
}
}
func warn[L](logger: L, msg: string): void where L: Logger {
logger.log(2u8, msg)
}
func smaller[T](a: T, b: T): T where T: Comparable[T] {
if (a < b) {
return a
}
return b
}
func demo(): uint32 {
warn(CountingLogger(lines = 0u32), "fan speed low")
return smaller(3u32, 4u32)
}
Listing 8-4: Bounds written as where clauses
A generic bound and an interface-typed parameter (Listing 8-2) both work. The generic version gives one specialized copy per logger type; the interface version gives one function with a dispatch branch. Prefer the interface parameter unless the call is on a hot path.
Phantom type parameters and explicit type arguments
A type parameter does not have to appear in any value. A phantom parameter tags a value with a type the compiler then checks — a capability for a uint64 resource is not interchangeable with one for a page. The type argument cannot be inferred, so you write it:
struct Cap[T] {
var id: uint64;
}
struct PageFrame { var number: uint64; }
func mintCap[T](id: uint64): Cap[T] {
return Cap(id = id)
}
func pageCap(): uint64 {
let c = mintCap[PageFrame](7u64) # Cap[PageFrame]
return c.id
}
Listing 8-5: Explicit type arguments for a phantom-typed API
The same bracket form instantiates a static method (Type[T].method(...)) and the layout queries size_of[T]() you met in chapter 5.
Compile-time sizes
Generic types cannot take a number as a parameter. A C++ or Rust habit — RingBuf<T, N> — does not parse:
@mode(freestanding)
struct RingBuf[T, const N: uint32] {
var head: uint32;
var count: uint32;
}
error[E0001]: `const` is a reserved word (§1-3.4) and cannot name a type parameter
--> src/main.lpfree:3:19
|
3 | struct RingBuf[T, const N: uint32] {
| ^^^^^
|
error[E0001]: expected RBracket, found Identifier
--> src/main.lpfree:3:25
|
3 | struct RingBuf[T, const N: uint32] {
| ^
|
error[E0002]: expected `{`
--> src/main.lpfree:3:25
|
3 | struct RingBuf[T, const N: uint32] {
| ^
|
error[E0003]: expected `;`
--> src/main.lpfree:3:26
|
3 | struct RingBuf[T, const N: uint32] {
| ^
|
error[E0001]: expected expression
--> src/main.lpfree:6:1
|
6 | }
| ^
|
Fix the capacity with a const and use it in the buffer type, which accepts any compile-time expression (§3-3.17b):
const RX_SLOTS = 64
struct RxRing {
var bytes: buffer[uint8, RX_SLOTS];
var head: int;
var count: int;
func isFull(): bool {
return this.count == RX_SLOTS
}
}
func pushByte(edits ring: RxRing, b: uint8): bool {
if (ring.isFull()) {
return false
}
ring.bytes[(ring.head + ring.count) % RX_SLOTS] = b
ring.count = ring.count + 1
return true
}
Listing 8-6: A fixed-capacity ring sized by a constant
One more limitation in 1.25: a generic struct cannot hold a buffer of its own type parameter (buffer[T, 16] inside struct Ring[T] reports E_STRUCT_CLASS_FIELD at each instantiation, even with a T: ValueType bound). Give the buffer a concrete element type, as above.
Generic interfaces
Interfaces take type parameters too. A source of values of some type:
interface Source[T] {
func next(): Option[T];
}
# Hands out frame numbers from a fixed range.
struct FrameRange implements Source[uint64] {
var current: uint64;
var limit: uint64;
func next(): Option[uint64] {
if (this.current >= this.limit) {
return None
}
let frame = this.current
this.current = this.current + 1u64
return Some(frame)
}
}
func countFrames(edits frames: FrameRange): uint64 {
var n: uint64 = 0
while (true) {
match frames.next() {
Some(f) => { n = n + 1u64 }
None => { return n }
}
}
return n
}
Listing 8-7: A generic interface implemented for one element type
Lifetime parameters
Types and functions can also be parameterized by a lifetime, written lifetime ~name in the bracket list. A borrowed field or parameter labelled borrow[~name] is then tied to that region, and the compiler checks that the value does not outlive what it borrows from:
# A view of a frame's header that borrows the frame's text.
struct HeaderView[lifetime ~frame] {
borrow[~frame] data: string;
var start: int;
}
func header[lifetime ~frame](borrow[~frame] packet: string): HeaderView[~frame] {
return HeaderView(data = packet, start = 0)
}
Listing 8-8: A lifetime-parameterized view
The labels are erased after checking; they cost nothing at run time. Chapter 11 explains regions and what the 1.25 checker enforces.
What freestanding leaves out
- Default interface methods — not available (shown above).
dynamic class— rejected withE_FREESTANDING_DYNAMIC; it needs a runtime type table.- Reflection —
core.reflectandcore.inspectcannot be imported in freestanding (chapter 20). Every question about types is answered at compile time. Any— the Language Reference excludesAnyfrom freestanding union types and generic code that would need a runtime type witness (§3-3.4.3, §1-4.5). The 1.25 checker does not reject anAnyreturn type yet; do not useAnyin freestanding code — use an enum or a union of concrete types.
Summary
- Interfaces are declared with
;-terminated method signatures and implemented withimplementson a struct or class; there is noimplblock. - Calls through an interface-typed value use static type-id dispatch; generics are monomorphized. Neither needs a runtime or an allocator.
- Default interface methods are not available in freestanding; write a free function over the interface instead.
- Write bounds as
whereclauses in 1.25; the inline form[L: Logger]does not give the body the interface's methods yet. - Explicit type arguments
f[T](…)instantiate phantom-typed APIs; there are noconst Ntype parameters — size buffers with constants. - Lifetime parameters (
[lifetime ~a],borrow[~a]) are checked and erased. dynamic class, reflection andAnydo not belong in freestanding code.
Next: error handling with Result.