[Pitch #2] Property Delegates by Custom Attributes

@Douglas_Gregor can you clarify how delegateValue actually works? Why does the compiler know that $property should be seen as delegateValue? If I use an instance of such delegate not as a property delegate but as a plain type, the compiler won't opt-in into this new shadowing behavior right?

It's essentially the same thing as with value: the real storage property gets a truly-hidden name (heh, we could call it $$property) and $property wraps access to it:

var $property: Ref<Int> {
  get { return $$property.delegateValue }
  set { $$property.delegateValue = newValue }
}

That's correct.

Doug

If you’re really serious about $$property maybe it should be hidden except for out-of-line initialization. That seems a little strange, but being unable to initialize the delegate out-of-line isn’t great either...

One more thing, this idea has not to ship in the first implementation or ever, but I'd like to explore it at least here publicly. Can we potentially improve the way how we can have a 'safer' property delegate that will have a non-optional value, but the delegating computed property can stay optional?

For example UIApplicationDelegate requires a var window: UIWindow? { get set } member, but in reality most application have a non-optional root window. It would be cool to have a property delegate that implements the optional delegating property with a non-optional value (potentially delayed).

1 Like

What would the setter for window do when it receives a nil value?

One idea of mine was that we could teach the compiler to understand nil as some reset calls on the property delegate, but I'm not sure if this can scale enough to be predictable and more importantly understandable by the swift developers.

So if you had DelayedMutable for example, it could be the delegate for an optional type while preserving value as non-optional, and it will automatically call reset when newValue is nil. But I'm not 100% sure about this.


That said we could have some extra rule that says if the delegating property is optional while value is non-optional you must implement some resetValue method on the delegate.

My (additional?) 2¢ on the storage naming - I'm uncomfortable with having the compiler generate names like _foo because then you have automatically generated names that invade the space of user-definable names. I think it's dodgy on general principle, and people could conceivably be using underscores for other purposes so you'd have conflicts.

Between the two I'd prefer $foo, but I still like something more explicit and obvious like #storage(foo). As for # being reserved for macro-ish things, wouldn't this be a macro that expands to whatever the real mangled storage name is?

5 Likes

Right, bit of a gotcha because in all cases except this corner case the initializer would only every be called once – and probably most people would assume that to hold

How about using as to access the underlying API?

(x as Lazy).reset()

I am also not a fan of @ everywhere; it is noisy as hell.

Why not use something lighter, e.g.

var x: Lazy Int = ...
2 Likes

Lazy is a type, so this effectively a type coercion. We can't overload the as syntax further in expression content.

Doug

2 Likes

Is there any chance that we're overthinking it and storage(of:) would be sufficient?

9 Likes

One thing to consider for people eyeing a possible Observable type is that one of KVO's greatest strengths is its ability to handle multi-dot key paths. If you have x.y.z and you set y, then you've generally implicitly set z as well, and should be notifying + tearing down observations for the old y and adding them to the new y.

It's not 100% clear to me how that weights the tradeoffs being discussed here except inasmuch as it probably means that directly adding observers to properties is not the right API-facing model (it's likely a fine implementation model).

3 Likes

Rather than pollute the review thread, I thought I'd post my experiences with the latest toolchain for this feature here.

I'm attempting to create an observable type using an existing pattern I use (observable around NotificationCenter).

  • It seems that delegated values aren't compatible with automatic nil initialization. Is that intentional or just a limitation of the current implementation?
  • Initializers with additional, even defaulted, parameters can't meet the init(initialValue:) requirement. Again, is this intentional or just a limitation of the current implementation?
  • There doesn't seem to be any way to reference a delegated property in a protocol. I know you can't use the delegate syntax itself in the protocol, but not being able to reference it at all makes thinks like generic observable network APIs impossible, at least in a way that guarantees the properties being updated are actually observable. Any alternatives here?
Code I'm using ```swift @propertyDelegate final class DelegateObservable { typealias ObservationClosure = (_ value: T) -> Void
var value: Value {
    didSet {
        center.postNotification(named: name, with: value)
    }
}

let name: Notification.Name = Notification.Name(rawValue: UUID().uuidString)
let center: NotificationCenter = .default

init(initialValue: Value) {
    value = initialValue
}

//    init(initialValue: Value, name: String = UUID().uuidString, center: NotificationCenter = .default) {
//        value = initialValue
//        self.name = Notification.Name(rawValue: name)
//        self.center = center
//    }

func observe(returningCurrentValue: Bool = true,
             queue: OperationQueue = .main,
             handler: @escaping ObservationClosure<Value>) -> NotificationToken {
    if returningCurrentValue {
        handler(value)
    }
    
    return center.observe(notificationNamed: name, queue: queue) { handler($0.payload()) }
}

}

final class SomeModelController {
static let shared = SomeModelController()

@DelegateObservable private(set) var model: Int = 0
let timer: DispatchSourceTimer


init() {
    timer = DispatchSource.makeTimerSource()
    timer.schedule(deadline: .now() + 1, repeating: 1)
    timer.setEventHandler {
        print(self.model)
        self.model += 1
    }
    timer.resume()
}

}

extension NotificationCenter {
/// Convenience wrapper for addObserver(forName:object:queue:using:) that returns a NotificationToken.
public func observe(notificationNamed name: NSNotification.Name?,
object: Any? = nil,
queue: OperationQueue? = .main,
using block: @escaping (Notification) -> Void) -> NotificationToken {
let token = addObserver(forName: name, object: object, queue: queue, using: block)

    return NotificationToken(notificationCenter: self, token: token)
}

/// Convenience function to post a notification with a generic payload.
public func postNotification<Payload>(named name: Notification.Name, with payload: Payload) {
    let notification = Notification(name: name, payload: payload)
    post(notification)
}

}

public extension Notification {
init(name: Notification.Name, payload: Payload) {
self.init(name: name, object: nil, userInfo: [String.payload: payload])
}

func payload<Payload>() -> Payload {
    guard let payload = userInfo?[String.payload] as? Payload else {
        fatalError("Unexpected payload type: \(Payload.self)")
    }
    
    return payload
}

func transform<T, U>(_ closure: (_ payload: T) -> U) -> U {
    return closure(payload())
}

}

/// Wraps the observer token received from NotificationCenter.addObserver(forName:object:queue:using:)
/// and unregisters it in deinit.
public final class NotificationToken: NSObject {
let notificationCenter: NotificationCenter
let token: Any

init(notificationCenter: NotificationCenter = .default, token: Any) {
    self.notificationCenter = notificationCenter
    self.token = token
}

deinit {
    notificationCenter.removeObserver(token)
}

}

private extension String {
static let payload = "payload"
}

</details>

I hit that too.

It was intentional, because there is more than one way to initialize the backing storage property. However, that does feel inconsistent, and would cause trouble for (e.g.) replacing something like @IBOutlet. Let's call this a bug in the implementation.

Intentionally narrow, but we could lift this to "all parameters not named initialValue are either variadic or have defaults`.

Interesting. I don't have a good alternative here---I guess one could allow property delegates to be specified on properties in a protocol, and it means we have requirements for both the property and the backing storage property.

Thanks!

Doug

2 Likes

Heads up. The link to the proposal is 404ing

1 Like

I‘d guess because it‘s merged and renamed with an SE number.

I think this is the new proposal: https://github.com/apple/swift-evolution/blob/master/proposals/0258-property-delegates.md

We're discussing this over here now.