Laziness

Kit is eager by default but provides opt-in lazy evaluation for when you need to defer computation. This gives you the predictable performance of eager evaluation while allowing lazy patterns where they provide value.

Overview

Lazy evaluation means deferring computation until the result is actually needed. This is useful for:

  • Expensive computations - Only compute if the result is actually used
  • Short-circuit evaluation - Skip unnecessary work based on conditions
  • Infinite structures - Work with conceptually infinite data
  • Memoization - Cache expensive results for reuse

Kit provides four key features for lazy evaluation:

Feature Syntax Description
lazy lazy(fn => expr) Create a deferred computation (re-evaluates each time)
memo memo(fn => expr) Create a cached computation (evaluates once)
force force(value) Evaluate a lazy/memo value
~ sigil fn(~param) Accept lazy values without auto-forcing

Creating Lazy Values

The lazy builtin wraps an expression in a thunk (a zero-argument function that will compute the value when needed):

# The expression is NOT evaluated yet
deferred = lazy(fn =>
  println "Computing..."
  42
)

println "Created lazy value"

# Now we force evaluation
result = force deferred
println result

Output:

Created lazy value
Computing...
42

Important: With lazy, the computation runs every time you call force:

counter = lazy(fn =>
  println "Evaluated!"
  1
)

force counter  # Prints "Evaluated!"
force counter  # Prints "Evaluated!" again
force counter  # Prints "Evaluated!" again

Memoization

If you want a lazy value that only computes once and caches the result, use memo:

cached = memo(fn =>
  println "Computing (only once)..."
  expensive-calculation()
)

first = force cached   # Computes and caches
second = force cached  # Returns cached result
third = force cached   # Returns cached result

Output:

Computing (only once)...

Use memo when the computation is expensive and the result doesn't change. Use lazy when you need fresh evaluation each time.

Forcing Evaluation

The force builtin evaluates a lazy or memoized value. It's also safe to call on non-lazy values - it simply returns them unchanged:

# Force on lazy/memo values evaluates them
force (lazy(fn => 42))   # => 42
force (memo(fn => 42))   # => 42

# Force on eager values is identity
force 42                 # => 42
force "hello"            # => "hello"
force [1, 2, 3]          # => [1, 2, 3]

Auto-Force Coercion

When you pass a lazy value to a function that expects an eager value, Kit automatically inserts a force call. This means you don't need to manually force values at every call site:

# Regular function expecting Int
square = fn(n) => n * n

# Pass a lazy value - it's automatically forced!
lazy-seven = lazy(fn => 7)
result = square lazy-seven

println result  # => 49

This works because square's parameter n is a regular parameter. Kit sees that you're passing a lazy value and automatically forces it before calling the function.

The Forceable Trait

Kit provides the Forceable trait for writing polymorphic code that works with both eager and lazy values. This trait defines a uniform interface for forcing evaluation.

Trait Definition

trait Forceable a
  force: a -> a

For eager types like Int, Float, String, and Bool, the force method is simply the identity function - it returns the value unchanged. For lazy types, it evaluates the deferred computation.

Built-in Implementations

The standard library provides Forceable implementations for common eager types:

# Eager types - force is identity
extend Int with Forceable
  force = fn(x) => x

extend Float with Forceable
  force = fn(x) => x

extend String with Forceable
  force = fn(x) => x

extend Bool with Forceable
  force = fn(x) => x

Using Forceable

The Forceable trait allows you to write functions that work uniformly with both eager and lazy values:

# Works with both eager and lazy values
compute = fn(x) => Int.force(x) * 2

compute 5                     # => 10
compute (lazy(fn => 5))      # => 10 (after forcing)
Note

For Lazy and Memo types, use the builtin force function directly. Generic extensions like extend Lazy a with Forceable require parameterized trait implementations which are not yet fully supported.

Lazy-Accepting Parameters

Sometimes you want a function to receive lazy values without forcing them automatically. This lets the function decide when (or if) to evaluate the value. Use the ~ sigil to mark a parameter as "lazy-accepting":

# The ~ sigil prevents auto-forcing
maybe-compute = fn(~value, should-force?) =>
  if should-force? then
    force value
  else
    0

# Create a lazy value with a side effect
expensive = lazy(fn =>
  println "Doing expensive work..."
  999
)

# The expensive work is NOT done here!
result1 = maybe-compute expensive false
println result1  # => 0

# Now the expensive work IS done
result2 = maybe-compute expensive true
println result2  # => 999

Output:

0
Doing expensive work...
999

The ~ sigil is essential for implementing patterns like:

  • Short-circuit evaluation - Only evaluate fallbacks if needed
  • Conditional computation - Skip expensive work based on conditions
  • Lazy defaults - Only compute default values when required

Lazy Sequences (Seq)

Kit provides a Seq module for working with lazy sequences - Clojure-style data structures that compute elements on demand. This enables working with potentially infinite data and efficient processing of large collections.

Creating Sequences

Function Signature Description
Seq.empty Seq a Empty sequence
Seq.cons a -> Seq a -> Seq a Prepend element to sequence
Seq.lazy-cons a -> (() -> Seq a) -> Seq a Prepend with lazy tail
Seq.singleton a -> Seq a Single-element sequence
Seq.repeat a -> Seq a Infinite sequence of same value
Seq.iterate (a -> a) -> a -> Seq a Infinite sequence by applying function
Seq.range-from Int -> Seq Int Infinite integer sequence from n
Seq.cycle [a] -> Seq a Infinite cycle of list elements
Seq.from-list [a] -> Seq a Convert list to sequence

