Enable adding `didSet`/`willSet` listeners away from definition of property

The order in which I structure my classes/structs is generally stored properties first, initializers, then all functional stuff such as computed properties, methods, static stuff, etc.

This makes it easier for me when refactoring a type as if I'm wondering whether the change should have an impact on the instance's state, I can scroll to the top and quickly look at the stored properties and after enumerating over each will know that either (a) something has to be updated or (b) I'm good.

This requires more reading if there is any functional code in that section of the type, as it's just more text I have to sort through in order to figure out what it is I have to do. There is minimizing/collapsing, but in my experience it's far from satisfactory as it doesn't necessarily persist across sessions or apply to all open windows displaying the type.

One workaround I've done in the past for this is to instead of using didSet or willSet, I made the property private, and then would just define a setter giving me the ability to do whatever I wanted before/after setting it. Obviously this approach requires care as the larger the type gets, the more likely it will be to forget using the setter and just assign new values to the property itself.

A solution to this would be if a single didSet/willSet listener could be defined separately from the property definition. This would allow me to organize it below the initializers and not crowd the stored property area.

2 Likes

How do you envision this? Something like this?

class C {
    var foo: Int
}

extension C.foo {
    willSet {
        print(newValue)
    }
    didSet {
        print(oldValue)
    }
}

No. Something more like this:

class C {

    let foo: Int

    // Other stored properties.

    init(_ foo: Int) {
        self.foo=foo
        // Initialize other stored properties.
    }

    foo {
        willSet {
            // Do stuff.
        }
        didSet {
            // Do stuff.
        }
    }
}

And this is the alternative you don't like?

class C {

    private var _foo: Int = 0
    
    // ...
    
    var foo: Int {
        get { _foo }
        set {
            // willSet set stuff
            _foo = newValue
            // didSet stuff
        }
    }
}

While I appreciate the motivation, I don't think that more language features would be that much better than what we have now:

class C {
  var foo: Int {
    willSet { foo_willSet(newValue) }
    didSet { foo_didSet(oldValue) }
  }

  init(_ foo: Int) {
    self.foo=foo
  }
}

private extension C {
  private func foo_willSet(_ newValue: Int) {

  }
  private func foo_didSet(_ oldValue: Int) {

  }
}

…or

class C {
  @Observed var foo: Int

  init(_ foo: Int) {
    _foo = .init(wrappedValue: foo)
    _foo.willSet = foo_willSet
    _foo.didSet = foo_didSet
  }
}

private extension C {
  private func foo_willSet(_ newValue: Int) {

  }
  private func foo_didSet(_ oldValue: Int, _ value: inout Int) {

  }
}
/// A workaround for limitations of Swift's observed properties.
///
/// Limitations of Swift's property observers:
/// 1. They are not mutable.
/// 2. They cannot be referenced as closures.
@propertyWrapper public struct Observed<Value> {
  public typealias WillSet = (_ newValue: Value) -> Void
  public typealias DidSet = (
    _ oldValue: Value,
    _ value: inout Value
  ) -> Void

  public var willSet: WillSet
  public var didSet: DidSet

  public var wrappedValue: Value {
    willSet { willSet(newValue) }
    didSet { didSet(oldValue, &wrappedValue) }
  }

  public var projectedValue: Self {
    get { self }
    set { self = newValue }
  }
}

// MARK: - public
public extension Observed {
  init(
    wrappedValue: Value,
    willSet: @escaping WillSet = { _ in },
    didSet: @escaping DidSet = { _, _ in }
  ) {
    self.wrappedValue = wrappedValue
    self.willSet = willSet
    self.didSet = didSet
  }
}
3 Likes

Yeah.

The property wrapper approach isn't bad. :+1:

Personally I tend to think that hidden side effects at the call sites are a bad thing. Your solution of using a function doesn't seem bad because it makes it obvious at the call sites that there are side effects. willSet/didSet/property-wrappers hide that one step further. If you were able to move those elsewhere then that would be pretty bad in terms of hidden side effects.

7 Likes

That includes many useful things I'm afraid. The mentioned willSet/didSet/property-wrappers, plain get/set, default function parameters, even a + b or a == b operation – do you want to write it as add(a, b), eq(a, b) to make it more obvious it can have side effects? x = nil can cause a spooky action at a distance effectively nulling out a weak reference in a different object in a different place of the app some time later. Tens and hundreds big and small useful things like these every swift developer uses every day.

@anon9791410 Does this code create a retain cycle by capturing foo_willSet and foo_didSet directly in the @Observed property?

class C {
  @Observed var foo: Int

  init(_ foo: Int) {
    _foo = .init(wrappedValue: foo)
    _foo.willSet = foo_willSet
    _foo.didSet = foo_didSet
  }
}

