Lambdas and Closures

Partial chapter — revision 0.9. This document is just partly done - there are some open decisions before this page becomes final.

Sec treats anonymous functions as ordinary statically typed callable values. A lambda uses the same fn vocabulary as a named function, while a closure is a lambda that explicitly carries values from an enclosing local scope.

let double := fn(value: int) int {
    return value * 2
}

let result := double(21)

Lambdas may be stored, passed to functions, returned from functions and invoked through function values. Capture is explicit so ownership, lifetime and allocation behavior remain visible to both the programmer and the compiler.

Rule: A lambda is an anonymous fn. A closure is a lambda with an explicit captured environment. Neither form introduces a separate dynamic function system.

Lambda vs Closure

A non-capturing lambda depends only on its parameters and non-local module-visible symbols:

let positive := fn(value: int) bool {
    return value > 0
}

No local environment is required for this callable beyond its code identity.

A closure explicitly captures one or more enclosing local bindings:

fn MakeOffset(offset: int) fn(int) int {
    return capture(offset) fn(value: int) int {
        return value + offset
    }
}

The returned callable has the same source-level parameter and return signature as any other fn(int) int, but it also carries an environment containing the captured offset value.

FormLocal environmentExample
LambdaNonefn(x: int) int { return x + 1 }
ClosureExplicit capturescapture(n) fn(x: int) int { return x + n }

Lambda Syntax

A block-bodied lambda omits the function name:

fn(parameter: Type) ReturnType {
    statements
}

For comparison, a named function is:

fn Add(left: int, right: int) int {
    return left + right
}

The corresponding anonymous callable is:

fn(left: int, right: int) int {
    return left + right
}

Parameter syntax, ownership modes, return checking, Result behavior and ordinary expression rules are shared with named functions.

No separate arrow-only language

Sec does not use an unrelated shorthand such as x => x + 1 where the function type and parameter declaration disappear. The callable remains an fn expression.

Expression Bodies

A lambda whose complete body is one expression may use the concise expression-body form:

let double := fn(value: int) int => value * 2
let isEven := fn(value: int) bool => value % 2 == 0

The expression is the lambda's returned value.

It is equivalent in meaning to:

let double := fn(value: int) int {
    return value * 2
}

The return type remains explicit. The shorthand changes only the body shape; it does not introduce implicit parameter types or a separate callable type.

Parameters

Lambda parameters use ordinary function parameter syntax:

let compare := fn(left: int, right: int) bool {
    return left < right
}

A trailing comma is allowed in multiline parameter lists:

let compare := fn(
    left: int,
    right: int,
) bool {
    return left < right
}

Parameter types are explicit. Borrowing and consuming parameter modes use the normal function rules:

let inspect := fn(value: ref Buffer) uint {
    return value.Length
}

let update := fn(value: ref mut Counter) void {
    value.Increment()
}

let consume := fn(-> value: Resource) void {
    Use(value)
}

A lambda parameter is local to that lambda body. Duplicate parameter names are invalid.

let invalid := fn(value: int, value: int) int {
    return value
}

Return Types

The return type is written explicitly after the parameter list:

let identity := fn(value: int) int {
    return value
}

let notify := fn(message: string) void {
    fmt.Println(message)
}

Normal function return analysis applies. Every continuing path in a non-void lambda must produce the declared result type.

let invalid := fn() int {
    // invalid: no return on this path
}

A void lambda may use a bare return:

let stop := fn() void {
    return
}

Function Types

A callable value has a statically known function type.

fn(int) int
fn(int, int) bool
fn(string) void
fn() bool

Parameter names are not part of the function type. Parameter types, ownership modes, callable capability, variadic shape, return type and other verified callable properties are part of compatibility.

let predicate: fn(int) bool := fn(value: int) bool {
    return value > 0
}

A lambda whose signature does not match the target callable type cannot be assigned.

let predicate: fn(int) bool := fn(value: string) bool {
    return value.IsEmpty
}

This is a type error.

Callable Environment Capability

A callable type also describes what authority invocation requires over the callable value or closure environment.

fn(int) int
mut fn() int
-> fn() Resource
Callable typeMeaning
fnReusable callable requiring only shared access to its environment
mut fnInvocation requires mutable / exclusive environment authority
-> fnInvocation consumes the callable value

Callable capability is independent of parameter ownership:

fn(-> Resource) void

describes a reusable callable that consumes its argument, whereas:

-> fn() Resource

