Skip to content

Interfaces and structs

Dusk’s object-oriented surface is deliberately small. Structs hold data, impl blocks attach methods, and interfaces provide polymorphism through vtable dispatch. There are no classes and no inheritance of any kind. Both an interface declaration and an impl block are gated behind @paradigm oop; a struct stays available in every paradigm, since it is plain data rather than an OOP construct. See Paradigm system for how the gates work.

A struct is a plain data container. Structs are paradigm agnostic: they are not gated by @paradigm oop and can be declared and used in any file.

struct Point {
x: float64,
y: float64,
}

A struct value is built with a literal that names every field:

p := Point { x: 1.0, y: 2.0 }

The checker validates struct literals: an unknown field name, a duplicated field, or a missing field is a compile error (since 0.2.5). Fields are read with dot syntax, p.x. Writing a field, p.x = v, requires the root binding to be declared mut, the same immutability rule that governs every projection (since 0.2.6).

Structs are passed by value, like everything else in Dusk. Passing a struct to a function copies it; the callee’s changes do not reach the caller’s copy. To share one struct, allocate it and pass the pointer. See Memory.

Methods are attached to a struct with an impl block. Since 0.5.4, an impl block requires @paradigm oop in its file, the same gate an interface declaration needs; the struct it attaches to stays ungated. Inside a method, self names the receiver.

counter.dusk
@paradigm oop
@paradigm procedural
struct Counter {
n: int64,
}
impl Counter {
func get() -> int64 {
return self.n
}
func add(k: int64) -> void {
self.n = self.n + k
}
}
func main() -> int32 {
mut c := Counter { n: 3 }
c.add(4)
println(c.get())
println(c.n)
return 0
}

This prints 7 twice: methods take the receiver by pointer, so an assignment through self updates the caller’s value in place. This is why a stateful allocator’s bump offset advances when it is passed through using. See Memory.

A method can also be called directly through a managed pointer to the struct. c.get() on a c: *Counter dispatches after the same generation check an explicit dereference runs, so calling a method on a freed receiver faults instead of reading stale memory.

Interfaces require @paradigm oop in the declaring file. The interface is the only OOP construct in the language.

interface DisplayName {
getName() -> string
setName(name: string) -> void
}

Four rules govern interfaces:

  • An interface defines a contract of method signatures.
  • A struct satisfies an interface by an explicit impl declaration. There is no structural or implicit conformance.
  • There is no inheritance. One interface cannot extend another.
  • Composition is the only way to combine behaviors.

An impl for an interface names both the interface and the struct:

impl DisplayName for Person {
func getName() -> string { return self.name }
func setName(name: string) -> void { self.name = name }
}

The checker enforces the contract:

  • An impl must provide every method of the interface; an incomplete impl is rejected (since 0.2.5).
  • A duplicate impl I for T is a checker error (since 0.2.6).
  • An impl block on a generic type is diagnosed rather than silently dropped (since 0.2.6).

A function parameter typed as an interface accepts any struct with the matching impl. Conformance is checked wherever a struct meets an interface type: at call sites (since 0.2.5), and at bindings and returns (since 0.2.6). A missing impl is a checker error, never an undefined symbol at link time.

speak.dusk
@paradigm oop
interface Speaker {
speak() -> int64
}
struct Dog {
sound: int64,
}
struct Cat {
sound: int64,
}
impl Speaker for Dog {
func speak() -> int64 {
return self.sound
}
}
impl Speaker for Cat {
func speak() -> int64 {
return self.sound
}
}
func describe(s: Speaker) -> int64 {
return s.speak()
}
func main() -> int32 {
d := Dog { sound: 70 }
c := Cat { sound: 99 }
println(describe(d))
println(describe(c))
return 0
}

Dispatch is static when the receiver’s concrete type is known at the call, which is the common case and is zero cost. It falls back to a vtable call only when the type is erased behind the interface, as in describe above. The Allocator interface behind alloc and free works the same way. See Memory.

Interface values do not cross threads. A spawn or submit capture or a channel element containing an interface value, wherever it sits, including buried in a struct or enum field, is a compile error, since the value may view the spawning frame. See Concurrency.

An interface value carries its data behind a box so a vtable call can find the concrete type. As of 0.4.2 that boxing is complete through every carrier: an interface value buried in a struct field, an enum payload, or an array element now boxes correctly, so method dispatch through those carriers works instead of miscompiling.

Two shapes are rejected outright, because the box would sit in a frame slot that outlives nothing or hide in a place the checker cannot follow:

  • Returning an interface value by value is rejected: returning an interface value is not supported; return the concrete type or a pointer to it. Return the concrete struct, or allocate it and return the pointer.
  • An interface value inside a tuple is rejected the same way at both a return and an argument: an interface value inside a tuple is not supported; return or pass the concrete type, or box it outside the tuple.

Slices of interfaces carry one more rule. You cannot pass a slice of a concrete struct where a slice of an interface is expected: cannot pass a slice of '<concrete>' as a slice of interface '<iface>'; a slice of concrete values cannot be reinterpreted as a slice of interfaces. The two share a machine shape, but reinterpreting one as the other would read every element as a boxed interface and corrupt memory. An array literal that coerces its elements one by one is fine, and passing a slice of an interface where that same interface is already expected is fine, since neither reinterprets an existing buffer.

There is no inheritance of any kind, not for structs and not for interfaces. A struct cannot extend a struct, and an interface cannot extend an interface. Code reuse happens through composition only: embed a struct as a field, or implement multiple interfaces on one struct.

Any type that implements the Display interface can be passed to print and println:

interface Display {
toString() -> string
}

A struct with a Display impl prints through its toString. The rules are strict (since 0.2.6):

  • Passing a struct with no Display impl to a print builtin is a compile error.
  • Printing an enum, a slice, a tuple, or a pointer is also a compile error.
  • Print never emits silence for a value it cannot render.
display.dusk
@paradigm oop
interface Display {
toString() -> string
}
struct Point {
x: int64,
y: int64,
}
impl Display for Point {
func toString() -> string {
return "Point"
}
}
func main() -> int32 {
p := Point { x: 1, y: 2 }
println(p)
return 0
}

This prints Point. The Display interface is declared in the program, as above, and the print builtins key on the impl Display for T providing toString() -> string. Primitive types have built-in printers and need no impl. See Builtins for the full printing rules and format strings.