Why Effects?

In my last post I described what an effect notation might look like using with-clauses and blocks. When discussing effects over dinner with lcnr a couple of days ago they asked a really good question that I’ll paraphrase here: “Why are effects the right answer? What do they provide that extensions to the trait system cannot?”

The answer to ”Why effects?” is not one of capabilities. It is true that a sufficiently powerful trait system could probably encode everything an effect system could. The answer however is one of accessibility: something being technically possible isn’t worth much if the amount of effort needed to achieve it puts it out of reach for a lot of people.

I think of “effects” as actually being three distinct features in a trench coat, which by being exposed via a unified interface create a pleasant and accessible experience. Those features are:

  • Implicit arguments: also known as “capabilities”, “dependency injection”, and “contexts”. This allows us to write signatures which encode: “if X is in scope”. For example: impl Meow with Tuna for Cat says that the trait Meow is now only implemented for Cat if there is a type Tuna in scope. See tmandry’s contexts and capabilities for more on this.
  • Typed coroutines: sometimes also colloquially referred to as “control-flow effects”, these are things like try, yield, throw 1, async/.await, and so on. These are “inverted functions” which when called don’t step into a callee, but instead they step out to the caller. This requires special functions which the compiler can convert into state machines, and effects (algebraic effect handlers) means that if you’re working with a bunch of these in the same code you don’t end up drowning in boilerplate 1.
  • Language primitives: there are things in the language which don’t reduce down to anything else and need to be encoded somehow. On the trait side things like Sized are used to describe pure language built-ins. And in the space of effects that would include things like panicking (perhaps questionable) and non-termination (less questionable). “This function guarantees termination” is the kind of statement that is only meaningful if the compiler can back that up with a termination checker. It’s not something that can be desugared to a coroutine.
1

Error handling in Rust (Try) does not rely on the coroutine transform. That’s partly for historic reasons and partly as an optimization. Errors don’t resume the way yielding does, so a coroutine transform is not necessary. Though error handling is one of the classic examples which effect handlers are used for, so it’s absolutely possible.

1

Coroutine A yields Foo, Coroutine B yields Bar, Coroutine C calls A and B and yields the sum of A and B. Now add two more variants and three more layers. That’s a lot of typing just to satisfy the compiler.

What I like about effects is that it has found a way to very tidily package all of these features up into a coherent system, backed by 40 years of research and literature. That to me feels reassuring because none of these features are small; and yet the amount of added language surface as a whole does not need to feel big. Less than it would be than if we considered implicits, every language primitive, and every coroutine flavor as its own special extension.

The only marked downside of effects in literature is that it’s often described as an entirely separate system from the type system, resulting in a “type and effect system”. That’s two systems instead of one. For Rust we probably wouldn’t want to add an additional resolver, and instead implement effect resolution using the trait resolver. I’m guessing there’s probably a paper in there for someone to write.