Pitch #2: Extend Property Wrappers to Function and Closure Parameters

Hello, Swift Community!

@filip-sakel and I have been working to refine the pitch for extending property wrappers to function and closure parameters. Below is the second iteration of the proposal draft.

Original pitch: Extend Property Wrappers to Function Arguments and Closure Parameters
Current draft proposal: https://github.com/filip-sakel/swift-evolution/blob/XXXX-extend-property-wrappers-to-functions-and-closures/proposals/XXXX-extend-property-wrappers-to-function-and-closure-parameters.md

We welcome any constructive feedback. Thank you!


Extend Property Wrappers to Function and Closure Parameters

Introduction

Property Wrappers were introduced in Swift 5.1, and have since become a popular feature abstracting away common accessor patterns for properties. Currently, applying a property wrapper is solely permitted on properties inside of a type context. However, with increasing adoption demand for extending where property wrappers can be applied has emerged. This proposal aims to extend property wrappers to function and closure parameters.

Motivation

Property wrappers have undoubtably been very successful. Applying a property wrapper to a property is enabled by an incredibly lightweight and expressive syntax. Therefore, library authors can expose complex behavior through easily understandable property-wrapper types in an efficient manner. For instance, frameworks such as SwiftUI and Combine introduce property wrappers such as State, Binding and Published respectively to expose elaborate behavior through a succint interface, helping craft expressive yet simple APIs. However, property wrappers are only applicable to type properties, shattering the illusion that they helped realize in the first place:

@propertyWrapper
struct Clamped<Value: Comparable> {

  init(
    wrappedValue: Value,
    to range: Range<Value>
  ) { ... }
  
  
  var wrappedValue: Value { 
    get { ... }
    set { ... }
  }
  
}


struct Percentage {

  @Clamped(to: 0 ... 100)
  var percent = 0
     
     
  mutating func increment() {
    percent += 1
    // Great!
  }

  mutating func adding(_ offset: Int) {
    percent += min(100, max(0, offset))
    //         ^~~~~~~~~~~~~~~~~~~~~~~~
    // We are forced to manually adjust 'offset' 
    // instead of utilizing the abstraction 
    // property wrappers offer.
  }

  mutating func adding(_ offset: Clamped<Int>) {
    //                   ^~~~~~~~~~~~~~~~~~~~
    // Unfortunately, we can't use 
    // '@Clamped(to: 0 ... 100)' here.
    
    percent += offset.wrappedValue
    //               ^~~~~~~~~~~~~
    // We must access 'wrappedValue' manually.
  }
  
}

As seen in the above example, it is quite awkward and unintuitive that property wrappers cannot be applied to function parameters. In this case, a property wrapper parameter would be useful for expressing and enforcing invariants about the offset argument to the adding method on Percentage. Inability to allow the property wrapper attribute on the offset parameter causes the API author to choose between making invariant checking an implementation detail, or forcing the invariant checking on every caller of the API.

This limitation in expressivity is emphasized by the fact that property wrappers were originally sought out to abstract away such patterns. As a result, elegant APIs are undermined by this limitation. Not only is this limiting users by forcing them to carefully read documentation, which may not cover a specific use case, to make sure no invariants have been violated, but it also limits API authors in what they can create. That is, API authors can't use property-wrapper types in closure parameters nor can code be seperated into functions that accept property wrapper syntax:

extension Percentage {

  func modify(
    inSeconds seconds: Int,
    block: @escaping (inout Clamped<Int>) -> Void
  ) { ... }
  
}


let myPercentage = Percentage(percent: 50)

myPercentage
  .modify(inSeconds: 3) { percent in
    percent.wrappedValue = 100
    //    ^~~~~~~~~~~~ 
    // Again, we have to access
    // count through 'wrappedValue'.
  }

In fact, establishing custom behavior on closure parameters is really powerful. For example, if such a feature were supported, it could be used in conjunction with Function Builders to expose data managed by a 'component' type. For instance, in SwiftUI ForEach could leverage this feature to expose the mutable state of its data source to its 'content' closure. This would enable users to more easily work with the data source itself inside the closure instead of accessing the original property, which is particularly painful when working with collections, as shown in this example:

struct MyView: View {

  // A simple Shopping Item that includes
  // a 'quantity' and a 'name' property.
  @State 
  private var shoppingItems: [Item]


  var body: some View {
    ForEach(0 ..< shoppingItems.count) { index in
  
      TextField("Enter the item's name...", $shoppingItems[index].name)
      
    }
  }
  
}

we would – with an appropriate initializer – be able to simplify the above code, reducing boilerplate as a result:

struct MyView: View {

  @State 
  private var shoppingItems: [Item]


  var body: some View {
    ForEach($shoppingItems) { @Binding shoppingItem in
    
      TextField("Enter the item's name...", $shoppingItem.name)
      
    }
  }
  
}

Proposed solution

We propose to extend the contexts were application of property-wrapper types is permitted. Namely, application of such types will be allowed on function and closure parameters:

Detailed design

Property wrappers are essentially sugar wrapping a given property with compiler synthesized code. This proposal retains this principle employing the following rules for transformation.

Property Wrappers on Function Parameters

Function parameters marked with a set of compatible property-wrapper custom attributes must conform to the following rules:

  1. Property wrapper function parameters must support initialization through their wrappedValue type. Therefore, all property-wrapper types must provide a suitable init(wrappedValue:).
  2. Each wrappedValue getter shall be nonmutating.
  3. Default values for such parameters must be expressed in terms of the innermost wrappedValue type.

Transformation of property-wrapper parameters will be performed as such:

  1. The external parameter name will remain unchanged.
  2. The internal parameter name will be prefixed with an underscore.
  3. The parameter will be bound to the backing property wrapper type.
  4. A local computed property representing wrappedValue will be synthesized by the compiler and named per the original (non-prefixed) parameter name. The accessors will mirror the wrappedValue accessors. A setter will only be synthesized for the local property if the wrappedValue setter is nonmutating, or if the wrapper is a reference type.
  5. If the property wrapper defines a projectedValue, a local computed property representing projectedValue will be synthesized by the compiler and named per the original parameter name prefixed with a dollar sign ($). The same accessor rules for wrappedValue apply to projectedValue.
  6. When passing an argument to a property wrapper parameter, the compiler will wrap the argument in the appropriate init(wrappedValue:) call.

Transformation Example:

@propertyWrapper
struct Percentage {

  init(wrappedValue: Int) { ... }
    
    
  var wrappedValue: Int {
    get { ... }
    set { ... }
  }
  
}

func reportProgress(
  @Percentage of progress: Int
) { ... }


reportProgress(of: 50)

In the above code, the reportProgress(of:) function and its caller are equivalent to:

func reportProgress(of _progress: Percentage) {

  var progress: Int {
    get { _progress.wrappedValue }
    // The setter accessor is not synthesized
    // because the setter of Percentage's  
    // 'wrappedValue' is mutating.
  }


  ...
  
}


reportProgress(of: Percentage(wrappedValue: 50))

Restrictions on Property Wrapper Function Parameters

@autoclosure

Function parameters with a property wrapper custom attribute cannot have an @autoclosure type. @autoclosure is unnecessary for the wrapped value itself because the wrapped value argument at the call-site will always be wrapped in a call to init(wrappedValue:), which already can support @autoclosure arguments. Using @autoclosure for the backing wrapper type would be misleading, because the type spelled out for the parameter would not be the true autoclosure type. Consider the reportProgress example from above with an @autoclosure parameter:

func reportProgress(
  @Percentage progress: @autoclosure () -> Int
) { ... }


reportProgress(of: 50)

The above code would be transformed to:

func reportProgress(
  progress _progress: @autoclosure () -> Percentage
) { ... }


reportProgress(of: Percentage(wrappedValue: 50))

The changing type of the @autoclosure is incredibly misleading, as it's not obvious that @autoclosure applies to the backing wrapper rather than the wrapped value. Therefore, using @autoclosure for a property wrapper function parameter will be a compiler error.

Property Wrappers on Closure Parameters

Since a property wrapper custom attribute is applied directly to the declaration that will be wrapped, application of a property wrapper type is only available within a closure expression. That is, the signature of a function that contains a closure cannot include the property wrapper attibute. Instead the application of the attrbute will be up to the caller of the function, which supplies the closure argument.

