Guides · 12
Interfaces
An interface is a behavioural shape: a set of method signatures. Any value whose type
has matching methods can be boxed into an interface value, and calls on that value
dispatch dynamically to whatever concrete type is inside. Vyi's twist on this familiar
idea comes from its method system: methods live in lexical scope, not inside types
(guide 04) — so which methods satisfy an interface is
decided by what's in scope at the place you box. This section builds up to why that
matters.
Declaring and satisfying an interface
An interface declares method signatures, separated by commas like struct fields.
Satisfaction is structural — a type satisfies an interface when methods with matching
names and signatures are in scope for it. There is no implements keyword and the
type's author never has to opt in:
import { stdout } from "@io"
interface Speaker {
fn speak() i32,
}
struct Dog { volume: i32 }
fn (d: Dog) speak() i32 {
return d.volume
}
fn main() !u8 {
con d = Dog{ volume: 42 }
con s: Speaker = &d // box: Dog satisfies Speaker
stdout.writeInt(i64(s.speak()))
stdout.write("
")
return Ok(0) // dynamic dispatch → 42
}interface Name { … } is shorthand for a type binding — the longhand form is
type Speaker = interface { fn speak() i32, }, and both declare the same thing.
If the methods aren't there, the box is rejected where you write it:
interface Speaker {
fn speak() i32,
}
struct Rock { weight: i32 }
fn main() !u8 {
con r = Rock{ weight: 3 }
con s: Speaker = &r // error: no speak() in scope for Rock
return Ok(0)
}Boxing and the fat pointer
The cast site — con s: Speaker = &d — is where an interface value is built. It is a
fat pointer: a pair of the data pointer and a vtable, a table with one function
entry per interface method. A call like s.speak() reads the entry and calls through
it — an indirect call, resolved at runtime by whatever vtable the value carries.
The data side is always a pointer. An interface value doesn't copy the concrete value
in — it points at it — so you box a *T, never a bare T:
interface Speaker {
fn speak() i32,
}
struct Dog { volume: i32 }
fn (d: Dog) speak() i32 {
return d.volume
}
fn main() !u8 {
con d = Dog{ volume: 3 }
con s: Speaker = d // error: box from a pointer — write &d
return Ok(0)
}(Why? A fat pointer holding an inline copy would need a different size per concrete
type; holding a pointer keeps every Speaker the same two words, whatever is inside.)
Interface values as parameters, fields, and returns
An interface type goes anywhere a type goes. A parameter of interface type accepts any satisfying concrete, and each boxed argument carries its own vtable — one function body, genuinely polymorphic calls:
import { stdout } from "@io"
interface Valued {
fn value() i32,
}
struct Doubler { n: i32 }
struct Tripler { n: i32 }
fn (d: Doubler) value() i32 { return d.n * 2 }
fn (t: Tripler) value() i32 { return t.n * 3 }
fn apply(v: Valued) i32 {
return v.value() // dispatches per argument
}
fn main() !u8 {
con d = Doubler{ n: 21 }
con t = Tripler{ n: 4 }
stdout.writeInt(i64(apply(&d) + apply(&t)))
stdout.write("
")
return Ok(0) // 42 + 12
}Struct fields and mut bindings hold interface values too, and reassignment installs
the new concrete's vtable:
import { stdout } from "@io"
interface Shape {
fn area() i32,
}
struct Sq { pub s: i32 }
fn (q: Sq) area() i32 { return q.s * q.s }
struct Ci { pub r: i32 }
fn (c: Ci) area() i32 { return c.r * c.r * 3 }
struct Scene { pub shape: Shape }
fn main() !u8 {
con sq = Sq{ s: 3 }
con ci = Ci{ r: 2 }
con scene = Scene{ shape: &sq }
mut current: Shape = &sq
con a = current.area() // 9, through Sq's vtable
current = &ci
con b = current.area() // 12, through Ci's vtable
stdout.writeInt(i64(scene.shape.area() + a + b))
stdout.write("
")
return Ok(0)
}A function can return an interface — the data side is a pointer, so what it points at must outlive the call. Returning a box over the callee's own local would dangle; box over pointers the caller handed you (or allocator-owned memory):
import { stdout } from "@io"
interface Shape {
fn area() i32,
}
struct Sq { pub s: i32 }
fn (q: Sq) area() i32 { return q.s * q.s }
struct Ci { pub r: i32 }
fn (c: Ci) area() i32 { return c.r * c.r * 3 }
fn pick(which: i32, sq: *mut Sq, ci: *mut Ci) Shape {
if which == 0 { return sq }
return ci
}
fn main() !u8 {
mut sq = Sq{ s: 3 }
mut ci = Ci{ r: 2 }
con first = pick(0, &sq, &ci)
con second = pick(1, &sq, &ci)
stdout.writeInt(i64(first.area() + second.area()))
stdout.write("
")
return Ok(0) // 9 + 12
}Scope-captured witness tables — the key idea
Here is the part that is distinctly Vyi. In most languages, "does type T have method m"
is a global fact about T. In Vyi, methods are free-standing declarations resolved
through lexical scope — so the question only has an answer somewhere in particular.
That somewhere is the cast site: when you box a value, the compiler resolves each
interface method exactly the way a direct call d.speak() written at that spot would
resolve, and freezes the winners into the vtable. The vtable is a witness of what was
in scope where you boxed.
This gives you open extension. A third-party type — one whose module declares no methods at all — can satisfy your interface, because you can put the methods in scope:
// file: geometry.vyi
// A library type you don't control — no methods here.
export struct Square {
pub side: i32,
}
// file: main.vyi
import { Square } from "./geometry"
import { stdout } from "@io"
interface Area {
fn area() i32,
}
// Your method on their type. It's in scope here, so here Square satisfies Area.
fn (s: Square) area() i32 {
return s.side * s.side
}
fn main() !u8 {
con sq = Square{ side: 6 }
con a: Area = &sq
stdout.writeInt(i64(a.area() + 6))
stdout.write("
")
return Ok(0) // 36 + 6
}And it gives you coherence without a global registry. Two modules can each define their
own Buffer with their own read — same spelling, different types, different bodies —
and each boxing captures its own scope's method. Nothing leaks across:
// file: reader.vyi
export interface Reader {
fn read() i32,
}
// file: telemetry.vyi
import { Reader } from "./reader"
struct Buffer {
payload: i32,
}
fn (b: Buffer) read() i32 {
return 100 + b.payload
}
export fn probe() i32 {
con buf = Buffer{ payload: 5 }
con r: Reader = &buf // captures THIS file's read
return r.read() // 105
}
// file: main.vyi
import { Reader } from "./reader"
import { probe } from "./telemetry"
import { stdout } from "@io"
struct Buffer { // a different Buffer, same name — no conflict
payload: i32,
}
fn (b: Buffer) read() i32 {
return 200 + b.payload
}
fn main() !u8 {
con buf = Buffer{ payload: 7 }
con r: Reader = &buf // captures this file's read
stdout.writeInt(i64(r.read()))
stdout.write("
")
stdout.writeInt(i64(probe()))
stdout.write("
")
return Ok(0) // 207 and 105
}The flip side: a method being in scope somewhere else in the program does you no
good. If read isn't visible where you box, the box fails — the compiler will never
quietly wire your value to some other scope's method:
// file: telemetry.vyi
// This module's Buffer has a read — but only in THIS scope.
export struct Buffer {
pub payload: i32,
}
fn (b: Buffer) read() i32 {
return b.payload
}
// file: main.vyi
import { Buffer } from "./telemetry"
interface Reader {
fn read() i32,
}
fn main() !u8 {
con b = Buffer{ payload: 9 }
con r: Reader = &b // error: read() is not in scope HERE
return Ok(0)
}Methods on the interface itself
An interface is a type, so it can be a receiver. A method declared on the interface is callable on any satisfying value — no boxing required at the call site — and its body runs against the interface, calling back through the declared methods:
import { stdout } from "@io"
interface Adder {
fn add(n: i32) i32,
}
struct Counter { base: i32 }
fn (c: Counter) add(n: i32) i32 {
return c.base + n
}
// One implementation for every type that satisfies Adder.
fn (a: Adder) bumped() i32 {
return a.add(1)
}
fn main() !u8 {
con c = Counter{ base: 40 }
stdout.writeInt(i64(c.bumped()))
stdout.write("
")
return Ok(0) // 41
}This is how you ship an interface with rich derived behaviour while keeping the required surface small: satisfy one or two methods, inherit the rest.
Dynamic dispatch over generics
Interfaces and generics (guide 11) compose in both directions. A generic interface is — like every generic type — a function returning a type:
import { stdout } from "@io"
fn Reader(known E: type) type {
return interface {
fn read() E,
}
}
struct Cursor { v: i32 }
fn (c: Cursor) read() i32 {
return c.v
}
fn readThrough(r: Reader(i32)) i32 {
return r.read()
}
fn main() !u8 {
con c = Cursor{ v: 42 }
con r: Reader(i32) = &c // Cursor's read() i32 matches Reader(i32)
stdout.writeInt(i64(readThrough(r)))
stdout.write("
")
return Ok(0)
}Reader(i32) is a concrete interface (instantiation is memoized, so every mention is
the same type), and satisfaction is checked against the substituted signatures.
In the other direction, monomorphized generic concretes box like anything else — even when the method's receiver is itself generic. The cast site binds the witness to the right monomorph:
import { stdout } from "@io"
interface Valued {
fn get() i32,
}
fn Box(known T: type) type {
return struct { pub value: T, pub tag: i32 }
}
fn (b: Box(T)) get() i32 { // generic receiver: one declaration, per-T bodies
return b.value + b.tag
}
fn main() !u8 {
con b: Box(i32) = _{ value: 40, tag: 2 }
con v: Valued = &b // witness = get() for the T = i32 monomorph
stdout.writeInt(i64(v.get()))
stdout.write("
")
return Ok(0) // 42
}Interfaces in the standard library
The canonical interface in the standard library is Allocator, from @mem. Its
required surface is two methods — alloc(size, align) and free(buf) — and the typed
conveniences new and delete are methods on the interface itself (the pattern from
above), with known T parameters so each element
type gets its own monomorph:
import { Allocator } from "@mem"
import { systemAllocator } from "@mem"
import { stdout } from "@io"
fn firstPlusLen(a: Allocator, n: i32) !i32 {
con buf = try a.new(i32, n) // typed allocation through the interface
defer a.delete(buf) // typed free; T inferred from buf
buf[0] = 40
return Ok(buf[0] + buf.len)
}
fn main() !u8 {
con v = try firstPlusLen(systemAllocator, 2)
stdout.writeInt(i64(v))
stdout.write("
")
return Ok(0)
}firstPlusLen works with any allocator — the system allocator here, an arena or a
fixed buffer in a test — because it only ever sees the interface. This is the shape to
copy in your own APIs: accept the interface, let callers choose the concrete.
See also: reference/methods-dispatch.