Abstracting Effects with Continuations

whwhyb1 pts0 comments

Abstracting effects with continuations

Abstracting effects with continuations

Representing errors as values is a powerful tool for writing reliable programs.<br>A result value wraps the desired value or holds information about why the computation failed.<br>An explicit result type represents the possibility of failure in the type system.<br>A function that returns a result forces the caller to account for failure.

The result is one instance of a bigger pattern.<br>A promise represents computation that is asynchronous.<br>Return a promise and the caller is forced to wait for the computation to complete before accessing the value.

A result and promise each track a specific detail about some computation.<br>To generalise over the details of a computation, use a continuation.<br>Using a continuation says: “I have no idea how you’re going to get the value, but when you do, this is what should be done next.”

Filinski, Representing Monads (1994) proves continuations can express any monad (such as Result and Promise).<br>We will not get into the maths of the proof.<br>This post is to demonstrate how to use continuations in Gleam.

A contrived example

We work with a fetch function that returns a value for a key.<br>The function is provided by the caller and takes a String key and returns a String value.

Accepting fetch as an argument allows us to fetch values from different data sources as needed.<br>A trivial implementation of fetch could ignore the key and always return "yes":

fn fetch(key: String) -> String {<br>"yes"

The business logic does the following.<br>For a fixed list of keys, transform each to uppercase, and return the length of the associated value.<br>A simple solution to this task looks like the following.

pub fn simple_func(fetch: fn(String) -> String) -> List(Int) {<br>let keys = ["a", " b"]<br>list.map(keys, fn(key) {<br>let key = string.uppercase(key)<br>let value = fetch(key)<br>string.length(value)<br>})

Moving into enterprise

Our simple_func is working very well, so the business looks to expand.<br>The business logic remains the same but our enterprise customers keep their data in all sorts of storage.<br>Some fetch implementations can’t always return a value.<br>To handle this we create a new version of our business logic where fetch returns a Result(String, Nil).

pub fn fallible_func(fetch: fn(String) -> Result(String, Nil)) -> Result(List(Int), Nil) {<br>let keys = ["a", " b"]<br>list.try_map(keys, fn(key) {<br>let key = string.uppercase(key)<br>use value

There is great success with the new function and soon after a request for an asynchronous data store arrives.<br>Another implementation that accepts a fetch that returns Promise(String) solves this.

pub fn async_func(fetch: fn(String) -> Promise(String)) -> Promise(List(Int)) {<br>let keys = ["a", "b"]<br>list.map(keys, fn(key) {<br>let key = string.uppercase(key)<br>use value promise.await_list

At this point we have three implementations to support all the different requirements.<br>In each version, the business logic is the same and the functions are similar.<br>But we are not reusing the business logic between implementations.

Even worse, the requirement for a fallible and async implementation, a fetch that returns Promise(Result(String, Nil)) is waiting for us.<br>We cannot reuse the fallible or async implementation and will require a fourth implementation of our function.

The missing abstraction

All that changes between implementations is what kind of computation fetch performs: direct, fallible, asynchronous or something else.<br>The common part of each implementation is how to create a key and what to do with the value if/when it is available.

A continuation allows us to represent this “before and after” relationship while being generic over the kind of computation happening.

pub type Continuation(t, a) =<br>fn(fn(a) -> t) -> t

The type of value our continuation wraps is a.<br>The (as yet unspecified) details of the computation are t.

We create a new version accepting a fetch that returns a Continuation(t, String).

import midas/continuation.{type Continuation as K}

pub fn task(fetch: fn(String) -> K(t, String)) -> K(t, List(Int)) {<br>let keys = ["a", "b"]<br>continuation.each(keys, fn(key) {<br>let key = string.uppercase(key)<br>use value

This task is now generic over the kind of computation.<br>The caller will choose the concrete type of t through the implementation of fetch it provides.<br>To extract the final value, the caller will need to provide a final callback.<br>A caller for a continuation based task is often called a runner or interpreter.

Simple runner

In the simple case fetch has no extra effects and it always resumes with a string.<br>It still returns a continuation, so the final value is wrapped with continuation.return.<br>Because the final value is unwrapped, the identity function is passed as the final callback.

This simple runner returns a List(Int) reflecting that fetch itself is infallible and synchronous.

import midas/continuation

fn run_simple(task) -> List(Int) {<br>let fetch = fn(_key) { continuation.return("yes")...

string fetch value continuation result list

Related Articles