Passing Custom Getter And Setter To Property Wrapper Initializer

Hello, Swift community!

A lot of use cases for property wrappers (like SwiftUI's @Binding or a simple @Lazy) rely on custom accessor closures of the form () -> WrappedValue and (WrappedValue) -> Void for the getter and the setter respectively. To that end, I'd like to suggest an improvement to the property wrapper mechanism to help improve the readability of such use cases.

Consider a the following property wrapper declaration:

@propertyWrapper
public struct Lazy<WrappedValue> {
	
	public init(_ makeWrappedValue: @escaping () -> WrappedValue) {
		self.makeWrappedValue = makeWrappedValue
	}

	public mutating var wrappedValue: WrappedValue {
		if let wrappedValue = theWrappedValue {
			return wrappedValue
		}
		let wrappedValue = makeWrappedValue()
		theWrappedValue = wrappedValue
		return wrappedValue
	}

	private let makeWrappedValue: () -> WrappedValue

	private var theWrappedValue: WrappedValue?
}

Although it looks fairly neat and readable, its use case certainly looks clunky:

final class ApplicationDelegate: NSApplicationDelegate {

	@Lazy({ NSWindow(/* ... */) })
	var mainWindow: NSWindow
}

Currently, properties with property wrapper annotations may not have a getter or a setter, so wouldn't it be a good idea to allow property wrappers to define special initializers that allow initializing the property wrapper with a getter and/or setter closures?

@propertyWrapper
public struct Lazy<WrappedValue> {
	
	public init(getWrappedValue makeWrappedValue: @escaping () -> WrappedValue) {
		self.makeWrappedValue = makeWrappedValue
	}

	public mutating var wrappedValue: WrappedValue {
		if let wrappedValue = theWrappedValue {
			return wrappedValue
		}
		let wrappedValue = makeWrappedValue()
		theWrappedValue = wrappedValue
		return wrappedValue
	}

	private let makeWrappedValue: () -> WrappedValue

	private var theWrappedValue: WrappedValue?
}

final class ApplicationDelegate: NSApplicationDelegate {

	@Lazy
	var mainWindow: NSWindow {
		NSWindow(/* ... */)
	}

Just like initializers with a special wrappedValue: WrappedValue parameter can take its value from the initialization expression, they would also treat the special getWrappedValue: @escaping () -> WrappedValue and setWrappedValue: @escaping (WrappedValue) -> Void as special parameters that can take their values to the declares getter and setter respectively.

The naming is, of course, highly bikesheddable.

What do you guys think?

1 Like

Does the Lazy example from proposal work for your use case?

@propertyWrapper
enum Lazy<Value> {
  case uninitialized(() -> Value)
  case initialized(Value)

  init(wrappedValue: @autoclosure @escaping () -> Value) {
    self = .uninitialized(wrappedValue)
  }

  var wrappedValue: Value {
    mutating get {
      switch self {
      case .uninitialized(let initializer):
        let value = initializer()
        self = .initialized(value)
        return value
      case .initialized(let value):
        return value
      }
    }
    set {
      self = .initialized(newValue)
    }
  }
}

Not really, because it doesn't include a custom setter like the one that can be passed to SwiftUI.Binding. I actually started out by using an autoclosure, but quickly ran into this problem. Also, when the subscript(_enclosingInstance: ... loses its underscore, the getter and setter passed to the initializer might take an extra self parameter, thus making Lazy and Binding very easy to implement and very easy to use.

I‘m not sure I can follow you, nor am I convinced to want the pitched feature as it makes things confusing. Your final syntax form is counterintuitive to me because you use the getter of the computed property which will be synthesized as your initialization getter, and if I understand you correctly you also want the same syntax for the setter.

Don‘t take me wrong, there is definitely something interesting hiding here. One quick idea could be some kind of partial initialization of nested property wrappers.

// `_mainWindow: Lazy<Binding<NSWindow>>`
@Lazy @Binding
var mainWindow: NSWindow = Binding(
  get: { ... },
  set: { newValue in
    ...
  }
)

The original motivation was to have this:

struct Circle: View {

    @State
    var radius: CGFloat = 1

    @Binding
    var diameter: CGFloat {
        get { radius * 2 }
        set { radius = newValue / 2 }
    }
}

Translated to this:

struct Circle: View {

    private var _radius: State<CGFloat> = .init(
        wrappedValue: 1
    )
    
    var radius: CGFloat {
        get { _radius.wrappedValue }
        set { _radius.wrappedValue = newValue }
    }

    var $radius: Binding<CGFloat> { _radius.projectedValue }

    private var _diameter: Binding<CGFloat> = .init(
        getWrappedValue: { (_ self: Circle) -> CGFloat in
            self.radius * 2
        },
        setWrappedValue: { (_ self: inout Circle, _ newValue: CGFloat) -> Void in
            self.diameter  = newValue / 2
        }
    )
    
    var diameter: CGFloat {
        get { _diameter.wrappedValue }
        set { _diameter.wrappedValue = newValue }
    }

    var $diameter: Binding<CGFloat> { _diameter.projectedValue }
}

I honestly don't get what would be confusing about this. Could you please elaborate on your opinion of the ergonomics of this syntax?

The problem is that you use the getter and setter of the computed property here! What if you want to add a didSet or willSet. Swift does not allow you to do that on regular computed properties today, this would create a strange exception. Any reader not familiar with property wrappers would not understand what these get and set will actually do, if they are part of the property or just translated to some closures.

Would this be a more elegant solution for you?

struct Circle: View {
  @State
  var radius: CGFloat = 1

  @Binding
  var diameter: CGFloat

  init() {
    _diameter = Binding(
      get: { self.radius * 2 },
      set: { newValue in self.radius = newValue / 2 }
    )
  }
}

I see what you're saying now. I think, if we rewind a little and think about why regular properties can't have both a setter and an observer (apparently, because "if you have a set, you can just put the contents of willSet before the line where you actually set the value and put the contents of didSet after"), we can apply the same logic to this case: If the property wrapper declares an initializer that accepts parameters that are mapped to the getter and/or the setter, then the property wrapper must acknowledge that doing anything non-obvious with those closures will lead to unexpected behavior and confusion (just like performing some non-trivial logic in a getter that is other that producing a value, preferably in constant time, is a bad idea). Having said that, assuming that those closures aren't called in a more-or-less expected way allows us to assume that the code directly before and directly after the line in the closure that actually does the setting can be though of as the willSet and didSet respectively and allowing explicit willSet and didSet when the property wrapper takes accessor closures can be forbidden without loss of functionality. Don't you agree?

Well, this, as far as I know, is exactly how it works today (except the fact, that there is no way to capture self in this scenario without optionals).

I get what you want, still I‘m not sure about that idea. I would like to hear some feedback from @Douglas_Gregor on this.

The original proposal included a way for the property wrapper to be initialised with an autoclosure of the wrappedValue, so you could define the wrapper initializer as:

public init(wrappedValue makeWrappedValue: @escaping () -> WrappedValue) {
	self.makeWrappedValue = makeWrappedValue
}

and use it as:

final class ApplicationDelegate: NSApplicationDelegate {
    @Lazy
    var mainWindow = NSWindow(/* ... */)

This should compile but doesn't work correctly at present - I'm working on it.

If that works, would you still want a getWrappedValue: / setWrappedValue: feature? I don't see a motivation for that because that's trying to improve the ergonomics of a very specific and limited use case (to pass closures that the property wrapper calls) that already works reasonably well with the current setup (like how Binding works).

Like a said earlier, the problem with the autoclosure setup doesn't allow specifying a custom setter.

What I mean is, you can pass the closures to the wrapper using alternate means, say:

struct Circle: View {
  @State
  var radius: CGFloat = 1

  @Binding(
    get: { self.radius * 2 },
    set: { newValue in self.radius = newValue / 2 })
  var diameter: CGFloat
}

which works reasonably well, but looks a bit odd.

Per my understanding, your suggestion is trying to improve how the code looks at the use site of a property wrapper in a specific scenario - when there's a need to pass getter and/or setter closures to a property wrapper. In my opinion, that's too specific and limited a scenario to warrant a change to the property wrappers feature.

Yes, that is exactly what I wanted to achieve.

If the use of init(wrappedValue: WrappedValue) allows the property wrapper to model stored properties, then the use of init(getWrappedValue: @escaping () -> WrappedValue, setWrappedValue: @escaping (WrappedValue) -> Void) would allow the property wrapper to model literally the only other type of property: computed property.
Why would you think that this is a limited scenario?

Maybe I'm seeing it as limited because I can't see its usefulness. Even in case of Binding, the closure-passing is not something a SwiftUI user would typically do. Can you present some use cases for what you're suggesting, like the many examples that were included in the proposal for property wrappers?

Just as a trivial use case:

struct RelativeCircle {

	@Clamped(0 ... 1)
	var radius: CGFloat = 1

	@Clamped(0 ... 2)
	var diameter: CGFloat {
		get { radius * 2 }
		set { radius = newValue / 2 }
	}
}

The overall pattern here is that many property wrappers don't care about the access mechanism of the property (whether it's stored or computed), and they care only about the specific logic that they apply to the access (whatever that access may be coming from).

1 Like

Why would you need a @Clamped(0 ... 2) for diameter? Wouldn't it work as what I think you intend with diameter being a regular computed property?

Terms of Service

Privacy Policy

Cookie Policy