Tzopilotl
Docs
GitHub

Tzopilotl by Example

4. Functions

4.1 Block Body

fn max(a Int, b Int) Int {
    if (a > b) {
        a
    } else {
        b
    }
}

-- The last expression in a block is the implicit return value
fn square(x Int) Int {
    x * x
}

-- Explicit return is also allowed
fn abs(x Int) Int {
    if (x < 0) {
        return -x;
    }
    x
}

4.2 Expression Body

-- Single-expression functions with = syntax
fn square(x Int) Int = x * x;

fn max(a Int, b Int) Int = a > b ? a : b;

fn average(a Float, b Float) Float = (a + b) / 2.0;

fn greet(who String) String = "Hi, " $ who $ "!";

4.3 Void Functions

-- Use Void as the return type for side-effecting functions
fn say_hello(name String) Void {
    println("Hello, " $ name $ "!");
}

4.4 Inferred Return Types

-- Omit the return type to infer it from the body
fn square(x Int) = x * x;           -- inferred as Int

fn add_one(x Int) { x + 1 }         -- block body works too

fn to_float(x Int) = x * 1.0;       -- inferred as Float

-- Works with if-else trailing expressions
fn abs_val(x Int) {
    if (x < 0) { -x } else { x }
}

-- Declaration order doesn't matter: foo calls bar, declared after it
fn foo(x Int) = bar(x) + 1;
fn bar(x Int) = x * 2;

-- Lambdas infer return types too
let mul = fn(a Int, b Int) { a * b };

-- Recursive and mutually recursive functions cannot infer their return type.
-- You must annotate the return type explicitly:
fn factorial(n Int) Int = if (n <= 1) { 1 } else { n * factorial(n - 1) };

4.5 Lambda Expressions

let double = fn(x Int) Int { x * 2 };
let add = fn(a Int, b Int) Int { a + b };

-- Lambdas capture from their lexical environment
let greeting = "hello";
let greet = fn(name String) String { greeting $ " " $ name };
"world" greet println;  -- "hello world"

4.6 Operator Overloading

Define a function whose name is the operator symbol to overload it for custom types. The compiler tries built-in rules first; if none match, it looks up a user-defined function.

Overloadable Binary Operators

CategoryOperators
Arithmetic+-*/%
Comparison==!=<<=>>=
Bitwise&|^<<>>>>>
Concatenation / Cons$::
Assignment<-->

Overloadable Unary Operators

OperatorMeaning
-Negation
!Logical not
~Bitwise not

Not Overloadable

&&, || (short-circuit logic), // (integer division), and |> (pipeline) cannot be overloaded. Use call (4.11) to make a type callable with (), and at / put! (4.12) to make it indexable with [].

Example

struct Point { x Float, y Float }

fn +(a Point, b Point) Point = Point { x: a.x + b.x, y: a.y + b.y };
fn -(a Point, b Point) Point = Point { x: a.x - b.x, y: a.y - b.y };
fn *(s Float, p Point) Point = Point { x: s * p.x, y: s * p.y };
fn -(p Point) Point = Point { x: -p.x, y: -p.y };  -- unary negation
fn ==(a Point, b Point) Bool = a.x == b.x && a.y == b.y;

let p1 = Point { x: 1.0, y: 2.0 };
let p2 = Point { x: 3.0, y: 4.0 };
println(p1 + p2);     -- Point { x: 4.0, y: 6.0 }
println(2.0 * p1);     -- Point { x: 2.0, y: 4.0 }
println(-p1);          -- Point { x: -1.0, y: -2.0 }
println(p1 == p1);     -- true

Operators as Function Arguments

Since operators are functions (that is what fn +(a, b) declares), an operator can be passed as a function argument directly. An operator token that sits immediately before a , or ) in a call's argument list denotes the operator as a function value, and desugars to the equivalent lambda: fold(0, +) means fold(0, fn(a, b) { a + b }). The parameter types are inferred from the call, exactly as they would be for that handwritten lambda.

[1, 2, 3, 4] fold(0, +) println;     -- 10
[1, 2, 3, 4] fold1(-) println;       -- -8
["a", "b", "c"] fold1($) println;    -- "abc"
[3, 1, 2] sort(>) println;           -- [3, 2, 1]
[30, 10, 20] grade(<) println;       -- [1, 2, 0]
[1, 2, 3, 4] scan(0, +) println;     -- [0, 1, 3, 6, 10]
[true, false] map(!) println;      -- [false, true]

