Modify Accessors

Hi Swift Evolution,

Some holiday reading for you, a pitch of a new way of writing mutable computed properties and subscripts.

For more information on the performance implications of this change and why it's an important feature to add, you might also find this talk from last year's Functional Swift Conference useful.

Proposal

We propose the introduction of a new keyword, modify, for implementing mutable computed properties and subscripts, alongside the current get and set.

The bodies of modify implementations will be coroutines, and they will introduce a new contextual keyword, yield, that will be used to yield a value to be modified back to the caller. Control will resume after the yield when the caller returns.

This modify feature is currently available (but not supported) from Swift 5.0 as _modify, for experimentation purposes when reviewing this proposal.

Motivation

Swift's get/set syntax allows users to expose computed properties and subscripts that behave as l-values. This powerful feature allows for the creation of succinct idiomatic APIs, such as this use of Dictionary's defaulting subscript:

var wordFrequencies: [String:Int] = [:]
wordFrequency["swift", default: 0] += 1
// wordFrequencies == ["swift":1]

However, while this provides the illusion of "in-place" mutation, this is actually implemented as three separate operations: a get of a copy of the value, the mutation on that returned value, and finally a set replacing the original value with the mutated copy.

This can be seen by performing side-effects within the getter and setter as in this sample code:

struct GetSet {
  var x: String =  "๐Ÿ‘‹๐Ÿฝ Hello"
    
  var property: String {
    get { print("Getting",x); return x }
    set { print("Setting",newValue); x = newValue }
  }
}

var getSet = GetSet()
getSet.property.append(", ๐ŸŒ!")
// prints:
// Getting ๐Ÿ‘‹๐Ÿฝ Hello
// Setting ๐Ÿ‘‹๐Ÿฝ Hello, ๐ŸŒ!

This simulation of in-place mutation works well for user ergnomics, but has a major performance shortcoming. This can be seen in even our simple GetSet type above. Strings in Swift are "non-trivial" types. Once they grow beyond a small fixed size, they allocate a reference-counted buffer to hold their contents. Mutation is handled via the usual copy-on-write technique: when you make a copy of a string, only the reference to the buffer is copied, not the buffer itself. Then, when the string is mutated, it checks if the buffer is uniquely referenced. If it isn't (because the string has been copied), it first makes a full copy of the buffer before then mutating the buffer, preserving the value semantics of string while avoiding unnecessary eager copies.

Given this, we can see the performance problem when appending to GetSet.property in our example above:

  • GetSet.property { get } is called, and returns a copy of x.
  • Because a copy is returned, the buffer backing the string is now multiply referenced.
  • The append mutation therefore triggers a full copy of the string's buffer.
  • GetSet.property { set } writes this copy back over the top of x.
  • The original string buffer's reference count drops to zero, freeing it up.

So, despite looking like in-place mutation, every mutating operation on x made through property is actually causing a full copy of x's backing buffer. This is a linear operation. If we were doing something like appending to this property in a loop, this loop would end up being quadratic in complexity. This is likely very surprising to the user and can be a major performance pitfall.

Since Swift 1.0, this problem has been avoided with Swift's Array type, using a non-public mechanism called an "addressor". Instead of a get and set, Array.subscript defined a single operation that returned the address of the element within the array to the caller. Swift would then mutate the value at that address. This worked, but was hard to use correctly and not a feature ever intended to be made available outside the standard library. It also didn't provide the ability to intercept and perform further processing of the value after the caller mutated it โ€“ which is why Dictionary still performed unexpected copies-on-write on values fetched by key (for reasons explained below).

As part of fixing the ABI for the standard library in Swift 5.0, this addressor mechanism was replace by coroutine-based "accessors". This proposal converts them to a full public feature.

Detailed Design

The GetSet type above could be implemented with modify as follows:

struct GetModify {
  var x: String =  "๐Ÿ‘‹๐Ÿฝ Hello"
    
  var property: String {
    get { print("Getting",x); return x }
    modify {
      print("Yielding",x)
      yield &x
      print("Post yield",x)
    }
  }
}

