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 |
Lazy Values vs Zero-Arity Functions
A zero-arity function like fn => expensive-work() already delays work until the
function is called. lazy and memo build on that pattern by turning the
thunk into an explicit delayed value that can be passed around, accepted by ~
parameters, and evaluated through force.
# Plain thunk: caller must know it is a function and call it as one
thunk = fn => expensive-work()
value1 = thunk
# Lazy value: APIs can choose whether to force it
delayed = lazy(fn => expensive-work())
value2 = force delayed
Use a plain zero-arity function when a function argument is exactly what you want. Use
lazy when you want an explicit delayed value, and use memo when the
delayed value should compute once and then cache the result.
Creating Lazy Values
The lazy builtin wraps a thunk (a zero-argument function that will compute the
value when needed) in an explicit delayed value:
# 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 eager types that expose a force
method. For lazy and memoized runtime values, use the builtin force function
directly.
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.
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
For explicit lazy or memoized values, use the builtin force function:
compute = fn(x) => (force x) * 2
compute 5 # => 10
compute (lazy(fn => 5)) # => 10 (after forcing)
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":
~ does not automatically convert the caller's expression into a thunk. It accepts
an explicit lazy(fn => ...) or memo(fn => ...) value without forcing it.
# 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 |
NonNegativeInt -> 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
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 |
NonNegativeInt -> 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 One Expensive Computation
Use memo when one delayed value may be forced multiple times and should only run
once:
cached-total = memo(fn =>
println "Computing report total..."
compute-report-total rows
)
subtotal = force cached-total
footer-total = force cached-total # Reuses cached result
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:
- Functions - Higher-order functions and closures
- Error Handling - Combining lazy evaluation with Option/Result
- Testing - Testing lazy code