[Pitch] `defer` statement that runs only on error

Also, since you can have multiple defer catch blocks enqueued, if you don't rethrow the error, wouldn't that prevent any previously-enqueued defer catches from firing?

defer catch { print("cleanup 1") } // never gets called
...
defer catch { print("cleanup 2") } // doesn't rethrow

Or would they all fire regardless, even though cleanup 2 caught the error and the function call is no longer throwing an error?

I don't think this is workable. Better to go back to the original idea and not have it catch anything (meaning returning to your original point about defer catch not being the correct spelling).

Two ideas:

if throws defer { ... }
// or
defer if throws { ... }
3 Likes

I’m envisioning this as equivalent to do { } catch { } without the nesting (but, of course, with the catch block written up top, as with vanilla defer):

try foo()
defer catch { /* A */ }
try bar()
defer catch { /* B */ }

// equivalent to:
do {
  try foo()
  do {
    try bar()
  } catch {
    // B
  }
} catch {
  // A
}

As far as I can tell, there’s no use of an “actually catching” defer catch which wouldn’t work with such a mechanical transform—what am I missing?

It's an editorial choice. It could be made to work, obviously. But it'd result in something unergonomic and prone to error, in my opinion.

IMO, the statement is not so "intuitive", and adds some mental burden when reading the code.

For example:

When reading this, though it looks like more streamlined, you need to keep remembering the defer catches. Whenever you reach a try statement you must "fetch" all the defer catches you encontered and know that all the defer catches may execute -- and in reverse order.

In contrary, the following piece is more clear and easy to reason about: you can easily know what it does for the state transition and what it does when something goes wrong -- two simple, clear, separated paths.

func transitionStates() throws {
  do {
    stateA = try computeSix()
    stateB = try computeSeven()
    stateC = try computeNine()
    try finishTransition()
  } catch {
    (stateA, stateB, stateC) = (0, 0, 0)
    throw error
  }
}
3 Likes

Is this significantly more burdensome than a normal defer? I suppose it is far more common to have several try statements in a single scope than several return statements…

No, however I do not usually have more than one defer in a single scope.

Maybe It's just about that example. I do think this is better:

func transitionStates() throws {
  defer catch {
    (stateA, stateB, stateC) = (0, 0, 0)
  }

  stateA = try computeSix()
  stateB = try computeSeven()
  stateC = try computeNine()
  try finishTransition()
}

But I'm still try to understand how much this improves upon:

func transitionStates() throws {
  do {
    stateA = try computeSix()
    stateB = try computeSeven()
    stateC = try computeNine()
    try finishTransition()
  } catch {
    (stateA, stateB, stateC) = (0, 0, 0)
    throw error
  }
}

This is the problem with toy examples. Of course your approach is better and more easy to understand than the one with multiple defers, but that is only because you are working with three integers.
However, what do you do, when it is very expensive to reset one of the state variables? Then you only want to reset the variables that have been already set, hence you need either a do-catch pyramid of doom – or the pitched alternative, which in this case is clearly superior.

For expensive rollback actions we can also avoid the do-catch pyramid:

func transitionStates() throws {
  var rollbackActions = [() -> Void]()
  do {
    stateA = try computeSix()
    rollbackActions.append { rollbackComputeSix() }
   
    stateB = try computeSeven()
    rollbackActions.append { rollbackComputeSeven() }
   
    stateC = try computeNine()
    rollbackActions.append { rollbackComputeNine() }
    
    try finishTransition()
  } catch {
    rollbackActions.reversed().forEach({ $0() })
    throw error
  }
}

This is also simple, straightforward and easy to understand.

If the rollback actions need the error (for example, the rollback action may not need to delete temporary files when the error is "permission denied"), you can also pass that error to the rollback block.

var rollbackActions = [(Error) -> Void]()
do {
  ...
} catch {
  rollbackActions.reversed().forEach({ $0(error) })
  throw error
}

You can also decide whether to "catch" / "rethrow" that error.

The downside of the "rollbackActions" is that you need to allocate an temporary array for it.


