apropos

On effects and encapsulation

There’s a lot of interesting work being done in the space of effect handlers right now – and a lot of work that needs to be done. The handlers themselves need to become a little more performant, the inference needs to become a little bit better, the applications need to become a little more straightforward.1 This is mostly all iterative. People have been working on these problems for a long time, and advancing steadily, one step at a time.

However: there is one thing research into effect handlers has not focused on, which I believe to be absolutely vital for broad adoption of such systems. Encapsulation.

what’s the deal with encapsulation?

So what’s up with encapsulation?

By encapsulation, I should note that I mean function encapsulation specifically – the guarantee that all^* effects and dependencies of a function are expressed and passed through its type signature. In practice, this isn’t a strict definition, and functions that are not pure functions do have dependencies on various side effects – but we’ll come back to that. For now, we’re going to look at effect handlers for their control flow properties with respect to functions.

First: it should be understood that effect handlers fundamentally break function encapsulation. They inherit this issue from their ancestor, resumable exceptions. So we’ll take a look at them to start (and for the benefit of any reader who’s unfamiliar with what these “effect handlers” things are). Now, a proper effects system allows for adding richer information about the tag and type of effect thrown and tracking these: but resumable exceptions are close enough in design to exhibit the problem we wish to showcase.

(define-exception DivideByZero)

;; Inferred: may throw a DivideByZero exception
(define (safe-divide a b)
  (unless (not (zero? b))
    (raise DivideByZeroExn))
  (/ a b))

(safe-divide 5 0) ;; Exception!

;; Inferred: may throw a DivideByZero exception
;; The DivideByZero exception may be resumed with a number
(define (resumable-divide a b)
  (if (not (zero? b))
    (/ a b)
    (raise DivideByZero)))

;; Evaluates to 0!
(try (resumable-divide 5 0)
  [(catch DivideByZero) => (resume 0)])

The idea behind resumable exceptions is, well… what if your exceptions were resumable? What if instead of needing to discard the entire inner computation upon throwing and catching a DivideByZero exception, you could resume the computation, with some sort of provided value? As it turns out, this is a quite powerful notion, and generalizing it slightly gives us the modern system of effect handlers – which are expressive enough to subsume all non-local control flow.

But there are some problems with expressiveness. When programming normally, a user can look at the type signature + argument names + any documentation comments on a function, and come away with a good understanding of what it does and how to use it. (It is for this reason I prefer programming in well-typed languages.) With resumable exceptions (even with exception inference): this is no longer the case. What does an exception raised from this function mean, and more importantly: how and should I resume it? What does resuming such an exception do? What is the meaning in context of a resumed value?

Tracking exceptions alone isn’t enough. Tracking the resumable types of exceptions is also not enough. Consider the following function: its signature and exception information, including any inferred information about the type expected by resumption, is identical the resumable-divide above. Yet its semantic behavior is much different. The value expected on resumption has changed from acting as a default value to acting as a denominator. Both approaches are reasonable. How can the user tell them apart?

;; Inferred: may throw a DivideByZero exception
;; The DivideByZero exception may be resumed with a number
(define (another-divide a b)
  (if (not? (zero? b))
    (/ a b)
    (let ([c (raise DivideByZero)])
      (resumable-divide a c))))

;; Loops!
(try (another-resumable-divide 5 0)
  [(catch DivideByZero) => (resume 0)])

These concerns could theoretically be assuaded by extra documentation in the function comments (so long as your exceptions are clearly distinct). But this isn’t anywhere near good enough. It’s like saying type signatures aren’t useful because you can document types in comments – nonsense! Static guarantees aside, even languages in the ML-tradition of total inference have begun to recognize that annotations at the function level are far and away worth the tradeoff of typing them, for the better errors and cognitive offloading they provide.2 The notion of some sort of overt function encapsulation is extraordinarily useful for the working programmer.

With resumable exceptions, all this modularity and abstraction of understanding goes out the window. In practice, the user needs to read the function source to understand what’s going on: to understand how to resume an exception. This is horrible! And this, I think, is a serious unaddressed problem with modern effect handlers: there is little focus on abstraction. But abstraction is necessary. Resumption of any sort suffers this problem. It is inherent to decoupling implementation from use.

solving for abstraction

Now – I think effect handlers are an underexplored area of research. There is a lot I would like to see in and around this design space, but I kinda doubt we’ll see serious work on it until they escape the ivory tower. Some things I’ve been thinking about are:

It is this last point which I think is the most interesting (and straightforwardly doable). Many programmers already have notions of exceptions, I/O, async/await, threading, etc in their head. Why not build on top of this? Why not, if the goal is to make a generally usable language, force encapsulation of effects by only permitting the use of overt effect primitives in libraries or other similar module structures?

We arrive back at the issue we left on the table earlier – side effects. The typical programmer already breaks function encapsulation, all the time. They just do so in ways they are familiar with. As programming language designers, we should aim to support and extend the ways in which programmers use languages today, not force the user into an idealized / “pure” programming paradigm: especially if said paradigm comes at a cost of usability.3

(define-module Exception
  (define (raise exn)
    (suspend 'Exn exn))

  (define (handle thunk handler)
    (try (thunk)
      [(catch 'Exn exn) => (handler exn)])))

(define-module Iteration
  (define (yield val)
    (suspend 'Iter val))

  (define (iter lst)
    (unless (empty? lst)
      (yield (first lst))
      (iter (rest lst))))

  (define (for-each thunk proc)
    (try (thunk)
      [(catch 'Iter val) => 
        (begin
          (proc val)
          (resume))])))

I think languages with effect systems should embrace existing paradigms.

I think effect handlers should not be a user-facing construct, but rather designed for use by library authors, to be wrapped in functions encapsulating / describing their behaviour.

I think such a language should do its best to make the control flow operations modeled in such a way feel like part of a language, whether that be by having functions syntactically usable like keywords, or by eschewing keywords entirely, or by something else altogether.

I think having a language that looks and behaves like an imperative language, yet that tracks and unifies side effects and non-local control flow through a system of effect handlers while providing encapsulated and familiar APIs, would be a wonderful thing.


If you’re an effects aficionado and want to chat, send me an email over at jj@toki.la – working on finishing up my undergraduate right now, but am thinking about applying for graduate school in programming languages in a couple of years, and am planning on pursuing some of this stuff on the side in the mean time. Always down to talk about effect handlers or PL design broadly!


  1. I think combining effect handlers with linearity tracking could solve a number of these problems. Multiple resumptions / multi-shot continuations seem much more trouble than they are worth: both cognitively and with respect to performance. All existing languages that recognize this (and so don’t support multi-shot continuations) enforce their linear usage dynamically (which seems a bit unfortunate). Also – both continuations and linear types are cool, therefore, linearly typed continuations must be SUPER cool.↩︎

  2. See modern Haskell, and Gleam. Both do not / did not require function annotations at first: both now conventionally rely upon them. The same can be said about many languages with types systems in the Damas-Hindley-Milner tradition.↩︎

  3. I’m more than a little influenced by my views as a linguist here. There is much to be said about the difficulty work in programming language theory has had in escaping the ivory tower… but I think an unfortunate focus on purity is part of it.↩︎