Chapter 10: Interfaces
An interface is a contract: a list of methods that any type claiming to implement it must provide. Code written against the interface works with every implementation, so you can render a list of different shapes, validate a form field with a list of different rules, or swap an in-memory store for a real one in tests.
Loup interfaces are nominal: a class or struct implements an interface only when it says implements Name. Having methods with the right names is not enough. That makes them closer to Java and C# interfaces than to TypeScript's structural types or Go's implicit interfaces. Rust programmers can read interface as "trait" and implements as an impl … for that lives on the type's declaration. The normative text is in the Language Reference §1-11.
Declaring and implementing an interface
@mode(web.compiled)
interface Shape {
func area(): float;
func name(): string;
}
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 Rect implements Shape {
pub let w: float;
pub let h: float;
new(w: float, h: float) { this.w = w; this.h = h; }
pub func area(): float { return this.w * this.h; }
pub func name(): string { return "rect"; }
}
func totalArea(shapes: list[Shape]): float {
var total = 0.0;
for (s in shapes) {
total = total + s.area();
}
return total;
}
func main(): void {
let shapes: list[Shape] = [Circle(1.0), Rect(2.0, 3.0)];
for (s in shapes) {
println(i"{$ s.name() $}: {$ s.area() $}");
}
println(totalArea(shapes));
}
Listing 10-1: An interface, two implementations, and a function that works with both
circle: 3.14159
rect: 6
9.14159
The interface body lists method signatures ending in ;. Interface methods are always public, so pub is optional there; the implementing class still writes pub func. A class extends at most one parent class but can implement any number of interfaces: class Badge : Widget implements Shape, Named.
list[Shape] holds objects of different classes. Each call to s.area() dispatches to the method of the object's own class.
Forgetting a method
Every method without a body in the interface must be implemented. Leave one out and the class is rejected where it is declared:
@mode(web.compiled)
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; }
}
func main(): void {
let s: Shape = Square(2.0);
println(s.area());
}
main.lpweb:8:1: error[E0001]: class 'Square' does not implement all methods of interface 'Shape'; missing: name(): string
The message lists the missing signature; add pub func name(): string { return "square"; }.
Getting a signature slightly wrong
The implementation must match the interface's parameter and return types exactly. Returning int where the interface says float is a different method, not an implementation of this one:
@mode(web.compiled)
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; }
}
func main(): void {
let s: Shape = Square(3);
println(s.area());
}
main.lpweb:7:1: error[E0001]: method 'area' in class 'Square' has return type 'int', incompatible with interface 'Shape' return type 'float'
Loup never converts between int and float for you (Chapter 3). Either store side as a float, or convert in the method: return (this.side * this.side) as float;.
Matching methods are not enough
If you come from TypeScript or Go, you might expect a class with an area(): float method to count as a Shape automatically. It does not:
@mode(web.compiled)
interface Shape {
func area(): float;
}
class Square {
pub let side: float;
new(side: float) { this.side = side; }
pub func area(): float { return this.side * this.side; }
}
func printArea(s: Shape): void {
println(s.area());
}
func main(): void {
printArea(Square(3.0));
}
main.lpweb:19:15: error[E0001]: argument 1 of 'printArea': expected 'Shape', found 'Square'
Declare the relationship: class Square implements Shape { … }. Nominal typing means that a type implements a contract only when its author says so, and the compiler then checks the whole contract at that one place.
Generic interfaces, and structs that implement interfaces
An interface can take type parameters, and a struct can implement an interface just as a class can. Here a form validator is any type that can check a value of some type T and maybe return an error message:
@mode(web.compiled)
interface Validator[T] {
func check(value: T): Option[string];
}
class NotBlank implements Validator[string] {
pub func check(value: string): Option[string] {
if (value.trim() == "") {
return Some("must not be blank");
}
return None;
}
}
class MinLength implements Validator[string] {
pub let min: int;
new(min: int) { this.min = min; }
pub func check(value: string): Option[string] {
if (value.len() < this.min) {
return Some(i"must be at least {$ this.min $} characters");
}
return None;
}
}
struct MaxTemperature implements Validator[float] {
limit: float;
func check(value: float): Option[string] {
if (value > this.limit) {
return Some(i"must be at most {$ this.limit $}");
}
return None;
}
}
func errors(value: string, rules: list[Validator[string]]): list[string] {
var found: list[string] = [];
for (rule in rules) {
match rule.check(value) {
Some(msg) => found.push(msg),
None => {},
}
}
return found;
}
func main(): void {
let password: list[Validator[string]] = [NotBlank(), MinLength(8)];
println(errors(" ", password));
println(errors("hunter2", password));
println(errors("correct horse", password));
let oven = MaxTemperature(limit = 250.0);
println(oven.check(275.0).unwrap_or("ok"));
}
Listing 10-2: A generic Validator[T] interface with class and struct implementations
["must not be blank", "must be at least 8 characters"]
["must be at least 8 characters"]
[]
must be at most 250
Validator[string] and Validator[float] are different interfaces: a list[Validator[string]] cannot hold a MaxTemperature. Chapter 11 covers type parameters in general.
Default methods and interface inheritance
An interface method can have a body. Implementers inherit it and may replace it. An interface can also extend other interfaces with :, and an implementer must then provide the methods of all of them (Language Reference §1-11.1):
@mode(web.compiled)
interface Named {
func name(): string;
}
interface Priced : Named {
func cents(): int;
func label(): string { # default method
return i"{$ this.name() $} ({$ this.cents() $} cents)";
}
}
class Item implements Priced {
pub let title: string;
pub let price: int;
new(title: string, price: int) { this.title = title; this.price = price; }
pub func name(): string { return this.title; }
pub func cents(): int { return this.price; }
}
class GiftCard implements Priced {
pub let amount: int;
new(amount: int) { this.amount = amount; }
pub func name(): string { return "gift card"; }
pub func cents(): int { return this.amount; }
pub func label(): string { # replaces the default
return i"{$ this.amount // 100 $} dollar gift card";
}
}
func main(): void {
let cart: list[Priced] = [Item("notebook", 450), GiftCard(2500)];
for (p in cart) {
println(p.label());
}
}
Listing 10-3: A default method, an interface that extends another, and an override of the default
notebook (450 cents)
25 dollar gift card
Default methods let an interface grow without breaking existing implementers: add a method with a sensible body and every implementation still compiles.
Spec and compiler disagree. The Language Reference (§1-8.3.1) says a method that replaces an interface's default method must be marked
override. Stage0 1.25.1 rejectsoverridethere withE_OVERRIDE_NO_TARGET("classGiftCardhas no supertype"), so Listing 10-3 leaves it off.overrideis still required, and accepted, when a class replaces a method inherited from a parent class (Chapter 9).
Checking which interface a value implements
x is I tests whether x's runtime type implements interface I. As with classes (Chapter 9), Loup 1.25 does not narrow the variable's type after the test, so use as? to get a value of the narrower type:
@mode(web.compiled)
interface Named {
func name(): string;
}
interface Priced : Named {
func cents(): int;
}
class Item implements Priced {
pub let title: string;
pub let price: int;
new(title: string, price: int) { this.title = title; this.price = price; }
pub func name(): string { return this.title; }
pub func cents(): int { return this.price; }
}
class Customer implements Named {
pub let email: string;
new(email: string) { this.email = email; }
pub func name(): string { return this.email; }
}
func describe(n: Named): string {
match n as? Priced {
Some(p) => { return i"{$ p.name() $} costs {$ p.cents() $} cents"; },
None => { return i"{$ n.name() $} has no price"; },
}
}
func main(): void {
let pen = Item("pen", 250);
println(describe(pen));
println(describe(Customer("ann@example.com")));
println(pen is Named);
}
Listing 10-4: Testing for an interface with is and converting with as?
pen costs 250 cents
ann@example.com has no price
true
If you find yourself writing many of these checks, consider adding a method to the interface instead, so each type answers the question itself.
Standard interfaces
The Language Reference defines a set of interfaces that the language itself uses (§1-11.2). These are the ones you will meet first, with what Loup 1.25 actually does with them.
Iterable[T] and Iterator[T]
A type that implements Iterable[T] works in a for loop. iter() returns an Iterator[T], whose next() returns Some(value) until it is exhausted and then None (Language Reference §1-11.3):
@mode(web.compiled)
class Countdown implements Iterable[int] {
pub let start: int;
new(start: int) { this.start = start; }
pub func iter(): Iterator[int] {
return CountdownIter(this.start);
}
}
class CountdownIter 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);
}
}
func main(): void {
for (n in Countdown(3)) {
println(n);
}
}
Listing 10-5: Implementing the iteration protocol by hand
3
2
1
The iterator's next() changes its own state, so it is a modifier. The interface declares a plain next(); implementing it with a modifier method is allowed, because the implementation only adds a contract.
Most of the time you do not need two classes. A generator function (gen func, whose body uses yield) produces an Iterable[T] for you, and a class that wraps a list can hand out the list's own iterator:
@mode(web.compiled)
gen func countdown(start: int): Iterable[int] {
var n = start;
while (n > 0) {
yield n;
n = n - 1;
}
}
class Playlist implements Iterable[string] {
var songs: list[string] = [];
new(songs: list[string]) { this.songs = songs; }
pub func iter(): Iterator[string] {
return this.songs.iter();
}
}
func main(): void {
for (n in countdown(3)) {
println(n);
}
for (song in Playlist(["intro", "verse", "chorus"])) {
println(song);
}
}
3
2
1
intro
verse
chorus
Comparable[T] and ==
Comparable[T] has one method, compareTo(other: T): int, which returns a negative number, zero or a positive number. Implementing it makes <, >, <= and >= work on your type (Language Reference §1-5.1):
@mode(web.compiled)
class Version implements Comparable[Version] {
pub let major: int;
pub let minor: int;
new(major: int, minor: int) { this.major = major; this.minor = minor; }
pub func compareTo(other: Version): int {
if (this.major != other.major) {
return this.major - other.major;
}
return this.minor - other.minor;
}
}
func newest(versions: list[Version]): Version {
var best = versions[0];
for (v in versions) {
if (v > best) {
best = v;
}
}
return best;
}
func main(): void {
let releases = [Version(1, 4), Version(2, 0), Version(1, 10)];
let n = newest(releases);
println(i"newest is {$ n.major $}.{$ n.minor $}");
println(Version(1, 9) < Version(1, 10));
}
Listing 10-6: compareTo gives a class the ordering operators
newest is 2.0
true
The method is named compareTo; a method called compare does not satisfy the interface. Generic code bounded by T: Comparable[T] cannot yet call compareTo or < on a T in Stage0 1.25.1 ("type 'T0' has no method 'compareTo'"), which is why newest takes a list[Version] rather than a list[T].
The Language Reference says Comparable also supplies == in terms of compareTo. Stage0 does not supply it yet, and == on two class instances compares identity, so this check gives the wrong answer:
@mode(web.compiled)
class Version implements Comparable[Version] {
pub let major: int;
pub let minor: int;
new(major: int, minor: int) { this.major = major; this.minor = minor; }
pub func compareTo(other: Version): int {
if (this.major != other.major) {
return this.major - other.major;
}
return this.minor - other.minor;
}
}
func main(): void {
let installed = Version(1, 2);
let required = Version(1, 2);
println(installed == required);
}
false
Two separately constructed objects are two objects, even with equal fields. Define == yourself with operator func ==; Comparable[T] extends Equatable[T], which is what allows the operator:
@mode(web.compiled)
class Version implements Comparable[Version] {
pub let major: int;
pub let minor: int;
new(major: int, minor: int) { this.major = major; this.minor = minor; }
pub func compareTo(other: Version): int {
if (this.major != other.major) {
return this.major - other.major;
}
return this.minor - other.minor;
}
pub operator func ==(other: Version): bool {
return this.compareTo(other) == 0;
}
}
func main(): void {
let installed = Version(1, 2);
let required = Version(1, 2);
println(installed == required);
}
true
A class that needs == but no ordering declares implements Equatable[T] and the same operator func ==. Without one of those interfaces, operator func == is E_OPERATOR_NO_INTERFACE.
⚠ Planned.
Hashable(func hashCode(): int) is specified to let a class be used as adictkey orsetelement by value. In Loup 1.25 a class instance used as a key is looked up by identity, even when it implementsHashableandEquatable. Key dictionaries by a string or integer field instead.
Rendering an object as text
println(x), str(x) and {$ x $} render a class instance structurally, as Name(field: value, …) (Language Reference §1-16.4). A method named to_string() does not change that, which surprises Python (__str__) and Java (toString) programmers:
@mode(web.compiled)
class Money {
pub let cents: int;
pub let currency: string;
new(cents: int, currency: string) { this.cents = cents; this.currency = currency; }
pub func to_string(): string {
let whole = this.cents // 100;
let frac = this.cents % 100;
let pad = if (frac < 10) { "0" } else { "" };
return i"{$ this.currency $} {$ whole $}.{$ pad $}{$ frac $}";
}
}
func main(): void {
let price = Money(1995, "USD");
println(i"Price: {$ price $}");
}
Price: Money(cents: 1995, currency: "USD")
to_string() runs only when you call it:
@mode(web.compiled)
class Money {
pub let cents: int;
pub let currency: string;
new(cents: int, currency: string) { this.cents = cents; this.currency = currency; }
pub func to_string(): string {
let whole = this.cents // 100;
let frac = this.cents % 100;
let pad = if (frac < 10) { "0" } else { "" };
return i"{$ this.currency $} {$ whole $}.{$ pad $}{$ frac $}";
}
}
func main(): void {
let price = Money(1995, "USD");
let fee = Money(305, "EUR");
println(i"Price: {$ price.to_string() $}");
println(i"Fee: {$ fee.to_string() $}");
}
Listing 10-7: Formatting an object by calling its method explicitly
Price: USD 19.95
Fee: EUR 3.05
⚠ Planned. The specified way to control how a type renders in
println, interpolation and template fences is theRenderableinterface,func render(ctx: RenderContext): string(Language Reference §1-16.4). In Loup 1.25,RenderContextis not available as a type, and the rendering pipeline does not call a class'srendermethod, so implementingRenderablehas no effect yet. Chapter 14 covers template fences.
Sendable
Sendable is a marker interface with no methods. It says that values of a type may cross an isolation boundary, such as a message to a web worker (Language Reference §2-1.10). Structs whose fields are all Sendable qualify, and you can say so explicitly with struct Point implements Sendable { x: int; y: int; }. The worker APIs that check it in the browser are planned (Chapter 18), so in Loup 1.25 the marker documents intent.
Async methods in interfaces
An interface method can be async (Language Reference §1-11.1). That is how you describe a data source whose real implementation will wait on the network while a test implementation answers at once:
@mode(web.compiled)
interface UserSource {
async func displayName(id: int): Task[string];
}
class FakeUsers implements UserSource {
pub async func displayName(id: int): Task[string] {
return i"user-{$ id $}";
}
}
async func greeting(src: UserSource, id: int): Task[string] {
let name = await src.displayName(id);
return "Welcome back, " + name;
}
async func main(): Task[void] {
println(await greeting(FakeUsers(), 42));
}
Welcome back, user-42
On the Loup 1.25 interpreter, calling an async function runs its body immediately; Chapter 15 explains what that means for ordering.
A worked example: pluggable storage
A settings page needs to remember a few values. In tests you want an in-memory store; in the browser you will want one backed by localStorage. An interface lets the rest of the app not care. Here a third implementation wraps any other store and records what was written, a decorator in the classic sense:
@mode(web.compiled)
interface Storage {
func get(key: string): Option[string];
func set(key: string, value: string): void;
}
class MemoryStorage implements Storage {
var items: dict[string, string] = {};
pub func get(key: string): Option[string] {
return this.items.get(key);
}
pub modifier func set(key: string, value: string): void {
this.items[key] = value;
}
}
class LoggingStorage implements Storage {
var inner: Storage;
pub var log: list[string] = [];
new(inner: Storage) {
this.inner = inner;
}
pub func get(key: string): Option[string] {
return this.inner.get(key);
}
pub modifier func set(key: string, value: string): void {
this.log.push(i"set {$ key $}");
this.inner.set(key, value);
}
}
safe func rememberLastPage(edits store: Storage, path: string): void {
store.set("last_page", path);
}
func main(): void {
let memory = MemoryStorage();
let logged = LoggingStorage(memory);
rememberLastPage(edits: logged, "/settings");
rememberLastPage(edits: logged, "/profile");
println(logged.get("last_page").unwrap_or("none"));
println(memory.get("last_page").unwrap_or("none"));
println(logged.log);
println(memory.get("theme").unwrap_or("light"));
}
Listing 10-8: Three Storage implementations behind one interface
/profile
/profile
["set last_page", "set last_page"]
light
rememberLastPage does not know which store it was given. LoggingStorage holds another Storage and forwards to it, so both names see the same data.
The interface declares set as a plain method and the classes implement it as modifier, which is allowed (an implementation may add a contract). The Language Reference (§1-11.1) also lets the interface itself say modifier func set(...), so that every implementation must be a modifier and calls through the interface need a safe context. Stage0 1.25.1 does not parse modifier inside an interface body ("expected func in interface body"), and it does not apply the safe-context check to calls made through an interface type.
⚠ Planned. A
LocalStorageclass backed by the browser's storage (web.storage, Chapter 18) is the production implementation this design is meant for. Noweb.*module ships in Loup 1.25.
When to reach for an interface
- Several types share behaviour, and callers should not care which one they hold (shapes, validators, stores).
- You want third-party code to be able to plug in (a storage backend, a logger).
- You want to swap a real implementation for a fake in tests.
- You want to add an optional behaviour with a default method.
If there is only one implementation, a plain class is enough. Add the interface when the second implementation appears.
Try it yourself
- Write a
Loggerinterface withinfo(msg: string)anderror(msg: string). ImplementConsoleLoggerwithprintln, andBufferLogger, which collects messages in a list for tests. - Add an
Emailvalidator to Listing 10-2 that requires an@(usevalue.contains("@")), and run the password rules against an e-mail address to see which rules apply. - Remove
implements Comparable[Version]from Listing 10-6 and read the error you get forv > best.
Summary
- An interface lists method signatures; a type implements it only by declaring
implements(nominal typing), and the compiler checks the whole contract there. - Missing methods, mismatched signatures and undeclared implementations are compile errors with specific messages.
- Interfaces can be generic (
Validator[T]), can extend other interfaces, and can provide default methods. Stage0 rejectsoverrideon a replaced interface default, although the specification asks for it. - Use
isto test for an interface andas?to convert; there is no narrowing afterisyet. Iterable[T]/Iterator[T]make a type work infor; agen funcis usually simpler.Comparable[T]needscompareToand gives</>;==on class instances is identity until you defineoperator func ==.- Rendering is structural; call your own formatting method explicitly.
Renderableand value-basedHashablekeys are planned. - Interface methods can be
async.modifiermethods inside an interface body are specified but not yet parsed.