Clarifying the semantics of abnormally terminating a storage access

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:

  1. Formal evaluation of the storage reference (meaning, the evaluation of any r-values contained within it, like subscript indices or class references).
  2. 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.
  3. 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.
  4. 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.
  5. 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 the self operand of a call to a mutating method, like storage?.mutate(); if the optional value turns out to be nil, the mutating 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 a nil 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 only defer 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.

2 Likes

To see if I've understood correctly, with code like this, where S1 is simply stored, and S2 has a setter, and we're throwing during step 4 of the read-write access:


struct S1 {
    var x: Int = 0
}

struct S2 {
    internal var _x: Int = 0
    var x: Int {
        get { return _x }
        set { _x = newValue }
    }
}

struct FooError: Error {}

func foo(_ i: inout Int) throws {
    i = 1
    throw FooError()
}

var s1 = S1()
try? foo(&s1.x)
var s2 = S2()
try? foo(&s2.x)
s1.x // 1 
s2.x // currently 1 because setters always called, but with proposal 0

Is that right?

I think my naive expectation would be that throwing an error would cause any changes already made to still happen (the access ends), while short-circuiting control flow would cause the setter to not be called (the access aborts).

I can also see how the mental model for aborts/aborts could also make sense, but it's really unfortunate that access in-place acts differently from setters. What do you think of the cost of inserting implicit temporary storage in potentially throwing mutating expressions and the value only being copied back to the real storage if it isn't aborted? That would make aborting a lot more consistent, if it were a reasonable thing to do.

Your example is exactly right.

Making all modifications to a temporary copy just in case the modification throws does not seem acceptable to me, no; it would completely undermine our ability to reliably perform operations in-place, which is critical both for the semantics of ownership and for the performance of copy-on-write collections.

The cost being too high doesn't surprise me...

Ok, FWIW, I think the proposed access-aborts model is a reasonable amount of complexity to learn if you really need to combine setters passed to inout params of throwing code, and a fairly unlikely pitfall for new coders to accidentally run into.

This approach generally makes sense to me. I think a helpful addition might be if methods can manually end accesses to variables early, before the throw call, if they want to be certain changes have gone through and not just leave the inout variable in some unknown state.

For example, given:

func foo(_ i: inout Int) throws {
    i = 1
    throw FooError()
}

If i is not stored (i.e. calls a setter) then I think it's fine for i to not be updated in this case. If, however, the method was written as this:

func foo(_ i: inout Int) throws {
    i = 1
    endScope(i)

    throw FooError()
}

I would expect the set to be called unconditionally, since the scope of the access was ended before the error was thrown. Would such an approach be practical/make sense (or is implicit in your design anyway)?

Hmm. The ownership manifesto talks about providing a way to end a local access, but the assumption is that that's done with the function's local knowledge about the access and what's necessary to end it. I can't think of how to apply that to inout parameters without passing in a callback with every inout argument just to support this. And that would turn something that's generally known statically to the compiler — the duration of an access — into a totally dynamic operation. Maybe we could find a way to support that as a new feature, but I don't think we'd want to make it a supported behavior for all inouts.

Suppose we forced modify to mark its yield site with try. The behavior would be reasonably obvious: Code after the try would not run. And if we actually supported do/catch in addition to defer, types could implement whatever behavior they wanted:

var x: Int {
  modify {
    // Write something like this to get write-on-abort:
    try yield &_xStorage
    // Equivalently:
    var copy = _xStorage
    defer { _xStorage = copy }
    try yield &copy
    // Or something like this to get discard-on-abort:
    var copy = _xStorage
    try yield &copy
    _xStorage = copy
    // Or here's some custom error handling:
    do {
      try yield &_xStorage
    }
    catch {
      throw ErrorContext(underlying: error, contextDescription: "while accessing x")
    }
  }
}

The advantage here is that we've defined away the double-throwing problem: the code you yielded to threw, and we're just reacting to that with the same control flow features we'd normally use to deal with that.

I suppose this would require us to at least in this context represent ? short-circuits (and any other future aborts) as errors; I don't know if that would be too costly.

