November 4, 2024 - Tagged as: en, plt.
The main use case of resumable exceptions would be collecting a bunch of errors (instead of bailing out after the first one) to log or show to the user, or actually recovering and continuing from the point of error detection, rather than in a call site.
Why not design the code to allow error recovery, instead of using a language feature? There are a few problems with this:
Without a language feature to do this, libraries will have to implement their own ways to recover from errors, causing inconsistencies and fragmented ecosystem of error handling libraries.
With resumable exceptions, any code can be trivially made to transfer control to a exception handler, and back. Manually refactoring code to do the same can be a big task. This may even be infeasible.
With resumable exceptions as a part of the language, libraries will be designed with resumption in mind. Libraries that would normally not allow error recovery will allow error recovery, as it will be easy to do, and it will be a common thing to resume from errors.
Modern programming languages have complex syntax. Parsers for these languages are often thousands of lines of code.
Ideally, all tooling for a language would share the parser, as it’s a significant amount of work to implement, debug, maintain parsers for such large languages.
However not all of these tools will have the same error handling behavior. A compiler cannot continue in the presence of a parse error, but a language server has to continue.
With resumable exceptions, the compiler can abort after a parse error, the language server can provide placeholder AST nodes for failing parse operations and resume. This flexibility does not make the parser API any more complicated than a parser that throws an exception in any other language. A one-off refactoring script that uses the parser library doesn’t have to deal with error recovery just because the language server, which uses the same parser, needs to recover from parse errors and continue parsing.
With resumable exceptions throw
expressions generate a value. The value depends on the exception type thrown. For example, a FooDecodingException
can be resumed with a value of Foo
provided by the handler.
This can be implemented with an abstract base class or typeclass/trait with a type parameter:
// in a system with classes:
abstract class ResumableException<Resume> {
prim Resume throw();
prim Never resume(Resume resumptionValue);
}
// or in a system with typeclasses/traits:
trait ResumableException<Resume> {
prim fn throw(self) -> Resume;
prim fn resume(resumptionValue: Resume) -> !;
}
Here the prim
keyword indicates that the throw
and resume
methods are provided by the compiler.
throw e
can then be type checked as e.throw()
, and resume e with value
can be type checked as e.resume(value)
. Or we can use the function call syntax instead of special syntax for throwing and resuming.
Whether to make exceptions thrown by a function a part of its type signature or not is an orthogonal concern.
The same considerations when designing non-resumable exceptions apply to resumable exceptions:
For example, it doesn’t make sense to resume from an ArgumentError
, so we don’t implement ResumableException
for it.
To be able to meaningfully resume from an exception, the exception type should document when exactly it is thrown, or have a resumption value type that is specific enough to give an idea on when it is thrown.
For example, an exception that can be resumed with an int
cannot be resumed without knowing what that int
is going to be used for, so this should be documented. But an exception FooDecodingError implements ResumableException<Foo>
makes it clear that it’s thrown when there’s an error during decoding a Foo
, and the resumption value is the value to be used as the Foo
being decoded.