describes a callable that is itself consumed when invoked.

Rule: Do not infer ownership loss from a parameter list alone. Parameter consumption and callable-environment consumption are separate properties.

Storing and Calling Function Values

A lambda expression may initialize a local binding:

let operation := fn(a: int, b: int) int {
    return a + b
}

let result := operation(10, 20)

The variable's type is inferred from the complete lambda signature.

A function value may also have an explicit type:

let operation: fn(int, int) int := fn(a: int, b: int) int {
    return a + b
}

Any expression producing a compatible callable may be invoked:

operation(1, 2)
callbacks[index](value)
GetHandler()(event)

Argument count, type, borrow mode, consuming mode and return behavior are checked exactly as for a named function call.

Named Function Values

A resolved named function may be used as a callable value:

fn IsPositive(value: int) bool {
    return value > 0
}

let predicate: fn(int) bool := IsPositive

The function itself is not copied into the variable. The variable receives a callable value referring to the resolved function according to the implementation's callable representation.

Overload Resolution

An overloaded function name must resolve to one concrete callable before it can be used as a function value.

fn Convert(value: int) string {
    ...
}

fn Convert(value: string) int {
    ...
}

let converter: fn(int) string := Convert

The target function type provides enough context to select the first overload.

Without sufficient context, the function value is ambiguous:

let converter := Convert    // invalid: overload is unresolved

A lambda itself is never an overload set. Each lambda expression has exactly one concrete callable signature.

Passing Lambdas

Functions may accept function values as ordinary typed parameters:

fn Apply(value: int, operation: fn(int) int) int {
    return operation(value)
}

A lambda may be supplied directly:

let result := Apply(
    10,
    fn(value: int) int => value * 2,
)

The lambda's full callable type participates in overload resolution. Return type alone still does not distinguish otherwise identical overload declarations.

Returning Lambdas

A function may return a callable value:

fn SelectOperation(add: bool) fn(int, int) int {
    if add {
        return fn(a: int, b: int) int => a + b
    }

    return fn(a: int, b: int) int => a - b
}

Every returned callable must be compatible with the declared function return type.

A captured closure may also be returned when its captured environment has valid ownership:

fn MakeMultiplier(factor: int) fn(int) int {
    return capture(factor) fn(value: int) int => value * factor
}

Explicit Capture

Enclosing local bindings are captured explicitly with capture(...) immediately before fn.

let factor: int := 2

let multiply := capture(factor) fn(value: int) int {
    return value * factor
}

Multiple names are comma-separated:

let inRange := capture(minimum, maximum) fn(value: int) bool {
    return value >= minimum && value <= maximum
}

A trailing comma is allowed:

let inRange := capture(
    minimum,
    maximum,
) fn(value: int) bool => value >= minimum && value <= maximum

Capture entries name visible enclosing local bindings. Arbitrary expressions are not written directly in the capture list; compute the value first and capture its name.

let offset := base + 1
let apply := capture(offset) fn(value: int) int => value + offset

No Implicit Capture

A lambda may not silently reach into an enclosing local scope.

fn MakeMultiplier(factor: int) fn(int) int {
    return fn(value: int) int {
        return value * factor    // invalid
    }
}

The capture must be visible:

fn MakeMultiplier(factor: int) fn(int) int {
    return capture(factor) fn(value: int) int {
        return value * factor
    }
}

Module-level functions, constants, imported symbols and visible type declarations do not require capture. They are resolved normally rather than stored as local closure state.

Rule: Local state is never captured merely because a lambda refers to it. The capture list is the source-level ownership boundary.

Capture Timing

A capture is evaluated when execution reaches the lambda expression.

let mut value: int := 1

let first := capture(value) fn() int => value

value = 2

let second := capture(value) fn() int => value

Here:

  • first() returns 1;
  • second() returns 2.

Capture expressions are evaluated in capture-list order and exactly once for each closure creation.

A closure created inside a loop is therefore created independently for each iteration:

for value in values {
    let current := capture(value) fn() int => value
    Use(current)
}

Value Capture

A plain capture entry captures the value of the named binding at closure creation.

let mut factor: int := 2
let multiply := capture(factor) fn(value: int) int => value * factor

factor = 4

let result := multiply(10)    // 20

The captured binding is part of the closure environment. Later reassignment of the outer binding does not rewrite that captured value.

A plain value capture is not an aliasing shortcut to the outer variable. Any form that requires shared or mutable borrowing must obey the ordinary borrowing and lifetime rules and must be represented explicitly by the applicable capture model.

