Lambdas and Closures
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.
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.
| Form | Local environment | Example |
|---|---|---|
| Lambda | None | fn(x: int) int { return x + 1 } |
| Closure | Explicit captures | capture(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 type | Meaning |
|---|---|
fn | Reusable callable requiring only shared access to its environment |
mut fn | Invocation requires mutable / exclusive environment authority |
-> fn | Invocation 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.
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.
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()returns1;second()returns2.
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.
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.
Diagnostics
Useful lambda and closure diagnostics identify the source-level cause.
| Problem | Diagnostic focus |
|---|---|
| Implicit local capture | Name the outer binding and require explicit capture |
| Duplicate capture | Name the duplicate captured binding |
| Wrong callable type | Show source and required function types |
| Use after captured move | Point to both the invalid use and the ownership-transferring capture |
| Invalid borrowed escape | Identify the owner lifetime and escaping closure path |
| Wrong call capability | Explain whether shared, mutable or consuming callable authority is required |
| Outer break / continue | Explain that a lambda is a function boundary |
| Invalid try propagation | Show 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 fnand-> fndistinguish 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
Optionfor 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.