Closure parameters marked with a set of property wrapper custom attributes must conform to the following rules:

  1. Each wrapper attribute must not specify any arguments.
  2. Each wrappedValue getter shall be nonmutating.
  3. Any contextual type for the parameter must match the outermost backing wrapper type.

The transformation of a property wrapper closure parameter will take place as follows:

  1. The parameter name will be prefixed with an underscore.
  2. The parameter will be bound to the backing property wrapper type.
  3. A local computed property representing wrappedValue will be synthesized by the compiler and named per the original (non-prefixed) parameter name. The accessors will mirror the wrappedValue accessors. A setter will only be synthesized for the local property if the wrappedValue setter is nonmutating, or if the wrapper is a reference type.
  4. If the property wrapper defines a projectedValue, a local computed property representing projectedValue will be synthesized by the compiler and named per the original parameter name prefixed with a dollar sign ($). The same accessor rules for wrappedValue apply to projectedValue.

Transformation Example:

@propertyWrapper
struct Reference<Value> {

  init(
    getter: @escaping () -> Value,
    setter: @escaping (Value) -> Void
  ) { ... }
    
    
  var wrappedValue: Value {
    get 
    nonmutating set
  }
    
  var projectedValue: Self {
    self
  }
  
}

typealias A = (Reference<Int>) -> Void

let a: A = { @Reference reference in
  ...
}

In the above example, the closure a is equivalent to:

let a: A = { (_reference: Reference<Int>) in

  var reference: Int {
    get { 
      _reference.wrappedValue
    }
    set { 
      _reference.wrappedValue = newValue
    }
  }

  var $reference: Int {
    get { 
      _reference.projectedValue
    }
  }
    
    
  ...
  
}

Source compatibility

This is an additive change with no impact on source compatibility.

Effect on ABI stability

This is an additive change with no impact on the existing ABI.

Effect on API resilience

This proposal introduces the need for property wrapper custom attributes to become part of public API. A property wrapper applied to a function parameter changes the type of that parameter in the ABI, and it changes the way that function callers are compiled to pass an argument of that type. Thus, adding or removing a property wrapper on a public function parameter is an ABI-breaking change.

Alternatives Considered

Allow Property Wrapper Attributes as Type Attributes

One approach for marking closure parameters as property wrappers is to allow property wrapper custom attributes to be applied to types, such as:

func useReference(
  _ closure: (@Reference reference: Int) -> Void
) { ... }


useReference { reference in
  ...
}

This approach enables inference of the wrapper attribute on the closure parameter from context. However, this breaks the property wrapper declaration model, and it would force callers into the property wrapper syntax. This approach also raises questions about anonymous closure parameters that have an inferred property wrapper custom attribute. If an anonymous closure parameter $0 has the wrappedValue type, accessing the backing wrapper and projected value would naturally use _$0 and $$0, which are far from readable. If $0 has the backing wrapper type, this would mean that naming the parameter would cause the value to change types, which is very unexpected for the user. Finally, the property wrapper syntax is purely implementation detail for the closure body, which does not belong in the API signature.

Future Directions

Support Property Wrapper Initialization from a Projected Value

Today, a property wrapper can be initialized from an instance of its wrappedValue type if the wrapper provides a suitable init(wrappedValue:). The same initialization strategy is used in this proposal for property wrapper parameters to allow users to pass a wrapped value as a property wrapper argument. We could extend this model to support initializing a property wrapper from an instance of its projectedValue type by allowing property wrappers to define an init(projectedValue:) that follows the same rules as init(wrappedValue:). This could allow users to additionally pass a projected value as a property wrapper argument, like so:

@propertyWrapper
struct Clamped<Value: Comparable> {

  ...
  

  init(projectedValue: Self) { ... }
  
}

func distanceFromUpperBound(
  @Clamped clamped: Int
) { ... }


distanceFromUpperBound(
  $clamped: Clamped(to: 0 ... 100, wrappedValue: 30)
) // returns: 70

Add Support for inout Wrapped Parameters is Functions

