Previously I was on the other side regarding the explicit try annotations, indeed for the extra sense of security. But indeed, there seems to be not so much we can do about restoring invariants other than discarding mutated state.
There is one place which I found was not covered completely by your model, which is when state is external to the program, modified via side-effects. We cannot use a trick like dropping the mutated copy of a (possibly persistent) data structure because the “mutation” of the state happens inherently in-place (we cannot copy the whole world, at least for now).
An example is creating a set of files on disk (shader graph file + shader pipeline configuration file). The two files should either be both created, or if one fails to get created, the other one should be cleaned up. We could try surrounding everything with do/catch blocks, but as the number of possible failure points increases, we have to undo more and more stuff in reverse order after each operation has failed:
/// Creates 3 files or none. Returns true iff success.
func create3(){
do { try createA() } catch {
return false
}
do { try createB() } catch {
try? deleteA()
}
do { try createC() } catch {
try? deleteB()
try? deleteA()
}
}
We can notice a pattern here, and I don't think it is productive to use do/catch in functions with more than 1-2 possible possible failure points when the state is manipulated only through side-effects.
I wrote a composable transactional undo/redo library that covers this problem well (see full usage example and library implementation).
public struct SelectAndBringToFront: CompositeActor {
public static func compositeExecute(
instruction shapeID: ShapeID?, on model: inout Document,
executor: inout CommandExecutor<Document>
) throws {
// Arbitrary control flow is allowed...
if let shapeID = shapeID {
// First bring to front, then select
_ = try executor.execute(
command: BringToFrontCommand.self, instruction: shapeID, on: &model)
_ = try executor.execute(
command: SelectShapeCommand.self, instruction: [shapeID], on: &model)
} else {
// Just deselect if instruction is nil
_ = try executor.execute(
command: SelectShapeCommand.self, instruction: [], on: &model)
}
}
}
In this model, every possibly failing operation needs to be a command. Once a command is successfully executed, a Command object is created and added to a list inside executor. In case some command fails its execution by throwing an exception, the composite command's execute function catches that exception and undoes all previous successful operations in the list backwards. Then it rethrows the error so parent commands can also unwind, or the top-level executor can handle the error explicitly (e.g. logging).
Note that you can create a new top-level executor in any scope, so this technique can be applied anywhere locally, given that you implement your most pritive failing operations as an undoable command.
Maybe a language feature or some custom operators could make this even more usable.
Omitting the try keyword could work if all possibly failing side-effecting operations are only implemented in terms of undoable commands. Otherwise it's indeed too easy to forget about not handling the error. I would suggest that if you don't know how to undo a side-effecting operation, return a Result type, otherwise implement an undoable command.
What do you think, are there any use cases / error handling patterns that wouldn't fit this model?