Because only an operator directly followed by , or ) is treated this way, ordinary prefix expressions in argument position are unaffected: max(1, -2) and min(3, -x + 1) parse as usual.

Rules and Limits

4.7 Function Overloading

fn describe(x Int) String = "an integer";
fn describe(x Float) String = "a float";
fn describe(x String) String = "a string";

42 describe println;     -- "an integer"
3.14 describe println;   -- "a float"
"hi" describe println;   -- "a string"

4.8 Default Arguments

Function parameters may have default values, specified with = expr after the type annotation. Parameters with defaults must come after all required parameters. Default expressions can reference preceding parameters.

-- Basic default value
fn greet(name String, greeting String = "Hello") String = greeting $ " " $ name;
greet("World", "Hi") println;   -- "Hi World"
greet("World") println;          -- "Hello World"

-- Multiple defaults
fn range_info(start Int = 0, stop Int = 10, step Int = 1) String {
    fmt("%^ to %^ by %^", start, stop, step)
}
range_info(1, 5, 2) println;  -- "1 to 5 by 2"
range_info(1, 5) println;     -- "1 to 5 by 1"
range_info(1) println;        -- "1 to 10 by 1"
range_info() println;         -- "0 to 10 by 1"

-- Default referencing a preceding parameter
fn make_range(lo Int, hi Int = lo + 10) String = fmt("%^ to %^", lo, hi);
make_range(5, 20) println;   -- "5 to 20"
make_range(5) println;       -- "5 to 15"

4.9 Variadic Arguments

The last parameter of a function may be variadic, prefixed with .... Extra arguments at the call site are packed into a single value automatically.

-- Untyped variadic: packs args into a Tuple
fn wrap(...xs) = xs;
wrap(1, "hello", true) println;  -- (1, hello, true)
wrap(42) println;                 -- (42,)
wrap() println;                   -- ()

-- Typed variadic: packs args into an Array
fn sumInts(...xs Int) Int {
    var total = 0;
    for (x : xs) {
        total = total + x;
    }
    total
}
sumInts(1, 2, 3) println;   -- 6
sumInts() println;            -- 0

-- Fixed and variadic parameters can be mixed
fn greet(greeting String, ...names String) String {
    var result = greeting;
    for (name : names) {
        result = result $ " " $ name;
    }
    result
}
greet("Hello", "Alice", "Bob") println;  -- Hello Alice Bob
greet("Hi") println;                       -- Hi

4.10 String Formatting with fmt

The built-in fmt function formats a string by substituting placeholders with variadic arguments. It is especially natural with pipeline syntax.

-- %^ is a positional placeholder (filled left to right)
"%^ + %^ = %^" fmt(1, 2, 3) println;       -- 1 + 2 = 3

-- %0 through %9 select by index (zero-based)
"%0 and %1 and %0" fmt("x", "y") println;  -- x and y and x

-- %% produces a literal percent sign
"100%% done" fmt() println;                -- 100% done

-- Works with any type
"value: %^" fmt(42) println;                -- value: 42
"pi = %^" fmt(3.14) println;               -- pi = 3.14
"list: %^" fmt(1::2::3::nil) println;     -- list: List(1, 2, 3)

-- Zero args is fine when no placeholders are used
"no placeholders" fmt() println;           -- no placeholders

4.11 Callable Objects

Any type can be made callable by defining a function named call whose first parameter is that type. This is similar to C++'s operator(). When a value of type T appears in call position, the compiler rewrites value(args...) to call(value, args...).

-- Define a callable struct
struct Adder { amount Int }
fn call(a Adder, x Int) Int = x + a.amount;

let add5 = Adder { 5 };
add5(10) println;     -- 15
add5(0) println;      -- 5

The call function supports all the same features as regular functions: overloading by arity and type, templates, and auto-mapping.

-- Overloaded by arity
struct Multiplier { factor Float }
fn call(m Multiplier, x Float) Float = x * m.factor;
fn call(m Multiplier, x Float, y Float) Float = (x + y) * m.factor;