Capture Ownership

Capture follows the captured value's copy and move classification. The compiler does not silently clone a resource merely because it appears in a capture list.

For a copyable value, capture may copy the value into the environment:

let factor: int := 2
let multiply := capture(factor) fn(value: int) int => value * factor

Use(factor)    // factor remains available

For a move-only value, capture transfers ownership into the closure environment when the capture is consuming according to the resolved ownership rules. The original source then becomes unavailable.

let resource := OpenResource()

let closeLater := capture(resource) fn() void {
    Close(resource)
}

Use(resource)    // invalid if ownership moved into closeLater

The compiler reports use-after-move at the later use and points back to the capture that transferred ownership.

Closure Copy and Move

A closure's own copy classification is derived from its environment.

  • A closure containing only safely copyable captured state may be copyable.
  • A closure containing move-only captured state is move-only.
  • A closure requiring exclusive mutable environment authority cannot be freely duplicated.
  • Copying a closure must never silently duplicate an external resource.

Moving a closure transfers its environment ownership exactly like moving any other move-only value.

Rule: A closure does not become copyable merely because its runtime representation could be copied bit-for-bit.

Closure Environment

A capturing closure conceptually consists of callable code plus an environment carrying its captures.

capture(factor) fn(value: int) int {
    return value * factor
}

may be lowered conceptually as:

code(environment, value) -> int

The hidden environment argument is an implementation detail. It does not appear in:

  • the source parameter list;
  • ordinary call syntax;
  • the visible argument count;
  • overload resolution as an extra source parameter.

The environment still matters semantically through ownership, lifetime, callable capability, destruction and transferability.

Escaping Closures

A closure escapes when it may outlive the lexical scope in which it was created. Returning a closure is the simplest example:

fn MakeOffset(offset: int) fn(int) int {
    return capture(offset) fn(value: int) int => value + offset
}

Because the closure owns its captured offset, the captured value remains valid after MakeOffset returns.

Escaping borrowed captures are valid only when the borrow can outlive every possible use of the closure. Sec does not extend the owner lifetime merely to make a closure compile.

Escape analysis determines whether the environment can remain in local storage or needs an owned representation with a longer lifetime. This analysis must preserve the no-hidden-heap rule.

Scope

A lambda body creates its own function scope.

The scope contains:

  • its parameters;
  • its explicit captures;
  • its local declarations;
  • ordinary nested block scopes.

It may also resolve visible module and imported declarations normally.

Normal no-shadowing and duplicate-declaration rules apply inside the lambda.

Lambda as a Control-Flow Boundary

A lambda is a function boundary. Control-flow statements inside it belong to the lambda, not to the function or loop that created the lambda.

return

fn Test() int {
    let operation := fn(value: int) int {
        return value * 2
    }

    return operation(10)
}

The inner return exits operation.

break and continue

A lambda cannot use break or continue to target an outer loop:

for item in items {
    let operation := fn() void {
        break    // invalid
    }
}

A loop created inside the lambda establishes its own valid break and continue targets.

Result and try

Lambdas use the ordinary typed error model.

let load := fn(path: string) Result[Data, IOError] {
    let data := try Read(path)
    return Ok(data)
}

Error propagation stops at the lambda boundary. A try inside the lambda must be compatible with the lambda's own declared return type.

let load := fn(path: string) Data {
    return try Read(path)    // invalid when Read returns Result[Data, IOError]
}

The enclosing function's return type does not provide an error propagation route through the lambda boundary.

Defer inside Lambdas

A lambda has its own function cleanup boundary. A defer registered inside a lambda executes when that lambda invocation exits, not when the enclosing function exits.

let operation := fn() void {
    defer {
        Log("lambda complete")
    }

    Work()
}

Captured values used by deferred code remain subject to the same ownership and lifetime checks as every other closure use.

Recursion

An unnamed lambda does not automatically gain a self-name. The variable being initialized is not available as a recursively callable binding inside its own initializer.

let factorial := fn(value: int) int {
    return value * factorial(value - 1)    // invalid direct self-reference
}

Use a named function when direct recursion is required:

fn Factorial(value: int) int {
    if value <= 1 {
        return 1
    }

    return value * Factorial(value - 1)
}

Optional Callables

A function value is either a valid callable value or no value exists at all.

Sec does not use a null function value. Optionality is explicit:

let handler: Option[fn(Event) void]