From the proposal review aspect:

Is the problem being addressed significant enough to warrant a change to Swift?

No.

a. Multi-step state transition and rollback is not a daily thing we need to handle. And also

b. When we do need to handle these rare situations. Existing do-catch can handle that quite well, with "do-catch-rethrow" or the "rollbackActions" example above.


EDIT

For performance critical situations defer-catch can (in theory) avoid the dynamic allocate. Maybe it's a nice tool to have. (Do we really write "do-try-catch-rollback" things in performance critical sections)

Even normal defer is already a problematic construct. It changes the simple flow of control, so imho it is usually better to avoid it and use techniques like RAII.
I don't think it is justified to add a special case defer:
Ask yourself, did you ever wanted to have such a tool before this pitch?
The problem is not very common, and the existing solutions do not add potential for confusion.

1 Like

I don't have a good way to measure a percentage of existing Swift code, but I did take a look at the codebase for a language I worked on prior to Swift that had this feature. Clay is an unsafe language with pervasive exceptions and rule-of-five value semantics like C++, but with a more minimal feature set. Being a non-mainstream language, there isn't a huge body of code to explore, but its onerror construct shows up in two interesting places in its standard library:

  • In the HashMap implementation, onerror is used to clean up a field in a partially-initialized data structure, if an error occurs before the data structure is fully initialized. In Swift, regular initializers do dataflow analysis and precisely clean up partial initializations for you automatically, but if you're writing unsafe code to initialize a data structure in raw memory, and you need to undo your partial work if an error occurs before initialization is complete, then defer-on-error could help.
  • Its default copy-assignment operator implementation for non-trivial types uses onerror to perform a safe reassignment sequence, moving the existing value in the destination to a temporary location, and using onerror to move the original value back if the copy of the source fails. Now again, Swift's assignment behavior for ARC references does the safety dance for you, and implicit copy operations are generally assumed not to fail, though with move-only types and C++ interop on the horizon, there will be cases where Swift code may need to descend to more manual rule-of-five manipulation as well.

It's a good sign that Swift's default behavior addresses at least two "in the wild" use cases. Maybe this is more of a low-level feature, though it seems very useful for those low-level cases.

3 Likes

I searched some popular D packages on Github for scope(failure) which is D's spelling for the same feature. My conclusion after looking in 10 packages or so that it's not used very often.

scope(failure) seems to be used for cleanup with non-RAII aware APIs or when handling files. There's also many cases where a unit test use it to print something when an exception is thrown. And there's a couple of scope(failure) { assert(false) } as a way to check at runtime that no exceptions are thrown.

But here's one example I find particularly interesting:

if(updateCount) {
	db.startTransaction();
	scope(success) db.query("COMMIT");
	scope(failure) db.query("ROLLBACK");
	...
}

Here, if the block exits with an exception it'll perform a rollback, otherwise a commit. And this is safe regardless of how the block exits, be it an early return, break, continue, or something is thrown. Note that if you were doing this with do-catch it'd be easy to do a single catch-and-rethrow to handle the rollback, but you'd still have to handle each non-throwing early exit separately. So the most needed feature in this particular case is scope(success), and scope(failure) is just riding along.


Github search results of interest (raw data)

https://github.com/vibe-d/vibe.d/search?q=scope(failure)
https://github.com/adamdruppe/arsd/search?q=scope(failure)
https://github.com/dlang/dub/search?q=scope(failure)
https://github.com/dlang/phobos/search?q=scope(failure)

Note, the last one is the standard library.

2 Likes

I believe such syntax would solve the existing problem of verbose error-handling. Perhaps we could add a do before try to clarify that is just as shorthand of do {} catch {}. If we want more sugar, we can introduce another generally useful feature: rethrow, which would be equivalent to the (slightly) more verbose throw error.

Interesting, thanks for looking into this! IIRC, D has strictly scoped variable lifetimes like C++, which makes RAII a more useful idiom. I suspect that makes even scope(success) relatively less interesting in their ecosystem.

2 Likes