let dbl = Multiplier { 2.0 };
dbl(3.0) println;         -- 6.0
dbl(1.5, 2.5) println;   -- 8.0

-- Overloaded by argument type
struct Formatter { prefix String }
fn call(f Formatter, x Int) String = f.prefix $ x toString;
fn call(f Formatter, x String) String = f.prefix $ x;

let fmt = Formatter { ">> " };
fmt(42) println;       -- >> 42
fmt("hello") println;  -- >> hello

-- Template call
struct Wrapper { value Int }
fn call<T>(w Wrapper, f (Int) T) T = f(w.value);

let w = Wrapper { 10 };
let sq = fn(x Int) Int { x * x };
w(sq) println;        -- 100

Chained calls work naturally — if the first call returns another callable type, it can be called immediately.

struct Counter { start Int }
struct Stepper { base Int, step Int }
fn call(c Counter, step Int) Stepper = Stepper { c.start, step };
fn call(s Stepper, n Int) Int = s.base + s.step * n;

let counter = Counter { 100 };
counter(5)(3) println;  -- 115  (100 + 5 * 3)

Auto-mapping works via pipeline syntax.

let nums = [1, 2, 3, 4, 5];
nums @ add5 println;  -- [6, 7, 8, 9, 10]

4.12 Indexable Objects

Any type can be made indexable by defining a function named at whose first parameter is that type. When a value with no built-in indexing appears in subscript position, the compiler rewrites obj[idx] to at(obj, idx). Likewise, defining put! enables index assignment: obj[idx] = v rewrites to put!(obj, idx, v). These are the subscript counterparts of call (4.11).

-- Define an indexable struct
struct Cycle { items [Int] }
fn at(c Cycle, i Int) Int = c.items[i % c.items length];

let cyc = Cycle { [10, 20, 30] };
cyc[0] println;   -- 10
cyc[4] println;   -- 20

The built-in indexable types (Array, Map, String, Range, and the persistent variants) keep their built-in behavior — a user at or put! never shadows them, and only non-builtin definitions enable the rewrites (the builtin at/put! for arrays and maps do not count). In the other direction, arrays provide at and put! as builtins with exactly the semantics of a[i] and a[i] = v (cyclic index included), so generic code can call the protocol functions uniformly over arrays and user indexable types.

Because the rewrite produces an ordinary call, at supports everything regular functions do: overloading by index type, templates, and auto-mapping. In particular, indexing with an Array or List of indices maps over a scalar at automatically.

cyc[[0, 1, 2, 3]] println;   -- [10, 20, 30, 10]
cyc[List(5, 3, 1)] println;  -- List(30, 10, 20)

-- Any index type works, not just Int
struct Env { pairs [String: Int] }
fn at(e Env, key String) Int = get(e.pairs, key, -1);

let env = Env { ["a": 1, "b": 2] };
env["a"] println;         -- 1
env["missing"] println;   -- -1

A template at makes lazy, computed collections cheap to build — here a virtual array backed by a closure, with derived views that index their source:

struct VA<T> { at (Int) T, len Int }
fn at<T>(a VA<T>, i Int) T = a.at(i);

let squares = VA { at: fn(i Int) = i * i, len: 10 };
squares[7] println;              -- 49

-- Each element of the view is looked up n times
fn stutter<T>(a VA<T>, n Int) = VA<T> { at: fn(i Int) T = a[i // n], len: a.len * n };

let st = squares stutter(2);
st[[0, 1, 2, 3, 4, 5]] println;  -- [0, 0, 1, 1, 4, 4]

Chained subscripts work naturally — if at returns another indexable value, it can be indexed immediately.

struct Grid { rows [[Int]] }
fn at(g Grid, r Int) [Int] = g.rows[r];

let grid = Grid { [[1, 2], [3, 4]] };
grid[1][0] println;  -- 3

On the write side, put! gives the type full control over what an index assignment means — here, writes wrap cyclically like the reads do.

fn put!(c Cycle, i Int, v Int) Void { c.items[i % c.items length] = v; }

let ring = Cycle { [1, 2, 3] };
ring[1] = 20;
ring[3] = 10;                -- wraps to slot 0
ring[[0, 1, 2]] println;    -- [10, 20, 3]