Functions and generics

Functions

Functions are declared with fn, take typed parameters, and declare a return type after the parameter list.

fn add(a: int, b: int): int {
    return a + b;
}

total: int = add(10, 20);

Arguments and return values follow Dune's uniform value semantics: scalars are independent values, while arrays and records are shared handles. Parameter reassignment stays local, but aggregate mutation is visible to the caller. See Values, copying, and mutation.

Functions may be overloaded by the number and types of their parameters; the type checker selects the matching definition at each call site.

Generics and bounds

Type parameters go in angle brackets. A parameter may carry a bound that constrains which types satisfy it.

fn square<T is numeric>(value: T): T {
    return value * value;
}

Available bounds:

  • integer — the integer types.
  • numeric — integers and reals.
  • comparable — supports == / !=.
  • ordered — supports <, <=, >, >=.

A contract name is also a valid bound: T is Display requires T to implement the Display contract.

Multiple bounds

A single type parameter can carry several bounds. Group them with + under one is, or repeat is for the same parameter — both mean "every listed constraint must hold":

// Grouped: T must be both ordered (for `<`) and comparable (for `==`).
fn spans<T is ordered + comparable>(low: T, value: T, high: T): bool {
    return (low < value) == (value == high);
}

// Repeated form is equivalent.
fn spans2<T is ordered, T is comparable>(low: T, value: T, high: T): bool {
    return (low < value) == (value == high);
}

When an argument fails a bound the diagnostic names the specific unmet constraint, e.g. type 'text' does not satisfy bound 'numeric' on 'T'.

Const generics and static shapes

A generic argument can also be a positive integer literal instead of a type. The matrix module uses this to carry the shape of a Matrix or Vector in its type, so shape mistakes become compile-time errors instead of runtime panics:

import matrix;

// A 3x3 matrix and a length-3 vector, spelled in the type.
a: matrix.Matrix<real64, 3, 3> = matrix.identity(3);
v: matrix.Vector<real64, 3> = matrix.vector([1.0, 2.0, 3.0]);

// The product's shape is checked and flows into the result type.
r: matrix.Vector<real64, 3> = a.mul_vector(v);

Matrix<T, Rows, Cols> takes two dimensions; Vector<T, Len> takes one. The type checker verifies shapes for the core operations — vector dot, matrix add/sub/mul/div (element-wise, same shape), matrix matmul/dot, and matrix–vector mul_vector/dot — and rejects incompatible ones:

m: matrix.Matrix<real64, 3, 3> = matrix.identity(3);
w: matrix.Vector<real64, 4> = matrix.vector([1.0, 2.0, 3.0, 4.0]);
bad = m.mul_vector(w);
// error: matrix-vector shape mismatch: matrix.Matrix<real, 3, 3> has 3 column(s)
//        but matrix.Vector<real, 4> has length 4

Static shapes are optional and coexist with the dynamic API: a plain Matrix<real64> (no dimensions) matches any shape, so existing code keeps working and a dynamic value is assignable to and from a statically-shaped binding. Shapes are a type-check-time concern only — the runtime representation is unchanged.

Phase 1 accepts integer literals only. Named const parameters, const expressions (N + 1), and shape inference from array literals are planned follow-ups (see issue #43).

Function values

Function types use fn(P1, P2): R. Named functions and lambdas are ordinary values: they can be stored in bindings and aggregates, passed, returned, copied, and invoked through any function-valued expression.

import array;

fn is_positive(value: int): bool { return value > 0; }
fn square(value: int): int { return value * value; }

values: [int] = [-2, 3, -1, 4];
result = values.filter(is_positive).map(square).sum();

An overloaded named function needs an expected function type so the checker can select one signature. Generic named functions are monomorphized at call sites; they cannot be stored by bare name without concrete type arguments.

Lambdas

A lambda starts with fn but has no name:

square = fn(value: int): int {
    value * value
};

result: int = square(6); // 36

The body is a full function body. It supports local bindings, loops, conditionals, when, early return, and a final tail expression. A unit lambda can use return;.

When the target has a function type, omitted annotations are inferred from that context:

increment: fn(int): int = fn(value) { value + 1 };

Without a contextual function type, omitted parameter and result annotations use the same int defaults as named functions. Explicit annotations are recommended at public or non-obvious boundaries because diagnostics then show the intended signature directly.

Lambdas do not declare their own generic parameter list. They may, however, appear inside a generic named function; monomorphization substitutes the concrete types through the lambda and its capture environment:

fn remember<T>(value: T): fn(): T {
    fn(): T { value }
}

answer = remember(42);
label = remember("Dune");

Closures and captures

A lambda becomes a closure when it references a binding outside its own body. Captures follow Dune's ordinary value semantics and are evaluated once when the closure is created:

  • scalars, text, and callable bindings are snapshots;
  • arrays and records copy their shared handles, so aggregate mutation remains visible through every alias;
  • rebinding the original variable later does not change an existing closure;
  • a captured name cannot be reassigned inside the closure;
  • nested closures forward any outer values needed by their own children.
factor: int = 10;
scale = fn(value: int): int { value * factor };
factor = 20;
scale(4); // 40: the closure captured 10

items = [1];
append = fn(value: int): unit {
    items.push(value); // aggregate contents may change
    return;
};
append(2); // the outer array is now [1, 2]

Factory calls create independent environments, so closures can safely outlive the function invocation that created them:

fn make_adder(base: int): fn(int): int {
    return fn(value: int): int { base + value };
}

add_two = make_adder(2);
add_ten = make_adder(10);

Calling function-producing expressions

Any expression with a function type is callable. Parenthesized lambdas can be invoked immediately, and calls can be chained when a function returns another function:

answer = (fn(value: int): int { value + 1 })(41);
same = make_adder(40)(2);

callbacks: [fn(): int] = [fn(): int { 40 }, fn(): int { 2 }];
also = callbacks[0]() + callbacks[1]();

The checker reports the complete expected and actual fn(...) signatures for argument, arity, and return mismatches. Calling a non-function value is rejected before bytecode generation.

Standard-library integration

import array; supplies map(fn(T): U), filter(fn(T): bool), reduce/fold, any, all, and count_where. Named functions and capturing lambdas use the same callback path:

offset = 3;
shifted = [1, 2, 3].map(fn(value: int): int { value + offset });

Function values and closures run on Dune's canonical bytecode VM; there is no separate backend or native-only closure behavior.

Foreign functions

foreign fn binds a Dune signature to a native C symbol. These are used sparingly (the standard library restricts them to a single sanctioned primitive).

foreign fn c_sqrt(value: real64): real64 = "sqrt";

print(c_sqrt(81.0));   // 9