Skip to content

Latest commit

 

History

History
99 lines (81 loc) · 4.04 KB

File metadata and controls

99 lines (81 loc) · 4.04 KB
title Closures and tail recursion

Closures and tail recursion

Tail self-recursion runs in constant stack (guaranteed)

A self-call is in tail position when it is the function's whole result. When a function returns such a call, the compiler guarantees constant stack: a tail-recursive function runs in constant stack at any depth:

count = (n :: Num, acc :: Num) -> Num => <
  n == 0 ? acc : count(n - 1, acc + n)   ~ the self-call IS the `:` branch → tail position
>

Tail position flows through the constructs that yield a value directly: ?/| match arms, ternary branches, and the tail of a < > block. A self-call outside tail position is ordinary recursion (e.g. n * fact(n - 1), whose result is multiplied first), and so is a tail call to a different function. The guarantee applies as written, with no marker. (See examples/tail_recursion.qn, which recurses 1,000,000 deep.)

Closures — capture by = (value) vs := (reference)

A function written inside another function's body is a closure: it can read the enclosing locals it refers to. How each name is captured is decided by the operator that bound it, the mutability rule of variables and records:

  • = captures by value — a frozen snapshot taken when the closure is created;
  • := captures by reference — one shared mutable cell. Writes through it, from inside the closure or outside, are visible to everyone sharing it, and the cell survives the frame that created it.
^ = () -> Num => <
  total := 0                 ~ `:=` -> captured BY REFERENCE
  bump = (n :: Num) => <
    total := total + n       ~ writes the SHARED cell; the effect persists across calls
    total
  >
  bump(10)                   ~ total -> 10
  bump(20)                   ~ total -> 30  (same cell)

  base = 7                   ~ `=`  -> captured BY VALUE (a frozen copy)
  addBase = (x :: Num) => < x + base >

  total + addBase(5)         ~ 30 + 12 = 42
>

A non-capturing nested function may recurse (fact = (n :: Num) => < … fact(n-1) … >). Nested closures may capture from any enclosing frame — the shared := cell is threaded through every level. A closure value may itself be captured by another closure and called. A closure may also be passed to a function whose parameter has the matching function type and called there.

Closures are monomorphic: parameters and captured values are concrete-typed (see Known limitations).

Returning a closure

A function's result may itself be a function: the return is written as a function type, and the body hands back a closure. Its captures live on the GC heap and outlive the frame that made them — by-value (=) snapshots and shared := cells alike:

adder = (n :: Num) -> (Num) -> Num => < (x) => x + n >

mkCounter = () -> () -> Num => <
  count := 0                 ~ the `:=` cell survives mkCounter's return
  () -> Num => <
    count := count + 1
    count
  >
>

^ = () -> Num => <
  add5 = adder(5)            ~ call it through a binding…
  seven = add5(2)
  answer = adder(40)(2)      ~ …or immediately: a call on a function-valued expression
  tick = mkCounter()
  tick()                     ~ 1
  seven + answer + tick()    ~ 7 + 42 + 2 = 51
>

The declared return type is a contextual-typing position: the lambda handed back takes its parameter types from it ((x) => x + n above: the return type says x is a Num), as a lambda argument takes them from the receiving signature. A returned closure is an ordinary function value: bind it, call it, pass it on to another function, or return it from another closure.

What is handed back is a closure value — a lambda literal, a named closure binding, or a function-typed parameter. A top-level function is handed back through a lambda that calls it (see Known limitations). (See examples/closures.qn.)