Modify Accessors

I might be completely off here and suggesting something impossible, but to me it seems logical to model it similarly to passing in a throwing closure as an argument:

// Modifier that does not support throwing
var property: String {
  modify {
    yield &x
  }
}

// Modifier that supports throwing
var property: String {
  modify rethrows {
    try yield &x
  }
}

Now, if someone writes a modify that does not support throwing, the compiler can generate an error when it's used with a throwing call. This makes it clear that more work is required to properly support throwing, which in trivial cases is just a matter of adding two keywords. A big advantage is that it makes you think about it as soon as you need it.

Perhaps 'rethrows' is technically not correct here since the modify body does not participate in the error handling, but the only semantic difference seems to be that the error cannot be caught in the body of the modify.

7 Likes

As for try, we may also want a syntactic sugar for the case we don't want to use it as the last statement, but we explicitly don't care if the remaining statement are not executed.

Something like try? and try!, but I'm reluctant to propose yield? as the effect would be the opposite of try? (which in case of error continue to the next statement, while yield? would stop in case of error).

The problem with this is that you only find out if your modify needs to support error handling at the call site. That’s a problem if the property is defined in a library that you can’t modify.

In other words: The yield doesn’t know where it’s going to be used, so it would always have to include the error handling stuff to be useful everywhere.

1 Like

The same applies to rethrowing higher order functions we have today.

1 Like

Oh, you’re right. Well then, in that case: I really like what you propose.

The beauty of it is that throwing properties is something that core team members expressed they would like to support at some point anyway (IIRC) and the syntax for that would probably look exactly like this.

modify will eventually be able to throw just like other accessors: Throwable accessors - #33 by John_McCall

1 Like

The “mark it as throwing” approach is similar to the alternative considered mentioned in the proposal, where instead of yield you have to write try yield. But with a twist that instead of forgetting to defer your cleanup, you forget to mark the thing as rethrows. The unfortunate outcome of this would be that 99% of use cases won’t support throwing use sites for no good reason because they don’t have essential cleanup code but the implementor didn’t jump through the hoop of adding rethrows and try. And it still leaves the difference with regular try in that you cannot catch it (albeit with an explanation for why you don’t need to catch it, because it’ll be rethrown).

The comparison with rethrows is interesting because it’s an example of a slightly frustrating syntax. I write a lot of high order functions and every time I write one that rethrows I forget to add the various try keywords and then compile and get annoyed that I have to sprinkle them back in, because I don’t think I’ve ever written a high-order function that needed to care about the closure argument throwing, ever. They’re all just happy to immediately terminate and rethrow. So over time I’ve stopped using rethrows except in example code or ABI-stable code (which alas is most of the code I write...).

Now, I think the weightings are slightly different for modify accessors. Their bodies will need to account for throwing yields more often than a high-order function needs to account for throwing closures. And they will also be used more often with throwing functions (because subscript access -> throwing mutating function is more common than writing a closure that throws). But still, it’s an example of the harm that comes from salting the common case.

3 Likes

In that case there is an advantage in requiring rethrows, because you can leave it out if you don’t want to write code to handle throws. We could use something like nothrow, but that doesn’t really fit the current model.

There is no need for nothrow keyword if we get this instead: Pitch: Genericizing over annotations like throws - #10 by Joe_Groff

modify { ... } would simply mean modify throws Never { ... }.

2 Likes

If throws Never is the default, you’d still need rethrows.

1 Like

But would‘t rethrows require some special treatment, because on function it determines if the whole function throws or not from the closure type passed into the function?!

In this case:

try? myArray.first?.throwingMutatingOp()

It’s throwingMutatingOp() that determines that first must be able to rethrow. If you didn’t mark the modify accessor of first as rethrows, this is an error.

So, if you don’t mark the modify accessor of first as rethrows, you are sure that you don’t have to handle a throw.

That is true, and irritating...but it does beat finding out at the call site after hours (or days?) of debugging that the modify needs to support error handling. In both cases if you can't modify the library you do have the fairly simple (and, again I admit irritating) workaround of avoiding modify by doing a get into a temporary.

1 Like

Do we need this exception only when it's the last line? We could just allow the terse yield &x syntax which implicitly exits on any sort of break or throw by the continuation, but also allow a more ergonomic yield &x else { ... } (my personal preference) or if yield &x { ... } else { ... }/guard yield &x else { ... }. This is still perhaps somewhat sharp, since it is not apparent from the simple cases that yield cleanup requires some sort of special syntax, but guard-style or if-else style syntax is much more familiar to most users of the language than defer, so I think it would be an improvement over requiring defer for any sort of mandatory cleanup.

I think the clarity wins would make this cheap at twice the price of a guard ... else { return }. Especially if we have a last-line exception.

