go: proposal: Go 2: local-only throw-catch error handling
This is a response to the 2018 request for error handling proposals.
My objective is to make the primary/expected code path clear at a glance, so that we can see what a function normally does without having to mentally filter out the exceptional processing. This is also the only thing I’m uncomfortable with about Go, as I’m absolutely in love with Go’s implementation of OOP. However, an extension of this solution supports chaining, so we could also address the issue of redundant error handling code.
Under this proposal, we would implement a local-only throw-catch feature that does not unwind the stack. The feature has no knowledge of the error type, relying instead on ‘!’ to indicate which variable to test for nil. When that variable is not nil, the local-only “exception” named for the assignment is thrown. catch statements at the end of the function handle the exceptions. The body of each catch statement is a closure scoped to the assignment that threw the associated exception, so that the variables declared at the point of the assignment are available to the exception handler.
Here is how the example from the original RFP would look. Mind you, the code would fail to compile if a thrown exception is not caught within the same function or if a caught exception is not thrown within the same function.
func CopyFile(src, dst string) error {
r, !err := os.Open(src) throw failedPrep
defer r.Close()
w, !err := os.Create(dst) throw failedPrep
_, !err := io.Copy(w, r) throw failedCopy
!err := w.Close() throw failedClose
catch failedPrep {
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
catch failedCopy {
w.Close()
os.Remove(dst)
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
catch failedClose {
os.Remove(dst)
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
}
For reference, here is the original code from the RFP:
func CopyFile(src, dst string) error {
r, err := os.Open(src)
if err != nil {
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
defer r.Close()
w, err := os.Create(dst)
if err != nil {
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
if _, err := io.Copy(w, r); err != nil {
w.Close()
os.Remove(dst)
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
if err := w.Close(); err != nil {
os.Remove(dst)
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
}
If we wanted to support chaining in order to reduce redundant error handling, we might allow something like this:
func CopyFile(src, dst string) error {
r, !err := os.Open(src) throw failure
defer r.Close()
w, !err := os.Create(dst) throw failure
_, !err := io.Copy(w, r) throw closeDst
!err := w.Close() throw removeDst
catch closeDst {
w.Close()
throw removeDst
}
catch removeDst {
os.Remove(dst)
throw failure
}
catch failure {
return fmt.Errorf("copy %s %s: %v", src, dst, err)
}
}
If we did allow chaining, then to keep the error handling code clear, we could require that a throw within a catch must precede the subsequent catch, so that execution only cascades downward through the listing. We might also restrict standalone throw statements to the bodies of catch statements. I’m thinking that the presence of the ‘!’ in the assignment statement should be sufficient to distinguish trailing throw clauses from standalone throw statements, should an assignment line be long and incline us to indent its throw clause on the next line.
We can think of the ‘!’ as an assertion that we might read as “not” or “no”, consistent with its usage in boolean expressions, asserting that the code only proceeds if a value is not provided (nil is suggestive of not having a value). This allows us to read the following statement as “w and not error equals…” or “w and no error equals…”:
w, !err := os.Create(dst) throw failedCreateDst
A nice side-benefit of this approach is that, once one understands what ‘!’ is asserting, it’s obvious to anyone who has ever used exceptions how this code works.
Of course, the solution isn’t wedded to ‘!’ nor to the keywords throw or catch, in case others can think of something better. We might also decide to call what is thrown and caught something other than an “exception”.
UPDATE: I replaced the switch-like case syntax with separate catch statements and showed the possible addition of a chaining feature.
UPDATE: I didn’t see it at the time I wrote this, but the proposal #27519 has some similarities.
About this issue
- Original URL
- State: closed
- Created 3 years ago
- Reactions: 32
- Comments: 21 (7 by maintainers)
To me the examples in the proposal are overwhelmingly more confusing than the traditional alternative.
Playing around with some demo client code I wrote, I think I prefer the following:
Here’s some code illustrating how we can use exception names to clearly name the errors inline while still moving lengthy, distracting error descriptions to the end of the function. It should suffice for the compile to just do macro-expansion.
Or perhaps we disagree as to what self-documenting code looks like.
My suggestion.
catchruns first thencatcheris called:Could even pass arguments to the catcher:
I removed
!errcause I dont like it and it could be suppressed. But if you’d rather put it back, ok.Edit:
Another option:
Or yet:
For the record, it wouldn’t be a “goto” due to closure requirements. As explained above, this would best be implemented as macro expansion.
In any case, I find several aspects of Go at odds with writing self-documenting code:
My experience with this thread suggests that the community in general does not value self-documenting code.
I don’t know that I’ve seen a strong preference for keeping error handling with the main code path as such. I’ve seen a strong preference for simplicity and clarity.
With this proposal, I’m inclined toward @carlmjohnson 's comment at https://github.com/golang/go/issues/48896#issuecomment-940573338. You’ve written
trapandcatchbut the construct is not a trap or catch as used in most languages, because it does not work across functions. It’s agotolabel. So why not usegoto?Back when the discussions for error handling were lively, there was a proposal to improve goto, so it could handle cases like this. (At present, goto won’t let you goto a section of code where new variables have been declared.) This is very similar to those proposals. The effect ends up looking like Ruby, in which methods end with a rescue block.
Having read through all those debates, the conclusion I came to is that there’s not really any way to improve Go’s error handling besides just adding
if err?as a shortcut forif err == nilandreturn ..., erras a placeholder for whatever the other return values are. Any other improvement either ends up being two ways to write the same thing or makes it easier to write code without wrapping errors, neither of which is desirable. In this case, the error handling is the same, but shunted off to the end, which I guess is okay, but it’s not clear why having it all at the end is that much better than having it all inline.Heck, the catch (or trap) code blocks could actually be macro-expanded where exceptions are thrown (or traps are triggered), eliminating the need to treat them as closures.