SLC: conversions for boundary/break

I’m thinking it can be useful to be able to adapt Label style apis to other error handling techniques, e.g.

inline def asEither[T, E](inline op: Label[E] ?=> T): Either[E, T] =
  boundary[Either[E, T]]: lblOk ?=>
    val earlyExited: E = boundary[E]: lblErr ?=>
      val ok: T = op(using lblErr)
      break(Right(ok))(using lblOk)
    Left(earlyExited)

for example, you can unit test the success path of a method (that requires a Label) without worrying about setting up the handler for it.

If you only use a single boundary then type inference would fix the result to T | E which is not always possible to distinguish which side is which, you really need two boundaries to deterministically handle each case

2 Likes

The asEither seems useful to me.


For those that, like me, need 5 minutes to parse that code because of the colon excess:

inline def asEither[T, E](inline op: Label[E] ?=> T): Either[E, T] =
  boundary[Either[E, T]] { lblOk ?=>
    val earlyExited: E = boundary[E] { lblErr ?=>
      val ok: T = op(using lblErr)
      break(Right(ok))(using lblOk)
    }
    Left(earlyExited)
  }

This reminds me of the post about how all the documentation being in this syntax becomes an impediment for many of us. I don’t mean to be snarky with my comment here, I actually had a lot of trouble reading the code.

3 Likes

here’s a more explicit version to explain how it works for those less familiar

inline def asEither[T, E](inline op: Label[E] ?=> T): Either[E, T] = {
  // outer boundary to allow `ok` case to jump out of the error boundary
  boundary[Either[E, T]] { (lblOk: Label[Right[Nothing, T]]) ?=>

    // inner boundary that introduces the `Label[E]`
    val earlyExited: E = boundary[E] { (lblErr: Label[E]) ?=>

      // either `op` finishes normally, returning `T`, or jumps
      // using `labelErr` to be the result of `earlyExited`
      val ok: T = op(using lblErr)

      // now we need to return the `ok` case from the method,
      // by jumping to the outer label.
      break(Right(ok))(using lblOk)
    }

    // ordinary block result with the `E` case
    Left(earlyExited)
  }
}
1 Like

I’ve experimented with this specific form, and outside of error handling I didn’t really find it to be preferable to the two-sided form:

extension (objectEither: Either.type)
  inline def Ret[L, R](inline r: Label[Either[L, R]] ?=> R): Either[L, R] =
    boundary{ Right[L, R](r) }

extension [L, R](either: Either[L, R])
  inline def ?[E >: Left[L, Nothing]](using Label[E]): R = either match
    case Right(r) => r
    case l: Left[L, R] => boundary.break(l.asInstanceOf[E])

The magic is in the .?. That is where most of the advantage is.

Then the question is: what do you do more, create a new Left value (in which case the asEither form slightly simplifies things), or pass on existing Left values (in which case the Either.Ret form avoids boxing/unboxing). For me the answer is: more the latter.

Furthermore, .? on a bare value that could be wrapped Left is dangerous, because it confuses unwrapping (which is what .? is brilliant at) and nonlocal control flow. So in practice, you really want to write something more like break(e). But Left(e).? is barely any longer, and for me anyway is visually way more distinct.

And then sometimes it is handy to want to return early with success, and it’s annoying to have to use a separate construct for it.

So, personal experience for me resolved to: something like asEither, but Either.Ret flavor instead. (I use the double boundary trick for some other control flow constructs I have, but .? is by far the one that saves me the most effort.)

One word of caution, however. Try murders constructs like these. You have to remember to keep the failure case OUT of the Try block. If you use it to, for instance, guard against IO failures, you have to make sure you manually propagate the error case, because at the very least, you won’t get the error you thought you were going to, and depending on what the Try was intended to catch, you might swallow errors entirely.

So instead, I distinguish between what should be caught at thread boundaries and not, and

extension (t: Throwable)
  def catchable: Boolean = t match
    case _: VirtualMachineError |  _: InterruptedException | _: LinkageError |
         _: ControlThrowable    | _: Break[?] => false
    case _ => true

inline def safe[X](inline x: => X): X Or Throwable =
  try Is(x)
  catch case e if e.catchable => Alt(e)

(I have an success-unboxed either called Or, where the thing either Is(x) because it’s unboxed, so it really is just x, or it’s Alt(y), the alternate type, which is boxed (necessarily). One can do the same thing with Either if one wants.)

Basically, Try is pernicious for code that uses control flow like this. (It is helpful, I guess, for threaded code.)

Edit: Ox does something similar for Either. I think it’s isomorphic to my flavor rather than asEither, but I forget.

2 Likes