The Loup Scripting Book — contents
  1. Chapter 1: Getting Started
  2. Chapter 2: Variables and Data Types
  3. Chapter 3: Functions and Closures
  4. Chapter 4: Control Flow
  5. Chapter 5: Collections
  6. Chapter 6: Structs
  7. Chapter 7: Enums and Pattern Matching
  8. Chapter 8: Classes and Objects
  9. Chapter 9: Interfaces and Generics
  10. Chapter 10: Error Handling
  11. Chapter 11: Modules and Packages
  12. Chapter 12: Strings, Interpolation, and Template Fences
  13. Chapter 13: Concurrency
  14. Chapter 14: Reflection and Dynamic Compilation
  15. Chapter 15: Reactive UI
  16. Chapter 16: Testing
  17. Chapter 17: JavaScript Host Interop
  18. Chapter 18: Building Script Tools
  19. Chapter 19: Memory Safety and Resource Management

Chapter 9: Interfaces and Generics

What you'll learn: how to declare an interface and implement it, give an interface default methods, use the standard interfaces that power ==, <, for and with, overload operators, and write generic functions and classes — including how to constrain a type parameter with where.

Two features let one piece of code work with many types. An interface names a set of methods; any class that implements them can be used where the interface is expected. Generics let a function or class take a type as a parameter, so Stack[int] and Stack[string] share one implementation and are both fully type-checked.

Part 1: Interfaces

Declaring and implementing an interface

@mode(host.scripted)

interface Shape {
    func area(): float;
    func name(): string;

    func describe(): string {
        return i"{$ this.name() $} with area {$ this.area() $}";
    }
}

class Circle implements Shape {
    pub let radius: float;

    new(radius: float) {
        this.radius = radius;
    }

    pub func area(): float {
        return 3.14159 * this.radius * this.radius;
    }

    pub func name(): string {
        return "circle";
    }
}

class Rectangle implements Shape {
    pub let width: float;
    pub let height: float;

    new(width: float, height: float) {
        this.width = width;
        this.height = height;
    }

    pub func area(): float {
        return this.width * this.height;
    }

    pub func name(): string {
        return "rectangle";
    }
}

func totalArea(shapes: list[Shape]): float {
    var total = 0.0;
    for (shape in shapes) {
        total = total + shape.area();
    }
    return total;
}

let shapes: list[Shape] = [Circle(1.0), Rectangle(3.0, 4.5)];
for (shape in shapes) {
    println(shape.describe());
}
println(i"total: {$ totalArea(shapes) $}");

Listing 9-1: An interface with two required methods and one default method

circle with area 3.14159
rectangle with area 13.5
total: 16.64159
  • interface Shape { … } lists method signatures. A signature ending in ; is required; a method with a body is a default that implementers get for free (§1-11.1).
  • class Circle implements Shape promises to provide every required method. Interface methods are public, so the implementations are pub.
  • list[Shape] holds any mix of implementers, and totalArea works for shapes that do not exist yet. Nothing about Circle and Rectangle is related except the interface.
  • Inside a default method, this is the implementing object; call the other methods as this.area().

A class can extend one class and implement several interfaces: class Thumbnail : Image implements Shape, Closeable { … }. Writing an interface where the superclass goes, class Circle : Shape, means the same as class Circle implements Shape (§1-8.3).

Mistake: leaving out a required method

This code does not compile
@mode(host.scripted)

interface Shape {
    func area(): float;
    func name(): string;
}

class Square implements Shape {
    pub let side: float;

    new(side: float) {
        this.side = side;
    }

    pub func area(): float {
        return this.side * this.side;
    }
}
main.lpscr:8:1: error[E0001]: class 'Square' does not implement all methods of interface 'Shape'; missing: name(): string

The message lists every missing method with its signature. (The Language Reference calls this error E_INTERFACE_NOT_IMPLEMENTED; Loup 1.25 reports it as E0001.) Add pub func name(): string { return "square"; }.

Mistake: a signature that does not match

An implementation must use the interface's parameter and return types exactly:

This code does not compile
@mode(host.scripted)

