Back when we were introducing error handling for Swift, I raised the concern that the rule requiring a try
label on every potentially-throwing statement was too indiscriminate. It's not that requiring a try
label is always a mistake, I argued—in fact, in some cases being forced to acknowledge a possible throw could be extremely helpful in reasoning about code—it's just that there are so many cases where it wasn't helpful, that:
- It would make code needlessly complex and hard to read.
-
try
would have little value in the places where it really mattered, because people would get used to silencing the compiler by thoughtlessly adding it in all the cases where it didn't matter. - That would cause people to make mistakes that, because error-handling paths are seldom tested, lay undiscovered for years and fail in the field.
- Perhaps worst, it would teach users to think about the correctness of error handling code by focusing primarily on control flow rather than invariants (I'll explain more below), which makes reasoning about it much harder, and leads easily to a overabundance of caution that acts to prop up the supposed need for boilerplate. The whole reason I got into language development was to empower programmers, and this feature, I said, would do the opposite.
IMO many of my concerns have been borne out, particularly the last one. There may be no better illustration than this pitch where it was proposed to omit try
because it “is likely to just cause noise, and annoy users in the majority of use cases where this does not matter.” The proposers recognized the problem of pervasive try
, but in fact the cases described were among those where it is most likely to matter. Failure to recognize that is a symptom of the disempowerment I described above. It would be almost impossible for anyone to make this mistake if they were focused on invariants rather than on control flow when thinking about error handling correctness.
Because it seems we're about to introduce another keyword with similar broad application requirements, IMO it's important to discuss the problem now, before we end up with code full of try await
s that don't actually serve the programmer.
When does requiring try
help?
It's worth asking why we ever want error propagation sources to be marked. The original rationale document is thin on details. Obviously, the rest of the function will be skipped in the case of a throw, but why should we care? I claim that there is only one reason to care: the rest of the function may be responsible for restoring a temporarily-broken invariant.
This may seem like an overly-broad claim, but in my experience it is true, and every time I think I've found an exception to this rule, it has turned out that there was an invariant I'd failed to recognize. While I welcome counterexamples, they probably don't affect my argument: even if a few elusive corner cases exist, what matters here is not whether eliminating try
marks can be proven to be harmless in all cases that don't break invariants, but how mandatory try
marking plays out in the great majority of code.
Once we recognize that broken invariants are the real issue, it becomes immediately obvious that large categories of functions and methods do not benefit from try
:
- Functions and methods that don't mutate anything (i.e. pure functions). That includes nearly all non-
mutating
methods, even more nearly allinit
s, and non-mutatingget
ters. - Functions that only mutate instances of well-encapsulated types through their public APIs. That includes nearly all functions that are not methods.
- Methods of well-encapsulated types that only mutate non-member instances of other well-encapsulated types through their public APIs.
- Methods whose only throwing operations occur before invariants are broken or after invariants have been restored.
What does this leave, to benefit from try
marking? In typical programming, it's actually a very limited set of cases: mutating
methods where an error may be thrown while an invariant is temporarily broken. In fact, if you look around at uses of “try
” in real code, you'll find very few of these. The keyword mostly appears where it can't make any real difference (hello, cod🐟e that [de-]serializes and code that uses lots of closures, I'm looking at you)! Ask yourself how many times being alerted to an error propagation point has saved you from making a programming mistake. For me the answer—and I'm not exaggerating—is zero.
What can we do about it?
Given that invariant-breaking is already a danger zone in which programmers need to exercise vigilance, it's tempting to suggest that on balance, we'd be better off without any try
marking at all. It certainly would have been worth considering whether the requirement for marking might have been better designed as an opt-in feature, enabled by something like the use of “breaks_invariants_and_throws
” in lieu of “throws
”. However, that ship has sailed. I actually would have liked to explore using higher-level language information to reason about where requiring try
was likely to be useful (e.g. leave it off non-mutating
methods on value types, and all init
s), but I fear that ship too has sailed.
Instead, I suggest using a keyword in lieu of throws
to simultaneously acknowledge that a function throws, and declare that we don't care exactly where it throws from:
func encode(to encoder: Encoder) throws_anywhere {
var output = encoder.unkeyedContainer()
output.encode(self.a) // no try needed!
output.encode(self.b)
output.encode(self.c)
}
Of course, “throws_anywhere
” is not beautiful, and frankly I'd like a much lighter-weight name. Back in the days when error handling was still under design, I proposed rethrows
for this purpose. Unfortunately, nobody liked the purpose, but they loved the name and used it for something else, so rethrows
would also need an _anywhere
variant. I'm totally open to name suggestions if we buy the general premise here.
This proposal generalizes the principle that allows try
to be placed anywhere in a throwing expression: sometimes, the exact source of error propagation doesn't matter, and if you had to write a try
for each precise source, f(try a, try b, try c)
would just be annoying boilerplate. If we can move the marker before f
, for many functions, we can equally well move it to the top level of the function.
What about async
?
We can apply the same line of inquiry to async
. First, why should we care that a call is async
? The motivation can't be about warning the programmer that the call will take a long time to complete or that the call would otherwise block in synchronous code, because we'll write async
even on calls from an actor into itself. No, AFAICT the fundamental correctness issue that argues for marking is almost the same as it is for error-handling: an async
call can allow shared data to be observed partway through a mutation, while invariants are temporarily broken.
So where is that actually an issue? Notice that it only applies to shared data: except for globals (which everybody knows are bad for concurrency, right?), types with value semantics are immune. Furthermore, the whole purpose of actors appears to be to regulate sharing of reference types. Especially as long as actors are re-entrant by default, I can see an argument for awaiting
calls from within actors, but otherwise, I wonder what await
is actually buying the programmer. It might be the case that something like async_anywhere
is called for, but being much less experienced with async
/await
than with error handling I'd like to hear what others have to say about the benefits of await
.
One last thing about async
: experience shows that pervasive concurrency leads to pervasive cancellation. In the case of our async
/await
design, we handle cancellation by throwing, so that means pervasive error handling. That is sure to make the problem of needless try
proliferation much worse if we don't do something to address it.
Thanks for reading,
Dave