Learn · Chapter 3 of 12

Functions

How to put a sequence of operations behind a name and call it. Capa's signatures are precise: parameter types, return type, no overloading.

The shape of a function

fun double(n: Int) -> Int
    return n * 2

The same five parts you would expect: the keyword fun, the name double, a parenthesised parameter list (each one annotated with a type), an optional return type after ->, and the body indented underneath.

Call it like any other function:

fun main(stdio: Stdio)
    let n = double(7)
    stdio.println("${n}")        // 14

Two function-shape rules to internalise early:

  1. Every parameter has a type annotation, always. The compiler does not guess from call sites. This is a deliberate readability rule: a function's signature is the most useful summary of what it does, so the signature must be self-describing.

  2. The return type is required when the function returns a value. A function with no return type implicitly returns Unit; you can write that explicitly as -> () but most code leaves it off.

No overloading, no defaults

A name is bound to exactly one function. You cannot have two functions called double with different parameter types. If you want two flavours, give them two names: double_int, double_float.

There are also no default argument values. Every parameter must be passed at every call site. The cost of typing some_lib.connect(host, port, timeout, retries) is small; the cost of having defaults silently change between library versions is large.

Named arguments

For functions with more than two or three parameters, positional calls become hard to read. Capa lets you pass arguments by name at the call site:

fun greet(name: String, age: Int) -> String
    return "${name} is ${age}."

fun main(stdio: Stdio)
    let s = greet(name: "Ana", age: 30)
    stdio.println(s)

Named and positional arguments cannot be mixed: a positional argument cannot appear after a named one. The analyzer also rejects unknown names, duplicates, and arity mismatches. Built-in methods (String, Map, Set, capabilities) only accept positional arguments because their parameter names are not tracked in the type system.

Functions that take other functions

A function type is written Fun(T1, T2, ...) -> R. A function that takes a function looks like:

fun apply_twice(f: Fun(Int) -> Int, x: Int) -> Int
    return f(f(x))

fun inc(n: Int) -> Int
    return n + 1

fun main(stdio: Stdio)
    stdio.println("${apply_twice(inc, 10)}")   // 12

You can also write a function in-place as a lambda. Lambdas are introduced with |param, ...| body:

fun main(stdio: Stdio)
    let add = |a: Int, b: Int| a + b
    stdio.println("${add(3, 4)}")         // 7

Lambdas have one important limit: they cannot capture capabilities from the outer scope. This is part of the capability discipline (you cannot smuggle authority through a closure). We come back to this in chapter 8.

The two error modes

First: a missing return.

fun double(n: Int) -> Int
    let r = n * 2
$ capa --run hello.capa
hello.capa:1:5: error: function 'double': declared return type 'Int',
                       but the body can fall through without returning a value
   1 | fun double(n: Int) -> Int
         ^

The analyzer reads every path through the body. If any path can reach the end of the function without a return, and the declared return type is not Unit, you get this error. Add return r on its own line at the end.

Second: a wrong return type.

fun double(n: Int) -> Int
    return "twice that"
$ capa --run hello.capa
hello.capa:2:12: error: return: expected Int, got String
   2 |     return "twice that"
                  ^

The return value's type must match the declared one exactly. There are no implicit conversions.

Where you are now

You can write functions, give them precise signatures, call them positionally or by name, and pass functions to other functions. Next: how to make decisions and loop. Control flow.