Code must then handle Some and None through the ordinary Option rules.

Equality and Ordering

Function values and closures do not have general semantic equality.

if first == second {    // invalid
}

Code identity, environment identity and captured values do not define one useful language-wide equality relation for arbitrary callables.

Ordering comparisons are likewise invalid:

first < second     // invalid
first >= second    // invalid

Lambdas and Generic Code

Generic functions may accept concrete function values like any other typed parameter.

fn Transform[T, U](value: T, mapper: fn(T) U) U {
    return mapper(value)
}

A call may provide a concrete lambda:

let text := Transform(42, fn(value: int) string {
    return value.ToString()
})

The lambda expression itself has one concrete callable signature. Generic behavior that introduces its own type parameters belongs in a named generic function declaration.

FFI Callbacks

A Sec closure is not automatically a C-compatible function pointer. A capturing closure may require both code and environment state, while a foreign ABI may accept only one raw code address.

Passing a Sec callable through FFI therefore requires the explicit callback and trampoline rules defined by the FFI boundary. The compiler must not silently discard the environment or invent a foreign lifetime contract.

Closures and Concurrency

A closure transferred to another task, thread or process carries the ownership and transferability requirements of its environment.

Capturing a value does not automatically make that value safe to transfer across a concurrency boundary. The later concurrency chapters define the required transferability, data-race and lifetime checks.

This keeps closure syntax independent from concurrency policy: the closure records what it owns or borrows; the concurrency operation validates whether that state may cross its boundary.

No Hidden Heap Allocation

Closure creation does not grant the compiler permission to silently introduce a heap allocation merely because an environment exists.

  • Non-capturing lambdas need no capture environment.
  • Non-escaping environments should remain in local storage when possible.
  • Constant environments may be represented statically when semantics permit.
  • Optimization may eliminate an environment entirely.
  • An escaping environment requires a valid owned storage strategy.

If the required lifetime cannot be represented under the active allocation rules, the program is rejected rather than silently changing its allocation model.

Rule: Closure lifetime may affect representation, but representation must not invent hidden heap ownership.

Diagnostics

Useful lambda and closure diagnostics identify the source-level cause.

ProblemDiagnostic focus
Implicit local captureName the outer binding and require explicit capture
Duplicate captureName the duplicate captured binding
Wrong callable typeShow source and required function types
Use after captured movePoint to both the invalid use and the ownership-transferring capture
Invalid borrowed escapeIdentify the owner lifetime and escaping closure path
Wrong call capabilityExplain whether shared, mutable or consuming callable authority is required
Outer break / continueExplain that a lambda is a function boundary
Invalid try propagationShow the lambda's own return type

Diagnostics should describe ownership and capability in source-language terms. Backend details such as environment pointer layout are not useful as primary user diagnostics.

Best Practices

  • Use a lambda when the behavior is small, local and has one concrete callable signature.
  • Use the expression-body form for genuinely simple one-expression lambdas.
  • Use a named function when the behavior needs direct recursion or deserves a stable name.
  • Keep capture lists small and intentional.
  • Do not capture a large value merely to avoid passing an explicit parameter.
  • Prefer value capture when the closure should own a stable snapshot.
  • Respect copy and move behavior; capture is not a cloning mechanism.
  • Use Option[fn(...)] when a callable may be absent.
  • Do not rely on closure identity or equality.
  • Keep foreign callback adaptation at an explicit FFI boundary.
  • Keep long-lived closures easy to reason about by minimizing borrowed environment state.

Summary

  • A lambda is an anonymous fn.
  • A closure is a lambda with explicit captured local state.
  • Block bodies use fn(...) ReturnType { ... }.
  • Single-expression bodies may use fn(...) ReturnType => expression.
  • Parameter and return rules are the ordinary function rules.
  • Function values are statically typed.
  • fn, mut fn and -> fn distinguish callable-environment capability.
  • Named functions may become function values after overload resolution.
  • Local capture is explicit through capture(...).
  • Local variables are never captured implicitly.
  • Capture occurs when the lambda expression is evaluated.
  • Capture follows ordinary copy, move, borrow and lifetime semantics.
  • A closure's copyability depends on its captured environment.
  • A lambda is a boundary for return, loop control and error propagation.
  • Callable values are never null; use Option for absence.
  • Function values have no general equality or ordering.
  • Closure creation does not authorize hidden heap allocation.
  • FFI and concurrency boundaries validate their own additional callable requirements.