A simple condition system for Java, without dynamic variables or reflection wizardry.
Exception systems divide responsibilities in two parts: signalling the exception (like throw
), and handling it (like try/catch
), unwinding the call stack until a handler is found. The problem is, by the time the error reaches the right handler, the context that signalled the exception is mostly gone. This limits the recovery options available.
A condition system, like the one in Common Lisp, provides a more general solution by splitting responsibilities in three parts: signalling the condition, handling it, and restarting execution. The call stack is unwound only if that was the handling strategy chosen; it doesn't have to be. This enables novel recovery strategies and protocols, and can be used for things other than error handling.
Beyond Exception Handling: Conditions and Restarts, chapter 19 of Peter Seibel's Practical Common Lisp, informs much of the descriptions (as one can plainly see; I hope he doesn't mind 😁), terminology and tests.
Although Common Lisp and at least some Clojure libraries use dynamic variables, Java has nothing of the sort. But it occurred to me one day that Java's try
-with-resources would be enough for a simple condition/restart system. So I gave it a shot 🤷
try
-with-resources for the win: Scope
is a resource which nests and closes scopes as execution enters and leaves try
clauses, and provides a place to hang the signalling, handling and restarting machinery. In practice, the end result looks something like this:
public void analyzeLog(String filename) throws Exception {
try (Scope scope = Scopes.create()) {
// establish a handler, which here picked a restart to use
scope.handle(MalformedLogEntry.class, (signal, ops) -> ops.restart(new RetryWith("...")));
// load file content and parse it
InputStream in = // ...
List<Entry> entries = parseLogFile(in);
// ...
}
}
public List<Entry> parseLogFile(InputStream in) throws Exception {
try (BufferedReader br = new BufferedReader(new InputStreamReader(in));
Scope scope = Scopes.create()) {
List<String> lines = // ...
List<Entry> entries = new ArrayList<>();
// create a restart, for skipping entries
final Restart SKIP_ENTRY = Restarts.on(SkipEntry.class, r -> SKIP_ENTRY_MARKER);
// parse each line, and create an entry
for (String line : lines) {
// establishing a new restart
Entry entry = scope.call(() -> parseLogEntry(line), SKIP_ENTRY);
// this is how the skipping is done
if (!SKIP_ENTRY_MARKER.equals(entry)) {
entries.add(entry);
}
}
// ...
}
}
public Entry parseLogEntry(String text) throws Exception {
try (Scope scope = Scopes.create()) {
if (isWellFormed(text)) {
return new Entry(text);
}
// signal a condition, and establish a restart
return scope.raise(
new MalformedLogEntry(text),
Entry.class,
Restarts.on(RetryWith.class, r -> parseLogEntry(r.getText())));
}
}
Basically, Maven (or Gradle; anything compatible with Maven repos, really) and GitHub Packages for the actual repo.
- There is no attempt whatsoever to make this thread-safe; to be honest, I'm not even sure what that'd look like.
- As far as I can tell, a full condition system would need continuations, or some form of nonlocal transfer of control without stack unwinding. Which in Java is... complicated. So, I'll punt on fullness for the moment. Let's see how far I get...