Functions, closures, and calls
Language reference · Previous: Core syntax and values · Next: Classes, modules, and methods
def make_adder(amount)
def add(value)
amount + value
end
add
end
add_forty = make_adder(40)
add_forty(2)
def always requires parentheses, even for a zero-argument function
(def foo()). A nested def produces a closure that can capture enclosing
locals. A top-level function's bare name is also a Callable value. Generic
functions can be specialized as references with identity[Int]; adding
parentheses, identity[Int](42), invokes the function instead. An enclosing
type variable works too: def reference[T]() = identity[T] returns a Callable
that retains that invocation's T. Specialized references preserve defaults,
variadic arguments, and parameter names, including before the function's
declaration. Closures are invoked directly as f(x); there is no .call method.
A bare generic name can also infer its specialization from an expected typed
Callable: accept(identity) specializes identity[T] to Int when accept's
parameter is Callable[[Int], Int]. The same context flows through nested Array
and Hash literals, keyword/spread calls, constructors, and annotated returns.
Optional and variadic signatures must accept the expected arity, and every type
parameter must have a consistent binding. Without such a context, a bare generic
function value retains its existing inference on each call.
For a generic instance method, the receiver's bindings specialize its expected
argument types first: box.accept(identity) can infer Int when box is a
Box[Int] and accept takes Callable[[T], T]. This also works through
inherited parent mappings, fixed subclasses, nested literals, keywords, and
spread calls. Explicit method type arguments supply their own slots separately.
Unresolved method variables provide no contextual hint, and nested calls restore
the outer receiver's argument context.
Constructors also use known class bindings. For example,
Processor[Int].new(42, identity) supplies Int to an initializer parameter
typed as Callable[[T], T]. Fixed subclasses and fixed parent slots provide the
same context, with inherited arguments projected into the initializer owner's
view. Positional, keyword, spread, and trailing-block forms use these bindings;
nested constructor calls restore the outer context. Slots that still depend on
constructor argument inference provide no contextual hint at this stage.
Tail-call optimization
Each d6 function call is an ordinary recursive call in the native
implementation, so ordinary recursion is bounded by a call-depth limit: about
9,600 levels in the main program (which runs on a 512 MiB stack;
DIAMOND_MAIN_STACK_MB changes it), and 150 inside a fiber or a thread, which
have 8 MiB stacks. Going past it is a clean call stack overflow error:
def sum(n, acc)
return acc if n == 0
return sum(n - 1, acc + n) # a self-recursive tail call
end
sum(100000, 0) # => 5000050000, not a stack overflow
A tail call -- a function's call to itself, or to another function that
tail-calls back to it, as the entire value of a return or as a function
body's own trailing expression, with nothing done to the result afterward --
runs in constant native stack space instead, however deep the recursion goes.
This is a transparent optimization, not new syntax: it only ever changes
whether a qualifying call blows the ordinary depth limit, never what a program
computes. A call that doesn't qualify falls back to ordinary recursion,
bounded exactly as before -- never an error, just not optimized.
What qualifies: return foo(...) or a trailing foo(...) with no
further operation applied, including one that ends the branch of a trailing
if/else or case (if n == 0 then true else odd?(n - 1) end as the
function's last expression), where foo is either the function currently
running, or a function on a cycle of such calls with it: even? returning
odd?(n - 1) while odd? returns even?(n - 1), or any longer ring. A tail
call to a function that does not call back through tail calls (a plain
delegating wrapper) is an ordinary call, so only the calls that can recurse
without bound are rewritten.
What doesn't, and stays ordinary bounded recursion instead of erring:
- Non-tail self-recursion --
1 + fib(n - 1), or anything else done to the result before returning, is unaffected either way. - A tail call lexically inside a
begin/rescue/ensureblock -- a live handler's own resume point is an offset into the current call's bytecode, which reusing that same call for "the next iteration" would invalidate. Disqualified regardless of whether the block's own handler would ever actually fire.
Generic and variadic functions qualify, and so do functions with different
arities or register counts on one cycle. Each in-place iteration gets the
argument list of its tail call, exactly as a fresh call would: a generic function's type variables are inferred again from the new
arguments (so T may be an Int in one iteration and a String in the
next), a variadic function collects the new call's overflow arguments, and a
default parameter the tail call leaves out takes its default again rather than
keeping the previous iteration's value.
DIAMOND_TRACE_TAIL_CALLS=1 prints how many calls ran in place when the program
exits, and --dump-bytecode shows which call sites were rewritten to TAIL_CALL.
None of these need to be memorized to write correct code -- they only
ever affect how deep a specific recursive shape can go before hitting
the ordinary depth limit, never what any program returns. The one
directly observable consequence: a function that recurses forever with
no base case, written as a qualifying tail call, now runs
forever instead of eventually raising SystemStackError -- worth knowing
if you were ever relying on "it'll eventually crash" as a substitute for
an actual base case or loop-termination check.
Blocks
total = 0
[1, 2, 3, 4].each() do |x|
total = total + x
end
doubled = [1, 2, 3].map() do |x|
x * 2
end
recv.method(args) do |params| ... end attaches an anonymous closure as
the call's own last argument — the same mechanism a named nested def
uses (same capture behavior, same underlying CLOSURE value), just
without a name. The block fills the method's last parameter, so a
method whose trailing parameter is something else, such as
def check(action: Callable[0], message: String = ""), can't take one:
the compiler says so and suggests a &block parameter, which takes the
block whatever comes before it. It works the same way on a direct call to a top-level
function: apply(5) do |n| n * 2 end.
Delimiters are do ... end only — there is no { ... } block form, and
none is planned. Every other d6 control construct (if, while,
def, class, case) already uses end, and { already means a Hash
literal, so arr.each { |x| ... } would collide with a bare Hash
argument the way it does in Ruby.
Inside a block, next (or next value) ends the current call of the block:
.map() do |x| next 0 if x < 0; x end. Inside a loop within the block,
next continues that loop as usual. A block's inferred result type
includes the values of any early next, not just its final expression.
break and return in a block reach past it, as in Ruby:
def first_negative(values: Array[Int]) -> Int | Nil
values.each() do |v|
return v if v < 0 # returns from first_negative
end
nil
end
found = [3, 8, 12].each() do |v|
break v if v > 5 # ends the each() call; found is 8
end
break value ends the call the block was passed to, which evaluates to
value (nil without one). return value returns from the def the
block is written in, checked against that def's -> Type. Both unwind
every call in between, running their ensure clauses on the way, and
rescue never catches them. Inside a loop within the block, break ends
that loop instead. A return in a block outside any def is a compile
error — use next.
A block called after that call or method has finished — stored and called
later, handed to a Fiber, or copied into a Thread — raises a runtime
error on break or return instead ("break from a block outside the call
it was passed to"). So does a break from a block that was passed on to
some other call: it can only end the call it was written for.
Block parameters are bare identifiers only — no : Type annotations, no
= default. do |x, y| ... end, or do ... end with no parameters at
all for a zero-arity block. A block with no parameters ignores whatever the
method passes it, so 3.times() do ... end runs three times. Otherwise a block
takes exactly as many parameters as the method passes: {"a": 1}.each() do |key|
fails with expected Callable[2], got Callable[1] rather than silently dropping
the value. Named closures and function references always need an exact match. A block can see every local visible at the
point it's written and shares each one it reads with the enclosing code (an assignment on
either side is seen by the other). Locals its body never names are left out, so they cost
nothing; nested defs capture every visible local.
self is one of those captured locals too. Inside a do ... end block
written in an instance or singleton method, self refers to that
method's own receiver, exactly like a named nested closure (see
"Classes and modules"'s own closure section) — [1].each() do |x| self.foo() end inside an instance method works the same as it would
outside the block. A block written outside any method still has no
self to capture, same as top-level code.
A function or method can bind its optional trailing block with &block.
Inside that lexical body, yield(args...) invokes the bound Callable and
block_given?() reports whether a block was supplied. yield accepts zero or
more arguments. Calling it without guarding an absent optional block is a
normal non-Callable type error.
Unlike Ruby, a method without &block does not take a block: passing one is an
error that says to add the parameter, and yield in its body is a fiber yield
(see Fibers).
The parameter may carry a Callable annotation, such as
&block: Callable[2]. Absence remains valid; a supplied block must satisfy the
declared Callable shape.
If the Callable annotation includes a return type, that type becomes the
compile-time type of yield. Anonymous blocks with a statically known final
expression also advertise that return type to Callable validation.
A callable with no declared or advertised return satisfies a return contract,
so code without annotations can pass blocks and functions to code with them. Its
results are checked instead, wherever the contract is relied on: calling the
value, yield, and the results of map, flat_map, group_by and map_values.
Parameter count and declared returns are still checked when the callable is passed.
def twice(callback: Callable[[Int], Int]) -> Int = callback(callback(1))
def add_one(value) = value + 1
def describe(value) = "got #{value}"
twice(add_one) # => 3
twice(describe) # TypeError: expected Int, got String
A generic parameter that only such a callable could bind stays unbound and accepts
any result: in Box#map[U], called from an unannotated method, Box[U].new(...) then
takes its binding from the constructor's arguments, as Box.new(...) does. A generic
class's own variable can be left open the same way
(see types and errors).
At statically resolved function, singleton, instance-method, and constructor
calls, concrete parameter types inside Callable[[...], Return] are applied to
corresponding do |...| locals. Callable values with typed nested Callable
parameters provide the same context. Explicit generic bindings are substituted
before the block body is compiled. For statically resolved top-level,
singleton, and instance-method calls, omitted bindings are inferred when a
concrete positional argument maps directly to a generic parameter. Array and
Hash literals also provide recursive bindings for nested collection parameters.
Mixed distinguishable members form unions. Differently parameterized members
with the same outer type remain distinct alternatives, such as
Array[Int] | Array[String]; exact duplicates are invalid. Fixed constructor
calls infer initialize type variables
from their positional arguments in the same way.
When the compiler can prove that an expression violates one of these
annotations, its diagnostic prints both complete type graphs, for example
expected Array[Int], got String or
expected Callable[[Int], String], got Callable[[Int], Int].
Spreading a homogeneously typed Array supplies its element contract to generic
parameters before a trailing block is compiled. This applies to functions,
singleton methods, instance methods, and constructors.
Keyword values likewise infer generic bindings from their named parameter slot
for statically resolved functions, singleton methods, instance methods, and
constructors. Explicit generic arguments take precedence.
For a union receiver, independently overridden methods still provide block
context when their complete parameter contracts are structurally identical.
Any incompatible override keeps the block untyped.
A statically resolved call to a non-generic top-level, singleton, or instance
method retains its declared return type at the call site. This includes nested
type graphs: a result declared as Array[Array[Int]] can be indexed twice and
used as an Int without losing the element contract. Fixed, spread, keyword,
and trailing-block calls behave consistently. Generic return graphs are
instantiated at compile time when explicit or inferred bindings resolve every
type variable. Positional, keyword, and homogeneous spread arguments contribute
bindings. Unresolved or conflicting bindings leave the result dynamically
typed. Calling a value with a typed Callable return contract and indexing a
typed collection carry their result facts into the following expression too.
Inference recurses through typed Callable parameter and return contracts, even
when the Callable is nested in an Array or Hash. A union-receiver call retains
its generic result only when all candidate implementations declare matching
signatures. Structurally identical returns reuse one graph; divergent declared
returns form a safe union. Indexing a union of typed Arrays joins its element
graphs, while indexing a typed Hash union joins its value graphs with Nil.
Native collection relays preserve these facts too. Array first and last
produce the joined element type; reverse, uniq, compact, sort,
sort_by, select, reject, take, and drop preserve it inside an Array.
Hash keys and values produce Arrays containing the joined key and value
types respectively.
Fallback reads, concat, and merge also join typed argument graphs for fixed
and named-keyword calls. Typed callback returns determine map, flat_map,
group_by, and map_values results. partition, zip, each_slice,
each_cons, and tally retain their nested Array or Hash structure.
min, max, min_by, and max_by produce the joined element type. find,
delete_at, and pop add Nil. A zipped pair includes Nil in the other
Array's position because shorter inputs are padded at runtime. Callable-union
returns are joined before constructing mapped collection types.
Collection facts widen after mutation. push, indexed assignment, and compound
indexed assignment join statically known values into Array element graphs or
Hash key/value graphs. Empty literals acquire a nested graph from their first
known write, and subsequent local expressions and LSP hover observe that state.
If a write is dynamically typed or would exceed the eight-member union ceiling,
the nested fact is discarded while the outer Array/Hash kind remains known.
The same widening applies when a directly referenced local has been boxed by a
nested closure; later reads of that captured local retain the updated graph.
Mutations written inside anonymous blocks or nested definitions also widen the
enclosing local conservatively, whether or not later control flow invokes the
closure.
Direct lexical aliases of an Array or Hash share mutation facts. Reassigning
one name detaches it from aliases that still reference the previous object.
A name bound to different objects on different if/case/loop branches
detaches after the branches join, so a mutation through it afterward does not
retroactively widen either branch's object -- unless every branch agreed on
the same object, in which case the shared identity (and mutations through it)
carry across the join.
A name assigned inside an if/elsif/else branch, rather than to the
if's own result, is also inferred as a union across the branches: if flag then x = 1 end; x is Int | Nil (the branch that skips the assignment
reads back nil, same as at runtime), and if flag then x = 1 else x = "s" end; x is Int | String. case/when and loop bodies join a branch-local
name the same way: Nil folds in for every when clause or loop exit that
doesn't independently bind the name, but not when every arm does --
case mode when 1 then picked = 1 when 2 then picked = "s" else picked = 2.5 end; picked is Int | String | Float, no Nil.
A mutation through one level of indexing off a plain local (matrix[0].push(v),
grid[0][1] = v) widens that local's own element graph, not just the
indexed value's -- matrix[0] is one element of matrix, and Array[T]
describes every element the same way, so the editor sees matrix itself as
Array[Array[T | ...]] afterward. A second level of indexing
(cube[0][0].push(v)) is out of scope: only cube's own type stays
tracked, not cube[0]'s.
Native collection blocks infer their parameter graphs from the receiver:
Array element callbacks, each_with_index element/index callbacks, and Hash
key/value callbacks expose those types to the body and editor hover.
Array#sum retains an Int result for Int elements and Int | Float for
numeric graphs containing Float. Other element graphs remain dynamic because
their repeated + dispatch may be overloaded.
A fixed-arity bound method reference such as object.convert retains its
declared parameter and return contract. Explicit generic references such as
object.wrap[Int] and Factory.wrap[Int] retain the substituted contract, so
their results compose with indexing, Callable arguments, and later calls.
When a bare generic singleton or bound instance reference is passed directly to
a statically resolved Callable parameter, d6 can infer those bindings from
the parameter's expected Callable graph. This works for positional and keyword
arguments on functions, singleton methods, instance methods, and constructors.
A declared function return supplies the same context to an endless body, an
explicit return value, or the final expression of a multi-line body. Earlier
statements do not inherit the final return expectation. A standalone reference
without either call-site or return context still requires explicit bindings.
The same context recurses through elements of an expected Array[T] and the
keys and values of an expected Hash[K, V]. A literal such as
[Tools.identity] can therefore satisfy Array[Callable[[Int], Int]] without
spelling [Int] on the reference. A spread Array receives this context when all
remaining positional parameters declare the same type. A direct spread literal
instead maps each element to its exact positional parameter, so heterogeneous
Callable contracts can resolve independently. A dynamically sized
heterogeneous spread expression does not guess element positions.
Those positions also participate in generic inference. For example,
second[T, U](*["ignored", 42]) -> U resolves T as String and U as
Int, so the result remains Int at the call site.
Fixed arguments before and after a spread bind their corresponding generic
parameters as well. A homogeneous non-literal spread contributes only to the
middle parameter range left between those fixed edges.
Keywords may follow a spread. Positional spread elements and fixed edges infer
against their positions, while each keyword infers against its named slot. A
trailing block remains the final declared parameter rather than filling a gap
left by a keyword.
Only dynamically unresolved bound methods remain conservatively untyped.
Callable unions retain a return fact when all arms agree structurally.
Resolved variadic bound methods retain their fixed argument prefix and declared
return type. Bound Callable values also accept keyword arguments using the
original method parameter names. A trailing block passed to a Callable union is
contextually typed when every arm declares the same nested Callable contract;
otherwise its parameters remain dynamically typed.
Compatible variance is accepted: one nested Callable contract may supply block
context when it can satisfy every arm through contravariant inputs and covariant
returns. Bound references preserve omitted optional arguments, so defaults are
still evaluated by the original method rather than replaced with nil.
This also applies when optional fixed arguments precede *rest. If no complete
Callable arm supplies union-block context, d6 may synthesize parameter
context from existing declared input types that safely accept every arm. If no
such input graph exists, it constructs a union of the arms' input graphs.
Return context uses an existing declared subtype only when it satisfies every
arm; synthetic return intersections are not inferred. Otherwise the block's
return type is determined by its body.
The complete parameter graph is visible inside the anonymous block. Calling a
method shared by every member of a synthesized union therefore retains a
structurally identical declared return graph for chained expressions.
Callable values accept the same trailing do ... end block as named function
and method calls, including calls with a spread Array. The block is appended as
the final positional argument.
Constructor calls accept the same trailing block for fixed and spread
arguments. The block is the final argument to initialize, which must declare
an &block parameter to bind it. Keyword constructor calls reconcile named
parameters first and then place the block in initialize's final slot.
Keyword arguments
def move(x, y, speed = 1)
# ...
end
move(3, 4) # positional
move(x: 3, y: 4) # keyword, any order
move(3, y: 4, speed: 2) # positional then keyword
A call to a top-level def may name its arguments instead of (or in
addition to) supplying them positionally. Positional arguments must come
first; once a keyword argument appears, every argument after it must also
be a keyword. A keyword argument can fill any parameter regardless of the
order it's written at the call site — the compiler resolves each name to
its declared position. Ordinary calls reorder entirely at compile time.
When a spread precedes keywords (move(*coordinates, speed: 2)), dedicated
spread bytecode merges the Array's positional values with those declared
slots at runtime, when the Array's length is known.
A keyword argument can skip earlier defaulted parameters: def f(a, b = 2, c = 3) ... end called as f(1, c: 5) runs with b at its default, 2. Each
skipped slot reaches the function marked as not supplied, so its default
expression runs, and it can still read the parameters before it. Leaving a
required parameter unfilled is a compile error (missing argument), or a
runtime one when a spread hides the count until runtime. A direct call that
skips a default goes through the same keyword dispatch as a call on a
Callable value, so it is not JIT-compiled the way a positional call is;
that matters only for a hot call.
Keyword arguments work for top-level functions, user-defined instance
methods, Callable values, constructors with a d6-defined initialize,
and class/module singleton methods. Dynamic calls retain keyword names until
runtime target selection. Native C-backed receiver methods use the central
signature registry documented in docs/internal/design.md.
Variadic parameters
def sum(*nums)
total = 0
nums.each() do |n| total += n end
total
end
sum() # => 0, nums == []
sum(1, 2, 3) # => 6, nums == [1, 2, 3]
def announce(name, *titles)
"#{titles.join(" ")} #{name}".strip()
end
announce("Ahab") # => "Ahab"
announce("Ahab", "Captain") # => "Captain Ahab"
A trailing *name parameter collects every argument beyond the ordinary
(non-variadic) ones into a plain Array — zero extra arguments collects
an empty one. Works on a top-level def, an instance method, a self.
singleton method, and a closure name() ... end alike. Deliberately
scoped narrow, matching this language's usual first-slice shape for a
feature like this (see delegate's own "bare parameter names only"):
- must be the last parameter, and at most one per parameter list;
- bare name only — no
: Typeannotation, no= default(neither means anything for a collectedArray); - a literal call site can still only ever supply at most 255 argument
expressions in total, variadic or not — a pre-existing limit on every
call form (
"too many call arguments"), not something specific to this feature. Call-site spread (below) isn't subject to it, since a spread argument's length is a runtime value, not one argument expression per element. A function/method declaration is separately capped at 32 parameters — see docs/internal/design.md's "No artificial call-argument/parameter ceiling" for why the two limits differ.
A variadic parameter widens Callable[N] matching too: a variadic
closure/function satisfies Callable[N] for any N at or above its own
required-argument count, not just an exact match — see docs/internal/design.md's
"Splat/variadic parameters" section for the full mechanism.
Call-site spread
def sum3(a, b, c)
a + b + c
end
args = [1, 2, 3]
sum3(*args) # => 6
def sum(*nums)
total = 0
nums.each() do |n| total += n end
total
end
sum(*(1..50).to_a()) # => 1275, no argument-expression limit here
foo(*array), receiver.method(*array), callable(*array),
ClassName.new(*array), and Namespace.method(*array) expand an Array's elements
into positional arguments at the call site — the caller-side counterpart
to a variadic parameter above. The supported slice is deliberately narrow:
- direct functions, user-defined instance methods, Callable values, constructors, module/class singleton methods, and native receiver methods are supported;
- one spread argument can appear before, between, or after fixed positional
arguments —
foo(1, *middle, 4)preserves left-to-right order; - all d6-defined targets may place keywords after the spread and any
fixed positional suffix —
foo(1, *middle, last: 4); collisions and gaps are diagnosed at runtime because the spread length is dynamic; - native C-backed receiver methods use the same keyword-after-spread form;
- explicit generic bindings compose with spread for functions and user-defined
instance/class/module methods (
identity[Int](*values)); - arity and method visibility are checked against the Array's actual length at runtime
(unlike an ordinary call, which the compiler validates against a
statically-known callee's arity where it can) — too few or too many
elements for a non-variadic target still raises
ArgumentError, same message as any other arity mismatch; a non-Arrayargument raisesTypeError. - native receiver spreads share ordinary native invocation's 255-argument bound;
See docs/internal/design.md's "Call-site spread" section for the full mechanism.