interface Shape {
    func area(): float;
}

class Square implements Shape {
    pub let side: int;

    new(side: int) {
        this.side = side;
    }

    pub func area(): int {
        return this.side * this.side;
    }
}
main.lpscr:7:1: error[E0001]: method 'area' in class 'Square' has return type 'int', incompatible with interface 'Shape' return type 'float'

Return float, converting the integer: return (this.side * this.side) as float;.

Overriding a default method

An implementing class may replace a default method by declaring its own:

interface Greeter {
    func name(): string;

    func greet(): string {
        return i"Hello, {$ this.name() $}!";
    }
}

class Friendly implements Greeter {
    pub func name(): string {
        return "Ada";
    }
}

class Formal implements Greeter {
    pub func name(): string {
        return "Dr. Lovelace";
    }

    pub func greet(): string {
        return i"Good afternoon, {$ this.name() $}.";
    }
}

let greeters: list[Greeter] = [Friendly(), Formal()];
for (g in greeters) {
    println(g.greet());
}
Hello, Ada!
Good afternoon, Dr. Lovelace.

The Language Reference requires override on a method that replaces an interface default (§1-8.3.1). The 1.25 compiler rejects it there instead — pub override func greet() in Formal reports E_OVERRIDE_NO_TARGET because "class Formal has no supertype" — so leave override off when replacing an interface default. (Replacing a method inherited from a class does need override; see Chapter 8.)

Interfaces that extend interfaces

An interface can build on others with :. An implementer must provide the methods of the whole chain:

interface Named {
    func name(): string;
}

interface Priced : Named {
    func cents(): int;

    func label(): string {
        return i"{$ this.name() $}: ${$ this.cents() // 100 $}.{$ this.cents() % 100 $}";
    }
}

class Item implements Priced {
    pub let title: string;
    pub let price: int;

    pub func name(): string {
        return this.title;
    }

    pub func cents(): int {
        return this.price;
    }
}

let widget = Item(title = "Widget", price = 1250);
println(widget.label());

let asNamed: Named = widget;
println(asNamed.name());
println(widget is Named);
Widget: $12.50
Widget
true

x is I tests whether a value implements an interface at run time, and x as I converts it once you know.

Standard Interfaces

The language and standard library define interfaces that hook into syntax (§1-11.2):

Interface Methods you write What it enables
Equatable[T] operator func ==(other: T): bool == and != (Chapter 8)
Comparable[T] compareTo(other: T): int <, >, <=, >=
Hashable hashCode(): int use as a dict key or set element (see the gap below)
Iterable[T], Iterator[T] iter(): Iterator[T]; next(): Option[T] for (x in value)
Closeable close(): void with (x = value) { … } (Chapter 8)
Finalizable finalize(): void cleanup on del (Chapter 8)
Dynamic provided by dynamic class obj!{field} (Chapter 8)
Add, Sub, Mul, Div, … operator func +(…) and friends operator overloading (below)
From[E] static func from(err: E): Self error conversion with ? (Chapter 10)

Comparable: ordering

compareTo returns a negative number, zero or a positive number for "less than", "equal" and "greater than":

class Version implements Comparable[Version] {
    pub let major: int;
    pub let minor: int;
    pub let patch: int;

    pub func compareTo(other: Version): int {
        if (this.major != other.major) {
            return this.major - other.major;
        }
        if (this.minor != other.minor) {
            return this.minor - other.minor;
        }
        return this.patch - other.patch;
    }

    pub operator func ==(other: Version): bool {
        return this.compareTo(other) == 0;
    }

    pub func text(): string {
        return i"{$ this.major $}.{$ this.minor $}.{$ this.patch $}";
    }
}

let installed = Version(major = 1, minor = 4, patch = 2);
let required = Version(major = 1, minor = 10, patch = 0);
println(installed < required);
println(installed == Version(major = 1, minor = 4, patch = 2));

var releases = [required, installed, Version(major = 0, minor = 9, patch = 9)];
releases.sort(func(a: Version, b: Version) => a.compareTo(b));
for (v in releases) {
    println(v.text());
}