(In generators, the same rule would apply—if you use a yield in the middle of the function, you need to do something with the return value. I think this is fine.)

Clean is important, but clear is more important. The proposed control flow for yield is not clear. There is absolutely nothing in the present syntax, in other parts of Swift, or in experience from other languages that suggests that yield might never return control flow to straight-line code, but it would still run defer blocks.

Let's be clear about two points:

  1. yield is a pretty advanced feature. modify accessors are a niche use case. Generators are something a programming class might teach, but probably towards the end of the course. I doubt they'll be the bread and butter of development in the way that, say, throws functions are. This means that, outside of specific teams that use it heavily, almost everyone who uses or reads a yield statement will be a beginner who doesn't already understand every nuance of its behavior.

  2. yield is a keyword, and keywords are the most weakly documented part of Swift. That means it's not easy to look up the behavior and see what yield does. (We ought to improve our tooling and documentation to address this, but it's not clear when or how that will happen.) Even more so than usual, people will rely on Google to understand yield, and even if our own documentation discusses the abort path, the random "here's what a modify accessor does" and "here's what a generator does" articles on people's blogs probably won't mention it.

I doubt yield will be used often enough or have relevant documentation that's accessible enough for people to understand this behavior. That means the only way people will find out about this behavior is if the feature is designed to surface it.

And I don't think the fact that many of the use cases for cleanup code involve unsafe APIs gets us off the hook. On the contrary—it makes doing this right more vital. The combination of rarely used features with unsafe code raises the stakes. The fact that running with scissors is dangerous doesn't mean that, if we see someone doing it, we should be happy to throw a couple hurdles in front of them.

Adding a Bool return value or doing one of the other things I suggested makes yield's behavior visible. It prompts a user unfamiliar with yield to ask the question, "what is this return value and what should I do with it?" I think it's better for them to ask this question up front and learn that they don't need to do anything special than it is for them to not ask it until they've spent hours debugging some seemingly impossible control flow.

If clarity at the point of use is our goal, we should favor a design that makes the abort path visible and clear.

19 Likes

Now that I let modify sink-in a little bit, here's my feedback on the pitched semantic, especially regarding the continuation/termination of yield statement.

Whether yield continue execution should depend on whether set would be called in the get-set semantic. That is, yield will continue iff set would be called, and terminate otherwise.

This would let use avoid combinatorial explosion should we introduce new non-trivial control flow, eg. async/await with get-set vs get-modify.

Furthermore, it'd allow for modify-only accessor. Something like this

var foo: Value {
  modify {
    sharedStateComputation()

    someGetComputingX()
    defer { getCleaning() }

    yield &x

    // Not called on get-only usage, or exception is called before any mutation.
    someSet(newValue: x)
  }
}

I think this structure would be most common even for get-modify semantic.

I think the discussion surrounding the error handling distracts us from the fact that yield shouldn't rely on the exception. Even if an exception is thrown, if the value is mutated and should be registered into the accessor, the set is still called. So the exception handling is orthogonal to get-set semantic, and so should get-modify.

To add, in the status quo, set is called even when the function throws:

struct Test {
    var _value: Int
    var value: Int {
        get {
            print("Getting")
            return _value
        }
        set {
            print("Setting to \(newValue)")
            _value = newValue
        }
    }
}
enum TestError: Error { case err }
func mutatingTest(a: inout Int) throws {
    a = 2
    throw TestError.err
}
extension Int {
    mutating func mutatingTest() throws {
        self = 1
        throw TestError.err
    }
}

var test = Test(_value: 3)

try? mutatingTest(a: &test.value)
// Getting
// Setting to 2

print("Current value", test.value) // 2

print()

try? test.value.mutatingTest()
// Getting
// Setting to 1

print("Current value", test.value) // 1

Note that throwing accessors are one important reason why I feel that design approaches that center around disallowing normal code after yield are non-starters. All of those ideas require the cleanup code to not throw, but presumably we do want to allow accesses that are ending normally (i.e. there hasn't already been an error thrown in the caller that aborted the access) to do things that might throw, like call a throwing setter.

For what it's worth, there are deeper semantic questions that we'll eventually need to settle about whether setters should be guaranteed to be run if an access is aborted.

5 Likes

But isn't it the status quo to call set on inout variables even if the function throw? Is there a merit in changing that, or is it simply that it's not documented?

That is the status quo, yes. However, it's semantically problematic to call the setter if it can throw, because now you might have two different errors but you can only throw one thing. Also, it's not at all obvious that we should call the setter when the access is aborted with an error; we don't have any real reason to think that the new value is "ready", and doing nothing seems like the more sensible default behavior.

7 Likes