Skip to content

7. Declarations

let x = 5 // immutable, inferred i32
let mut y: f32 = 1.5 // mutable
const MAX_ENTITIES = 4096 // compile-time constant, no storage
let _ = returns_something() // explicitly discard

let without an initialiser requires a type annotation and default-initialises (§6.2). Shadowing in an inner scope is allowed; shadowing in the same scope is an error — the main.dz sample shows how easily a duplicate let b slips past you.

fn lerp(a: f32, b: f32, t: f32) -> f32 {
return a + (b - a) * t
}
pub fn find(items: &[]Entity, id: EntityId) -> ^Entity { ... }
fn spawn(w: &mut World, pos: Vec3, hp: i32 = 100) -> EntityId { ... }
  • The return type is omitted for void.
  • A fallible function is marked by !-> in place of ->; the marker sits on the arrow rather than on the return type so that it lands at a fixed column near the left edge, which is the same argument §9.5 makes for a leading propagation keyword.
  • Default arguments must be trailing, and are evaluated at the call site — which is what makes loc: SourceLocation = #location name the caller’s line rather than the function’s.
  • There is no function overloading. [DECIDED — §18.5] One name, one function. Imported C++ overloads are the exception (§15.3), because we do not control those headers.
  • Constructors may be overloaded on arity and parameter types, since Rect(w, h) and Rect(x, y, w, h) are genuinely the same operation. A call — and a base initialiser, : Base(…) (§7.3) — reaches the constructor that takes that many arguments, counting those its defaults supply, and whose parameters the arguments fit. Where several fit, the one every argument’s type matches exactly is chosen, an untyped literal matching the type it would default to; if that still leaves more than one, the call is ambiguous and an error. So Color(r: u8, g: u8, b: u8, a: u8 = 255) and Color(gray: f32) are told apart by Color(1, 2, 3) and Color(0.5), while Span(from: i32, to: i32 = 0) beside Span(len: i32) makes Span(4) an error.

Arguments are evaluated left to right, each completely before the next: in println!("{} {}", touch(), COUNT.hits), touch() has run by the time COUNT.hits is read. The receiver of a method call is evaluated before its arguments. [DECIDED — §18.49]

The same holds for every expression with more than one operand: both sides of a binary operator (read_u8(r) << 8 | read_u8(r) reads the high byte first), the container and the index of xs[i], the bounds of a slice or range, and the elements of a tuple or array literal. A struct or union literal evaluates its fields in the order they are written, whatever order the type declares them in. && and || still skip their right operand when the left decides. Assignment is the one exception: the value is evaluated before the place it is stored in — in xs[next()] = compute(), compute() runs first — for = and for every compound assignment alike. [DECIDED — §18.49]

An argument converts when its type says how. A type that implements std’s ImplicitAs<T> (§14.4) is converted to a T where it is passed to a parameter of type T, by calling its convert(&self) -> T:

impl ImplicitAs<&cstr> for String { fn convert(&self) -> &cstr { return self.as_cstr() } } // in std
rl::DrawText(format!("score {}", score), 10, 12, 24, rl::LIGHTGRAY) // a String, as a cstr
rl::LoadTexture(path) // a Path, likewise
  • Only arguments convert — positional, named and variadic ones’ fixed part — never a let, a field, a return or an operator’s operand. What convert borrows, as as_cstr borrows a string’s bytes, is used by the call and cannot be kept past it.
  • The argument is borrowed, not moved, as any &self method borrows its receiver, so it is still usable after the call; a temporary, such as a format!, lives until the call returns. A reference to a type that converts converts as the type does.
  • Once at most. A Pounds that converts to Ounces, which converts to &cstr, is not a &cstr.
  • Not to choose or infer. A C++ overload set is still chosen by exact types (§15.3), and a generic parameter is still inferred from the argument’s own type (§8.1).
  • Opt-in. A method or constructor never converts on its own, unlike C++’s single-argument constructors, which do unless marked explicit. Between two number types there is none: neither type is a program’s own, so only std could declare one, and it does not (§5.1).

Where an impl of it may be written is the orphan rule’s ordinary answer, which counts the trait’s argument (§7.6): impl ImplicitAs<Vec2> for rl::Vector2 belongs with Vec2. [DECIDED — D-0089]

Variadic parameters. One parameter may take any number of arguments, written name: ..T. In the body it is a read-only slice, &[]T:

fn sum(xs: ..i32) -> i32 {
let mut t = 0
for x in xs { t += x }
return t
}
sum() // an empty slice
sum(1, 2, 3) // the arguments, in a temporary array on the caller's stack
sum(..numbers) // spread: an existing slice or array, passed as it is
  • There is at most one, and it has no default; its arguments come after every fixed one.
  • ..xs spreads a slice or array, and must then be the only variadic argument — sum(1, ..xs) is an error; build the slice you want first. Forwarding is inner(..args).
  • Arguments are evaluated left to right like any others, moved into the array when they are move-only, and dropped when the call’s statement ends.
  • Arguments of different types share a slice through trait references: with things: ..&Describe, tell(cat, 7, Cat{}) borrows each argument as a &Describe for the call. ..^Describe works too, though a call through a ^T is unsafe (§5.2).
  • Methods, constructors and generic functions may be variadic; fn biggest<T: Ord>(first: T, rest: ..T) infers T from the arguments.
  • As a value, a variadic function is a plain fn(&[]T) -> R that takes its slice.

C variadic functions (printf) are still not imported (§15.2). [DECIDED — §18.56]

Arguments passed by name. The parameters after a variadic one cannot be reached by position — every positional argument there is one more variadic argument — so they are passed by name, name = value, after all the others:

fn total(xs: ..i32, scale: i32 = 1, loc: SourceLocation = #location) -> i32 { … }
total(1, 2, 3) // scale = 1, loc = this line
total(1, 2, scale = 10)
total(loc = here, scale = 2) // any order; no variadic arguments at all
  • Only these parameters are passed by name, and only by name. Naming any other parameter — or passing a name to a function with no variadic parameter — is an error (DZ0558), as is a name the function does not have, a name given twice, or a positional argument after a named one.
  • One with a default may be left out, whichever others are given; its default is evaluated at the call, as any default is. One with no default must be passed.
  • A name is name = value only inside an argument list; assignment is a statement, never an expression, so the two cannot be confused. [DECIDED — §18.72]

extern fn declares a function whose body is not Dazzle: extern fn monotonic_nanos() -> u64, with no braces. Only the standard library may write one (DZ0317), at module level; its body is C++ the compiler emits beside the runtime shim (§16.4), and it is how std reaches the clocks and the file system. It has no type parameters and is not fallible — the Dazzle function that calls it builds any Error. A program reaches C and C++ through a cpp dependency instead (§15.1). [DECIDED — §18.65]

pub struct Transform {
position: Vec3
rotation: Quat = Quat::identity()
scale: Vec3 = Vec3{1, 1, 1}
pub fn matrix(&self) -> Mat4 { ... }
pub fn translate(&mut self, d: Vec3) { position += d }
}

A method’s receiver is its first parameter: &self to read the value, &mut self to mutate it. The receiver is the only place a method says it mutates — there is no mut fn, which would say it a second time and could disagree (§18.34). Inside a method, fields are in scope unqualified (position), and self.position is also valid — and required when a local shadows a field.

A function with no receiver is an associated function: it belongs to the type, not to a value of it, and is called through the type — Quat::identity(), Buffer<f32>::with_capacity(8). It has no self, so the type’s fields are not in scope in it, and calling one on a value (q.identity()) is an error (DZ0520). Inside the type’s own body a bare Buffer is Buffer<T>, as everywhere else there.

A const declared in a type is an associated constant, named through the type: Textures::Missing. It writes its type, and its value is a constant or the address of a static — unsafe { &mut MISSING_TEXTURE } — and nothing that runs code (DZ0553). It is evaluated where it is named, so the address of a #[thread_local] static is the naming thread’s own.

A struct is laid out as C++ lays out the same struct. Its fields are in memory in the order they are declared, a base’s first (§7.3, Inheritance), with the padding and alignment the target’s C++ compiler gives them; the compiler never reorders fields. So a Dazzle struct is layout-identical to a C or C++ struct that declares the same fields in the same order, and can be passed to one by pointer; T::size and T::align (§5.5) are that compiler’s answer. Where size matters, order the fields yourself, largest alignment first: a: u8, b: u64, c: u8 is 24 bytes, and b: u64, a: u8, c: u8 is 16. [DECIDED — D-0085]

Struct literals:

let t = Transform{ position: Vec3{0, 1, 0} } // unlisted fields take their defaults
let v = Vec3{1, 2, 3} // positional, allowed when all fields listed
let s: ButtonState = .Transition{} // leading-dot inference from expected type

: Base after a struct’s name names the one struct it inherits from — struct Button : UiElement. One base, never more, and never a trait: those are implemented with impl (§7.6). [DECIDED — 18.32]

pub struct UiElement {
protected rect: Rect
pub shown: bool = true
pub virtual fn draw(&self) // no body: `UiElement` is abstract
pub virtual fn hit(&self, p: Vec2) -> bool { return rect.contains(p) }
UiElement(r: Rect) { rect = r }
}
pub struct Button : UiElement {
pub label: const &str = ""
Button(r: Rect, text: const &str) : UiElement(r) { label = text }
pub override fn draw(&self) { ... }
pub override fn hit(&self, p: Vec2) -> bool {
return UiElement::hit(self, p) && shown
}
}

A base may be an instance of a generic struct: struct Button : Panel<i32> inherits Panel<i32>, its fields and methods with T read as i32, and is built through Panel<i32>’s constructor, : Panel(clicks). A generic derived type may pass its own parameter on — struct Slider<T> : Panel<T> — and a non-generic base is as before. A &Button converts to a &Panel<i32> and to no other instance of Panel.

The base comes first in memory, so &Derived → &Base and ^Derived → ^Base are implicit and cost nothing. A by-value Derived where a Base is wanted is an error: copying one would silently drop everything the derived type adds, which is C++’s slicing. A base’s fields and methods are the derived type’s, in scope unqualified inside its methods exactly as its own are, and a base’s field or method may not be redeclared — a name means one thing in a type, whichever declaration it came from.

Constructors. A base with a constructor must be constructed through it, written after the derived constructor’s parameters: Button(r: Rect, text: const &str) : UiElement(r) { … }. Leaving it out is an error. A base without a constructor is default-initialised (§6.2) and needs nothing written, though : Base() may be written to say so. A type that has a constructor — its own, or an inherited one — is not built with a struct literal: the constructor is what establishes the type’s invariant, and a literal would step around it. The base’s arguments are ordinary expressions over the constructor’s parameters, including ones that need statements — : UiElement(r or Rect{}) — but not return or or return: the base is built before the constructor begins, so there is nothing yet to return from.

virtual and override. A method is dispatched statically unless it says otherwise. virtual fn opts one into runtime dispatch; override fn replaces one, and must match the base’s signature exactly. Both are checked in both directions: an override with nothing to override is an error, and so is a method that redeclares a base’s without saying override. A virtual fn with no body makes its struct abstract — there is nothing to call, so no value of it can exist, by value, in a field or as a parameter. An abstract type behind a & or a ^ is exactly what it is for. A derived type becomes concrete by overriding every body-less virtual it inherits.

Base::method(self, …) calls the base’s version without virtual dispatch, which is how an override builds on what it replaces rather than discarding it. drop becomes virtual automatically as soon as a type or one of its bases has a virtual method, so a value destroyed through a base reference runs the right one.

Implementations are inherited. A Button is Inspect if UiElement is, and a more specific impl Inspect for Button replaces the inherited one. Dispatch stays static (§7.6): through a &UiElement it is UiElement’s implementation that runs, and reaching the derived behaviour from there is what a virtual method is for.

pub enum Sprite : u16 {
None = 0
Default
Selected
Pressed
}
let s = Sprite::Selected

Enums are named integers with an optional backing type (i32 by default). They are not implicitly convertible to that backing type; use as. A switch on an enum must be exhaustive or carry a default arm.

Like the integers they are, enums implement Eq, Hash and Ord without an impl: == and < compare values, so an enum is a HashMap key and satisfies T: Ord. [DECIDED — §18.54]

A variant may be written .Selected wherever the enum is already known, which is most places worth writing one:

let s: Sprite = .Selected
fn draw(s: Sprite) { ... } draw(.Pressed)
if s == .Default { ... } if .Default == s { ... }
return .None Style{ sprite: .Selected }
let s = if hot { .Pressed } else { .Default }

Sprite::Selected always works and is what you write where there is no expected type to read it from — including let s = Sprite::Selected, since a bare let has nothing to infer from. The leading dot is the same mechanism a union variant uses (§7.5) and the same one .Circle{ radius: 1 } uses; it is written .Selected, never Sprite.Selected, because . is the runtime operator and its left side is always a value (§3.3). [DECIDED — §18.24]

A union is a tagged union — a sum type, not C’s overlapping storage.

pub union Shape {
Circle { radius: f32 }
Rect { w: f32, h: f32 }
Point {} // no payload
}
let s = Shape::Circle{ radius: 2 }
switch s {
case Circle(c) { area = PI * c.radius * c.radius }
case Rect(r) { area = r.w * r.h }
case Point { area = 0 }
}

A variant with no payload is written with empty braces, Point {}, and built as Shape::Point or Shape::Point{}.

A bare variant is an existing struct. Written without braces, a variant names a struct in scope and carries a value of it:

struct DefaultState {
pub fn update(&self, input: &Input) -> ButtonState? { … }
}
struct TransitionState {
pub t: f32 = 0
pub fn update(&mut self, ctx: &ButtonCtx, btn: &mut Button) -> ButtonState? { … }
}
union ButtonState {
DefaultState
TransitionState
}
let s: ButtonState = .TransitionState{} // built as a `TransitionState` is: `t` is 0
switch s {
case DefaultState(d) { d.update(input) } // `d` is a `DefaultState`
case TransitionState(&mut t) { t.update(ctx, btn) }
}

The payload is the struct itself — its fields, defaults, visibility and methods — so a variant literal is checked as that struct’s literal and a case binding has the struct’s type. A bare name with no struct of that name in scope, or one naming an enum, a union, a trait or a generic struct (which has nowhere to write its arguments), is an error rather than an empty variant: otherwise a misspelling, or a struct added or removed elsewhere, would change what the union holds without a word. [DECIDED — §18.45]

A union’s size is its largest variant plus a tag, aligned. Shape::Kind is an automatically generated enum of the variant names, so s.kind() gives you something cheap to store or compare. A union owns its active payload: a variant may hold a String, a List or a struct with a drop, and the union drops whichever variant is active when it dies, drops the old variant before a new one is assigned, and moves by moving the active payload. A union is copyable only when every variant is. [DECIDED — §18.52]

A union may declare methods, after or among its variants, written as a struct’s are:

union ButtonState {
DefaultState
TransitionState
ChangeSprite
pub fn name(&self) -> &str {
return switch self {
case DefaultState { "default" }
case TransitionState { "transition" }
case ChangeSprite { "change" }
}
}
pub fn step(&mut self, ctx: &ButtonCtx) {
let next: ButtonState? = switch self {
case TransitionState(&mut t) { t.update(ctx) }
default { none }
}
self = next or return // replaces the whole union
}
}

self is the union, and its payload is reached with switch self. A method has the same visibility rules as a struct’s (§4), and a method without self is called through the union’s name, ButtonState::initial(). A union declares methods only: its fields are its variants, it is built from a variant, and copying and dropping it are already defined by its variants (§18.52), so a union has no constructors, copy or drop of its own. kind is taken by the generated s.kind(). [DECIDED — §18.78]

Untagged/overlapping storage, should interop ever need it, is a separate #[raw] union.

pub trait Allocator {
fn alloc(&self, size: usize, align: usize) -> ^mut u8
fn realloc(&self, p: ^mut u8, old_size: usize, new_size: usize, align: usize) -> ^mut u8
fn free(&self, p: ^mut u8, size: usize, align: usize)
}
pub trait Drawable {
fn bounds(&self) -> Rect
fn draw(&self, batch: &mut SpriteBatch)
fn is_visible(&self, cam: &Camera) -> bool { // default method
return cam.frustum.contains(self.bounds())
}
}

A trait is implemented by an impl block, and checked where it is written:

pub struct Arena {
m_base: own ^mut u8
m_used: usize
}
impl Allocator for Arena {
fn alloc(&self, size: usize, align: usize) -> ^mut u8 { ... }
fn realloc(...) -> ^mut u8 { ... }
fn free(...) { }
}

The impl holds exactly the trait’s methods — every one the trait gives no default for, with the declared signature, and nothing the trait does not declare — and chooses each associated type the trait leaves open (§8.5). A trait’s supertraits need implementations of their own: impl Ord for S requires an impl Eq for S as well. Inside the methods self is the type implemented for, and its fields are in scope unqualified as in the type’s own methods (§7.3) — all of them when the impl is in the type’s module, and only the pub ones otherwise (§4).

Where an impl may be written: with the trait, or with the type. In the module that declares the trait, or the one that declares the type, and nowhere else. That is what lets a module add behaviour to a type it did not write, without touching it:

// the editor module: `Inspect` is its own; `engine::Transform` and `f32` are not
pub trait Inspect { fn inspect(&self, ui: &mut Ui) }
impl Inspect for engine::Transform { fn inspect(&self, ui: &mut Ui) { ... } }
impl Inspect for f32 { fn inspect(&self, ui: &mut Ui) { ... } }

— and it is also what keeps implementations from colliding. Two libraries cannot both implement a third’s trait for a fourth’s type, so a program using both is never left to choose, and code that can see both the trait and the type can always see the implementation that joins them. Implementing someone else’s trait for someone else’s type means wrapping the type in a struct of your own. [DECIDED — §18.31]

A trait’s type arguments count too. impl Mul<Vec2> for f32 and impl ImplicitAs<Vec2> for rl::Vector2 may be written in the module that declares Vec2: the types an impl joins are its type, then each of the trait’s arguments in order, and any one declared in the module is enough. It stays collision-free: two modules could only both write one implementation if each named the other’s type, which would make their dependencies a cycle (§3.2). As in Rust, a bare type parameter before the module’s own type ends the search — impl<T> Pair<T, Mine> for Theirs would also cover Pair<Yours, Mine>, which Yours’s module could write too. [DECIDED — D-0087]

An imported header’s types belong to the module that carries its recipe (§15.1). That module may implement any trait for them — impl Mul for rl::Matrix, so raylib’s own matrix takes * — as if it had declared them. There is one such module in any build, since two recipes for one library are an error. Methods are not added this way: a struct’s methods are written in its body (§7.3), and C++ already wrote this one’s. [DECIDED — D-0088]

Generic implementations have parameters of their own: impl<T: Inspect> Inspect for Buffer<T> applies to every Buffer<T> whose T is Inspect. An implementation for every type at once — impl<T> Inspect for T — is not allowed, since it would overlap every other implementation of the trait. Nor is one for a pointer or reference type — impl Hash for ^Node — since a method call reaches through a pointer to what it points at (§5.2) and so could never find it; to give a pointer behaviour of its own, wrap it in a struct and implement the trait for that. Two implementations of one trait that could apply to the same type at the same arguments are an error; at different arguments they are how a type takes several right-hand sides (§11.2).

A type’s own method wins over a trait’s of the same name. When two traits both provide a method, the call names the one it means: Describe::name(&v). drop and copy are never called by name (§6.1). [DECIDED — §18.31]

Static dispatch happens whenever the trait is a generic bound (§8): the call is monomorphized and direct.

Dynamic dispatch happens through ^Trait or &Trait, which is a fat pointer:

struct TraitPtr { data: ^mut void, vtable: ^VTable }

The vtable lives in static storage; the object carries nothing — no vptr, no header, no size change. A Vec3 implementing Drawable is still 12 bytes. This is the same principle as the reflection design (§13): metadata sits beside the data, never inside it. [DECIDED — §18.6]

Each vtable is additionally reachable by a stable, deterministic id — a hash of the implementing type’s and the trait’s fully qualified names — rather than only by its address. That costs nothing now and is what makes dynamic dispatch re-resolvable after a hot-reload swap (§17.6).

The cost is that ^Drawable is 16 bytes while ^Sprite is 8. The compiler will not let you silently confuse them, and the LSP shows the fat-pointer-ness on hover.

A static is a variable declared at module scope, and there is one of it for the whole program — or one per thread, with #[thread_local]:

static FRAME_COUNT: u64 = 0
pub static PALETTE: [4]Color = [BLACK, WHITE, RED, BLUE]
#[thread_local]
static SCRATCH: TempArena = TempArena{}
  • The type is written. A static is storage that every user of it relies on, so it is never inferred from its initialiser.
  • The initialiser is built from constants: literals, consts, null, none, and struct, array, tuple and variant literals of those (DZ0553). Anything more is filled in from main.
  • A static whose value takes code is built when the program starts, in a fixed order. A struct literal leaves fields to their defaults, and a default may call a constructor, which may read another static. Every such static is built before main’s body runs: a module’s statics after those of the modules it uses, so a value can read what its module depends on, and within a module in order of declaration. A static read before its turn, by a value built ahead of it, is built where it is read; one read while it is being built has no value to give, and the program panics, naming it. A static of constants alone exists before the program runs and costs nothing to read. [DECIDED — D-0094; T-0166]
  • Reading one and changing one are both safe, and checked as a parameter is (§6.7). A static is one place for the whole program, so a reference into it dies when something kills what it points into, and a function that kills memory a static owns says so in its kills clause, naming the static: fn add(v: i32) kills SCORES.list@items. Every caller names that kill in its own clause in turn, up to an entry point (main, a #[test] or a #[bench], which nothing calls). A write that kills nothing, such as FRAME_COUNT += 1, needs no clause. A kill inside unsafe is vouched for there and needs none either, as every change to a static once did. [DECIDED — D-0094]
  • Nothing moves out of a static, and a move-only one is not copied either (DZ0402). It belongs to the program rather than to any function, exactly as a borrow does.
  • Every static with something to drop is dropped when the program ends, after main returns or on exit, in an order the program can rely on: a module’s statics before those of the modules it uses, so a drop can still read what its module depends on, and within a module in reverse order of declaration. Each is left at its default, as a moved-from value is (§6.4), so a drop that runs later and reads a static already dropped sees an empty value, never freed memory. [DECIDED — D-0094]

const has no storage and is folded into its uses; static is one place in memory. The standard allocators (§14.2) are statics. [DECIDED — §18.46]