Listing 9-2: A comparable version number

true
true
0.9.9
1.4.2
1.10.0

Notice that 1.4.2 < 1.10.0, which a string comparison would get wrong. list.sort always takes a comparator function; passing a.compareTo(b) sorts in the type's natural order.

Why does Version also define ==? The Language Reference says Comparable[T] extends Equatable[T] and supplies == from compareTo (§1-11.2). Loup 1.25 does not do that yet:

This code does not do what it should
class Version implements Comparable[Version] {
    pub let major: int;
    pub let minor: int;

    pub func compareTo(other: Version): int {
        if (this.major != other.major) {
            return this.major - other.major;
        }
        return this.minor - other.minor;
    }
}

let installed = Version(major = 1, minor = 4);
let required = Version(major = 1, minor = 4);
println(installed >= required);
println(installed == required);
true
false

>= uses compareTo, but == still compares identity. Until the default arrives, give every Comparable class an explicit operator func ==, as Listing 9-2 does.

Iterable and Iterator: making a type work with for

A type works with for when it implements Iterable[T], whose iter() returns a fresh Iterator[T]. The iterator's next() returns Some(value) until it returns None:

class CountdownIterator implements Iterator[int] {
    var current: int;

    new(start: int) {
        this.current = start;
    }

    pub modifier func next(): Option[int] {
        if (this.current <= 0) {
            return None;
        }
        let value = this.current;
        this.current = this.current - 1;
        return Some(value);
    }
}

class Countdown implements Iterable[int] {
    pub let start: int;

    new(start: int) {
        this.start = start;
    }

    pub func iter(): Iterator[int] {
        return CountdownIterator(this.start);
    }
}

for (n in Countdown(3)) {
    println(n);
}
println("liftoff");

Listing 9-3: A custom iterable

3
2
1
liftoff

next() changes the iterator's current field, so it is a modifier (Chapter 8); implementing a non-modifier interface method with a modifier one is allowed. Because iter() creates a new iterator each time, a Countdown can be looped over more than once. For most cases a generator — gen func (Chapter 3) — is shorter.

Hashable

Hashable declares hashCode(): int. Two values that are == must have the same hash code.

Known gap. In Loup 1.25, dict keys and set elements that are class instances are compared by identity, even when the class implements Equatable and Hashable: two Tag("a") objects are two different keys. Use strings, numbers, enums or structs as keys until this is implemented.

⚠ Planned. Renderable (func render(ctx: RenderContext): string) is specified to control how a value appears in interpolated strings and template fences (§1-11.2, Chapter 12). In 1.25 the type RenderContext is not defined and interpolation does not call render; an object interpolates as ClassName(field: value, …).

Operator Overloading

Each operator belongs to an interface: + to Add, - to Sub, * to Mul, / to Div, % to Mod, == to Equatable, a[k] to Index, and so on (§1-5.1). To overload an operator, implement the interface and write operator func:

class Vector2 implements Add[Vector2, Vector2], Sub[Vector2, Vector2], Mul[float, Vector2] {
    pub let x: float;
    pub let y: float;

    new(x: float, y: float) {
        this.x = x;
        this.y = y;
    }

    pub operator func +(other: Vector2): Vector2 {
        return Vector2(this.x + other.x, this.y + other.y);
    }

    pub operator func -(other: Vector2): Vector2 {
        return Vector2(this.x - other.x, this.y - other.y);
    }

    pub operator func *(factor: float): Vector2 {
        return Vector2(this.x * factor, this.y * factor);
    }
}

let a = Vector2(1.5, 2.0);
let b = Vector2(3.0, 4.5);
println(a + b);
println(b - a);
println(a * 2.0);

var position = Vector2(0.0, 0.0);
position += a;
position += a;
println(position);

Listing 9-4: Vector arithmetic with operator interfaces

Vector2(x: 4.5, y: 6.5)
Vector2(x: 1.5, y: 2.5)
Vector2(x: 3.0, y: 4.0)
Vector2(x: 3.0, y: 4.0)

