When a function runs it can, very generally, do two things:
In most programming languages, you can make some checks on the first option, using the function’s return type. If, e.g. in C you specify a function like
int f() { return 5; }
You know for sure that it can only return an integer (or another value that is casted into an integer), because of its return type. However, a function with the same signature (return and argument types) can also be
int f() {
launch_missiles();
return 5;
}
or
int f() {
write_to_disk();
return 5;
}
or
int f() {
access_network();
return 5;
}
And there is no way to check which of these “side-effects” the function can or does perform.
Kima allows you to specify this. Using effect types. Every function is annotated
with an effect type that specifies what kind of side effects it is allowed to
perform. There are many effect types, and you can also define new ones within
Kima, but the most basic ones are pure
and IO
:
pure
is the default effect type for functions which do not declare one and it
simply does not allow any kind of side effect.
IO
is the “all-powerful” effect. It is meant to allow for any kind of input or
output, as well as unchecked exceptions.
See Builtins for the other built-in effects
Within a function annotated with an effect type you are allowed to use other functions annotated with the same effect. Trying to call functions with incompatible effects within one another will be a compile-time error.
Defining a new effect is done with the effect
keyword. An effect is defined as
a function signature or as a set of other effects. For example, to define a
stateful integer effect:
effect get() -> int
effect set(newVal: int) -> unit
effect intState { get, set }
This is all that is required. You can then define functions which use this effect:
fun incrementState(increment: int) : state -> unit {
let newVal = get() + increment;
set(newVal);
}
fun squareState() : state -> unit {
set(get() * get());
}
The problem now is that whatever other function you might call squareState
in
will also have to have the state
effect. In other words, you can never
discharge the state
effect. This is where effect handlers come in.
Effect handlers allow you to run effectful functions inside functions that don’t support their effect. They do this by allowing you to describe some effect in terms of another (or no effect!). Here is an example:
fun purifiedState(init: int) : pure -> int {
let procedure = fun() {
incrementState(4);
squareState();
get();
}
var procState = init;
handle procedure() {
get(): { procState }
set(newVal): { procState = newVal }
}
}
# purifiedState(1) == 25
All that was needed here was to describe the basic effect operations in order to
be able to run a state
function in a pure
context!
Since state
is defined as the combination of get
and set
you can even
define functions using only one of the two operations:
fun getF(f: (int) -> int) : get -> int {
f(get());
}
This function can the be used inside other state
functions or handle
d to run
in another context:
fun getonly() -> int {
let procedure = fun() : state {
getF(fun(x: int) { x + 1; })
}
handle procedure() {
get() -> { 1 }
}
}
Here we only need to handle the get()
effect.