This proposal doesn't currently support marking function parameters to which wrapper types have been applied inout. We deemed that this functionality would be better tackled by another proposal due to its implementation complexity. However, such a feature would be really useful for wrapper types with value semantics and it would simplify the mental model. Furthermore, it could alleviate some confusion for users that don't understand the difference between a setter with value semantics and one with reference semantics.

Accessing Enclosing Self from Wrapper Types

There's currently no public feature that allows a wrapper to access its enclosing Self type:

@propertyWrapper
struct Mirror<EnclosingSelf, Value, Path>
  where Path : KeyPath<EnclosingSelf, Value> { 
  
  let keyPath: Path 


  init(of keyPath: Path) { ... }
  
}


struct Point {

  private var _vector: SIMD2<Double>
  
  
  init(x: Double, y: Double) {
    self._vector = SIMD2(x: x, y: y)
  }
  
  
  @Mirror(of: \._vector.x)
  var x
  
  @Mirror(of: \._vector.y)
  var y
  
} ❌
// In the above use, we'd access the enclosing
// type's '_vector' property through the provided
// keyPath. However, today that's invalid.

Furthermore, extending this feature's availability to function and closure parameters be really powerful:

func valueAndIdPair<Value>(
  @Mirror of property: Value
) -> (value: Value, id: Int) {
  (value: property, id: $property.keyPath.hashValue)
}

It's important to note that allowing use of such a feature in function parameters would entail some limitations. For example, a parameter marked with a wrapper type referencing enclosing self would be an error for a non-instance method, as the accessors for the wrappedValue and projectedValue need an enclosing self instance.

Add Wrapper Types in the Standard Library

Adding wrapper types to the Standard Library has been discussed for types [such as @Atomic] and @Weak, which would facilitate certain APIs. Another interesting Standard Library wrapper type could be @UnsafePointer, which would be quite useful, as access of the pointee property is quite common:

let myPointer: UnsafePointer<UInt8> = ...

myPointer.pointee 
//        ^~~~~~~ 
// This is the accessor pattern property 
// wrappers were devised to tackle.

Instead of writing the above, in the future one might be able to write this:

let myInt = 0