The two type arguments are the right-hand operand's type and the result type: Mul[float, Vector2] means "Vector2 * float gives a Vector2". position += a is rewritten to position = position + a, so it works as soon as + does.

Mistake: forgetting the interface

This code does not compile
@mode(host.scripted)

class Vector2 {
    pub let x: float;
    pub let y: float;

    new(x: float, y: float) {
        this.x = x;
        this.y = y;
    }

    pub operator func +(other: Vector2): Vector2 {
        return Vector2(this.x + other.x, this.y + other.y);
    }
}

let sum = Vector2(1.0, 2.0) + Vector2(3.0, 4.0);
main.lpscr:12:5: error[E_OPERATOR_NO_INTERFACE]: E_OPERATOR_NO_INTERFACE: operator func '+' on 'Vector2' requires `implements Add` on the enclosing type
main.lpscr:17:11: error[E_OPERATOR_MISSING]: type 'Vector2' does not implement 'Add' required for operator '+' (E_OPERATOR_MISSING)

The first error is at the declaration, the second at the use. Add implements Add[Vector2, Vector2] to the class header.

Mistake: one type argument

The Language Reference declares interface Add[T, R = Self], so implements Add[Vector2] should mean Add[Vector2, Vector2]. Loup 1.25 does not support the default yet:

This code does not compile
@mode(host.scripted)

class Meters implements Add[Meters] {
    pub let value: float;

    pub operator func +(other: Meters): Meters {
        return Meters(value = this.value + other.value);
    }
}
main.lpscr:3:25: error[E0001]: type 'Add' expects 2 type argument(s), got 1
main.lpscr:6:5: error[E_OPERATOR_NO_INTERFACE]: E_OPERATOR_NO_INTERFACE: operator func '+' on 'Meters' requires `implements Add` on the enclosing type

Write both arguments: implements Add[Meters, Meters].

Some operators cannot be overloaded at all: and, or, not, ??, ?, =, ., ?., is, as, as?, typeof and instanceof.

⚠ Planned. Unary minus (interface Neg { operator func -(): Self; }) is specified but not implemented in 1.25: an operator func -() with no parameter is rejected, and a class cannot declare both a unary and a binary -.

Interfaces or Inheritance?

Use a base class when… Use an interface when…
the types really are kinds of one thing (a Cat is an Animal) unrelated types share a capability (a file and a socket can both be Closeable)
the subclasses share fields and implementation a type has several independent capabilities
the hierarchy is small and stable other people should be able to plug in their own implementations

A class has at most one superclass but any number of interfaces, and in Loup 1.25 interfaces are also the more complete feature: the compiler checks that every required method is present, which it does not yet do for abstract classes (Chapter 8).

Part 2: Generics

Generic functions

A type parameter goes in square brackets after the name. The compiler infers it from the arguments:

func firstOr[T](items: list[T], fallback: T): T {
    if (items.length() == 0) {
        return fallback;
    }
    return items[0];
}

func swapEnds[T](items: list[T]): list[T] {
    if (items.length() < 2) {
        return items;
    }
    var result = items.clone();
    let first = result[0];
    result[0] = result[-1];
    result[-1] = first;
    return result;
}

println(firstOr([3, 4], 0) + 1);

let noNames: list[string] = [];
println(firstOr(noNames, "nobody"));

println(swapEnds([1, 2, 3, 4]));
println(swapEnds(["a", "b", "c"]));
println(firstOr[int]([7], 0));

Listing 9-5: Generic functions with inferred type arguments

4
nobody
[4, 2, 3, 1]
["c", "b", "a"]
7

firstOr([3, 4], 0) + 1 type-checks because T is int at that call, so the result is an int. firstOr[int](…) gives the type argument explicitly. Note the typed noNames: an empty literal [] has no element type of its own, so firstOr([], "nobody") fails to infer T; give the empty list a type first.

Generic classes

class Stack[T] {
    var items: list[T] = [];

    pub modifier func push(item: T): void {
        this.items.push(item);
    }

    pub modifier func pop(): Option[T] {
        if (this.items.length() == 0) {
            return None;
        }
        return Some(this.items.pop());
    }

