Functions
Functions are the primary unit of code organization in Neuro.
Function Declaration
Basic Syntax
func function_name(param1: Type1, param2: Type2) -> ReturnType {
// function body
return value
}Components:
funckeyword- Function name (identifier)
- Parameter list (optional)
- Return type annotation (required unless void)
- Function body (block of statements)
Simple Function
func greet() -> i32 {
return 0
}Function with Parameters
func add(a: i32, b: i32) -> i32 {
return a + b
}Function without Return Value
func do_something() {
// Implicit void return
val x: i32 = 10
}Parameters
Parameter Syntax
Each parameter requires:
- Parameter name
- Type annotation
func process(input: i32, flag: bool) -> i32 {
if flag {
return input * 2
} else {
return input
}
}Multiple Parameters
func complex(a: i32, b: i32, c: i32, d: i32) -> i32 {
return a + b + c + d
}Parameter Passing
A parameter of scalar type is passed by value (copied). Reference parameters (&T / &mut T)
pass a borrow instead, see Borrows; a non-Copy value passed by value is moved
rather than copied.
func modify(x: i32) -> i32 {
mut temp: i32 = x
temp = temp + 10
return temp // Returns modified value, original unchanged
}
func test() -> i32 {
val original: i32 = 5
val result: i32 = modify(original)
// original is still 5
// result is 15
return result
}Named Arguments
An argument may be passed by name at the call site. Named arguments may appear in any order, and any number of positional arguments may come first.
func connect(host: string, port: i32, timeout: i32) -> i32 {
return port + timeout
}
val a = connect("localhost", 8080, 30) // all positional
val b = connect("localhost", port: 8080, timeout: 30)
val c = connect("localhost", timeout: 30, port: 8080) // same call as bExternal labels
A parameter may be declared with two names: an external label the caller writes and
an internal name the body uses, written external internal: T. The external label is
then required at every call site.
func clamp(_ value: f32, min lo: f32, max hi: f32) -> f32 {
if value < lo { return lo }
if value > hi { return hi }
return value // the body says lo / hi, never min / max
}
val x = clamp(1.5, min: 0.0, max: 1.0)
// val y = clamp(1.5, 0.0, 1.0) // error: min and max must be namedAn external label of _ suppresses the call-site name: the argument is positional and
naming it is an error.
func scale(_ value: f32, _ factor: f32) -> f32 {
return value * factor
}
val r = scale(2.0, 3.0) // no names accepted
// val s = scale(value: 2.0, factor: 3.0) // error: both are positional-onlyRules
- Positional arguments must precede named arguments.
- Named arguments may appear in any order relative to each other.
- A plain
name: Tparameter may be passed positionally or asname: value. - An
external internal: Tparameter must always be namedexternal:. - A
_ name: Tparameter must always be positional. - Two parameters of one function may not answer to the same call-site name.
- Free functions, associated functions (
Type::f(...)), and methods (x.m(...)) all accept named arguments. Closures, the panic builtins, enum variants, and newtype constructors declare no parameter names, so a label on one is an error.
Arguments are bound to parameters before type checking, and are evaluated in the order they are written, exactly as a positional call evaluates them left to right. Naming them therefore changes which parameter each value reaches, never when it is computed:
func bump(r: &mut i32) -> i32 { *r = *r + 1
return 0 }
func combine(first: i32, second: i32) -> i32 { first * 10 + second }
func main() -> i32 {
mut x = 0
// `bump` is written first, so it runs first and `x` is read as 1: the call is
// `combine(first: 1, second: 0)` and `n` is 10.
val n = combine(second: bump(&mut x), first: x)
return n - 10
}A call that only passes values costs nothing at runtime: it produces exactly the IR the equivalent positional call produces. A call that reorders arguments carrying effects binds each of them to a temporary first, which is one binding per argument and no other cost.
Limitation
A method call is matched on the method name alone, because the receiver's type is not yet known when arguments are bound. If two different types declare a method of the same name with different parameter names, a named argument on that method is rejected; write the call positionally, or rename one of the methods. Positional calls are never affected.
Return Values
Explicit Return
Use the return keyword to exit a function with a value:
func explicit_return() -> i32 {
return 42
}Multiple Return Points
func conditional_return(x: i32) -> i32 {
if x > 0 {
return 1
} else if x < 0 {
return -1
} else {
return 0
}
}Expression-Based Returns (Implicit Return)
The last expression in a function body automatically becomes the return value (Neuro has no semicolons; statements are newline-terminated):
func implicit_return() -> i32 {
42 // No 'return' keyword needed
}
func add(a: i32, b: i32) -> i32 {
a + b // Implicit return
}Mixing explicit and implicit returns:
func mixed(x: i32) -> i32 {
if x > 10 {
return 100 // Explicit return for early exit
}
x * 2 // Implicit return for normal case
}Key points:
- The last expression in the body is the return value
- Must match function return type
- Can mix with explicit
returnstatements - There are no semicolons; a stray
;is a parse error
Void Return
Functions without a return value:
func no_return() {
val x: i32 = 10
// Implicit void return at end
}
func explicit_void() {
val x: i32 = 10
return // Explicit void return
}Function Calls
Basic Call
func add(a: i32, b: i32) -> i32 {
return a + b
}
func main() -> i32 {
val result: i32 = add(5, 3)
return result
}Nested Calls
func double(x: i32) -> i32 {
x * 2
}
func add(a: i32, b: i32) -> i32 {
a + b
}
func main() -> i32 {
val result: i32 = add(double(5), double(3))
// double(5) = 10, double(3) = 6
// add(10, 6) = 16
return result
}Call Expressions
Function calls can appear anywhere an expression is expected:
func compute() -> i32 {
val x: i32 = add(1, 2) + add(3, 4) // 3 + 7 = 10
return x
}Recursion
Basic Recursion
func factorial(n: i32) -> i32 {
if n <= 1 {
1
} else {
n * factorial(n - 1)
}
}
func main() -> i32 {
factorial(5) // Returns 120
}Tail Recursion
Tail calls are not guaranteed to be eliminated. At -O0 every call takes a stack frame, so a
deep tail recursion can overflow the stack; from -O1 up, LLVM usually turns a function's
tail call to itself into a loop:
func factorial_tail(n: i32, acc: i32) -> i32 {
if n <= 1 {
acc
} else {
factorial_tail(n - 1, n * acc)
}
}
func factorial(n: i32) -> i32 {
factorial_tail(n, 1)
}Mutual Recursion
func is_even(n: i32) -> bool {
if n == 0 {
true
} else {
is_odd(n - 1)
}
}
func is_odd(n: i32) -> bool {
if n == 0 {
false
} else {
is_even(n - 1)
}
}The main Function
Every Neuro program requires a main function as the entry point:
func main() -> i32 {
// Program entry point
return 0 // Exit code
}Requirements:
- Must be named
main - Must return
i32(exit code) - Must not have parameters
Any other signature is refused with InvalidMainSignature: the C runtime passes argc as
the first argument and reads the return register as the exit status, so no other shape has a
defined exit code. Only the root file's main is the entry point.
Exit codes:
0= success- Non-zero = error (convention)
Function Scope
Local Variables
Variables declared in a function are local to that function:
func scoped() -> i32 {
val x: i32 = 10 // Local to scoped()
return x
}
func other() -> i32 {
// val y: i32 = x // Error: x not in scope
return 0
}Parameter Scope
Parameters are local variables initialized with argument values:
func params(a: i32, b: i32) -> i32 {
// a and b are local variables
val sum: i32 = a + b
return sum
}Shadowing
Inner scopes can shadow outer scope variables:
func shadowing() -> i32 {
val x: i32 = 1
if true {
val x: i32 = 2 // Shadows outer x
// Inner x is 2
}
// Outer x is still 1
return x
}Type Checking
Argument Type Checking
Function calls are strictly type-checked:
func takes_i32(x: i32) -> i32 {
return x
}
func test() -> i32 {
val x: i64 = 100
return takes_i32(x) // Error: expected i32, found i64
}Return Type Checking
All return paths must match the declared return type:
func type_checked(flag: bool) -> i32 {
if flag {
return 42 // OK: i32
} else {
return 3.14 // Error: expected i32, found f64
}
}Argument Count Checking
func two_params(a: i32, b: i32) -> i32 {
return a + b
}
func wrong_count() -> i32 {
return two_params(1) // Error: expected 2 args, found 1
}Function Examples
Mathematical Functions
func abs(x: i32) -> i32 {
if x < 0 {
-x
} else {
x
}
}
func max(a: i32, b: i32) -> i32 {
if a > b { a } else { b }
}
func min(a: i32, b: i32) -> i32 {
if a < b { a } else { b }
}
func clamp(x: i32, min_val: i32, max_val: i32) -> i32 {
min(max(x, min_val), max_val)
}Boolean Logic Functions
func and(a: bool, b: bool) -> bool {
a && b
}
func or(a: bool, b: bool) -> bool {
a || b
}
func xor(a: bool, b: bool) -> bool {
(a && !b) || (!a && b)
}
func implies(a: bool, b: bool) -> bool {
!a || b
}Recursive Functions
func fibonacci(n: i32) -> i32 {
if n <= 1 {
n
} else {
fibonacci(n - 1) + fibonacci(n - 2)
}
}
func gcd(a: i32, b: i32) -> i32 {
if b == 0 {
a
} else {
gcd(b, a % b)
}
}
func power(base: i32, exp: i32) -> i32 {
if exp == 0 {
1
} else {
base * power(base, exp - 1)
}
}Best Practices
1. Keep Functions Focused
Each function should do one thing well:
// Good: focused functions
func is_positive(x: i32) -> bool {
x > 0
}
func absolute_value(x: i32) -> i32 {
if is_positive(x) { x } else { -x }
}2. Use Descriptive Names
// Good: clear purpose
func calculate_area(width: i32, height: i32) -> i32 {
width * height
}
// Bad: unclear
func calc(w: i32, h: i32) -> i32 {
w * h
}3. Use Expression Returns for Simple Functions
// Good: concise
func double(x: i32) -> i32 {
x * 2
}
// Verbose (but acceptable)
func double_verbose(x: i32) -> i32 {
return x * 2
}4. Use Explicit Returns for Complex Logic
// Good: explicit returns for clarity
func complex_logic(x: i32) -> i32 {
if x > 100 {
return 100
}
if x < 0 {
return 0
}
return x
}5. Minimize Nesting
// Good: early returns reduce nesting
func process(x: i32) -> i32 {
if x < 0 {
return 0
}
if x > 100 {
return 100
}
return x * 2
}
// Bad: deeply nested
func process_nested(x: i32) -> i32 {
if x >= 0 {
if x <= 100 {
return x * 2
} else {
return 100
}
} else {
return 0
}
}Common Mistakes
Missing Return
func missing_return(x: i32) -> i32 {
if x > 0 {
return x
}
// Error: missing return for x <= 0 case
}
// Fix: add else branch
func fixed(x: i32) -> i32 {
if x > 0 {
return x
} else {
return 0
}
}Unreachable Code
Statements after a return compile without a warning and never run:
func unreachable() -> i32 {
return 42
val x: i32 = 10 // accepted, never executed
}Stray Semicolon
func wrong() -> i32 {
val x: i32 = 42
x; // error: unexpected token Semicolon (Neuro has no semicolons)
}
// Fix: remove the semicolon
func right() -> i32 {
val x: i32 = 42
x // Implicit return
}Not in the Language
Neuro has no default parameter values and no variadic functions. A caller that wants to skip an argument passes it by name, and a function that takes any number of values takes a slice:
func sum(values: &[i32]) -> i32 {
mut total = 0
for v in values { total = total + v }
total
}
val xs = [1, 2, 3]
val s = sum(&xs) // 6A spread operator at call sites (f(..args)) is planned; see the
Quick Roadmap.
Panic Builtins
Three compiler-known builtins terminate the program when an unrecoverable condition is
reached. They follow the abort, no unwinding model: each prints a diagnostic
(message at file:line:col) to standard error and calls abort(). The stack is not
unwound, so future Drop / defer cleanup runs only on normal scope exit, never during a
panic.
func divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic("division by zero")
}
a / b
}
func main() -> i32 {
assert(divide(10, 2) == 5) // passes silently
val x = divide(1, 0) // prints "panic: division by zero at main.nr:3:9" and aborts
return 0
}| Builtin | Signature | Behaviour |
|---|---|---|
panic | panic(msg: string) | Print panic: <msg> at <loc> and abort. |
assert | assert(cond: bool) | Abort with assertion failed at <loc> only when cond is false; otherwise continue. |
unreachable | unreachable() | Print internal error: entered unreachable code at <loc> and abort. |
Because these builtins diverge (they never return), a call may appear anywhere a value is
expected, including the implicit-return (tail) position of a non-void function:
func parse_digit(c: i32) -> i32 {
if c >= 48 && c <= 57 { c - 48 } else { panic("not a digit") }
}A user-defined function whose name is panic, assert, or unreachable shadows the builtin
within the program.
return, break, and continue diverge the same way, so an if or match arm that
uses one takes its type from its siblings rather than imposing one on them, see
expressions.md.
Standard Output Builtins
Two more compiler-known builtins write text to standard output. Unlike the panic
family they return: the result is void, so a call is a statement and not a value.
| Builtin | Signature | Behaviour |
|---|---|---|
print | print(text: string) | Write text to stdout. |
println | println(text: string) | Write text followed by a newline. |
The newline is one \n byte. Standard output is the platform's text stream, so on
Windows it reaches the console or pipe as \r\n, exactly as it would from a C program.
func main() -> i32 {
val name: string = "Neuro"
val ratio: f64 = 0.875
print("hello from ")
println(name)
println("phase 2 is {ratio:.2} done")
return 0
}hello from Neurophase 2 is 0.88 doneEach takes exactly one argument, and that argument is text: an owned string or an
immutable &string sub-slice (text.slice(1..4)). There is no variadic form and no
format string at the call site, because there is no need for one: string
interpolation has already rendered every hole, with
its full format mini-language, into one ordinary string before the call is reached:
println("{label:>10}: {count} of {total}")The text is read, not consumed, so the value stays usable afterwards:
val text: string = "kept"
println(text)
println(text) // still valid: printing does not move its argumentA user-defined function named print or println shadows the builtin within the program,
exactly as one named panic does. Both are compiler builtins rather than declarations, so
@no_prelude does not take them away.
Output is buffered. A call copies its bytes into a page-sized buffer, and the buffer
reaches the operating system when it fills. So a printing loop pays one write for
thousands of lines rather than one, or for println two, per call.
That buffer is emptied on every path out of the program, so buffering never costs output:
when main returns, when the panic runtime aborts, and when a debug-build arithmetic
overflow traps. A line printed before a panic therefore still appears, and it appears
before the panic's diagnostic on standard error rather than after it.
When standard output is a terminal, the buffer is emptied at the end of every
println instead of only when it fills, so a program's progress is visible while it runs.
A pipe or a file gets the full buffer. This is the behaviour of C's standard library, and
of every language's standard output built on it. print writes no line terminator and so
ends no line: its bytes wait for the next println or for the program to finish.
Generic Functions
A function may declare type parameters in angle brackets after its name. Each type parameter stands for a type supplied at the call site; the compiler generates one specialized copy of the function per distinct set of concrete type arguments (monomorphization), so a type parameter carries zero runtime cost.
// A single template, instantiated at each concrete type it is called with.
func identity<T>(x: T) -> T {
x
}
// Multiple type parameters.
func second<T, U>(a: T, b: U) -> U {
b
}
func main() -> i32 {
val a = identity(41) // identity<i32>
val f = identity(2.5) // identity<f64>, a separate specialized copy
val s = second(1.5, 7) // second<f64, i32> -> 7
return a + s // 41 + 7 = 48
}Type-argument inference. Type arguments are inferred from the call's value arguments where
possible. A type parameter that appears only in return position cannot be inferred and must be
supplied explicitly with a turbofish (identity::<i32>(x)).
What a generic body may do. On an unbounded type parameter the body may use only operations valid for any type: binding a value, returning it, passing it to another function, and building or observing tuples. Operations that need a known concrete type (arithmetic, comparison, field access, method calls) are rejected on a bare type parameter:
func bad<T>(a: T, b: T) -> T {
a + b // error: `+` is not defined on the unbounded type parameter T
}Generic types at a type parameter. A generic struct or enum may take the function's own
parameter as its argument, in the signature and in the body. A call infers T through the
instance it is handed, so unwrap_or(Option::Some(40), 0) below is an i32:
func first_some<T>(x: T) -> Option<T> {
Option::Some(x)
}
func unwrap_or<U>(o: Option<U>, d: U) -> U {
match o {
Option::Some(v) => v,
Option::None => d,
}
}Option<T> moves like the T it holds. A generic that calls itself with a type built from its
own parameter (grow(Option::Some(x)) inside grow<T>) would need a new instance at every level,
so the compiler refuses it once the chain of instances passes a fixed depth.
Bounds. A trait bound (func f<T: Shape>(x: &T)) is enforced: the bound's methods become
callable on the type parameter inside the body, and a type argument that does not implement the
trait is rejected at the call site.
func scaled_area<T: Shape>(s: &T, factor: i32) -> i32 {
s.area() * factor // dispatched through the `Shape` bound
}
// error: type argument `NoImpl` for 'T' does not implement required trait 'Shape'Ownership through a type parameter
A generic body is checked once, before any type argument is known, so a type parameter has
to answer for every instantiation at once. It is therefore treated as non-Copy: a
T-typed binding is moved when it is placed into a new owner, and reading it afterwards is a
use of moved value error even where the call site later supplies i32.
func dup<T>(v: T) -> T {
val a = v
val b = v // error: use of moved value 'v'
b
}That is what leaves a type argument unconstrained: because the body already holds for the most
restrictive case, a non-Copy type argument needs no further check.
struct Holder {
name: string
}
func identity<T>(x: T) -> T {
x
}
val h = identity(Holder { name: "held" }) // identity<Holder>: moved in, moved back out
val s = identity("text") // identity<string>
val n = identity(41) // identity<i32>The value is passed by value and returned by value, and a Drop type's
destructor runs exactly once per value, whether the callee consumes it or hands it back.
A bound is how a body earns more. T: Copy makes T copy instead of move, so dup above
compiles as dup<T: Copy>, and a Vec<T> may be built in the body. T: Clone gives the body
v.clone(), which returns a T; a Copy bound gives it too. The scalars, string, a struct
that derives the trait and one that implements it by hand all meet these bounds alike (see
Structs). A reference does not meet them yet.
func dup<T: Copy>(v: T) -> (T, T) {
val a = v
val b = v // fine: every instance copies
(a, b)
}
func twice<T: Clone>(v: &T) -> T {
v.clone()
}Restrictions. Without a Copy bound, the same conservatism bars a T from the positions
restricted to Copy types: a closure borrows a T-typed binding rather than copying it, so it
may move it out only as a move closure, and [v, v] or (v, v) over a T duplicates one
owner, so the second slot is a use of a moved value. Generic struct and
enum type arguments carry no Copy requirement either: the instance holds the value and
moves with it (see Types → Ownership of an element). Generic
impl blocks are supported.
Const (value) parameters
A generic parameter list may also declare a const parameter, a compile-time value (of an
integer type), written const NAME: T. A const parameter is usable as an array length and as a
value in the body, and each distinct value is monomorphized into its own specialized code (zero
runtime cost). Const parameters are inferred from array-argument lengths:
func sum<const N: u32>(a: [i32; N]) -> i32 {
mut total: i32 = 0
for x in a {
total = total + x
}
total // N is inferred from the array's length
}
val xs: [i32; 3] = [10, 20, 12]
val s = sum(xs) // sum<3> -> 42where clauses
For a readable signature, constraints may move into a where clause after the return type. A
where clause carries trait bounds (where T: Shape, enforced exactly as an inline bound is) and
value predicates over const parameters, a boolean expression checked at every instantiation
and reported at the offending call:
func head<const N: u32>(a: [i32; N]) -> i32 where N > 0 {
a[0] // guaranteed non-empty by `where N > 0`
}Turbofish, explicit generic arguments
When inference cannot reach a parameter (or you want to be explicit), supply the arguments at the
call with a turbofish ::<...>. This is the only call-site form for explicit generic arguments;
arguments may be types or const values:
val a = identity::<i32>(5) // explicit type argument
val b = zeros::<4>() // explicit const argumentStatic and Dynamic Dispatch
A trait bound can be satisfied two ways, and the keyword chooses which.
impl Trait, static dispatch
impl Trait is anonymous-generic sugar. Each concrete type flowing through it produces a
specialized copy of the function, exactly as a named type parameter does, so it carries
zero runtime cost and no pointer indirection.
// These two signatures compile to the same code:
func train(model: &impl Model) -> i32 { model.step() }
func train<T: Model>(model: &T) -> i32 { model.step() }Each impl Trait parameter is its own anonymous parameter, so one call may bind two
different concrete types, unlike a single shared <T>:
func total(a: &impl Shape, b: &impl Shape) -> i32 { a.area() + b.area() }
total(&square, &rect) // valid: two different typesIn return position, impl Trait names the one concrete type the body produces. It
resolves transparently to that type, and the compiler verifies the type implements the
trait:
func make() -> impl Shape { Square { side: 3 } }The body's result must be a direct constructor (a struct literal or enum value) for the
concrete type to be inferable. Returning a binding or a call result is rejected with
cannot infer the concrete type of the impl Shape return.
dyn Trait, dynamic dispatch
dyn Trait is a single runtime type that can hold any implementor. Method calls go
through a vtable: the reference carries a pointer to the value plus a pointer to that
concrete type's method table, and the call jumps through a fixed slot.
A trait object is unsized, so it only appears behind a reference, &dyn Trait or
&mut dyn Trait. A bare dyn Trait is a compile error.
// One function body serves every implementor.
func measure(s: &dyn Shape) -> i32 { s.area() }
measure(&square) // &Square coerces to &dyn Shape
measure(&rect) // &Rect coerces to &dyn ShapeA derived or builtin implementor coerces too: a struct with @derive(Debug) becomes a
&dyn Debug, and so does &n for an i32. A &string does not yet
(BUG-126).
Use dyn when values of different concrete types must be handled behind one
interface; use impl Trait (or a named bound) when each call site has one concrete type.
| Form | Dispatch | Cost | Use when |
|---|---|---|---|
impl Trait | static | zero (monomorphized) | one concrete type per call site |
&dyn Trait | dynamic | one vtable indirection | a heterogeneous set behind a uniform interface |
Object safety
A trait is usable as dyn only if it is object-safe: every method must dispatch on a
&self or &mut self receiver and name Self nowhere else, so the vtable has a fixed
layout. A method that consumes self by value, one with no receiver at all, or one that
takes or returns Self makes the trait unusable as a trait object (it remains fully usable
through impl Trait and named bounds).
trait Consume { func take(self) -> i32 } // not object-safe: consumes self
trait Maker { func build() -> i32 } // not object-safe: no receiver
trait Grow { func grown(&self) -> Self } // not object-safe: returns SelfA trait declaring an associated type is also rejected as dyn today: the trait-object type
would have to bind it (&dyn Iterator<Item = u32>), and that form is not implemented yet.
A &mut self method requires a &mut dyn Trait receiver; mutations through it are
visible to the caller.
Closures and Lambdas
A closure is an anonymous callable written with pipe-delimited parameters,
|params| body. It can capture variables from the enclosing scope.
// Single-expression body (no braces): the return type is the body's.
val square = |x: i32| x * x
val nine = square(3) // 9
// Block body: the return type is its last expression's, or write it.
val clamp = |x: i32| {
if x < 0 { 0 } else { x }
}
val wide = |x: i32| -> i64 { x as i64 * 2 }
// The binding's type gives `x` its type.
val cube: (i32) -> i32 = |x| x * x * x
// Zero parameters.
val make = || 42
// `move` takes every capture by value: the closure owns what it captured.
val label = 100
val tagged = move |x: i32| x + labelCapture
A closure captures each free variable its body uses, and how it holds one depends on that use, variable by variable:
| The body | The capture | Example |
|---|---|---|
only reads a Copy value | a copy | |n: i32| n + offset |
| only reads any other value | a shared borrow & | |n: i32| name.len() + n |
writes it (assigns, takes &mut, calls a &mut self method) | a mutable borrow &mut | || -> void { count += 1 } |
func main() -> i32 {
val offset = 10
val shift = |n: i32| n + offset // copies `offset`
val a = shift(5) // 15
mut count = 0
{
val bump = || -> void { count += 1 } // borrows `count` mutably
bump()
bump()
} // the borrow ends with `bump`
a + offset + count // 27
}A borrowing closure holds its borrow for as long as it lives, exactly as val r = &x does: the
binding cannot be moved while a closure reads it, and cannot be used at all while one writes it.
End the closure's scope, as the block above does, before using the binding again.
A move closure takes every capture by value. A Copy capture is copied into the closure, which
keeps its own copy from call to call; any other capture is moved in, so its source binding is gone,
and the closure releases it when its last copy is gone:
mut n = 0
val tick = move || -> i32 {
n += 1
n
}
tick() // 1
tick() // 2, and `n` is still 0A closure without move may not move a capture out (pass a string it captured to a function
that takes one by value, say): it only borrowed it. The diagnostic asks for move.
Call modes
How a closure uses its captures decides how it may be called. One that only reads them can be
called any number of times; one that writes a capture is a mut function; a move closure that
moves a capture out can be called once. A parameter's function type says which it accepts:
| Parameter type | Accepts | The callee may |
|---|---|---|
f: (T) -> U | closures that only read their captures, and named functions | call f any number of times |
f: mut (T) -> U | those, and closures that write a capture | call f any number of times |
f: once (T) -> U | every closure | call f once |
func twice(f: mut () -> void) {
f()
f()
}
func finish(report: once () -> u64) -> u64 {
report()
}
func shout(message: string) -> u64 { message.len() }
func main() -> i32 {
mut count = 0
{
twice(|| -> void { count += 1 })
}
val banner = "done" + "!"
val n = finish(move || -> u64 { shout(banner) })
count + n as i32 // 2 + 5
}A closure that writes a capture passed where a plain (T) -> U is asked, or a once closure
where a mut one is, is a type mismatch. Calling a once value a second time, or copying it, is
a use of a moved value. once is a mode only in front of a function type; elsewhere it is an
ordinary name.
Inference
A closure parameter without an annotation takes its type from the function type the closure's
context gives it: the parameter it is passed to, the typed binding, field or place it
initializes, or the position it is returned from. The return type is the annotation, else the
context's, else the body's own. .map, .zip and .reduce on a tensor, and .map / .filter
in a for head, hand their closure the element type:
func twice(f: (i32) -> i32, x: i32) -> i32 { f(f(x)) }
func main() -> i32 {
val k = 3
val t: Tensor<f32, [3]> = [1.0, 2.0, 3.0]
val doubled = t.map(|x| x * 2.0) // `x` is `f32`
val total = t.reduce(0.0, |acc, x| acc + x) // the seed types `acc`
twice(|x| x + k, 1) // 7
}A parameter that nothing types is an error, so val f = |x| x + 1 alone is refused: a later
call does not type it. A literal called where it is written takes its parameter types from the
arguments, so (|x| x + 1)(4) needs none. Annotate the parameter, or give the binding a function type. A block
body that takes its return type from its last expression may not return; write the return
type if it needs to.
Function types and higher-order functions
A closure or function value has the type (T1, ...) -> R. A function that takes
one as a parameter and calls it is a higher-order function:
func apply(v: i32, f: (i32) -> i32) -> i32 {
f(v)
}
func main() -> i32 {
val bump = 3
apply(5, |x| x + bump) // 8
}A function's own name is a value of its function type too, so apply(5, double) passes
func double(x: i32) -> i32 directly, and val f = double binds it. An associated function is
one through its path, val s = Point::scale. A generic function has no single type until its
type arguments are named, so its bare name is refused; name the instance with a turbofish
instead, val f = identity::<i32>.
A higher-order function may be generic. The closure passed to it takes part in inferring its type parameters: it is typed after the call's other arguments, so it sees what they bound, and its result binds the rest:
func map_one<T, U>(value: T, f: (T) -> U) -> U {
f(value)
}
func main() -> i32 {
val wide = map_one(7, |x| x as i64 * 3) // T = i32, U = i64
wide as i32
}Any expression of function type can be called, not only a name: a closure literal called where
it is written, (|x: i32| -> i32 { x + 1 })(3), or a function chosen by an if,
(if fast { quick } else { exact })(v).
Returning and storing closures
A closure that borrows nothing owns its environment, so it can be returned, stored in a struct or an enum, and copied like any other value:
struct Scaler { apply: (f32) -> f32 }
func make_adder(n: i32) -> (i32) -> i32 {
|x| x + n // copies `n`
}
func scaler(factor: f32) -> Scaler {
Scaler { apply: |x| x * factor }
}
func main() -> i32 {
val add_two = make_adder(2)
val again = add_two // a copy: both call the same closure
val half = scaler(0.5)
val f = half.apply
add_two(1) + again(2) + f(8.0) as i32 // 3 + 4 + 4
}Each closure compiles to a { function pointer, environment pointer } value; a call dispatches
indirectly through it. The environment is a heap block every copy shares (a closure with no
captures has none), freed with the last copy along with whatever a move closure moved into it.
Because the copies share it, every copy of an FnMut closure sees the writes any of them makes.
A value that may borrow lives only in the scope that made it:
- a closure that borrows a capture, or copies a reference into its environment;
- a function-typed parameter, since its caller may have passed such a closure;
- a closure that captures either.
Such a value may initialize a binding, be passed to a function-typed parameter or read by a built-in that only calls it, or be called. It may not be returned, assigned, stored inside another value, or yielded from a block:
error: this value may borrow from the scope that built it (a closure that borrows a capture, afunction-typed parameter, or a closure holding either), so it lives only as long as that scopeNot yet supported
- Returning a closure over the function values a function was handed
(
func compose(f: (i32) -> i32, g: (i32) -> i32) -> (i32) -> i32 { move |x| g(f(x)) }). - Storing a borrowing closure in a holder that dies before what it borrows.
- A closure literal that writes through a borrow, written as the argument of a built-in method
(
.map,.filter,.zip,.reduce); bind it withmutfirst, so the borrow rules see it beside what the method walks. A literal that only reads what it borrows goes there directly, and aforhead's literal keeps its borrows until the loop ends. - A closure without
movetaking a capture by value because its body moves it out. - An instantiated associated function or generic method as a value on its own
(
val f = Cell::new::<i32>); it works as a|>target and as a later>>stage.
References
- Types - Function types and type checking
- Variables - Local variables in functions
- Control Flow - If/else and function control flow
- Expressions - Expression-based returns