private extension C {
  private func foo_willSet(_ newValue: Int) {

  }
  private func foo_didSet(_ oldValue: Int, _ value: inout Int) {

  }
}

No, things like a + b and a == b generally do not have side-effects, if they did then that probably wouldn't be great. In practice I only had actual difficulties reading code that (ab)used didSets.

2 Likes

Thank you @anon9791410 for the examples.

When printing the value of foo, the second one crashes the program after complaining about Simultaneous accesses.

...
private extension C {
  private func foo_willSet (_ newValue: Int) {
      print ("\(type (of: self)): \(#function): \(foo) \(newValue)")
      // print ("\(type (of: self)): \(#function): \(newValue)")

  }
  private func foo_didSet (_ oldValue: Int, _ value: inout Int) {
      print ("\(type (of: self)): \(#function): \(foo) \(oldValue)")
      // print ("\(type (of: self)): \(#function): \(oldValue) \(value)")
  }
}
...
Example1.Type: main()...
C: foo_willSet(_:): 2 3
C: foo_didSet(_:): 3 2
C: foo_willSet(_:): 3 5
C: foo_didSet(_:): 5 3
Example2.Type: main()...
Simultaneous accesses to 0x600001708010, but modification requires exclusive access.
Previous access (a modification) started at ObservedProperty`C.foo.setter + 67 (0x100003cc3).
Current access (a read) started at:
0    libswiftCore.dylib                 0x00007ff815b7a790 swift::runtime::AccessSet::insert(swift::runtime::Access*, void*, void*, swift::ExclusivityFlags) + 442
1    libswiftCore.dylib                 0x00007ff815b7a9f0 swift_beginAccess + 66
2    ObservedProperty                   0x0000000100003c30 C.foo.getter + 49
3    ObservedProperty                   0x0000000100004890 C.foo_willSet(_:) + 470
4    ObservedProperty                   0x0000000100004850 implicit closure #2 in implicit closure #1 in C.init(_:) + 42
5    ObservedProperty                   0x00000001000045e0 thunk for @escaping @callee_guaranteed (@unowned Int) -> () + 15
6    ObservedProperty                   0x0000000100005260 Observed.wrappedValue.willset + 80
7    ObservedProperty                   0x0000000100005390 Observed.wrappedValue.setter + 176
8    ObservedProperty                   0x0000000100003c80 C.foo.setter + 91
9    ObservedProperty                   0x0000000100005bd0 static Example2.main() + 642
10   ObservedProperty                   0x0000000100003c10 main + 14
11   dyld                               0x00007ff80717f990 start + 2432

Example2 Details
private class C {
  @Observed var foo: Int

  init (_ foo: Int) {
    _foo = .init (wrappedValue: foo)
    _foo.willSet = foo_willSet
    _foo.didSet = foo_didSet
  }
}

private extension C {
  private func foo_willSet (_ newValue: Int) {
      print ("\(type (of: self)): \(#function): \(foo) \(newValue)")
      // print ("\(type (of: self)): \(#function): \(newValue)")

  }
  private func foo_didSet (_ oldValue: Int, _ value: inout Int) {
      print ("\(type (of: self)): \(#function): \(foo) \(oldValue)")
      // print ("\(type (of: self)): \(#function): \(oldValue) \(value)")
  }
}

/// A workaround for limitations of Swift's observed properties.
///
/// Limitations of Swift's property observers:
/// 1. They are not mutable.
/// 2. They cannot be referenced as closures.
///
@propertyWrapper
public struct Observed <Value> {
  public typealias WillSet = (_ newValue: Value) -> Void
  public typealias DidSet  = (_ oldValue: Value, _ value: inout Value) -> Void

  public var willSet: WillSet
  public var didSet:  DidSet

  public var wrappedValue: Value {
    willSet { willSet (newValue) }
    didSet  { didSet  (oldValue, &wrappedValue) }
  }

  public var projectedValue: Self {
    get { self }
    set { self = newValue }
  }
}

// MARK: - public
public extension Observed {
  init (wrappedValue: Value,
    willSet: @escaping WillSet = { _ in },
    didSet:  @escaping DidSet  = { _, _ in }
  ) {
    self.wrappedValue = wrappedValue
    self.willSet = willSet
    self.didSet = didSet
  }
}

enum Example2 {
    static func main () {
        print ("\(type (of: self)): \(#function)...")
        let c = C (2)
        
        c.foo = 3
        c.foo = 5
    }
}

I can't explain why this is happening. Any insights?

Of course. It was just the easiest way to write the example. I recommend unowned.

If you're going to write code that refers to an instance, while it is being set, it's going to need to be a reference type—i.e. make Observed a class, not a struct. In either case, refer to the property in didSet as value, not foo.

In any case, the Observed wrapper can't have as much functionality as the missing language feature it partially recreates would have.