I do appreciate that argument. However, to be clear, we're not talking now about omitting it, simply making it possible to change its granularity so it applies to a block.
If we're gonna worry about this nuanced decision, I think we have to acknowledge that there's already an even more nuanced decision about granularity—where in the expression to place it—that nobody second-guesses. That's because it very seldom matters for exactly the same reasons that precisely locating the throwing statement seldom matters. Ultimately, some form of try block lets us replace many little nuanced decisions with one big one. The reader can quickly think, “is there any chance we're breaking an invariant here? No? then this is OK.
So I guess I would want to understand how you would provide clarity on where the bright line is between “must have try” and “can omit it”. How would you explain it to a novice coder such that they have minimal cognitive load making the decision?
In this post I gave some ideas about how tooling can help, but you're asking about guidelines, which is really a much more important question. So thanks for that ![]()
For the novice coder, I'd explain it this way:
-
tryhelps us identify the places where control flow jumps out of a function, just likereturn. If you're unsure, just use it wherever the compiler tells you to, as close in the throwing expression to the actual propagation point as possible. But… -
Just making the compiler happy isn't enough to make your code correct. The whole point of writing
tryis to give you an opportunity to think about the consequences of that early return and make sure anything that needs to be cleaned up is taken care of (usually withdefer). You can't leave this step out! -
If you use Swift errors enough, pretty soon you'll start to recognize patterns where you have to write several
trys in a function, but there's nothing to clean up and there never will be.- The simplest examples are functions that compute new values and don't have side-effects (show example).
- Also, functions like the
encodeandinit(from:)methods ofCodabletypes, whose effects are on a coder that is expected to be incomplete in case of error (the partial initialization of an instance is not an effect, since the compiler takes care of cleanup for you). (show example)
In general, any function that doesn't need to take special clean-up actions in case of an early exit (including translation of errors thrown by its callees), has correct error handling regardless of exactly where the error is thrown. That's where you might consider using a
try/try doblock at the top level of your function. That will make your code more readable and signal to a reader that there's a quick way to validate that you've thought your error-handling through correctly: simply confirm that there are no clean-ups. (link to article about what kind of clean-ups make sense and which don't). -
Another guideline: avoid putting
catchordeferstatements insidetry/try doblocks [if we don't have the compiler ban that outright, which I think we should]. If you find yourself wanting to do that, replace the block with expression-leveltrymarkers, and think about the consequences of early exit before and after thecatch/deferto make sure you've got it right.
Similarly, if we accepted the idea that there was some subset of the current places marked with await where it was very important to so mark it and other places where it was not, how would you describe that line to a novice coder - how would minimize the cognitive load for the author when deciding to await or not?
I wouldn't presume to give guidelines about await yet, because a) I'm not at all sure there exist any awaits that are low-value, and b) more importantly, I don't understand what it means in the programming model yet. A big part of the point of challenging its value in this thread is to identify that meaning if it exists.