Modify Accessors

I think terminating yield due to exception (at lease for accessor) is NOT a consistent choice with respect to other parts of the language. In fact, mutator throwing and mutator failing to mutate are different scenario. This problem exists elsewhere, even outside the accessor.

Admittedly, all throwable mutations are more-or-less throwable functions with inout argument. Lets have:

func foo(x: inout String) throws {
  x = "Updated"
  throw SomeError()
}

var x = "Not Updated"
try? foo(&x)
print(x) // Here

A quick run to the playground shows that x changes to Update. Now the real question is, "should it?".

There are 3 sensible outcomes, depending on the semantic:

  1. "Not Updated"—Mutation fails, and should be treated as never occurred.
  2. "Updated"—Any mutation before the throwing will remain intact.
  3. x becomes uninitialised.

#1 is mostly infeasible. It doesn't revert external effects, such as network connection, so the user will need to do the reversion anyway. Keeping track of which and when the variables are being reverted would be a logical nightmare. It also misses optimisation opportunities.

#3 is somewhat sensible as one can argue that throwing exception may lead the program into an invalid state. Though it has a glaring problem if x is part of a larger struct. Do we uninitialise container of x as well? Also, since throws is recoverable, losing both the original state ("Not Updated"), as well as the pre-exception state ("Update") could be counter productive.

#2 is a reasonable choice. If foo decides that it should do #1, it can do so manually by storing, and loading x. It also provides a good amount of optimisation opportunity (as does #3).



The reason I bring this up is because I believe accessing variable should be get-set-storage-modify-agnostic. Whichever combination of the accessor is used, if x mutates, the accessor should be given opportunity to register new value. And the scenario above well captured the mutation behaviour of x. Now if we agree on choice of #2 (and acknowledge that the language is already using #2). We can see that the functions below are equivalent from the perspective of x.

func fooThrow(x: inout String) throws {
  x = "Updated"
  throw SomeError()
}

func foo(x: inout String) {
  x = "Updated"
}

Further cementing the idea that mutation of x is irrelevant to the function return-status.

So now we're left with the 3 semantically equivalent behaviours:

  • (Storage) new value is written back to the storage
  • (Get-Set) set is called
  • (Modify) yield continues

This sounds like a very consistent accessor story.

At which point, there's no case that prevents value from being written back to the storage (yet), so I don't think yield terminating is being consistent with get-set-storage.



I find the rationale for yield-termination (vs continuation) in accessor rather... weird.

This sounds like a fallacy. Indeed yield may terminate for generator, but that doesn't mean that it must terminate somewhere for the accessor. It should be considered separately on its own merit, rather than being shoehorned. It should be a tool we can use in the arsenal, while not being forced to use it.

Also this:

It may look like yield should terminate because an exception is thrown, but in fact, it should terminate because the control goes outside of the for-loop, simply because break, return, and labelled continue could cause the same to happen (and as said, mutation occurrence isn't tied to function success).

Since we don't have the equivalent of breaking out of the loop in the accessor. They're not really apt comparison.

PS

Apologies if it starts get noisy. I tried to look at it from different angles. In the end, it seems to be weaved into a pretty simple and interesting accessor/mutator story.

8 Likes