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 positionalval 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
While Neuro doesn't yet optimize tail calls, you can write tail-recursive functions:
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 (Phase 1)
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 functionsfunc 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 purposefunc calculate_area(width: i32, height: i32) -> i32 { width * height} // Bad: unclearfunc calc(w: i32, h: i32) -> i32 { w * h}3. Use Expression Returns for Simple Functions
// Good: concisefunc 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 clarityfunc complex_logic(x: i32) -> i32 { if x > 100 { return 100 } if x < 0 { return 0 } return x}5. Minimize Nesting
// Good: early returns reduce nestingfunc process(x: i32) -> i32 { if x < 0 { return 0 } if x > 100 { return 100 } return x * 2} // Bad: deeply nestedfunc 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 branchfunc fixed(x: i32) -> i32 { if x > 0 { return x } else { return 0 }}Unreachable Code
func unreachable() -> i32 { return 42 val x: i32 = 10 // Warning: unreachable}Stray Semicolon
func wrong() -> i32 { val x: i32 = 42 x; // Error: `;` is not a valid token (Neuro has no semicolons)} // Fix: remove the semicolonfunc right() -> i32 { val x: i32 = 42 x // Implicit return}Not Yet Implemented
Default Parameters
// Not yet implementedfunc greet(name: string, greeting: string = "Hello") -> string { greeting + ", " + name}Variadic Functions
// Not yet implementedfunc sum(values: ...i32) -> i32 { // Sum all arguments}Spread / Variadic Call Sites
// Not yet implemented (Phase 7)val args = [1, 2, 3]sum(...args)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 Neuro
phase 2 is 0.88 done
Each 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}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'Restrictions (this phase). Type arguments are restricted to Copy types. Generic structs and
impl blocks are supported too (see Structs).
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 (parsed, still unenforced) 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 argumentval 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; richer forms arrive with closures and iterators.
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 Shapemeasure(&rect) // &Rect coerces to &dyn ShapeUse 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, so the vtable has a fixed layout. A method that consumes
self by value, or one with no receiver at all, 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 selftrait Maker { func build() -> i32 } // not object-safe: no receiverA &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 inferred.val square = |x: i32| x * xval nine = square(3) // 9 // Block body: an explicit return type is required.val clamp = |x: i32| -> i32 { if x < 0 { 0 } else { x }} // Zero parameters.val make = || 42 // `move` forces every capture to be taken by value (required when the closure// would outlive the captured bindings).val label = 100val tagged = move |x: i32| x + labelCapture
A closure captures each free variable its body reads by value; the variable
must be Copy this phase, and it remains usable after the closure is created
because it was copied, not moved:
func main() -> i32 { val offset = 10 val shift = |n: i32| n + offset // captures `offset` by value val a = shift(5) // 15 a + offset // `offset` still usable -> 25}Capturing a non-Copy value (such as a string), or assigning to a captured
variable inside the closure, is a compile error in this phase.
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: i32| x + bump) // 8}Each closure compiles to a { function pointer, environment pointer } value with
no heap allocation; a call dispatches indirectly through it.
Not yet supported
- Parameter-type inference (
|x| x * xwithout an annotation). - Passing a closure to a generic higher-order function
(
func f<T, U>(x: T, g: (T) -> U) -> U). - The
Fn/FnMut/FnOncedistinction with by-reference, mutable, or move-of-owned capture (all capture is currently by-valueCopy). - Returning or storing a closure so it escapes its defining scope, and
dyn Fn.
References
- Types - Function types and type checking
- Variables - Local variables in functions
- Control Flow - If/else and function control flow
- Expressions - Expression-based returns