var getModify = GetModify()
getModify.property.append(", ๐ŸŒ!")
// prints:
// Yielding ๐Ÿ‘‹๐Ÿฝ Hello
// Post yield ๐Ÿ‘‹๐Ÿฝ Hello, ๐ŸŒ!

Things to note about this example:

  • the get is never called โ€” the property access is handled entirely by the modify call
  • the yield is similar to a return, but control returns to the modify after the append completes
  • there is no more newValue โ€“ the yielded value is modified by append
  • the yield uses the & sigil, similar to passing an argument inout

Unlike the get/set pair, the modify operation is able to safely yield a value to the caller at "+0" โ€“ that is, if the yielded value is a reference or contains references (as String can in our example), their reference counts do not need to be increased when they are yielded. This can be done safely because, unlike with a return statement, the yield is just temporarily giving up control to the caller, then resumes after the caller (in this case, the append) completes. The caller is "borrowing" the value yielded by the coroutine.

The get is still used in the case of only fetching, not modifying, the property:

_ = getModify.property
// prints:
// Getting ๐Ÿ‘‹๐Ÿฝ Hello, ๐ŸŒ!

While a modify is sufficient to allow assignment to a property:

getModify.property = "Hi, ๐ŸŒ, 'sup?"
// prints:
// Yielding ๐Ÿ‘‹๐Ÿฝ Hello, ๐ŸŒ!
// Post yield Hi, ๐ŸŒ, 'sup?

it is also possible to supply both a modify and a set. The set will be called in the case of straight assignment, which may be more efficient than first fetching/creating a value to then be overwritten:

struct GetSetModify {
  var x: String =  "๐Ÿ‘‹๐Ÿฝ Hello"
    
  var property: String {
    get { x }
    modify { yield &x }
    set { print("Setting",newValue); x = newValue }
  }
}
var getSetModify = GetSetModify()
getSetModify.property = "Hi ๐ŸŒ, 'sup?"
// prints:
// Setting Hi ๐ŸŒ, 'sup?

Pre- and post-processing in modify

As with set, modify gives the property author an opportunity to perform some post-processing on the new value.

Consider the following implementation of an enhanced version of Array.first that allows the user to modify the first value of the array:

extension Array {
  var first: Element? {
    get { isEmpty ? nil : self[0] }
    modify {
      var tmp: Optional<Element>
      if isEmpty {
        tmp = nil
        yield &tmp
        if let newValue = tmp {
          self.append(newValue)
        }
      } else {
        tmp = self[0]
        yield &tmp
        if let newValue = tmp {
          self[0] = newValue
        } else {
          self.removeFirst()
        }
      }
    }
  }
}

This implementation takes the same approach as Swift.Dictionary's key-based subscript.

  • If the entry was not there, it adds it.
  • If nil is assigned, it removes it.
  • Otherwise, it mutates it.

Because the fetch and update code are all contained in one block, the isEmpty check is not duplicated (unlike with a get/set pair). Instead, the state of whether the array was empty or not is captured by the program location in the coroutine when the element is yielded. Notice that there are two yields in this modify implementation, for the empty and non-empty branches.

The rules for accessor yields are similar to that of deferred initialization of let variables: it must be possible for the compiler to guarantee there is exactly one yield on every path. The call must not contain any path with either zero or more than one yield. This is the case here, as there is a yield in both the if and the else. More complex cases where the compiler cannot guarantee this will need refactoring, or use of fatalError() to assert unreachable code paths.

Yielding and exclusive access

The optional return value of first in the code above means that, even with a modify, we have introduced the problem of triggering copy-on-write when mutating via our first property. We cannot yield the value in the array's buffer directly because it needs to be placed inside an optional. That act of placing inside the optional creates a copy.

We can work around this with some lower-level unsafe code. If the implementation of Array.first has access to its underlying buffer, it can move that value directly into the optional, yield it, and then move it back:

