Docs · Language
Methods and dispatch
A method is a free-standing fn with a receiver written before the name —
methods are not members of a type's definition, and which methods a type has is
a property of what's in scope, not of the type's identity. This page is the
precise reference for receiver forms, method-set visibility, overload
selection, and interface dispatch. Narrative introductions: guides
04 — Functions and closures,
12 — Interfaces, and
14 — Modules and packages.
Receiver forms
| Receiver | Example | The method gets |
|---|---|---|
| value | fn (p: Point) name() | a copy; changes never reach the caller |
| pointer, writable | fn (p: *mut Point) name() | the original's address; may mutate |
| pointer, read-only | fn (p: *con Point) name() | the original's address, for reading only |
| type | fn (Point) name() | no value — called on the type itself |
There is no bare *T receiver — a pointer receiver states its write
permission, *mut or *con, like every pointer type
(reference/memory-model). Methods may be declared on any
type; the pointer rules below are an overlay on one level of *mut / *con
around that type.
import { stdout } from "@io"
struct Point {
x: i32,
y: i32,
}
fn (Point) origin() Point {
return Point{ x: 0, y: 0 }
}
fn (p: Point) lengthSquared() i32 {
return p.x * p.x + p.y * p.y
}
fn (p: *con Point) xCoord() i32 {
return p.x
}
fn (p: *mut Point) translate(dx: i32, dy: i32) {
p.x = p.x + dx
p.y = p.y + dy
}
fn main() !u8 {
mut p = Point{ x: 3, y: 4 }
con before = p.lengthSquared() // 25 — value method: copy
p.translate(1, 1) // *mut method: auto-ref of mut place
con after = p.lengthSquared() // 41
con x = p.xCoord() // *con method: auto-ref (read-only)
mut pm: *mut Point = &p
con throughPtr = pm.lengthSquared() // value method on *mut: load a copy
con o = Point.origin() // type receiver
stdout.writeInt(i64(before + after + x + throughPtr + o.x - 24 - 4 - 41)) // prints 42
stdout.write("\n")
return Ok(0)
}Receiver adaptation
Instance methods adapt across one level of *mut / *con around the
receiver's base type. Type-receiver methods (fn (T) …) do not participate —
they stay type-only.
| Declared | Call subject | Adaptation | Mutates caller? |
|---|---|---|---|
T | value of T | pass a copy | no |
T | *mut T / *con T | load a copy of the pointee | no |
*mut T | writable place of T | auto-ref as *mut | yes |
*mut T | value of type *mut T | pass the pointer | yes |
*mut T | con T, param, or *con T | not a candidate | — |
*con T | value of T (mut or con) | auto-ref as *con | no |
*con T | *mut T / *con T | pass / widen to *con | no |
So *con methods work on every kind of instance source of T. Value methods
always copy. *mut methods need a place you may write through.
Ranking. When several same-name methods apply: (1) exact receiver shape, (2) else pointer adaptation over value copy, (3) else ordinary argument-signature / proximity rules.
import { stdout } from "@io"
struct Cell { n: i32 }
fn (c: Cell) tag() i32 { return 1 }
fn (c: *mut Cell) tag() i32 { return 2 }
fn (c: *con Cell) tag() i32 { return 3 }
fn main() !u8 {
mut c = Cell{ n: 0 }
mut pm: *mut Cell = &c
con pc: *con Cell = &c
// exact shape each time: 1 + 2 + 3
stdout.writeInt(i64(c.tag() + pm.tag() + pc.tag()))
stdout.write("\n")
return Ok(0)
}Diagnostics. A *mut method that is not a candidate is reported like any
missing method, with a note that it is defined for *mut T but not callable
on this subject:
struct Point { x: i32 }
fn (p: *mut Point) bump() {
p.x = p.x + 1
}
fn main() !u8 {
con p = Point{ x: 1 }
p.bump() // error: type Point has no method "bump"
// note: method is defined for *mut Point, but not callable on an immutable value
return Ok(0)
}Inside a pointer-receiver method, field access dereferences for you: p.x, not
(*p).x.
Static vs instance calls
A type receiver (fn (Point) …) declares a static method, called on the
type: Point.origin(). The two call shapes do not cross:
- a type-receiver method is not callable through an instance —
p.origin() is an error;
- an instance method is not callable through the type —
Point.lengthSquared(p) is an error; the receiver is passed only by
receiver position.
struct P { x: i32 }
fn (P) zero() P { return P{ x: 0 } }
fn main() !u8 {
con a = P{ x: 3 }
con b = a.zero() // error: static method — call P.zero()
return Ok(0)
}Method sets are scope-determined
A type's definition lists fields and nothing else. Anyone can declare a method on any type — including primitives and types from other packages:
import { stdout } from "@io"
fn (n: i32) doubled() i32 {
return n * 2
}
fn main() !u8 {
con a: i32 = 7
stdout.writeInt(i64(a.doubled())) // prints 14
stdout.write("\n")
return Ok(0)
}Methods travel with types. An exported method declared in the same file as its receiver type rides along when the type is imported — you don't import methods by name, and they follow an import alias too:
// file: geometry.vyi
export struct Point { pub x: i32, pub y: i32 }
export fn (a: Point) manhattan(b: Point) i32 {
return (a.x - b.x) + (a.y - b.y)
}
// file: main.vyi
import { Point: P } from "./geometry"
import { stdout } from "@io"
fn main() !u8 {
con a = P{ x: 5, y: 5 }
con b = P{ x: 1, y: 2 }
stdout.writeInt(i64(a.manhattan(b))) // prints 7 — methods follow the alias
stdout.write("\n")
return Ok(0)
}A method declared in a different file than its receiver type is an extension: it belongs to that file's exports and reaches you through an import — commonly a side-effect import of the whole file, since methods are called through the receiver rather than by bare name:
// file: shape.vyi
export struct Circle { pub r: i32 }
// file: area.vyi
import { Circle } from "./shape"
export fn (c: Circle) area() i32 { return 3 * c.r * c.r }
// file: main.vyi
import { Circle } from "./shape"
import "./area" // load the extension methods
import { stdout } from "@io"
fn main() !u8 {
con c = Circle{ r: 2 }
stdout.writeInt(i64(c.area())) // prints 12
stdout.write("\n")
return Ok(0)
}A file the program never imports contributes nothing — drop the
import "./area" line and the call fails:
// file: shape.vyi
export struct Circle { pub r: i32 }
// file: main.vyi
import { Circle } from "./shape"
fn main() !u8 {
con c = Circle{ r: 2 }
return Ok(0)) // error: type Circle has no method "area"
}Method sets are deliberately not part of type identity: a Circle is the
same nominal type everywhere; only the operations available on it vary with
what's in scope. This is what interface witness capture builds on
(below).
Resolution and duplicates
Two different types can carry same-named methods with no conflict — the receiver's type picks the method:
import { stdout } from "@io"
struct A { v: i32 }
struct B { v: i32 }
fn (a: A) tag() i32 { return 1 }
fn (b: B) tag() i32 { return 2 }
fn main() !u8 {
con a = A{ v: 0 }
con b = B{ v: 0 }
stdout.writeInt(i64(a.tag() * 4 + b.tag())) // prints 6
stdout.write("\n")
return Ok(0)
}Two declarations with the same receiver type and signature resolve by proximity — the definition closest to the use site wins. The precedence order at a call site: the local scope chain (innermost block outward to the file's own declarations), then the receiver value's import flavor (the alias its annotation named), then methods that traveled in with other imports. Each file's calls resolve against its own view; declaring your own version of a library's method changes your call sites, never the library's.
Overload selection
Functions and methods may share a name when their signatures differ; the call site selects. What selects today:
| Differs by | Free function | Method |
|---|---|---|
| arity | selects | selects |
| receiver type | — | selects |
| parameter types at the same arity | selects | does not select (planned) |
import { stdout } from "@io"
struct A { v: i32 }
fn (a: A) get() i32 { return a.v }
fn (a: A) get(extra: i32) i32 { return a.v + extra } // arity selects
fn free(x: bool) i32 { return 1 }
fn free(x: i32) i32 { return 2 } // param type selects
fn main() !u8 {
con a = A{ v: 1 }
stdout.writeInt(i64(a.get() + a.get(2) + free(5) * 4 + free(true))) // prints 1+3+8+1 = 13
stdout.write("\n")
return Ok(0)
}On a single receiver type, two same-arity methods are not distinguished by their parameter types — the call type-checks against one of them and mismatches error:
struct A { v: i32 }
fn (a: A) get(x: bool) i32 { return 1 }
fn (a: A) get(x: i32) i32 { return 2 }
fn main() !u8 {
con a = A{ v: 1 }
return Ok(0)) // error: same-arity methods don't select by type
}Arity counts suppliable arguments: a call may omit trailing parameters that declare defaults (guide 04).
Interface satisfaction and witness capture
An interface is a set of method signatures. Satisfaction is structural and
checked at the cast site: a type satisfies an interface where methods with
matching names and signatures are in scope for it — no implements, and the
type's author never opts in. Boxing takes a pointer (&d), because an
interface value is a fat pointer: data pointer + vtable.
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: satisfaction checked HERE
stdout.writeInt(i64(s.speak())) // dynamic dispatch → prints 42
stdout.write("\n")
return Ok(0)
}If the methods aren't in scope at the cast site, 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))
}The witness is captured at the cast site. Boxing resolves each interface method exactly as a direct call 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. Consequences (guide 12 demonstrates each):
- Open extension — your method on a third-party type satisfies your
interface, because the method is in scope at your cast site.
- Coherence — two files can each box their own
Bufferwith their own
read; each cast captures its own scope's method, and nothing leaks
across.
- No action at a distance — a method in scope somewhere else in the
program does not satisfy a box written here:
// file: telemetry.vyi
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))
}Two more dispatch facts:
- 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 and dispatches back through the declared methods (this is how @mem's
Allocator ships new/delete on top of alloc/free).
- Generic receivers. A method with a generic receiver
(fn (b: Box(T)) get() …) boxes per monomorph — the cast site binds the
witness to the T in hand (guide
12).
Union receivers
A method call on a tagged-union value (e: SaveError | ParseError) has no
method of its own to find — dispatch is member-wise: the method must be
defined on every member, and the call compiles to a runtime switch on the
union's tag, each arm invoking that member's method with the live member as
its receiver. A member without the method rejects the call at the call site
(`method "m" is not defined for union member T`).
The members' methods need not agree on a return type: the call's static type
is the implicit-widening union of the member return types (identical
returns collapse to that single type), and each arm's result widens into it.
Leading known parameters work as on any receiver — each member resolves its
own monomorph from the call's type arguments, spelled out or inferred:
import { stdout } from "@io"
enum SaveError { DiskFull }
struct ParseError { code: i32 = 0 }
fn (e: SaveError) describe() i32 { return 100 }
fn (e: ParseError) describe() bool { return e.code == 7 }
fn (e: SaveError) pick(known T: type, v: T) T { return v }
fn (e: ParseError) pick(known T: type, v: T) T { return v }
fn main() !u8 {
con u: SaveError | ParseError = SaveError.DiskFull
con a = u.describe() // i32 | bool — the SaveError arm: 100
con p = u.pick(i32, 20) + u.pick(3) // per-member known monomorphs
switch typeof a {
i32: {
stdout.writeInt(i64(a - 100 + p + 19)) // prints 42
stdout.write("\n")
return Ok(0)
},
bool: { return Ok(0) },
}
}Receivers are values here as a semantic fact: a union is a value whose
live member lives inline in its {tag, payload} block, so there is no
*mut member place for a pointer receiver to point at — pointer-receiver
methods do not participate in union dispatch. To mutate, switch on the member
(switch typeof) and rebuild the union value.
@-methods and member access
The @ sigil is part of an operator method's name. Plain member access never
resolves to one — a.add(b) does not find @add:
struct W { v: i32 }
fn (a: W) @add(b: W) W { return W{ v: 9 } }
fn main() !u8 {
con a = W{ v: 1 }
con b = a.add(a) // error: W has no method "add" — the name is @add
return Ok(0)
}Operator methods are reached through their operator (a + b) or the explicit
sigiled spelling (a.@add(b)); both resolve by the rules on this page. The
full operator↔method table is in reference/operators.
See also: reference/operators, guide 14 — Modules and packages for export/import mechanics, guide 12 — Interfaces for the interface model in full.