    pub func peek(): Option[T] {
        if (this.items.length() == 0) {
            return None;
        }
        return Some(this.items[-1]);
    }

    pub func size(): int {
        return this.items.length();
    }
}

let undo: Stack[string] = Stack();
safe {
    undo.push("type 'hello'");
    undo.push("delete word");
}

var last: Option[string] = None;
safe {
    last = undo.pop();
}
println(last.unwrap_or("nothing to undo"));
println(undo.peek().unwrap_or("empty"));
println(undo.size());

Listing 9-6: A generic stack

delete word
type 'hello'
1

T stands for whatever the stack holds. A Stack[string] only accepts strings: undo.push(42) is a compile error.

When the constructor takes a T, the type argument is inferred from the arguments, as for functions: with class Box[T] { pub let value: T; new(value: T) { this.value = value; } }, Box(41) is a Box[int].

Mistake: Stack[int]() without a type annotation

Stack has no constructor parameter to infer T from, so you might pass the type argument in the call. In Loup 1.25 that is not enough:

This code does not compile
@mode(host.scripted)

class Stack[T] {
    var items: list[T] = [];

    pub modifier func push(item: T): void {
        this.items.push(item);
    }
}

let numbers = Stack[int]();
safe {
    numbers.push(1);
}
main.lpscr:13:18: error[E0001]: argument 1 of 'push': expected 'T0', found 'int'

T0 is the compiler's name for the unresolved type parameter: the explicit [int] on the constructor call is not applied to the new object. Annotate the binding instead, as Listing 9-6 does: let numbers: Stack[int] = Stack();.

Generic structs

Structs and enums take type parameters the same way (Chapters 6 and 7):

struct Pair[A, B] {
    first: A;
    second: B;
}

func swap[A, B](pair: Pair[A, B]): Pair[B, A] {
    return Pair(first = pair.second, second = pair.first);
}

let setting = Pair(first = "port", second = 8080);
let flipped = swap(setting);
println(flipped.first + 1);
println(flipped);
8081
Pair(first: 8080, second: "port")

Constraining a type parameter with where

Inside a generic function, T could be any type, so you can only do with a T what every type supports. Comparing two Ts is not one of those things:

This code does not compile
@mode(host.scripted)

func largest[T](items: list[T]): T {
    var best = items[0];
    for (item in items) {
        if (item > best) {
            best = item;
        }
    }
    return best;
}
main.lpscr:6:13: error[E0001]: type 'T0' has no method 'compareTo'

> is defined through Comparable.compareTo, and nothing says T has it. Add a constraint with a where clause after the return type (§1-11.5):

func largest[T](items: list[T]): T where T: Comparable[T] {
    var best = items[0];
    for (item in items) {
        if (item > best) {
            best = item;
        }
    }
    return best;
}

class Version implements Comparable[Version] {
    pub let major: int;
    pub let minor: int;

    pub func compareTo(other: Version): int {
        if (this.major != other.major) {
            return this.major - other.major;
        }
        return this.minor - other.minor;
    }
}

println(largest([3, 9, 4]));
println(largest(["pear", "apple", "plum"]));

let newest = largest([Version(major = 1, minor = 2), Version(major = 1, minor = 10)]);
println(i"{$ newest.major $}.{$ newest.minor $}");

Listing 9-7: A constrained generic function

9
plum
1.10

Built-in types implement the standard interfaces: int, float and string are all Comparable. Several constraints are separated by commas: where T: Comparable[T], T: Hashable.

Known gaps in generic constraints (Loup 1.25).

  • The Language Reference also allows the constraint inside the brackets, func largest[T: Comparable[T]](…), and joining bounds with + (T: Comparable[T] + Hashable, §1-12). The bracket form is accepted but not used — the body still reports type 'T0' has no method 'compareTo' — and + in a where clause does not parse. Use where with commas. The same applies to class type parameters: class SortedBag[T: Comparable[T]] cannot compare its elements yet, and a where clause on a method does not help either.
  • A constraint is not checked where the function is called. The Language Reference makes a call with a type that does not satisfy it a compile error (E_WHERE_BOUND_NOT_MET); in 1.25 the call compiles and fails when the missing method is reached, as shown below.
  • A value of a type parameter cannot be passed where Any is expected: str(value), println(value) or i"{$ value $}" with value: T report expected 'Any', found 'T0'. Return the value to code that knows its type, or format it there.