extension Array {
  var first: Element? {
    modify {
      var tmp: Optional<Element>
      if isEmpty {
        // Unchanged
      } else {
        // Illustrative code only, Array's real internals are fiddlier.
        // _storage is an UnsafeMutablePointer<Element> to the Array's storage.

        // Move first element in _storage into a temporary, leaving that slot  
        // in the storage buffer as uninintialized memory.
        tmp = _storage.move()

        // Yield that moved value to the caller
        yield &tmp
        
        // Once the caller returns, restore the array to a valid state
        if let newValue = tmp {
          // Re-initialize the storage slot with the modified value
           _storage.initialize(to: newValue)
        } else {
          // Element removed. Slide other elements down on top of the
          // uninitialized first slot:
          _storage.moveInitialize(from: _storage + 1, count: self.count - 1)
          self.count -= 1
      }
    }
  }
}

During the yield to the caller, the array is in an invalid state: the memory location where the first element is stored is left uninitialized, and must not be accessed. This is safe due to Swift's rules preventing conflicting access to memory. For the full duration of the coroutine, the call to modify has exclusive access to the array. Unlike a get, the modify is guaranteed to have an opportunity to put the element back (or to remove the invalid memory if the entry is set to nil) after the caller returns from the yield, restoring the array to a valid state in all circumstances before any other code can access it.

Throwing callers

The above code avoids the CoW issue, but is incorrect in the case of a throwing caller:

try? myArray.first?.throwingMutatingOp()

When throwingMutatingOp throws, control returns back to the outer caller. The body of Array.first { modify } terminates, and the code after the yield does not execute. This would result in the yielded element being discarded, and the memory location for it in the array being left in an uninitialized state (leaving the array corrupted).

For this reason, it is important to put code that restores the array to a valid state into a defer block. This block is guaranteed to execute, even in the case of the yielded-to function throwing.

extension Array {
  var first: Element? {
    modify {
      var tmp: Optional<Element> = nil
      if isEmpty {
        // unchanged
      } else {
        // put array into temporarily invalid state
        tmp = _storage.move()

        // code to restore valid state put in defer
        defer {
          if let newValue = tmp {
           _storage.initialize(to: newValue)
          } else {
            _storage.moveInitialize(from: _storage + 1, count: self.count - 1)
            self.count -= 1
          }
        }
        
        // yield that moved value to the caller
        yield &tmp
    }
  }
}

Now that the valid state restoration is in a defer block, the array is guaranteed to be back in a valid state by the time control is returned to the caller.

The body of the modify does not get to participate in the error handling. This is not like nested function calls, where an inner call could catch and potentially supress or alter an error. The modify has no knowledge to what operation it is yielding and whether it could throw, any more than a get/set pair would.

Note that our efficient implementation now handles errors slightly differently to the first version that made a copy. That first version was safe in the face of exceptions, but discarded the update if an error was thrown (because the code to unwrap the optional and replace the value never ran). Whereas in this version, the yielded value is always replaced with whatever value is in the optional when the error was thrown. This means that whether the value in the array is updated depends on whether the caller threw the error before or after updating the value.

Alternatives Considered

The need to place mandatory cleanup inside a defer block is definitely a sharp edge. It would be easy to overlook this and assume that the cleanup code can just be placed after the yield. An alternative design could be to always run subsequent code, even when the caller throws. There are a number of reasons why the defer approach is preferable.

The next likely use of coroutines in the language is as an alternative mechanism for sequence iteration caller generators. This could also offer the option for mutating for...in syntax:

extension Array: MutableGenerator {
  // let's not discuss naming/syntax here...
  func generateMutably() {
    for i in 0..<count { 
      yield &self[i]
    }
  }
}

// allowing something like...
for &x in myArray {
  x += 1
}

Similar to how any method on a property yielded by modify can throw, anything in the body of the for loop could throw, and be caught outside the loop. In addition, the user may just break out of the loop. In either case, the generateMutably function should immediately terminate, not run to completion. A simplified model where the straight line code just continues is not viable. More fine-grained control of cleanup is also likely needed. The implementation of generateMutably might need cleanup either for each element yielded, or after iteration is finished, or both. Using defer for cleanup provides full control of this.

With both accessors and generators, it might also be desirable to drive different behavior on early vs regular termination. Early termination can be handled in a defer block, while straight-line code can be used for when the implementation was allowed to run to completion.

Given that a yield may be to a throwing caller, there is an argument that the keyword should be try yield to indicate this and remind the user. However, this is likely to just cause noise, and annoy users in the majority of use cases where this does not matter. This use of try would also be inconsistent with the rest of the language: it would not need to be wrapped in a do...catch block nor would try? or try! make sense.

The majority of modify accessors are expected to be simple, directly yielding some storage. Most times mandatory cleanup will be needed will be when dealing with unsafe constucts as seen in our first implementation. As always when using unsafe operations, the user must have a full understanding of exactly how the language operates in order to manually ensure safety. As such, understanding how errors are handled when yielding is unavoidable.

Future Directions

The ability to borrow elements from a collection is a key part of the future support of move-only types. If an array were to hold a move-only type, then mutation via a modify that yields a mutable borrowed value is not just an optimization over a get/set pair โ€” it's essential, as the value could not be copied.

There is a similar possible enhancement for coroutine-based fetching of read-only values: read instead of get. This will also be necessary for containers of move-only values. For example, it would be necessary to implement even the current read-only version of Array.first, similar to the mutating version described here.

However, unlike with modify, there is little to motivate adding this feature to the language until we have move-only types. A read property would allow for avoiding reference counting in some circumstances, but this micro-optimization is currently outweighed by the overhead of setting up the coroutine. The exact rules for when to prefer read over get also need exploration, so adding this feature should be deferred for now.

Further ergonomic enhancements to the language may be needed over time to use this new feature. For example, it is not possible to yield from within a closure passed to another function. This makes using this feature in conjunction with the current "scoped" buffer pointer pattern found throughout the standard library difficult. Solving this is a complex language design problem, and should not hold up making the feature available. The future language design will be better informed by feedback from widespread use of modify.

ABI Implications

Adding a new modify accessor to an existing type or computed property has the same ABI implications as adding a getter, setter or function. It must be guarded by availability on ABI-stable platforms.

Renaming the current _modify used by the standard library to modify does not affect the ABI. The mangling already uses a different (shorter) identifier for these symbols, which will remain the same.

93 Likes

I have been experimenting with _modify ever since seeing the talk from last year's Functional Swift Conference that is referenced at the start of the thread.

It has been working well for me and I am excited to see this going through evolution. I have been using it with property wrappers where you apply mutations and it has worked very well in my experience.

As for the rough edge of needing to place clean up code in a defer before yielding is unfortunate but I don't think it should at all hold up making modify an officially supported feature. I imagine that documentation around modify will be added to The Swift Programming Language book and it should certainly cover this case so people are aware of it when applicable.

2 Likes

I want to point out a recent discussion bit (though the thread itself is about something else) about avoiding CoW: [ANN] CeedNumerics released (SN's ShapedArray API discussion) - #31 by Lantua.

Essentially there is a problem when the yielding value is not a simple element value, but a more complex variable containing self like most Subsequence.

In those cases modify couldn't help avoiding CoW due to the similar problem the safe variance of first example has.

It could be orthogonal to the modify (as a separate accessor), somewhat related (borrow context block + modify accessor), or even as part of the future direction (move-only type?). So I think it's worth pointing out.

I don't think that belongs in this pitch. I'm not completely averse to mentioning it but the proposal is pretty long already and it's not directly related and needs quite a lot of explanation.

Similar, but different in that alas the problem is not fixable even with lower-level techniques, unlike with the first example. To solve this problem needs different language features.

Yea, that's my conclusion as well that we'd need new language feature (and no experimental/low-level features thus far solve this).

Agreed that it's not directly related to the point of including it in the proposal. Only that this proposal may help/hinder the feature that solves this. I'll just mention it again in the review thread and I'll be satisfied.

Itโ€™s great to see this moving forward in the SE process.

Agreed, but I think this is acceptable. Iโ€™m more concerned about the difference between writeback semantics and modify semantics from the perspective of the user. If one needs to guarantee preservation of the original value without a guarantee that the property or subscript has writeback semantics a defensive copy would be necessary. This is a copy that isnโ€™t necessary today, which means that introducing a modify accessor to an existing property as an optimization could break existing code using that property in a subtle way.

At minimum, I think we should encourage a culture of documenting the mutation semantics of mutable properties and subscripts. If tooling is able to provide this information automatically that would be even better.

I think this section could use a little bit of clarification. Since the defer block is always executed you canโ€™t really have different behavior. You can only use straight-line code to add additional cleanup in the case where the modify runs to completion.

5 Likes

I'm glad to see modify moving forward.

I wonder: Is this a good time to complain about the syntax of yield? Surely that should be done before a feature is added that depends on it.

Introducing yield is part of this proposal so it's definitely in-scope to discuss. Right now, the keyword is unofficial. yield just happens not to have needed an underscore because there's no way you can use it without having first used an underscored feature like _modify.

Not sure I can endorse complaining. Now is a good time to offer your thoughts on yield and maybe suggest well-justified alternatives :).

3 Likes

So this is about yield. I think it should look less magical.

To me it looks like you're just calling an implicitly-passed closure that can throw, a bit like in this function:

func modify(_ mutator: (inout T) throws -> ()) rethrows {
    try mutator(&x)
}

The first difference is you can't catch any error it might throw, which would be problematic in the context of a property accessor. This could be expressed by replacing throws with yields and try with yield and let the compiler enforce the restriction:

func modify(_ mutator: (inout T) yields -> ()) rethrows {
    yield mutator(&x) // like `try`, but can't catch errors
}

The second difference is you must call the closure once and only once on all paths. This could be enforced if you had a @once attribute on the parameter type (similar @escaping):

func modify(_ mutator: @once (inout T) yields -> ()) rethrows {
    yield mutator(&x)
}

So if you now change this to accessor syntax, it would become:

var x: Int {
  get { _x }
  modify(mutator) {
     // type of mutator is: @once (inout Int) yields -> ()
     yield mutator(&_x)
  }
}

and like with other property accessors, mutator here would likely be implicitly declared in normal usage:

