[swift-evolution] [Proposal] Property behaviors
Michel Fortin
michel.fortin at michelf.ca
Thu Jan 21 12:16:25 CST 2016
Le 20 janv. 2016 à 21:44, Joe Groff <jgroff at apple.com> a écrit :
>
>> On Jan 20, 2016, at 6:12 PM, Michel Fortin <michel.fortin at michelf.ca> wrote:
>>
>> Le 19 janv. 2016 à 21:38, John McCall via swift-evolution <swift-evolution at swift.org> a écrit :
>>
>>> One of my worries about this proposal in general is that you’ve listed out half-a-dozen different uses and every single one seems to require a new twist on the core semantics.
>>
>> That's my general feeling too about this proposal. I just didn't know how to express what you said above.
>
> I *did* somewhat strategically pick my examples to try to cover the breadth of different things I see someone wanting to do with this feature.
>
>>
>> To me this proposal feels like it's is about trying to find a solution to multiple problems at once. A new problem arise that looks like it could be solved by a behavior, so the behavior feature expands to accommodate it. It looks like the wrong approach to me.
>>
>> The correct approach in my opinion would be to try to make various parts of this proposal standalone, and allow them to combine when it makes sense. For instance, if you wanted to define a standalone feature for defining custom accessors that can be used everywhere, you wouldn't come with something that requires a behavior annotation at the variable declaration. You'll come with something simpler that might looks like this:
>>
>> custom_acccessor willSet<T>(newValue: T) { // define a custom accessor...
>> set { // ... by redefining the setter...
>> willSet(newValue) // ...inserting a call to the accessor here...
>> currentValue = newValue // ...before calling the underlying setter
>> }
>> }
>> custom_acccessor didSet<T>(oldValue: T) {
>> set {
>> let oldValue = currentValue
>> currentValue = newValue
>> didSet(oldValue)
>> }
>> }
>> custom_acccessor willChange<T>(newValue: T) {
>> willSet {
>> if currentValue != newValue {
>> willChange(newValue)
>> }
>> }
>> }
>>
>> Then at the declaration point you just directly use the globally accessible accessor:
>>
>> var myvar: Int {
>> willChange { print("will change to \(newValue)") }
>> }
>>
>> This fulfills at least one of the use cases. Can't we do the same treatment to each proposed use cases and see if there are other parts that can stand on their own?
>
> I considered this approach. It works for behaviors that don't need to control a property's storage and only change the property's access behavior. To be fair, that covers a lot of ground, including things like observing, NSCopying, resetting, and locking synchronization. We would still need a feature, which could certainly be a different one, to generalize annotations that control the storage policy for decorated properties, which could cover things like laziness, indirect storage, unowned/weak-ness, dirty-tracking, C-style atomics, and pointer addressability—basically, anything where a plain old stored property of the API type isn't sufficient. (You could even throw get/set in this bucket, if you wanted to be super reductionist.) Finally, there's the feature to add operations on a *property* independent of its *type*, which interacts usefully with both other features—you need a way to reset a resettable or lazy property; maybe you want to bypass a synchronized property's lock in one place, etc. We'd like to improve on the "classic" answer of exposing an underlying ivar or property in these cases.
>
> If you want to break it down in micro-features, I guess there are three here:
>
> 1. Factoring out storage patterns,
> 2. Factoring out accessor patterns, and
> 3. Adding per-property operations.
>
> (1) tends to be tightly coupled with (2)—if you're controlling storage, you almost certainly want to control the accessors over that storage. And (3) is useful with both (1) and (2). If there are separate features to be factored out here, I think they're very entangled features.
No single language feature necessarily has to "control" the accessor. The accessor is really of a pile of "custom accessor code" wrapping one another, some in the variable declaration, some in the variable's behaviours, and so why not some others in a global accessor declaration? They just pile up on top of each other.
Last post was about (2), so let's try to attack (1) using this approach. Basically, this is going to be the same thing as your behaviour proposal, minus `var` inside of it, minus custom accessors defined inside of it, minus functions inside of it (we'll revisit that at the end), and where the base property is implicit (`currentValue`) and the initializer is implicit too (`initValue`).
I'll start with an "identity" behavior, a behavior that wraps a base property while actually doing nothing:
// identity for type T creates storage of type T
storage_behavior identity<T>: T {
// regular initializer, works with eager or deferred initialization
init {
// initValue is implicitly defined in this scope
currentValue = initValue // initializing the storage
}
get {
return currentValue
}
set {
currentValue = newValue
}
}
Sometime you need to force the initializer to be eager or deferred. So we can do that with a keyword, in which case for `eager` the `initValue` becomes available anywhere inside the behavior, and for `deferred` it becomes available everywhere outside of `init`:
// identity for type T creates storage of type T
eager storage_behavior eagerIdentity<T>: T {
// initValue is implicitly defined in this scope
// eager initializer (takes not argument)
init {
currentValue = initValue // initializing the storage
}
get {
return currentValue
}
set {
currentValue = newValue
}
}
Note at this point how this is basically the same syntax as with the `custom_accessor` examples from my last post, minus there is no accessor to call in `get` or `set`, plus you have the ability to affect the storage. The main difference for the user is that, since it's a behavior, you opt in to it by annotating the variable. Whereas in the case of `custom_accessor` you opt it by writing the custom accessor body inside of the variable's accessors.
Ok, now let's write lazy:
// lazy for type T creates storage of type T?
deferred storage_behavior lazy<T>: T? {
init {
// defered initValue not available in this scope
currentValue = nil // initializing the storage
}
get {
if currentValue == nil {
currentValue = deferredInitValue
}
return currentValue! // converting from T? to T
}
set {
currentValue = newValue // converting from T to T?
}
}
Now let's make it atomic:
storage_behavior atomic<T>: Atomic<T> {
init {
currentValue = Atomic(initValue)
}
get {
return currentValue.payload
}
set {
currentValue.payload = newValue
}
}
Note how I can avoid adding variables inside the behavior by just changing the underlying type to be some kind of wrapper of the original type. That makes the feature simpler.
We can also write synchronized (for variables in a context where self is Synchronizable):
storage_behavior synchronzied<T where Self == Synchronizable>: T {
init {
currentValue = initValue
}
get {
return self.withLock {
return currentValue
}
}
set {
self.withLock {
currentValue = newValue
}
}
}
Here is a minimalistic logging behavior:
storage_behavior logging<T>: T {
// implied "identity" version of init, get, and set.
willChange {
print("\(currentValue) will change for \(newValue)")
}
}
This last example is interesting: it shows that you can use a globally-defined accessor inside of the behavior; you don't have to define a `set` or a `get` to make it useful. This logging behavior is basically just a shortcut for defining a variable like this:
var myvar: Int {
willChange {
print("\(myvar) will change for \(newValue)")
}
}
@logging var myvar2: Int // way less boilerplate!
So in the end, a storage behavior model defined like this brings to the table the `init` and the modified storage type. Otherwise it's just a nice and convenient way to avoid repeating boilerplate you can already write inside of a variable declaration. Which is great, because with less special rules the whole behaviour feature looks much more approachable now.
For micro feature (3) I'm not sure what to suggest right now. I'm not 100% convinced exposing functions is necessary, and the visibility rules in your proposal seem quite complex. And while I can see its usefulness, I don't see why this feature should be limited to property behaviours. I think it would make a lot of sense to have that available directly in the variable declaration too, somewhat like this:
var myvar: Int = 0 {
mutating func reset() { myvar = 0 }
}
myvar#.reset() // some syntax to call the function
And if this was allowed, it'd only be natural that you could write the same thing inside of a behavior because behaviours are all about reducing the boilerplate of writing those custom accessors:
eager storage_behavior resettable<T>: T {
mutating func reset() { myvar = initValue }
}
@resettable myvar: Int = 0
myvar#.reset()
If you want, this could be extended to allow computed and stored properties inside of the variable declaration, which would naturally extend to the behaviour too.
And that could be the final part of the puzzle. Three simpler features that can be useful on their own but which you can also combine to fulfill all the use cases of your proposal.
--
Michel Fortin
https://michelf.ca
More information about the swift-evolution
mailing list