I would make setters use write-on-abort behavior. Their behavior needs to be consistent with the behavior when writing directly to storage, and that behavior is write-on-abort. If we're worried about double-throwing, we could decide that set can't throw, only modify can. (A plain modify is sort of like a rethrows function—it can only apply try to yield or to something in a catch block, and can only throw in a catch too. modify throws could throw anywhere in its body.)

I'm really unhappy with the idea that memory-backed storage behaves differently from computed storage (get/set or yielding), because it's not always obvious when you have memory backing and when you don't. This isn't something people should normally be thinking about even in non-resilient code, and (IIUC) it isn't observable today without violating exclusivity. The closest thing we have today is the rare cases where you need pointers to variables to be persistent, but that's both niche and something we already think is really finicky. I don't want to extend that to all inout accesses.

I disagree with Greg that this won't come up for new coders. Computed properties are easy to write and can immediately be used inout, and on top of that, every class property is computed by default, because it might be overridden.

I think if it's a requirement that we allow inout to directly access memory (via actual stored variables or via addressors), then it's a requirement that any writes that occur are acknowledged and persisted, even if the storage being accessed is computed in this case. That rule allows us to short-circuit calling the setter for optional chaining, but not throwing, because arbitrary mutations of the inout parameter could have happened by the time of the throw.

What about modify in this scenario? I'm not sure. Consider Dictionary's subscript(_:default:), where the default value is nil (or maybe one of its properties is nil). Should this add the key to the dictionary or not?

dict[absentKey, default: .init()].propThatIsInitiallyNil?.mutate()

As originally proposed it's actually up to the implementer of Dictionary, since they could perform the update of the backing buffer before or after the yield. I don't think we can force there to have been no updates yet, and it would in fact hurt the performance of Dictionary this subscript slightly to do so. But I still think that's a better direction.

I disagree with Greg that this won't come up for new coders. Computed properties are easy to write and can immediately be used inout , and on top of that, every class property is computed by default, because it might be overridden.

This isn't true. Everything I said about the storage implementation was referring to its actual implementation, not to the way we access it — with the exception of accessing a dynamic property or through an @objc protocol.

1 Like

Do you have an opinion about how to define the behavior of throwing setters, or do you think we just shouldn't add that feature because it creates problems like this?

But I still think that's a better direction.