  modify {
     yield mutator(&_x)
  }

This final syntax is slightly different to the proposed one, which is why I think it's worth discussing now.

So in short I'd suggest:

  1. add yields with a similar meaning to throws, but calling it would require yield instead of try and won't allow catching errors.
  2. add @once to force a function to be called once on all paths.
  3. modify just becomes an accessor with a parameter of type @once (inout T) yields -> ().

No magic: just new restrictions you can put on function types, and a new accessor taking advantage of this.


And I know this is not for discussing the syntax for mutating in a loop, but it'd basically be the same thing as above but without @once:

extension Array: MutableGenerator {
  func generateMutably(_ mutator: (inout Element) yields -> ()) rethrows {
    for i in 0..<count { 
      yield mutator(&self[i])
    }
  }
}

I might be missing some subtleties about this however. I've never used a language that had this feature.

13 Likes

I like this proposal, but I'm concerned by this sharp edge.

It's possible that the nonlocal control flow will feel natural in practiceโ€”yield is a control flow keyword, and this is a consistent feature of it. But I'm worried that the rarity of the abort path actually being taken will mean that people will only think of it as yielding a value and resuming later, not as possibly terminating linear execution. They would write code that almost always works, but breaks when a caller can throw.

I agree that the subsequent alternative of continuing as though nothing was thrown isn't viable, but I wonder if a design tweak could prove a clearer indication that you need to think about the error path. For instance, suppose yield had a Bool result, returning true for a successful return and false for an abort. If you wrote something like the first example in "Yielding and exclusive access", you would get a warning on the yield line because you were implicitly discarding the return value. In this case, you want to run the subsequent code in both circumstances, so you can just explicitly discard it instead with _:

        // Move first element in _storage into a temporary, leaving that slot  
        // in the storage buffer as uninintialized memory.
        tmp = _storage.move()

        // Yield that moved value to the caller
        _ = yield &tmp
        
        // Once the caller returns, restore the array to a valid state
        if let newValue = tmp {
          // Re-initialize the storage slot with the modified value
           _storage.initialize(to: newValue)
        } else {
          // Element removed. Slide other elements down on top of the
          // uninitialized first slot:
          _storage.moveInitialize(from: _storage + 1, count: self.count - 1)
          self.count -= 1
        }

In the generateMutably() example, you would get the same warning. But here the appropriate thing to do would be to return early:

    for i in 0..<count { 
      guard yield &self[I] else { return }
    }

If we feel strongly that yield correctness requires us to force some kind of nonstandard control flow, we could require it to be used in a guard statement:

yield &foo      // Illegal
_ = yield &foo  // Illegal

guard yield &foo else {
  // Slightly different rules from usual here:
  // 1. You must return or call a `Never`-returning function; break,
  //    continue, throw, etc. are not allowed.
  // 2. You cannot yield before returning, even in contexts like
  //    `generateMutably()` that would otherwise allow multiple yields.
  return
}

(If the differences from a normal guard ... else block are too important, we could also just drop the guard keyword and make yield ... else a thing.)

The code you'd end up with is very similar to the correct "Yielding and exclusive access" example, but there's an explicit reminder in the code that yield returns early:

