The Loup Freestanding Book — contents
  1. Chapter 1: What Is Freestanding Mode?
  2. Chapter 2: Building a Bare-Metal Binary
  3. Chapter 3: Integer Types and Wrapping Arithmetic
  4. Chapter 4: Functions and Control Flow
  5. Chapter 5: Structs and Layout Control
  6. Chapter 6: Enums and Pattern Matching
  7. Chapter 7: Classes and Allocators
  8. Chapter 8: Interfaces and Generics
  9. Chapter 9: Error Handling with Result
  10. Chapter 10: Ownership Verbs
  11. Chapter 11: Borrow and Lifetimes
  12. Chapter 12: Unique Ownership
  13. Chapter 13: Memory Primitives
  14. Chapter 14: Inline Assembly
  15. Chapter 15: MMIO with Volatile
  16. Chapter 16: Interrupts and Naked Functions
  17. Chapter 17: Entry Points and Panic
  18. Chapter 18: Sections and the Linker
  19. Chapter 19: Multicore and Atomics
  20. Chapter 20: The Available Standard Library Subset
  21. Chapter 21: Example — x86_64 Kernel Stub
  22. Chapter 22: Testing and Deploying Firmware
  23. Chapter 23: Memory Safety and Kernel Development Patterns
  24. Chapter 24: synchronized Classes and Lock Ordering

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:

This code does not compile
@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:

This code does not compile
@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:

This code does not compile
@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:

This code does not compile
@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 with E_FREESTANDING_DYNAMIC; it needs a runtime type table.
  • Reflection — core.reflect and core.inspect cannot be imported in freestanding (chapter 20). Every question about types is answered at compile time.
  • Any — the Language Reference excludes Any from 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 an Any return type yet; do not use Any in freestanding code — use an enum or a union of concrete types.

Summary

  • Interfaces are declared with ;-terminated method signatures and implemented with implements on a struct or class; there is no impl block.
  • 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 where clauses 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 no const N type parameters — size buffers with constants.
  • Lifetime parameters ([lifetime ~a], borrow[~a]) are checked and erased.
  • dynamic class, reflection and Any do not belong in freestanding code.

Next: error handling with Result.