What an unmet constraint does today:

This code throws at run time
func largest[T](items: list[T]): T where T: Comparable[T] {
    var best = items[0];
    for (item in items) {
        if (item > best) {
            best = item;
        }
    }
    return best;
}

class Crate {
    pub let kilos: int;
}

let heaviest = largest([Crate(kilos = 10), Crate(kilos = 30)]);
println(heaviest.kilos);
Uncaught RuntimeError: internal interpreter limitation: unsupported instruction: CallVirtual compareTo on user class Crate — method not found

Crate does not implement Comparable, so there is no compareTo to call. Implement Comparable[Crate] (comparing kilos) and the call works.

A Complete Example: A Small Data Pipeline

Interfaces and generics together give you code that you extend by adding classes, not by editing it:

interface Source[T] {
    func name(): string;
    func rows(): list[T];
}

interface Step[T] {
    func apply(rows: list[T]): list[T];
}

interface Sink[T] {
    func write(rows: list[T]): int;
}

func runPipeline[T](source: Source[T], step: Step[T], sink: Sink[T]): int {
    let rows = source.rows();
    let kept = step.apply(rows);
    return sink.write(kept);
}

class LinesSource implements Source[string] {
    pub let text: string;

    pub func name(): string {
        return "lines";
    }

    pub func rows(): list[string] {
        return this.text.split("\n");
    }
}

class DropBlank implements Step[string] {
    pub func apply(rows: list[string]): list[string] {
        return rows.filter(func(row: string) => row.trim().length() > 0);
    }
}

class PrintSink implements Sink[string] {
    pub let prefix: string;

    pub func write(rows: list[string]): int {
        for (row in rows) {
            println(this.prefix + row);
        }
        return rows.length();
    }
}

let written = runPipeline(
    LinesSource(text = "alpha\n\nbeta\n   \ngamma"),
    DropBlank(),
    PrintSink(prefix = "> "),
);
println(i"{$ written $} rows written");

Listing 9-8: A pipeline of pluggable stages

> alpha
> beta
> gamma
3 rows written

runPipeline knows nothing about lines, blank rows or printing. A CSV source, a deduplicating step or a file sink are new classes that implement Source[T], Step[T] or Sink[T]; runPipeline does not change.

Summary

  • An interface lists required methods (ending in ;) and default methods (with a body); a class implements it and must provide every required method with the exact signature.
  • In 1.25, replace an interface default without override; replace a superclass method with override.
  • Interfaces can extend interfaces; x is I and x as I test and convert at run time.
  • Comparable gives <, >, <=, >=; define == explicitly as well. Iterable/Iterator make a type work with for; Closeable works with with.
  • Operators are overloaded by implementing their interface with both type arguments (Add[Other, Result]) and writing operator func.
  • Generic functions and classes take type parameters in square brackets; arguments are inferred where possible. Annotate the binding when a constructor has nothing to infer from.
  • Constrain type parameters with where T: Interface[T]; in 1.25 the bracket form is not used and unmet constraints fail at run time.
  • Class-instance keys in dict and set, Renderable and unary - overloading are not implemented yet.

Try It Yourself

  1. Add a Triangle to Listing 9-1 (Heron's formula needs sqrt from core.math) and check that totalArea needs no change.
  2. Implement Comparable[Version] on a version with a pre-release tag (1.4.0-beta sorts before 1.4.0), sort a list, and print it.
  3. Write class Queue[T] with modifier methods enqueue and dequeue(): Option[T], and use it with strings and with Version objects.
  4. Write func countAbove[T](items: list[T], limit: T): int where T: Comparable[T] and call it with numbers and with strings.
  5. Add a Step[string] that upper-cases every row (toUpperCase()) and chain two steps in Listing 9-8 by writing a Chain[T] step that holds two other steps.