didSet called when initializing optional property with property wrapper

The code below initializes A.a and A.b without triggering their respective didSet, as expected. But when A.b has a property wrapper, didSet is called (both for the property wrapper and for the property itself).

The only way I found to work around this unexpected behaviour is to invert the order of property initialization in A.init (i.e. initialize A.b before A.a), which apparently makes the compiler realize that after initializing A.b there is still a non-optional property to be initialized, forcing it to ignore didSet.

Is this expected or a bug?

class A {
    var a: Int {
        didSet {
            print("didSet a", a)
        }
    }
//    @Wrapper var b: Int? {
    var b: Int? {
        didSet {
            print("didSet b", b)
        }
    }
    
    init(a: Int, b: Int?) {
        self.a = a
        self.b = b
    }
}

@propertyWrapper class Wrapper<Value> {
    var wrappedValue: Value {
        didSet {
            print("didSet wrapper", wrappedValue)
        }
    }
    
    init(wrappedValue: Value) {
        self.wrappedValue = wrappedValue
    }
}

let _ = A(a: 0, b: 1)
1 Like

Does the inconsistency still arise if you change the spelling of the type from Int? to Optional<Int>? I seem to remember reading something about that.

It doesn't happen with Optional<Int>... now I'm even more confused.

i'm not entirely sure this is precisely what's going on, but i think this is some sort of emergent confusing behavior from the interaction of various language features (and possibly under-specification of how things are supposed to work).

IIUC observing accessors (willSet/didSet) of instance properties will not be run until after the object is fully initialized. with 'regular' stored properties, it seems you actually can't trigger them from init without some indirection, even if self is known to be definitely initialized. you either need to use self from a non-initializer context, or alias the fully-initialized value and perform a mutation through that to get them to fire:

class X {
  var p: Int {
    didSet { print("set p") }
  }

  init() {
    self.p = 0  // not yet initialized here; `didSet` not called
    self.p = 1  // initialized, but observer not triggered... not sure exactly why
    let me = self // if we alias the fully initialized instance...
    me.p = 2      // then mutations trigger the property observers
    // prints: "set p"
  }
}

additionally, if you declare a mutable stored property of type T? i think it implicitly 'counts' as being initialized (to nil), so does not require being explicitly set in an initializer. i believe this is a special-cased behavior of mutable, optional properties that use the shorthand optional syntax specifically:

class One {
  let i: Int?
  init() {} // 🛑 Return from initializer without initializing all stored properties
}

class Two {
  var i: Optional<Int>
  init() {} // 🛑 Return from initializer without initializing all stored properties
}

class Three {
  var i: Int?
  init() {} // ✅
}

class Four {
  var i: Int? {
    didSet { print("set i") }
  }
  init() {
    let me = self
    me.i = 1  // prints: "set i"
  }
}

setting aside property wrappers, and using an approximate 'desugaring' of what they turn into, then the combination of these two things i think explains why you get the behavior you reported:

class A {
  var a: Int {
    didSet {
      print("didSet a", a)
    }
  }

  var _b: Int? {
    didSet {
      print("didSet _b \(_b, default: "nil")")
    }
  }
  var b: Int? {
    get { _b }
    set { _b = newValue }
    // for some reason you can't add property observers for 'manually'
    // wrapped properties, but you can with property wrappers...
    // some discussion here:
    // https://forums.swift.org/t/unexpected-behavior-with-property-wrappers-and-willset-didset/76887
  }

  init() {
    self.a = 0
    // at this point A is fully initialized as _b is `nil` by default
    self.b = 1 // calling this indirects through `b` and triggers the `_b` property observer
    // prints: "didSet _b 1"
  }
}

so... not sure if that's a particularly satisfying explanation because i agree it's still pretty confusing, but maybe it's better than nothing?

2 Likes

Thanks for your help. Since this is confusing behaviour to me, I created a bug report on GitHub.

2 Likes