I'm not sure I follow. If the language generally makes a statement that changes will always be written back to storage, surely the standard library — and any other implementor of modify coroutines, but especially the standard library — has a higher-level obligation to make their storage behave that way even if it theoretically has the option to grown bananas out of its ears. Arguably in that case the language shouldn't even surface the difference between normal and abnormal termination of a coroutine accessor, and instead the accessor always just resumes normally after the yield and runs to completion. (This doesn't work for generators, though; it would be totally unreasonable to try to run a generator to completion when the caller breaks out of the loop. So assuming we add generators someday, there would be a huge semantic difference in the behavior of yield between them.)

You're right that this would create an observable difference in how the storage is implemented, though. And yeah, it wouldn't be "transactional" for stored properties; that justification doesn't really hold up.

This may just be my own prejudices/anecdotal evidence, but I think combining either inout or mutating and throws is fairly rare. At least on Apple platforms with Cocoa, most "normal" coding is generally either dealing with reference objects or simple assignment.

Whether it is rare or not, though, if you are calling one of these mutating/inout functions/methods, treating it as a black box and not knowing the internal order of operations, if an error is thrown, its straightforward to think: "Well, I can't really know whether the change happened or not". So whichever behavior we choose here doesn't effect the coder who's only calling this sort of code, because they're already in poorly defined limbo on the error path.

So now we're left with the writer of the mutating/inout function. If we're writing a mutating struct method, the self access is always in-place (I think? Is this true?), so no behavior change. So for inout, with the proposed abort, what we're telling the function author is: if you throw, your inout parameters may or may not actually get changed, no matter what your internal code is before the throw.

That's unusual semantics for an unusual case, but if it leads to significant performance opportunities I'd be okay with it. Mostly because the whole idea of needing to specify what happens to inout values when an API throws strikes me as only necessary for really horrible API designs.

2 Likes

Does it mean that the behavior of a program changes if a property initially defined as a regular stored property becomes a computed property? I think of libraries upgrades, for example: a library might want to keep a property for API stability, but eventually change its implementation.

1 Like

This is not correct. (You can work this out with the case of calling a mutating struct method on a computed class instance property.)

One answer to throwing setters would be to only allow it for modify implementations and use try yield like Brent said. Then it's very obvious what happens in the case where an error is thrown during access. But that's clearly not a full answer since we still expect more people to write set than modify. I suppose for consistency we'd say the set error wins over the original error, since that's what would happen if you called the setter during a catch block and then tried to rethrow. It would also still be possible to disallow set to throw while still allowing get and modify to.

I don't like any of these answers, but I don't feel like throwing setters are important enough to break the uniformity between memory and computed backing. At that point my practical impression of the language would be "it's unspecified whether the writeback happens", just like it's unspecified today whether a temporary is used. If we really want to go this way, I'd honestly prefer that to be the formal language rule in the case of throws, rather than something proper about storage.

Oops, you're right. Thanks for the correction.

I have believed that if I have a func foo(_ i: inout T) throws, that if the function modifies i, then the caller will see the modification, even if the function does throw. Is this wrong?

Some amount of behavior change when you change implementations is inevitable.

For example, exclusivity is enforced on accesses to memory, not storage. The exact details of how you define storage can change what accesses to memory actually occur and when.

To demonstrate, this program violates exclusivity: a.title is stored, so when it is passed inout, it is passed in-place and exclusivity on it is enforced for the duration of the call.

  class A {
    var title: String
  }
  let a = A(title: "hello, world!")
  func accumulate(into string: inout String) {
    string += a.title
  }
  accumulate(into: &a.title)

If we change it to so that a.title naively wraps a private property, all the accesses to the private property are instantaneous and the program works:

  class A {
    private var _title: String
    var title: String {
      get { return _title }
      set(newTitle) { _title = newTitle }
    }
  }

If we change it again to use a modify accessor to allow in-place access to the private property, the access now overlaps the call to accumulate and the program violates exclusivity again:

  class A {
    private var _title: String
    var title: String {
      get { return _title }
      modify { yield &_title }
    }
  }

Of course, that's a specific danger of reference types, whereas this would apply even with value types.

Note that, as you can see above, the modify generalized accessor makes it possible to change implementations without changing this kind of user-detectable detail. In fact, we can view this proposal almost entirely in terms of modify implementations. What I'm suggesting is that we perform modifications like this:

   var computedProperty {
     get { ... }
     set { ... }
     modify { /* implicit */
       var temp = computedProperty /* call the getter */
       yield &temp
       computedProperty = temp /* call the setter if the yield resumed normally */
     }
   }

whereas what we do today is more like this, which of course you could just write out explicitly:

   var computedProperty {
     get { ... }
     set { ... }
     modify { /* implicit */
       var temp = computedProperty /* call the getter */
       defer { computedProperty = temp /* call the setter unconditionally */ }
       yield &temp
     }
   }

I think this is really one of the key questions. There are certainly cases where people could reasonably say, no, look, I modified this here, why is the change disappearing? It's strange behavior.

That is how it works today. The suggestion is that maybe it shouldn't work that way if the property passed is computed — that the setter might not be called. But I'm starting to think that maybe that's too confusing.

Marking yield in some way to make it clearer that it can fail is an idea I've tossed around and generally like. My biggest concern with it is the use of the try keyword, in large part precisely because it implies that you can catch and actually handle an abort of the access. The accessor really has nothing to do with the error that was thrown (if that's even why it's being aborted) and does not have any context that would let it make a sensible decision about which error to prefer. I feel quite strongly that the reason for the abort needs to be hidden from the accessor.

(I was hoping to talk about all of these issues later, in the pitch for generalized accessors, but I don't mind them coming up here, too.)

I doubt this is acceptable to most of the people who are asking for throwing accessors.

On the other hand, yield being able to "fail" is somewhat inherent to its nature—it's giving up control flow to unrelated code, which may choose to abandon the coroutine instead of ever resuming it. I think your proposed semantics in the OP make sense, where failure in the caller causes the coroutine context to clean up (including defers) and nothing else. A failure in the middle of a coroutine-driven access can be seen as a specific case of the more general situation of coroutine abandonment. If a generator or async coroutine continuation were abandoned, I would expect similar behavior.

3 Likes