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)
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
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:
- Functions - Higher-order functions and closures
- Error Handling - Combining lazy evaluation with Option/Result
- Testing - Testing lazy code