Effects System β
Kettle tracks side effects in the type system. Functions must declare what effects they use, making code easier to reason about.
What Are Effects? β
Effects represent operations that go beyond pure computation:
- IO β I/O operations (printing, reading/writing files)
- Fail[E] β Operations that can fail with error type E
- Quantum β Quantum operations (creating qubits, gates, measurement)
A function declares its effects with the with clause:
fn read_number() -> Int with IO, Fail[String]
-- can do IO and can fail
end fn
fn create_superposition() -> Qubit with Quantum
-- can do quantum operations
end fnThe with Clause β
Declare effects after the return type:
-- Pure function (no effects)
fn add(a: Int, b: Int) -> Int
a + b
end fn
-- Function with Fail effect
fn divide(a: Int, b: Int) -> Int with Fail[String]
match b == 0
True -> fail("division by zero")
False -> a / b
end match
end fn
-- Function with multiple effects
fn read_and_parse() -> Int with IO, Fail[String]
content = read_file("data.txt")
-- ...
end fnThe Fail Effect β
Fail[E] represents computations that can fail with an error of type E.
Using fail β
Trigger a failure:
fn divide(a: Int, b: Int) -> Int with Fail[String]
match b == 0
True -> fail("division by zero")
False -> a / b
end match
end fnUsing try β
Propagate failures from called functions:
fn compute(x: Int, y: Int) -> Int with Fail[String]
a = try divide(x, y) -- propagates failure if divide fails
b = try divide(a, 2)
a + b
end fnUsing catch β
Convert a failing computation to a Result:
fn safe_divide(a: Int, b: Int) -> Result[Int, String]
catch divide(a, b)
end fn
fn main() -> Unit using file_io
match safe_divide(10, 0)
Ok(n) -> print("Result: ${to_string(n)}")
Err(msg) -> print("Error: ${msg}")
end match
end fnUsing catch with Default β
Provide a fallback value:
fn divide_or_zero(a: Int, b: Int) -> Int
catch divide(a, b) else 0
end fnEffect Propagation β
Effects propagate up the call stack. If you call a function with effects, you must either:
- Declare the same effect β let it propagate
- Handle the effect β use
catch,using, etc.
-- Option 1: Propagate
fn caller() -> Int with Fail[String]
try divide(10, 2)
end fn
-- Option 2: Handle
fn caller() -> Result[Int, String]
catch divide(10, 2)
end fnThe Quantum Effect β
Quantum operations require the Quantum effect:
fn bell_pair() -> Tuple[Int, Int] with Quantum
(a, b) = bell()
(measure(a), measure(b))
end fnHandling Quantum Effects β
Use using to provide a quantum backend:
fn main() -> Unit using quantum_simulator(shots = 100), file_io
results = bell_pair() -- runs 100 times
print(to_string(results))
end fnIO Effect β
File operations require the IO effect:
fn load_config(path: String) -> String with IO, Fail[String]
read_file(path)
end fn
fn save_data(path: String, data: String) -> Unit with IO, Fail[String]
write_file(path, data)
end fnThe IO effect covers:
print(value)β Print to standard outputread_file(path)β Read file contentswrite_file(path, content)β Write to a filefile_exists(path)β Check if a file exists
Effect Handlers: with vs using β
There's an important distinction between declaring effects and handling them:
withβ Declares that a function requires certain effectsusingβ Provides handlers that fulfill those effects
How It Works β
When you write fn foo() -> T with IO, you're saying "this function needs IO capabilities." The function can call print, read_file, etc., but it doesn't specify how those operations work.
When you write fn main() -> Unit using file_io, you're providing a handler that actually implements IO operations. The handler connects your code to the real world (file system, console, etc.).
-- This function REQUIRES IO (declares the need)
fn greet(name: String) -> Unit with IO
print("Hello, ${name}!")
end fn
-- This function PROVIDES IO via file_io handler
fn main() -> Unit using file_io
greet("World") -- works because file_io handles IO
end fnBuilt-in Handlers β
Kettle provides these handlers:
| Handler | Handles | Description |
|---|---|---|
file_io | IO | Console and file operations |
quantum_simulator | Quantum | Simulates quantum circuits locally |
quantum_simulator(shots=N) | Quantum | Runs circuit N times, returns list of results |
Multiple Handlers β
Provide multiple handlers separated by commas:
fn main() -> Unit using quantum_simulator(shots = 100), file_io
-- Can use both Quantum and IO effects
end fnWhy This Design? β
Separating declaration (with) from handling (using) enables:
- Testing β Swap handlers to mock IO or quantum operations
- Portability β Same code runs on simulator or real quantum hardware
- Clarity β Function signatures show exactly what effects are needed
Combining Effects β
Functions can have multiple effects:
fn read_and_parse() -> Int with IO, Fail[String]
content = read_file("number.txt")
match to_int(content)
Some(n) -> n
None -> fail("invalid number in file")
end match
end fnEffect Polymorphism β
Higher-order functions like map, filter, and fold are effect-polymorphicβthey propagate effects from their callbacks to their callers.
How It Works β
When you pass an effectful callback to map, the effects propagate:
-- Pure callback: map is pure
pure_result = map(fn(x: Int) -> x * 2, [1, 2, 3])
-- IO callback: map becomes IO
fn print_each(nums: List[Int]) -> List[Int] with IO
map(fn(x: Int) -> print(to_string(x)); x, nums)
end fn
-- Quantum callback: map becomes Quantum
fn apply_hadamard(qubits: List[Qubit]) -> List[Qubit] with Quantum
map(fn(q: Qubit) -> hadamard(q), qubits)
end fnDefining Effect-Polymorphic Functions β
You can define your own effect-polymorphic functions using effect parameters:
-- Syntax: effect E in type parameter list
fn twice[T, effect E](f: Fn(T) -> T with E, x: T) -> T with E
f(f(x))
end fn
-- Works with pure functions
twice(fn(x: Int) -> x + 1, 5) -- 7
-- Works with IO functions
fn greet(name: String) -> String with IO
print("Hello, ${name}!")
name
end fn
fn main() -> Unit using file_io
twice(greet, "World") -- prints twice
end fnThe effect parameter E captures whatever effects the callback has and propagates them to the caller.
Practical Use Case: Gradient Computation β
Effect polymorphism is particularly useful for gradient computation in quantum algorithms:
-- Works with both pure and quantum cost functions
fn gradient[effect E](f: Fn(Float) -> Float with E, theta: Float) -> Float with E
shift = 1.5708 -- pi/2
(f(theta + shift) - f(theta - shift)) / 2.0
end fn
-- Pure function
fn parabola(x: Float) -> Float
x * x
end fn
-- Quantum function
fn quantum_cost(theta: Float) -> Float with Quantum
q = qubit()
q <- ry(theta)
(e, _) = expect_z(q)
e.value
end fn
-- Same gradient function works for both
grad1 = gradient(parabola, 1.0)
fn quantum_gradient_example() -> Float with Quantum
gradient(quantum_cost, 0.5)
end fnWhy Effects Matter β
Effects make code honest about what it does:
- Testability β Pure functions are easy to test
- Refactoring β Effect signatures show dependencies
- Reasoning β You know what a function can and can't do
A function without effects is guaranteed to be pureβsame inputs always produce same outputs, no side effects.
Next Steps β
- Linear Types β Resource safety for qubits
- Quantum Computing β Quantum programming guide