        // Move first element in _storage into a temporary, leaving that slot  
        // in the storage buffer as uninintialized memory.
        tmp = _storage.move()

        // Once the caller returns, restore the array to a valid state
        defer {
          if let newValue = tmp {
            // Re-initialize the storage slot with the modified value
             _storage.initialize(to: newValue)
          } else {
            // Element removed. Slide other elements down on top of the
            // uninitialized first slot:
            _storage.moveInitialize(from: _storage + 1, count: self.count - 1)
            self.count -= 1
          }
        }

        // Yield that moved value to the caller
        guard yield &tmp else { return }

And generateMutably() would be exactly as it would be for a Bool-returning yield:

    for i in 0..<count { 
      guard yield &self[I] else { return }
    }

Or, if we don't want to allow arbitrary code to run in the failure pathโ€”just choose between "continue because there's cleanup code" and "return immediately"โ€”there are a number of ways we could explicitly force the user to state which behavior they want. Hereโ€™s a strawman syntax to demonstrate what I mean:

        // in Array.first.modify
        yield(fallthrough) &tmp

      // in generateMutably()
      yield(return) &self[I]

I won't bikeshed every possibility there, but I'm sure you get the idea.

18 Likes

I'm glad this topic if finally moving forward. I like it, ship it. :ship:

However I was really surprised by the defer magic that was presented. Is this a designed behavior? Honestly it feels like a hack or an abuse of an undocumented (I think) behavior of defer.

I quickly tested it with this code:

struct Value {
  enum Error: Swift.Error {
    case something
  }
  var number = 0

  mutating func increment() {
    number += 1
  }

