d6 docs

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/ensure block -- 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 : Type annotation, no = default (neither means anything for a collected Array);
  • 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-Array argument raises TypeError.
  • 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.

Edit this page on GitHub