Bidirectional Effects

Effekt implements a feature called “bidirectional effects” or “bidirectional control-flow”. The idea of bidirectional effects has been introduced by Zhang et al. in their OOPSLA’20 paper:

“Handling bidirectional control flow”. Yizhou Zhang, Guido Salvaneschi, and Andrew C. Myers. 2020. Proc. ACM Program. Lang. 4, OOPSLA, Article 139 (November 2020).

Effects in Both Directions

Let us assume the following standard definition of an exception effect:

effect exc(msg: String): Nothing

The new feature of bidirectional effects is that (effect) operations can now be declared to use additional effects. For example, here is the definition of a Config interface that features a potentially failing port operation:

interface Config {
  def port(): Int / { exc }
}

Calling a bidirectional effect introduces both the effect itself, as well as its exposed implementation effects at the callsite.

def user1(): Unit / { Config, exc } =
  println(do port())

The handler for Config can now trigger the exception effect in the scope of the call to port:

def noConfig { prog: => Unit / Config }: Unit / {} = try {
  prog()
} with Config {
  def port() = resume { do exc("No port configured.") }
}

Note, how the return type of the handler noConfig mentions the empty effect set, even though it uses the exc effect operation.

This is only sound, since the exc effect operation is handled at the call-site of port:

def user2(): Unit / { Config } = try {
  println(do port())
} with exc { msg => println(msg) }

We can run the example to observe the result:

noConfig { user2() }

Difference to “Normal” Handling

What would have happened if we would have defined Config as follows?

interface Config {
  def port(): Int / {}
}
effect exc(msg: String): Nothing

First of all, the typing of user1 would have changed to not mention the exc effect operation, anymore.

def user1(): Unit / { Config } =
  println(do port())

But can we implement noConfig, now that exceptions are not allowed by the interface anymore?

Turns out we can. However, the exception is now raised at the handling site (that is implementation site of Config), not at the callsite to port:

//                        Now the handler of Config requires exception handling
//                                                vvv
def noConfig { prog: => Unit / Config }: Unit / { exc } = try {
  prog()
} with Config {
  def port() = do exc("No port configured.")
}

At the handling site, we now also need to deal with the exception, while the user of port is agnostic of the potential exception:

try {
  noConfig { user1() }
} with exc { msg => println("Used port, but no config available.") }