withUnsafePointer(to: ...) { @UnsafePointer value in

  print(value) // 0
  
  $value.withMemoryRebound(to: UInt64.self {
    ... 
  }
  
}

As a result, unsafe code is not dominated by visually displeasing accesses to pointee members; rather, more natural and clear code is enabled.

56 Likes

This is fantastic! I only have relatively small comments below.

For the closure case, we might need to require parentheses around a single, attributed parameter to disambiguate between attributes that apply to the parameter and those that apply to the closure, eg, the ForEach closure would be:

{ (@Binding shoppingItem) in ...}

This bit is interesting. Ever since SE-0003, parameters have been immutable. I feel like we shouldn’t let property wrappers change that, even when the wrappedValue is nonmutating.

Property wrappers on stored struct properties already interact with parameters when we synthesize the memberwise initializer. Is there a common subset between that synthesis and this proposal that we can document here? Perhaps for some set of property wrappers the memberwise init can be described as if it used this feature?

I’m looking forward to using this feature, thank you Holly and Filip!

Doug

5 Likes

It'd be unfortunate that the rules around omitting parens around parameters would have to be changed in this one case (and, seemingly, not if the attribute would be applied to the second parameter). What do you have in mind with respect to attributes that "apply to the closure"?

1 Like

This bit is interesting. Ever since SE-0003 , parameters have been immutable. I feel like we shouldn’t let property wrappers change that, even when the wrappedValue is nonmutating.

This makes sense to me. I hadn't thought about the fact that even though mutating the parameter is possible with a nonmutating setter, mutation of the parameter won't be reflected in the (wrapped value) argument that was passed after the function returns.

Property wrappers on stored struct properties already interact with parameters when we synthesize the memberwise initializer. Is there a common subset between that synthesis and this proposal that we can document here? Perhaps for some set of property wrappers the memberwise init can be described as if it used this feature?

:+1:t2: I will write up a section about this! I think it makes sense to synthesize a property-wrapped parameter if the wrapper has an init(wrappedValue) (or perhaps init(projectedValue) in the future), but I need to think through whether this would impact existing code. I think it's source compatible within a module because the caller will pass the same type of argument regardless. Can generated memberwise inits somehow become public API?

This is a typo right? I think you mean offset, not percentage? Wouldn't the latter be clamped automatically? Btw in this case it doesn't seem to make sense to clamp offset, since the result will be clamped anyway, but that's a detail.

1 Like

And a question: how would this work in practice? It seems to me that this can't be achieved without adding an overload to ForEach? And possibly other compiler changes. Maybe I'm missing something here.

This is a typo right? I think you mean offset , not percentage ? Wouldn't the latter be clamped automatically? Btw in this case it doesn't seem to make sense to clamp offset, since the result will be clamped anyway, but that's a detail.

Yes, it's a typo - sorry for the confusion! In this case, clamping the offset does change the behavior if the offset is negative. It's a contrived example, but it's meant to remind the reader of assertions or adjustments they write on parameter values that would be useful to 1) write an abstraction for, and 2) be able to communicate to callers through the API itself.

And a question: how would this work in practice? It seems to me that this can't be achieved without adding an overload to ForEach ?

Yes, the proposal does call out that this specific example would require an appropriate ForEach initializer. It's meant to be an example of what library authors could create using this proposed feature. That said, this example would work with the existing ForEach initializer if you extend Binding to conform to RandomAccessCollection (where the binding value is a collection), so if you wanted to try out this example with the PR toolchain once it's ready, you can!

Maybe we can have parentheses-less case be application to argument, and require parentheses when applied to the whole closure:

{ @Foo a in /* wrap a */ }
{ @Foo (a) in /* wrap closure */ }

IMO, initialization from projected value should rather be passing the wrapper directly.

Haha that's exactly what I just tried, and it does work with Swift 5.3. Sorry I missed that you mentioned the new initialiser, I was confused by what the exact purpose was, it seemed to me that the annotation on the closure argument (@Binding shoppingItem) didn't add anything since the shoppingItem would already be a binding automatically.

In the clamp example I see the idea, you apply a transformation to the argument, that could be useful. But with bindings I'm confused, because if the shoppingItem in the closure isn't already a binding, it seems that we could never turn it into one on the fly in a meaningful way, I mean what would it then bind to?

extension Binding: RandomAccessCollection where Value: RandomAccessCollection { }

struct Item: Identifiable {
    var id: String
    var name: String
}

extension Binding: Identifiable where Value: Identifiable {
    public var id: Value.ID { wrappedValue.id }
}

struct MyView: View {
    @State private var items: [Item]

    var body: some View {
        ForEach($items) { item in // item is already Binding<Item>
            TextField("Enter the item's name...", text: item.name)
        }
    }
}

And of course you are right about clamping negative offsets.

I like this design~

Can the transformation be restricted to the function body, so that adding/removing @Percentage doesn't break ABI?

// Original
func reportProgress(
  @Percentage of progress: Int
) {
  if progress.isMultiple(of: 10) {
    print(progress)
  }
}
// Transformed
func reportProgress(
  of progress: Int
) {
  // Synthesized by the compiler.
#if __inout__(progress)
  var _progress = Percentage(wrappedValue: progress)
  defer { progress = _progress.wrappedValue }
  var progress: Int {
    get { _progress.wrappedValue }
    set { _progress.wrappedValue = newValue }
  }
#else
  let _progress = Percentage(wrappedValue: progress)
  var progress: Int { _progress.wrappedValue }
#endif
  do {
    // Unchanged from the original.
    if progress.isMultiple(of: 10) {
      print(progress)
    }
  }
}

The synthesized do statement exists so that progress can still be shadowed (SE-0003).

I think most of the Future Directions section could be moved into a new manifesto document.

1 Like

Can the transformation be restricted to the function body, so that adding/removing @Percentage doesn't break ABI?

I thought about this a little bit originally, but there are some issues with this approach:

  1. This limits the ability for overload resolution to choose init(wrappedValue:) based on the type of the argument passed, rather than the parameter type.
  2. This prevents us from being able to extend the feature to enable the call-site to pass a projected value or the backing wrapper directly.
1 Like

It's not "this one case", though. You have to parenthesize to add a type annotation:

_ = { i: Int in  // error: consecutive statements on a line must be separated by ';'
  print(i)
}

Dreadful error message, but the parentheses-less form is already very limited.

Calling conventions are one example that would fit into the language today, e.g.,

let callback = { @convention(c) result in print(result) }

Looking forward to concurrency, there might be some need for attributes on closures there that state something about where we expect the closure to be run (on a particular actor, concurrently with its context, etc.), so my concerns are somewhat speculative. I don't want to complicate the future for a local syntactic optimization here.

Doug

2 Likes

Best be if we just always require parentheses for closure with anything other than labels, which should be congruent w/ the current behaviour. We can just relax them later after we have more attributes. Though I feel like we could make it so that:

  • Require parentheses for cases w/ type annotation and/or type attributes.
  • Can omit parentheses for cases w/ only labels and/or declaration attributes.
  • Declaration attributes bind more strongly to the label than to the closure.
1 Like

I was confused by what the exact purpose was, it seemed to me that the annotation on the closure argument ( @Binding shoppingItem ) didn't add anything since the shoppingItem would already be a binding automatically.

Yes, the closure parameter is already a Binding. Using the @Binding attribute enables the property wrapper syntax in the closure body, so you can easily access the wrappedValue and the binding itself. Perhaps a more enlightening example of why you might want this is:

struct MyView: View {
    @State private var items: [Item]

    var body: some View {
        ForEach($items) { (@Binding item) in
            TextField(item.name, text: $item.name)
        }
    }
}

In your example, you'd have to write item.wrappedValue whenever you want to access the item itself.

IMO, initialization from projected value should rather be passing the wrapper directly .

Could you please elaborate on this suggestion? Are you suggesting using the syntax outlined in the future direction (e.g. fn($arg: backingWrapper)) but without wrapping the argument in a call to init(projectedValue:)? Or are you suggesting a different syntax for enabling passing the backing wrapper directly?

I personally think using the $ syntax for passing the backing wrapper directly would be confusing, because $ represents the projected value, which 1. not all property wrappers have, and 2. doesn't necessarily have the same type as the wrapper itself. I would think that if we support passing the backing wrapper directly, it'd naturally look something like fn(_arg: backingWrapper)

Neat. Is there any syntactic ambiguity that arises if these sit outside the braces? Seems apropos given our discussion on the other side about placing attributes ahead of the thing they’re labeling, and not after:

let callback = @convention(c) { ... }
// versus
let callback = { @convention(c) in ... }
4 Likes

I don't have a strong opinion on the syntax itself, but I think that it'd be more common to pass in the wrapper instead of the projected value. It is particularly important when the wrapper itself is class-bound.

1 Like

Doesn't this limitation also apply to memberwise initializers from the original SE-0258 proposal?

I'd be surprised if this was accepted. For comparison, we can't directly pass a closure argument to an @autoclosure parameter, and autoclosure forwarding is banned in Swift 5 mode (SR-5719).

If the reportProgress function is able to use @Clamped(to: 0...100) instead of @Percentage, the author wouldn't expect/want an argument with different limits, e.g. Clamped(wrappedValue: Int.min, to: Int.min ... Int.max)

Doesn't this limitation also apply to memberwise initializers from the original SE-0258 proposal?

Yes. As Doug alluded to earlier, this proposal can potentially improve the way memberwise initializers are generated when property wrappers are involved.

I'd be surprised if this was accepted. For comparison, we can't directly pass a closure argument to an @autoclosure parameter, and autoclosure forwarding is banned in Swift 5 mode (SR-5719).

I'm confused by the purpose of this comparison. What is this meant to illustrate?

If the reportProgress function is able to use @Clamped(to: 0...100) instead of @Percentage , the author wouldn't expect/want an argument with different limits, e.g. Clamped(wrappedValue: Int.min, to: Int.min ... Int.max)

I agree. In this proposal, property wrappers with additional arguments in the wrapper attribute are not supported on parameters (that's why we were trying to find a different example of a wrapper that doesn't need other arguments on the PR). There have been a few ideas for how to support this in the future while ensuring that callers can't change the arguments specified in the wrapper attribute.

Terms of Service

Privacy Policy

Cookie Policy