Functions and closures
Memory: a closure keeps its own copy of what it captures, so it still works after the original is gone.
A function in Tycho can be a first-class value — bound, passed, stored, returned, and called indirectly. A closure captures by value, like every other value in the language, which is what lets a returned closure work with no extra annotations. And any function can be called in method position with x.f(a), without classes.
Parameter passing modes
A parameter is passed in one of three modes (spec §15.2):
- Default (read-only). A scalar is copied. A heap-bearing value (a
string,bytes, a dynamic array[T], a map or a heap-bearing struct) is a read-only borrow — the descriptor is copied, the backing storage is shared, and mutating the parameter is a compile error. A fixed array[N]T, avector[N]T, or a struct that inlines one has no heap descriptor: the whole value is copied. The caller's value is unchanged either way, so this is value semantics with the copy elided where nobody can tell. inout— an exclusive, call-scoped mutable borrow, written&xat the call site and equivalent tox = f(x). The same variable may not be passed to twoinoutparameters of one call.sink— the callee owns and consumes the argument. After passing a variable to asinkparameter the caller MUST NOT use it again; that is a compile error, not a silent copy. A fresh literal or a call's result is adopted with no copy.
At a call site the cost is the size of the emitted value: string, bytes, [T] and map descriptors are a few words with shared backing storage, while [N]T and structs that inline one copy all N elements. inout and sink are the ways to avoid that copy. See spec §15.2 for the shape table.
An inout parameter is forwarded to another inout parameter with & again at the nested call site:
fn bump(n: inout int):
n += 1
fn twice(n: inout int):
bump(&n)
bump(&n)
fn main():
x := 0
twice(&x)
println(str(x))
2
A sink argument must be the variable's only mention in the function, and no named variable can be adopted inside a loop. So a consuming chain is written as nested calls — each call's result handed straight to the next — rather than as d = add(d, x) in a loop (spec §15.2).
sink and inout are mutually exclusive, and neither may combine with a variadic parameter.
Variadic parameters
A final parameter written xs: ...T is variadic: it takes any number of trailing arguments. Inside the body xs is a [T], and a call packs its trailing arguments into that array. It is shorthand for an array parameter — the packed [T] is a value, copied at the boundary like any array.
fn sum(xs: ...int) -> int:
acc := 0
for x in xs:
acc = acc + x
return acc
sum(1, 2, 3) # packs [1, 2, 3] -> 6
sum() # []int -> 0
nums := [4, 5, 6]
sum(nums...) # spread: pass an existing array -> 15
- Only the last parameter may be variadic, and it may follow fixed parameters (
fn tagged(tag: string, xs: ...int)). f(a, b, c)packs;f(arr...)spreads an existing[T](so a variadic function can forward its own args:sum(xs...)). A spread argument must be the sole variadic argument; a spread is only valid into a variadic parameter.- The generic form
xs: ...$TinfersTfrom the arguments —count(1, 2, 3)bindsT = int,count("a", "b")bindsT = string. An empty call has nothing to infer from, socount()is a compile error — but you can name the type instead:count$(int)()supplies the empty[]int. - Packing is a property of the call, not of how the callee was named:
vp.sum(1, 2, 3)through a package qualifier packs identically, and so does a call carrying explicit type arguments.
First-class function values
A top-level function used as a value has type fn(P1, ..., Pn) -> R (drop the -> R for a void return):
fn dbl(x: int) -> int:
return x * 2
fn apply(g: fn(int) -> int, x: int) -> int: # higher-order: takes a function
return g(x)
fn main():
f := dbl # f : fn(int) -> int
print(str(f(5))) # 10 (indirect call)
print(str(apply(dbl, 21))) # 42
A function value is either a reference to a named function — it captures nothing, so it is just a code pointer that costs nothing extra and never expires — or a closure (below). This is what gives you map/filter/reduce-style helpers over concrete function arguments without generics. Two things cannot become values: builtins (len, push, …) and functions with inout parameters. Everything else is first-class — storable in a struct field, array, map value, or tuple.
Closures (lambdas)
A lambda is an anonymous function written inline; its body is a single expression (an implicit return). Parameter and return types are elidable wherever an expected fn type supplies them — apply(fn(x): x * 2, 21):
fn apply(f: fn(int) -> int, x: int) -> int:
return f(x)
fn main():
n := 10
addn := fn(x: int) -> int: x + n # a closure: captures n
print(str(apply(addn, 5))) # 15
Closures capture by value: the captured variable is copied into the closure when it is created, so the closure is independent of any later change to the original. A closure is a plain value, with no shared references.
a := [10, 20]
get_len := fn() -> int: len(a)
push(a, 30) # mutate the original after capture
print(str(get_len())) # 2 -- the closure kept its own copy, not 3
A closure can also escape — be returned from the function that created it:
fn make_adder(n: int) -> fn(int) -> int:
return fn(x: int) -> int: x + n # captures n, then escapes
fn main():
add5 := make_adder(5)
print(str(add5(100))) # 105
This needs no lifetime annotations: a returned closure carries its captured values with it, like any other returned value. Function values are full members of the data model — a closure can live in a container and be called once stored, and a returned closure can be applied inline:
ops := [make_adder(1), make_adder(100)] # an array of closures
print(str(ops[1](5))) # 105
print(str(make_adder(7)(3))) # 10 (apply a returned closure inline)
When a container of closures outlives its scope, each closure's captured values travel with it. The common higher-order patterns (map/filter/reduce, predicates, comparators, factory functions, dispatch tables) are all covered.
Methods (UFCS)
x.foo(a) is sugar for foo(x, a) — a "method" is just a free function whose first parameter is the receiver (UFCS, uniform function call syntax). There are no classes, no inheritance, and no self: calls resolve on the receiver's compile-time type, the receiver is passed by value like any argument, and any type can be a receiver — a struct, an int, anything. Calls chain, including on call results:
struct Vec:
x: int
y: int
fn add(p: Vec, q: Vec) -> Vec:
return Vec(p.x + q.x, p.y + q.y)
fn norm1(p: Vec) -> int:
return p.x + p.y
fn doubled(k: int) -> int:
return k * 2
a := Vec(1, 2)
b := Vec(3, 4)
_m := a.add(b).norm1() # == norm1(add(a, b))
n := 21
_d := n.doubled() # == doubled(n) -- an int receiver
This extends to the built-ins that take a receiver-shaped first argument, so the operations you reach for most read the same way your own functions do:
s := "a,b"
xs := [1, 2]
_parts := s.split(",") # == split(s, ",")
xs.push(9) # == push(xs, 9)
_k := xs.len() # == len(xs)
_f := n.to_float() # == to_float(n)
recv.name(args) on a built-in rewrites to name(recv, args) and is checked by the exact same argument rules as the plain call — a wrong receiver type is rejected, not silently coerced. The receiver-first built-ins include split, find, len, push, pop, reserve, keys, str, substr, chr, the to_* conversions, is_null, sqrt/pow/floor/fabs, and the map_* forms. Constructors (Some/Ok/Err/None) and built-ins taking no arguments (args, now, ncpu, …) have no receiver and stay plain calls.
One disambiguation rule: if the receiver's struct has a *fn-typed field* with the same name as a free function, the field wins — h.cb(5) calls the stored function value, not a free cb.
How this is implemented — capture copies, how escaping closures carry their environment, and why sink adoption is restricted — is in design-functions, for maintainers.