Explicit Laziness in Nic: Strict by Default, Lazy When You Ask
Most functional languages fall into two camps:
- Strict (ML, Rust, Go): evaluate everything immediately
- Lazy (Haskell): evaluate nothing until needed
Nic takes a different approach:
Strict by default, lazy only when you ask for it.
This avoids the unpredictability of full laziness while giving you the power of lazy, memoized computations when you explicitly request them.
Nic's lazy model is simple:
- Laziness lives inside explicit
'lazy { ... }'blocks - The block must explicitly
'return value;' - The result is a
'Lazy[T]' - Evaluating it is explicit via
'.eval()'or'force(lazy)' - It memoizes automatically
- Zero implicit forcing
- Zero hidden thunks
- Zero runtime cost when unused
Let's break it down.
Strict by default
In Nic:
fn main() -> unit {
let x = expensive(); // immediately runs expensive()
}This is predictable and easy to reason about — important qualities for a systems language.
But sometimes you need the opposite: defer a computation until (and unless) it's needed.
The lazy { } block
A lazy value is created using a 'lazy { ... }' block:
fn expensive() -> i32 {
println('Computing...');
return 42;
}
fn main() -> unit {
let lazy_val = lazy {
return expensive();
};
println('Before forcing');
let result = lazy_val.eval();
println('After forcing');
}Output:
Computing...
After forcingThe 'expensive()' function runs only when 'eval()' is called.
Lazy values evaluate once (built-in memoization)
Once evaluated, the lazy value is cached:
let x = lazy {
println('Running...');
return 100;
};
let a = x.eval(); // prints 'Running...'
let b = x.eval(); // memoized
let c = x.eval(); // memoizedOnly the first 'eval()' executes the computation.
This makes lazy values ideal for:
- cached configuration loading
- deferred AST transformations
- expensive computations that might not be needed
- infinite streams
Lazy[T] is not T (no implicit forcing)
This mistake in many languages creates confusion.
In Nic:
'Lazy[T]'is its own type- Nic never forces it automatically
- Passing a
Lazy[T]whereTis expected is a compile-time error
fn use_int(n: i32) -> unit { println('got int'); }
fn main() -> unit {
let v = lazy { return 42; };
use_int(v); // ERROR
use_int(v.eval()); // OK
}No surprises.
No hidden evaluation.
No debugging nightmares.
Laziness inside structs (defer expensive fields)
struct Config {
name: string,
data: Lazy[string],
}
fn load_data() -> string {
println('Loading from disk...');
return 'dataset';
}
fn main() -> unit {
let cfg = Config{
name: 'app',
data: lazy { return load_data(); }
};
println(cfg.name); // no load yet
println(cfg.data.eval()); // loads here
}This is incredibly powerful for:
- parsing large files
- lazy-loading modules
- deferred computation in compilers (AST → IR)
- loading assets in games
Conditional laziness (only compute if needed)
fn expensive_default() -> i32 {
println('computing default');
return 999;
}
fn get_value(use_default: bool) -> i32 {
let default = lazy { return expensive_default(); };
if use_default {
return default.eval();
} else {
return 42;
}
}If 'use_default == false', the expensive computation never runs.
Infinite streams powered by lazy tails
Explicit laziness makes infinite structures easy and cheap:
enum Stream[T] {
Nil,
Cons(T, Lazy[Stream[T]]),
}
fn integers_from(n: i32) -> Stream[i32] {
return Cons(n, lazy { return integers_from(n + 1); });
}Access only what you need:
fn take(n: i32, s: Stream[i32]) -> *List[i32] {
if n <= 0 { return new Nil; }
return match s {
Nil -> new Nil,
Cons(x, rest) -> new Cons(x, take(n - 1, rest.eval()))
};
}Why explicit laziness instead of implicit Haskell-style laziness?
| Feature | Haskell | Nic |
|---|---|---|
| Laziness | implicit, everywhere | explicit, opt-in |
| Forcing | implicit | explicit .eval() |
| Performance | unpredictable thunks | predictable strictness |
| Debugging | harder | trivial |
| Memoization | sometimes | always |
| Type safety | lazy has no type | Lazy[T] is explicit |
Nic gives you:
- Haskell’s power
- Rust’s predictability
- Zero surprise costs
- A clean type system
Zero-cost optimization
Nic's compiler aggressively removes overhead:
Lazy value never forced?
→ compiled away entirely.Lazy value forced exactly once?
→ compiled to a single direct value.Lazy used multiple times?
→ compiled to a memoized structure (efficient).
This keeps laziness safe to use even in performance-sensitive areas.
When to use laziness
Great for:
- expensive optional computations
- deferred loading
- infinite streams
- avoiding work inside branches
- caching results of pure functions
Avoid when:
- value is cheap
- value is always needed
- complex mutation patterns (prefer timelines)
Summary
| Feature | Syntax |
|---|---|
| Create lazy | 'lazy { return x; }' |
| Force (method) | 'lazy_val.eval()' |
| Force (function) | 'force(lazy_val)' |
| Type | 'Lazy[T]' |
| Memoization | automatic |
Explicit laziness gives Nic a powerful middle ground:
strict by default, lazy when you need it, predictable always.
Next up:
How Lazy Evaluation Interacts With Temporal Zippers and Infinite Timelines.