TYCHO Search

Search documentation

    ↑ ↓ to move · Enter to open · Esc to close

    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):

    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
    

    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.