[swift-evolution] [Proposal] Property behaviors
Joe Groff
jgroff at apple.com
Thu Dec 17 18:36:21 CST 2015
Thanks for the thorough feedback, Kevin! There's a lot to work through here, but here are some quick responses:
> On Dec 17, 2015, at 3:44 PM, Kevin Ballard via swift-evolution <swift-evolution at swift.org> wrote:
>
> I'm a bit concerned about passing in the Container like this. For class types it's probably fine, but for value types, it means we're passing a copy of the value in to the property, which just seems really weird (both because it's a copy, and because that copy includes a copy of the property).
True, it's fundamentally problematic for property behaviors to try to reach back up through containing value types. It's already been noted that passing all the parameters all the time to both the behavior function and subscript implementation is a potential code size problem as well. Allowing the operations to only take the parameters they're interested in would help here.
> Also the only example you gave that actually uses the container is Synchronized, but even there it's not great, because it means every synchronized property in the class all share the same lock. But that's not how Obj-C atomic properties work, and there's really no benefit at all to locking the entire class when accessing a single property because it doesn't provide any guarantees about access to multiple properties (as the lock is unlocked in between each access).
>
> FWIW, the way Obj-C atomic properties work is for scalars it uses atomic unordered loads/stores (which is even weaker than memory_order_relaxed, all it guarantees is that every load sees a value that was written at some point, i.e. no half-written values). For scalars it calls functions objc_copyStruct(), which uses a bank of 128 spinlocks and picks two of them based on the hash of the src/dst addresses (there's a comment saying the API was designed wrong, hence the need for 2 spinlocks; ideally it would only use one lock based on the address of the property because the other address is a local stack value). For objects it calls objc_getProperty() / objc_setProperty() which uses a separate bank of 128 spinlocks (and picks one based on the address of the ivar). The getter retains the object with the spinlock held and then autoreleases it outside of the spinlock. The setter just uses the spinlock to protect writing to the ivar, doing any retains/releases outside of it. I haven't tested but it appears that Obj-C++ properties containing C++ objects uses yet another bank of 128 spinlocks, using the spinlock around the C++ copy operation.
>
> Ultimately, the point here is that the only interesting synchronization that can be done at the property level is unordered atomic access, and for any properties that can't actually use an atomic load/store (either because they're aggregates or because they're reference-counted objects) you really do want to use a spinlock to minimize the cost. But adding a spinlock to every single property is a lot of wasted space (especially because safe spinlocks on iOS require a full word), which is why the Obj-C runtime uses those banks of spinlocks.
It seems like all of this could still be captured by a behavior, if you gave synchronized enough overloads for all the special cases.
> In any case, I guess what I'm saying is we should ditch the Container argument. It's basically only usable for classes, and even then it's kind of strange for a property to actually care about its container.
>
> var `foo.lazy` = lazy(var: Int.self, initializer: { 1738 })
>
> This actually won't work to replace existing lazy properties. It's legal today to write
>
> lazy var x: Int = self.y + 1
>
> This works because the initializer expression isn't actually run until the property is accessed. But if the initializer is passed to the behavior function, then it can't possibly reference `self` as that runs before stage-1 initialization.
Referencing `self` in `lazy` initializers is hit-or-miss today. This seems like another problem that could be solved by making the behavior function take only the parameters it's interested in. If lazy() doesn't accept an initializer, but Lazy's subscript operator does, then you know the initializer isn't consumed during init and can safely reference `self`.
> A property behavior can model "delayed" initialization behavior, where the DI rules for var and let properties are enforced dynamically rather than at compile time
>
> It looks to me that the only benefit this has versus IOUs is you can use a `let` instead of a `var`. It's worth pointing out that this actually doesn't even replace IOUs for @IBOutlets because it's commonly useful to use optional-chaining on outlets for code that might run before the view is loaded (and while optional chaining is possible with behavior access, it's a lot more awkward).
There are several advantages over IUO that I see. The behavior can ensure that a mutable delayed var never gets reset to nil, and can dynamically enforce that an immutable delayed var/let isn't reset after it's been initialized. It also communicates intent; as I mentioned to Matthew in another subthread,
>
> let (delayed) x: Int
> ...
> self.x.delayed.initialize(x)
> ...
>
> Allowing `let` here is actually a violation of Swift's otherwise-strict rules about `let`. Specifically, Delayed here is a struct, but initializing it requires it to be mutable. So `let (delayed) x: Int` can't actually ever be initialized. You could make it a class, but that's a fairly absurd performance penalty for something that provides basically the same behavior as IOUs. You do remark later in detailed design about how the backing storage is always `var`, which solves this at a technical level, but it still appears to the user as though they're mutating a `let` property and that's strictly illegal today.
>
> I think the right resolution here is just to remove the `letIn` constructor and use `var` for these properties. The behavior itself (e.g. delayed) can document write-once behavior if it wants to. Heck, that behavior was only enforcing write-once in a custom initialize() method anyway, so nothing about the API would actually change.
True, I'm on the fence as to whether allowing `let`s to be implemented with user code is a good idea without an effects model. Taking away the `let` functionality and having a separate `immutableDelayed` behavior is a more conservative design for sure.
> Resettable properties
>
> The implementation here is a bit weird. If the property is nil, it invokes the initializer expression, every single time it's accessed. And the underlying value is optional. This is really implemented basically like a lazy property that doesn't automatically initialize itself.
>
> Instead I'd expect a resettable property to have eager-initialization, and to just eagerly re-initialize the property whenever it's reset. This way the underlying storage isn't Optional, the initializer expression is invoked at more predictable times, and it only invokes the initializer once per reset.
>
> The problem with this change is the initializer expression needs to be provided to the behavior when reset() is invoked rather than when the getter/setter is called.
Yeah, the implementation here is admittedly contorted around the fact reset() can't receive the initializer expression directly, and we don't want to store it anywhere in the property.
>
>
> Property Observers need to somehow support the behavior of letting accessors reassign to the property without causing an infinite loop. They also need to support subclassing such that the observers are called in the correct order in the nested classes (and again, with reassignment, such that the reassigned value is visible to the future observers without starting the observer chain over again).
Yeah, overriding is an interesting problem that needs to be solved. An observer can at least reliably poke through the property by assigning to the `observable` behavior's property, which is more reliable than our current model.
> Speaking of that, how do behaviors interact with computed properties? A lazy computed property doesn't make sense (which is why the language doesn't allow it). But an NSCopying computed property is fine (the computed getter would be handed the copied value).
A behavior decides what kinds of property it supports by overload resolution. If you accept an `initializer`, you act like a stored property. If you accept `get` and `set` bodies, you can act like a computed property. You can also accept your own custom set of accessors if that's necessary.
>
> The backing property has internal visibility by default
>
> In most cases I'd recommend private by default. Just because I have an internal property doesn't mean the underlying implementation detail should be internal. In 100% of the cases where I've written a computed property backed by a second stored property (typically named with a _ prefix), the stored property is always private, because nobody has any business looking at it except for the class/struct it belongs to.
>
> Although actually, having said that, there's at least one behavior (resettable) that only makes sense if it's just as visible as the property itself (e.g. so it should be public on a public property).
>
> And come to think of it, just because the class designer didn't anticipate a desire to access the underlying storage of a lazy property (e.g. to check if it's been initialized yet) doesn't mean the user of the property doesn't have a reason to get at that.
>
> So I'm actually now leaning to making it default to the same accessibility as the property itself (e.g. public, if the property is public). Any behaviors that have internal implementation details that should never be exposed (e.g. memoized should never expose its box, but maybe it should expose an accessor to check if it's initialized) can mark those properties/methods as internal or private and that accessibility modifier would be obeyed. Which is to say, the behavior itself should always be accessible on a property, but implementation details of the behavior are subject to the normal accessibility rules there.
I don't think we can default to anything more than internal. Public behaviors become an API liability you can never resiliently change, and we generally design to ensure that API publication is a conscious design decision.
>
> A behavior declaration
>
> This has promise as well. By using a declaration like this, you can have basically a DSL (using contextual keywords) to specify things like whether it's lazy-initialized, decorators, and transformers. Same benefits as the protocol family (e.g. good compiler checking of the behavior definition before it's even used anywhere), allows for code code-completion too, and it doesn't litter the global function namespace with behavior names.
>
> The more I think about this, the more I think it's a good idea. Especially because it won't litter the global function namespace with behavior names. Behavior constructors should not be callable by the user, and behaviors may be named things we would love to use as function names anyway (if a behavior implements some functionality that is useful to be exposed to the user anyway, it can vend a type like your proposal has and people can just instantiate that type directly).
Agreed, I'm inclined to think a behavior decl is the way to go too.
-Joe
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.swift.org/pipermail/swift-evolution/attachments/20151217/c7ae17ae/attachment.html>
More information about the swift-evolution
mailing list