Example: Infinite Sequences

# Infinite sequence of natural numbers
naturals = Seq.range-from 0

# Take first 10
first-ten = naturals
  |> Seq.take 10
  |> Seq.to-list

println first-ten  # => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

# Infinite fibonacci sequence
fibs = Seq.iterate (fn((a, b)) => (b, a + b)) (0, 1)
  |> Seq.map (fn((a, _)) => a)

first-fibs = fibs |> Seq.take 10 |> Seq.to-list
println first-fibs  # => [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Transforming Sequences

Function Signature Description
Seq.map (a -> b) -> Seq a -> Seq b Transform each element
Seq.filter (a -> Bool) -> Seq a -> Seq a Keep elements matching predicate
Seq.take Int -> Seq a -> Seq a Take first n elements
Seq.drop Int -> Seq a -> Seq a Skip first n elements
Seq.take-while (a -> Bool) -> Seq a -> Seq a Take while predicate holds
Seq.flat-map (a -> Seq b) -> Seq a -> Seq b Map and flatten
Seq.concat Seq a -> Seq a -> Seq a Concatenate two sequences

Realizing Sequences

Warning

Seq.to-list forces all elements - do not use on infinite sequences! Use Seq.realize n seq to safely realize a bounded number of elements.

Function Signature Description
Seq.to-list Seq a -> [a] Force all elements to list
Seq.realize Int -> Seq a -> [a] Force up to n elements to list
Seq.first Seq a -> Option a Get first element
Seq.nth Int -> Seq a -> Option a Get element at index
Seq.fold (b -> a -> b) -> b -> Seq a -> b Reduce sequence to value
Seq.find (a -> Bool) -> Seq a -> Option a Find first matching element

Example: Processing Large Data

# Process a potentially large data stream efficiently
process-data = fn(data) =>
  data
    |> Seq.from-list
    |> Seq.filter (fn(x) => x > 0)
    |> Seq.map (fn(x) => x * 2)
    |> Seq.take-while (fn(x) => x < 1000)
    |> Seq.to-list

# Find primes lazily
is-prime? = fn(n) =>
  if n < 2 then false
  else
    Seq.range-from 2
      |> Seq.take-while (fn(i) => i * i <= n)
      |> Seq.all? (fn(i) => n % i != 0)

primes = Seq.range-from 2
  |> Seq.filter is-prime?

# Get first 20 primes
first-primes = primes |> Seq.take 20 |> Seq.to-list

Practical Use Cases

Short-Circuit Evaluation

Implement an "or-else" pattern that only evaluates the fallback if needed:

or-else = fn(primary, ~fallback) =>
  match primary
    | Some x -> x
    | None -> force fallback

# When primary has a value, fallback is never computed
result1 = or-else (Some 42) (lazy(fn =>
  println "This won't print!"
  0
))
println result1  # => 42

# When primary is None, fallback IS computed
result2 = or-else None (lazy(fn =>
  println "Computing fallback..."
  99
))
println result2  # => 99

Expensive Default Values

Avoid computing expensive defaults when they're not needed:

get-config = fn(key, ~default-fn) =>
  match lookup-config key
    | Some value -> value
    | None -> force default-fn

# The default is only computed if lookup fails
timeout = get-config "timeout" (memo(fn =>
  println "Reading from slow config file..."
  read-file "/etc/app/defaults.json"
    |> parse-json
    |> get "timeout"
))

Caching Expensive Computations

Use memo to cache results that are expensive to compute:

# Fibonacci with memoization
fib-memo = fn(n) =>
  cache = memo(fn =>
    if n <= 1 then n
    else (fib-memo (n - 1)) + (fib-memo (n - 2))
  )
  force cache

Integration with Option and Result

Lazy values compose naturally with Kit's error handling types. You can defer computations that might fail and handle errors when forcing:

# Lazy computation that might fail
fetch-user = fn(id) =>
  lazy(fn => database-lookup id)

# Force and handle errors
match force (fetch-user 42)
  | Ok user -> greet user
  | Err e -> log-error e

For functions that require a value (not Option/Result), the ? operator works as usual after forcing:

process-user = fn(user) => ...

# Force the lazy, then unwrap Result (or propagate error)
process-user (force (fetch-user 42))?

Design Rationale

Why Eager by Default?

Unlike languages like Haskell that are lazy by default, Kit is eager by default with opt-in laziness. This design fits Kit's goals because:

  • Predictable performance - Hidden thunks cause unexpected allocations in systems code
  • C interop - Can't pass thunks to C functions; eager evaluation matches C semantics
  • Easier debugging - Stack traces are straightforward without deferred evaluation
  • Zero overhead - The common (eager) case has no runtime cost

Trade-offs

Approach Pros Cons
Lazy by default Automatic optimization, infinite structures Unpredictable performance, space leaks
Eager by default (Kit) Predictable, simple mental model Must explicitly opt-in to laziness
Dual functions No magic, explicit Code duplication, refactoring pain

Kit's approach - eager by default with opt-in laziness - provides the best fit for a systems-oriented functional language where predictability matters.

Summary

Concept Syntax Behavior
Create lazy lazy(fn => expr) Defers computation, re-evaluates each force
Create memoized memo(fn => expr) Defers computation, caches result after first force
Force evaluation force(value) Evaluates lazy/memo; identity for eager values
Lazy-accepting param fn(~x) Accepts lazy values without auto-forcing
Auto-force Implicit Non-~ params automatically force lazy arguments

Next Steps

Now that you understand laziness, explore: