LEP-006: Side Effects


Created:Saturday, May 7, 2022
Author:David Delassus
Category:Language design
Status:FINAL

Abstract

This LEP specifies how Letlang will handle side effects and exceptions, as well as their Rust implementation.

Rationale

In mathematics, functions have no side effects, they will always return the same result given the same parameters:

f(x) = 2x + 1
f(0) = 1
f(1) = 3

In software development, such functions are called pure.

Yet, not every functions can be pure, like:

  • getting input from the user
  • getting the current time
  • performing a request to an external service

Such impure functions have side effects.

Decoupling the handling of a side effect from a function, while still maintaining type safety is a requirement.

The developer must be able to specify how side effects should be handled and what value they return.

NB: This is especially helpful when writing a test suite, in order to mock the real world.

Unhandled side effects must be handled by the Letlang runtime which provides a safe interface with the real world.

Exceptions are a special kind of side effect: they do not give back control to the function that triggered them.

Specification

Effect signature

In order to be able to perform a side effect, Letlang must know its signature (similar to a function signature[1]):

effect get_input(prompt: string) -> (@ok, string) | (@error, atom);

Perform expression

To perform a side effect, you must call it like a function and prefix the call with the perform keyword:

result := perform get_input(">>> ");

This will interrupt the execution of the function and go to the handler of the side effect. If the side effect is not handled by any handler, it will bubble up to the runtime.

If the runtime does not know how to handle the effect, the program will abort.

Throwing exceptions

The main difference between a side effect and an exception is that exceptions do not give back control to the function.

Any Letlang value can be thrown as an exception using the throw keyword:

let @never_reached = throw @runtime_error;

This will interrupt the function and go to the handler of the exception. If the exception is not handled by any handler, it will bubble up to the runtime, which will abort the program.

Capturing side effects

The developer can capture side effects (and exceptions) triggered by a block of code with a do {} block with an intercept clause, which (like functions[1]) consists of a sequence of expressions:

do {
  perform get_input(">>> ");
}
intercept get_input(prompt) {
  "foobar";
};

The intercept clause of the do {} block captures the side effect to handle it. It consists of a sequence of expressions, the value of the last expression is the value returned by the perform keyword.

The value of the last expression of the do {} block will be the value returned by the whole block:

let "foobar" = do {
  perform get_input(">>> ");
}
intercept get_input(prompt) {
  "foobar";
};

To capture exceptions, we add a catch clause to the do {} block:

do {
  throw @error
}
catch exception {
  @silenced;
};

Since the throw keyword never returns, the value of the last expression of the catch clause will be the return value of the whole do {} block:

let @ok = do {
  throw @error
}
catch exception {
  @ok;
};

Finalization

The do{} block can have a finally clause to be executed after the block and the catch clauses:

let @ok = do {
  @ok;
}
finally {
  @do_nothing;
};

NB: The return value of the finally clause is ignored.

If an exception is thrown in the finally clause, it overrides uncatched exceptions:

let @bar = do {
  do {
    throw (@error, @foo);
  }
  finally {
    throw (@error, @bar);
  };
}
catch (@error, reason) {
  reason;
};

Rust implementation

The generator[2] feature from Rust is still unstable, therefore, we rely on the crate genawaiter[3] implementation.

Every function bodies and do{} blocks (and their clauses) are generators.

use genawaiter::stack::let_gen;

let_gen!(code_block, {
  // ...
});

The perform keyword instantiates the supplied effect with its arguments and then yields:

use letlang_runtime::*;
use genawaiter::{stack::let_gen, yield_};

let_gen!(code_block, {
  // ...

  // perform implementation
  let effect_instance = /* ... */;
  let effect_result = yield_!(FunctionInterruption::Effect(effect_instance));

  // ...
});

The throw keyword yields with the supplied value:

use letlang_runtime::*;
use genawaiter::{stack::let_gen, yield_};

let_gen!(code_block, {
  // ...

  // throw implementation
  let exc = /* ... */;
  yield_!(FunctionInterruption::Exception(exc));

  // ...
});

NB: There is no need to capture the return value of the yield_! macro since the execution won’t resume.

The intercept and catch clauses will match the yielded values from the generator:

use genawaiter::GeneratorState;

// the following code is inside a generator too:

let mut state = code_block.resume(); // starts the function

loop {
  match state {
    GeneratorState::Complete(value) => {
      return value;
    },
    GeneratorState::Yielded(FunctionInterruption::Exception(exc)) => {
      // match catch clauses or re-raise exception like this:
      yield_!(FunctionInterruption::Exception(exc));
      // no need to return, this generator won't resume after this yield
    },
    GeneratorState::Yielded(FunctionInterruption::Effect(effect_instance)) => {
      // match intercept clauses or bubble up effect like this:
      let effect_result = yield_!(FunctionInterruption::Effect(effect_instance));

      // resume function with result:
      state = code_block.resume_with(effect_result);
    }
  }
}

Rejected Ideas

Resume statement

At some point, a resume statement within an intercept clause was considered, but since the code block of the clause already evaluates to a value, there is no need to introduce a statement.

This also prevents human errors like forgetting the resume statement.

References

ReferenceTitleLink
1LEP-005: Functions/lep/005/
2Rust generatorshttps://doc.rust-lang.org/nightly/unstable-book/language-features/generators.html
3genawaiter cratehttps://docs.rs/genawaiter/latest/genawaiter/