  mutating func incrementAndThrow() throws {
    increment()
    throw Self.Error.something
  }
}

struct Test {
  private var _value = Value()
  var value: Value {
    get {
      _value
    }
    _modify {
      defer {
        print("post yield from defer", _value)
      }
      print("pre yield", _value)
      yield &_value
      print("post yield", _value)
    }
  }
}

var test = Test()
test.value.increment()
print("----")
try? test.value.incrementAndThrow()

This was the result:

pre yield Value(number: 0)
post yield Value(number: 1)
post yield from defer Value(number: 1)
----
pre yield Value(number: 1)
post yield from defer Value(number: 2)

Edit: I think I start to understand why this works like it works, but honestly using defer as a way to enforce the execution of post yield code still feels strange to me.

1 Like

@Ben_Cohen just to clarify, the possible addition of throwing accessors should theoretically not have any sharp edges like in your proposal as the control flow rules will remain the same as today in case of a modify that can also throw?


@Joe_Groff I wonder if any of the current design decisions will make things harder for the previously pitched async keyword: Concrete proposal for async semantics in Swift ยท GitHub

:crossed_fingers: we can find the best solution to move all that forward without any regressions.

I like it, but the syntax needs work, especially the error-handling.

Itโ€™s also likely that this syntax needs to work for general coroutines, so itโ€™s very important to get right.

Also an irrelevant, but annoying part; get and set are so well paired. modify doesnโ€™t quite fit - even if itโ€™s correct...

Given the pitch dedicates several paragraphs to the design and use of this behavior, this paragraph is phrased rather impolitely. Explanations for why you dislike the design, observations about gaps in the text vs the implementation, and alternative proposals for syntax (like in @michelfโ€™s post) , would be more productive.

2 Likes

Very excited to see this land publicly.

I feel similarly to @beccadax about the potential for confusion over what code is guaranteed to run or not and under what circumstances. Even though I am comfortable calling this feature "advanced," I think stricter call site handling would not need to mean worse ergonomics.

What I think I desire is a requirement that using yield means explicitly either always doing anything coming after the yield or only doing what comes after the yield if no errors are thrown. The following therefore reads nicely as an alternative to not using a defer block for indicating the code in the condition should be executed only upon successful yielding

if yield &value {
}

However, an else clause does not capture the alternative of "always do this" but rather it says "only do the following on failure." The defer block already does about as good of a job of stating "always do this" I think.

So, what about disallowing any code to follow a yield unless it is enclosed in the conditional block.

yield &value
... // anything here is a compile time error

So you use a defer block to always execute something and you use the positive branch of an if to only do something if yield was successful.

1 Like

I also rather like the strawman syntax suggested here for returning or falling through.

Thanks so much @Ben_Cohen for moving this forward and for taking the time explaining how (& why) everything works and what the problems are. In SwiftNIO we have been suffering for a long time now given the absence of modify, in our CircularBuffer double ended queue and elsewhere.

I'm a strong +1 on this. I acknowledge the sharp edges of the throw/modify problem and I'd love if we find a better solution but I could definitely live with it as is today.

4 Likes

I'm concerned about the introduction of yield for this one particular case. I've only ever come across yield in discussions around asynchronous programming, and (having never used a yield-ing language before), have no idea how it works or even what it does.

I'm also concerned about the sharp edges around cleanup.

These two things, combined with @michelf's observations lead me to suggest:

What if the modify block had a parameter passed in (like newValue to the set block), and it was by this parameter that you could provide the inout value to be modified?

struct GetModify {
    var x = "Hello, world"

    var property: String {
        get { return x }
        set { x = newValue }
        modify { provide(&x) }
    }
} 

Here I've called the parameter "provide", and its type is (inout T) โ†’ Void, where T is the type of the property.

An approach like this would have no ambiguity around how cleanup would happen (it'd happen after you provide the value), it is consistent with other "magic" parameters (like newValue, oldValue, error, etc), and it defers (ha ha) the yield discussion to when we're actually going to discuss how asynchronicity will work in Swift as a language feature.

5 Likes

I havenโ€™t had a chance to read/think this through in detail yet, but my gut reaction is a strong +1.

I might tweak one thing... should it be spelled mod, instead of modify? So that itโ€™d line up column-wise with get like set does?

Excuse me? I have no idea how you interpret my wording as impolitely. Honestly stating that you already wrote everything in the original post I should have understood everything without questioning anything makes me feel like an idiot (sorry for being honest even though such words are not appropriate or productive for the rest of the conversation).

We can break down the quote into two things:

  • Iโ€˜m surprised by the slightly unusual behavior of the code/control flow of modify, but I kinda understand it now as modify receives the control after the chained operation has fully finished.
  • I said that the usage of defer feels a little like a hack - there were other issues with defer in the past like a use case of using defer from inside an init to trigger/avoid didSet on stored properties). And I shared my opinion that the behavior is either totally new or already just not fully documented, which isnโ€˜t clear to me from the proposal itself.

So for my defence, I never said that I dislike the design. That said, if anyone feels like asking questions and not being an expert is not fitting in this thread or SE for being productive/helpful, then I might need stay out of it entirety.

5 Likes