I need to start with a quick review of Swift's formal access semantics (as described in the ownership manifesto; reading just the three paragraphs under "Accesses" is fine). The evaluation of a storage reference expression in Swift is formally ordered like this:
- Formal evaluation of the storage reference (meaning, the evaluation of any r-values contained within it, like subscript indices or class references).
- Arbitrary other work which is formally ordered after the storage reference, depending on where it appears syntactically. For example, if the storage reference is the LHS of an assignment, the RHS is evaluated after it but before the next step.
- Begin the formal access to the storage reference. In general, Swift has to commit to what kind of access it's doing at this point: it can't start the access as a read and then "promote" it to a write later on.
- Arbitrary other work which might use the actively-accessed storage reference: for example, storing into it, or binding it to an
inout
parameter and then calling a function. - End the formal access.
What work is done in steps 3 and 5 depends on how the storage is implemented. For example, if the storage is just backed by memory, step 3 merely involves computing the right address, and step 5 does no user-visible work. But if the storage is implemented with a getter and a setter, step 3 involves calling the getter (unless this is a write-only access) and step 5 involves calling the setter (unless this is a read-only access).
In those cases where there is interesting work to do in step 5, an interesting question arises: what should we do if step 4 does something to abnormally terminate the evaluation of the larger expression?
There are two ways to abnormally terminate an expression today (ignoring things like abort()
), but it's far from unimaginable that we could add more:
-
Something in step 4 can throw an error. Note that, in a read/write access, this could happen after some mutations have already been made; for example, imagine passing the storage as an
inout
argument to a throwing function. It isn't obvious that it's wrong to finish the access normally in this case, especially since any mutations would still be visible in the storage if the access had been in-place. On the other hand, there's a certain "transactional" elegance to skipping the setter in this case. -
The access could be "short-circuited" using the
?
operator. In the simplest example of this, the storage is theself
operand of a call to amutating
method, likestorage?.mutate()
; if the optional value turns out to benil
, themutating
method is never called.Since nothing actually mutates the storage when this happens, it's tempting to say that this should start off as a read-only access. Naturally, in a read-only access to a computed property, the setter shouldn't be called; see SR-7257. However, that's not actually a reasonable model in general, because we can expect that the value often won't be
nil
, and if so we do need to be set up to perform a mutation. Either we have to perform two accesses in sequence, first a read and then a read-write (which could be quite expensive, depending on how the access is implemented, and even introduce novel semantic problems, like if the second access produces anil
value when the first didn't), or we have to have some way of upgrading an access from a read to a write (which would greatly complicate the procedures for performing all accesses). So instead we treat the whole thing as a read-write from the start, which creates this situation where we'd sometimes nonetheless like to avoid calling the setter.
Note that there's nothing forcing us to treat these two cases the same way. We could treat an error as an abnormal termination which aborts accesses without calling setters etc., but a ?
operator's short-circuit as normal control flow which does call setters; or, of course, the reverse; or we could try to recognize certain cases specially and then apply a general rule to the rest.
Also, I'm only talking here about abnormal terminations that still cause control flow to propagate out. Terminating a function by terminating the entire thread/process bypasses all of these semantics, and I'm not proposing to change that.
As a further consideration, there have been proposals to add throwing accessors to the language. In conjunction with these abnormal terminations, a throwing accessor raises the question of which one takes priority. For example, in the expression storageWithThrowingSetter.mutatingMethodThatCanThrow()
, if the method throws, and we have to call the setter, and the setter also throws, what should we do? The neatest answer to that question is to define it away by simply preventing anything from being called on an abnormal path that could throw an error — i.e., we wouldn't call throwing setters — but this would give throwing setters radically different semantics from non-throwing setters paths. The alternative approach to the question is to come up with some arbitrary answer to it, e.g. that the second error always takes priority and the first is silently discarded, but that would really just substitute one kind of confusing behavior for another.
The current rule is that accesses are always finished normally even when the overall evaluation is terminated abnormally, meaning that setters are always called. The disadvantages of this are:
- It can be unexpected. Some of the confusion here is really the result of other aspects of the model, as discussed above, which can't be fixed without massive and unlikely changes. Still, it's undoubtedly true that some of the confusing cases would disappear if we stopped calling setters.
- It's somewhat more limited expressively because it doesn't lend itself to a "transactional" model: once an access has been initiated, there's no clean way to end it that doesn't actually do the write-back. It seems sensible to allow an error to terminate an access abnormally.
- It has some code-size costs.
- It doesn't admit a non-arbitrary solution for throwing setters.
I propose that both of these cases (thrown errors and ?
short-ciruits) be treated as abnormal terminations of expression evaluation. An abnormal termination has the semantics that, when it is unwinding the current evaluation state, active formal accesses are aborted rather than ended. What this means depends on how the access is ultimately implemented:
- If the storage is simply stored, or if the access doesn't require any sort of semantic final step (e.g. it is a read-only access that just invokes a getter), there is no difference between the two ways of finishing an access.
- If the access is implemented by reading into a temporary and then calling the setter to write it back, then ending the access invokes the setter, but aborting it does not.
- If the access is implemented by calling a generalized accessor (which haven't yet been pitched, but I include them for completeness), then ending the access resumes execution of the accessor normally, but aborting it causes the accessor to terminate at the
yield
point, executing onlydefer
actions on the way out.
It would be a general requirement that aborting a formal access can never lead to "external" control flow in the original function, such as throwing an error.
The main disadvantage of this approach is that it's more conceptually complex: to really understand the semantics of accesses, this would be part of what you'd need to know. I do think the rule is more intuitive, so I think learning it wouldn't be a problem, but it's unquestionably true that "the setter is always called" is even easier to learn. On the other hand, I think throwing accessors will force us to confront this anyway, and it would really bad if they had different basic semantics on this point — for example, if the rule was that aborting an access caused the setter to be called only if it was formally non-throwing.
Anyway, I'd love to hear people's thoughts on this.