[Pitch #5] SE-0293: Extend property wrappers to function and closure parameters

Yes. There's an experimental implementation in the compiler.

1 Like

My idea is not to involve the wrapper decl module at all. And decide whether to use Impl or API expansion based solely on the init chosen at the function decl.

We could say that the existence of projectedValue in the wrapper decl module makes the wrapper API-viable (since init(projectedValue:) needs to match the type). Whether the the wrapper is treated as API or Impl then depends on the func decl itself.

The problem I see with the current heuristic is that the users can declare init with @autoclosure or projectedValue downstream, which is visible to the function declaration, but it can't use those init since the wrapper is Impl from the wrapper decl module.

We can outlaw these kind of usage but init with @autoclosure ought to be picked by local variable wrapper just fine, leading to a dissonance between local var wrapper and its supposed sugar.

Oh, I thought you were suggesting two levels of type checking because of your "double the type checking" comment. I don't think it's a good idea to allow the compiler to make a different decision based on which module the wrapper is applied in. I believe that how a certain type can be used as a property wrapper should be up to the author of the property wrapper, not the users of that property wrapper.

I also can't think of a situation where a client would want to overload init(wrappedValue:) to take in an @autoclosure instead of the plain wrapped value type. I don't think we should be designing the semantics of this feature for this case.

Perhaps that is true. I don't have a strong case for that to happen either. Though I think we should keep such scenarios in mind, especially when the users may wish to utilize an obviously API wrapper that isn't up-to-date upstream (in case of init by projected value). Who knows what the Userlandia is up to :woman_shrugging:. Also, it's usually minor inconsistencies like this that make a feature difficult to extend.

It's getting further and further away from being local var sugar, both deliberately and accidentally. Calling it as such might be garnering more confusion at this point.

The wrapper already needs to prepare for unexpected extensions (esp. w/ init), so why is it no good with the Imp vs API distinction? Even the notion of retroactive conformance and extensions all points toward the idea that the author just provides the base definition, and how it is used is up to the downstream users.

I don't think that putting that responsibility on the wrapper author is a feature (nor is it hugely detrimental, frankly speaking), but more of an inconvenience. I'd actually buy it more if the reason is that it unacceptably impacts the type checking performance, which is likely.

I'm even more inclined that if the function author opt-in to init-by-wrapped-only, then there's a good chance that they think of it as an implementation detail:

func foo(@Wrapper() x: ...)

In cases, like this, should Wrapper really API, even if we already disable init(projectedValue:)?

This is why I specifically said how it's used as a property wrapper, not as a regular type. Yes, you can extend types you don't own in Swift to add functionality, but this same principle doesn't apply the same way to property wrappers. For example, you can't extend the wrapper to add your own wrappedValue setter if the wrapper doesn't provide one. You also can't extend a wrapper and add your own projectedValue to opt into $ syntax when applying the wrapper to a property.

Property wrappers also already have special initializer lookup rules to determine whether a property wrapper can be initialized out of line or default initialized - lookup is done directly on the nominal type (it doesn't even look in extensions within the defining module, let alone outside of it). If it can be initialized using one of these special mechanisms, then the initializer expression is built and type checked normally, which is why I was considering two levels of type checking. The only exception is when you initialize the property wrapper inline using =. Then, the type-checked initializer expression is used to determine which type the memberwise initializer takes in, and whether the backing wrapper can be (re)-initialized out-of-line via wrapped value. I'm not sure if this was a deliberate design decision or an oversight, because it's certainly inconsistent.

Deciding whether a wrapper is part of a (potentially public) function signature, affects ABI, etc, is a very different and much more impactful decision, and it's not sound for this decision to be made differently across modules for the same property wrapper attribute.

No, it's not (I already answered this question in a different thread, and I will add this clarification to the proposal). The whole idea behind "API wrapper" is that a wrapper is only API if it needs to impact the argument at the call-site. This wrapper does not, unless its init(wrappedValue:) needs an @autoclosure.

Urgh... and I did just say inconsistencies make things difficult to extend :disappointed:. Welp, not much we can do about that.

That'd be nice. I re-read the entire proposal before commenting on that, and my impression is still that once API, always API. It is perhaps partly due to me not properly understanding this paragraph:

To ensure that the compiler always makes the same decision regardless of which module the property wrapper is used from, the compiler will only look in the defining module of the property wrapper for these initializers. To account for overloading and property wrapper composition, the compiler will infer the complete property wrapper interface type before determining whether the property wrapper has an external effect.

I've been interpreting that it checks the wrapper defining module for those initializers, then decide whether the wrapper is Impl or API. Once the decision is made, all wrappers are treated the same way (Impl vs API). So that example should be API if Wrapper has init(projectedValue:) in the wrapper defining module. Where did the reasoning go wrong? Maybe I'm just not understanding the meaning of interfact type correctly :face_with_monocle::face_with_monocle:.


In any case, thanks for pointing out the behaviour of the property wrapper! It's a much-needed information for me to figure how the entire thing holds against existing behaviour, even if it's not exactly fit for the proposal text. Unfortunately, it's so complex that I wouldn't be able to figure out from the experiments & the original proposal (SE-0258) alone :pleading_face:.

I do know some limitation that wrappedValue and projectedValue must be in the wrapper defining module, but didn't think that init is also this... complex, to put it lightly.

1 Like

Ah sorry, I think what's missing from the sentence I wrote is that the compiler will use the interface type of the property wrapper when applied to a declaration (and the fact that it's using interface types is probably irrelevant). Consider this function:

func generic<T>(@Binding @Logged value: T) { ... }

The full interface type of the property wrapper (i.e. _value) is Binding<Logged<T>>. The compiler will determine if the wrapper has an init(wrappedValue:) and/or init(projectedValue:) in the defining module of the wrapper that can take in Logged<T> (or an autoclosure thereof) and Binding<Logged<T>>, respectively.

So, the compiler will look in the defining module for initializers, but the result can be different based on which type the wrapper is applied to. However, for a given (applied) interface type, the compiler will always make the same decision regardless of which module the wrapper is applied in.

The part about attribute arguments "disabling" the wrappers in the function signature not being clear is just a result of poor wording on my part. The proposal says that this is one of the cases where the property wrapper must affect the argument at the call-site:

  1. The property wrapper supports projected-value initialization, allowing the caller to pass an instance of the projected-value type.

But this logic is slightly wrong here. It should say something like "If the caller is allowed to pass an instance of the projected-value type, which means the property wrapper supports projected-value initialization and there are no arguments in the wrapper attribute". I'll get this clarification in the proposal as well.

Totally agree that property wrapper initialization is extremely complex and it can be hard to figure out what's going on! I'm not sure if there's a way to simplify it without breaking code, but at the very least perhaps we can add some tooling (e.g. via SourceKit or SourceKitLSP) to help programmers figure out what's going on in synthesized code, especially when ad-hoc protocols like @propertyWrapper are involved.

3 Likes

I know I am bit late to the game here but is there a spelling for how to use these with closures?
e.g. if it is a part of the declaration of the closure itself:

func foo<Bar>(_ bar: Bar, _ apply: (@Wrapped Bar) -> Void) { }

This does not seem to work as I would expect:
it claims the following - ./main.swift:38:38: error: unknown attribute 'Wrapped'

was this part of the syntax considered?

There is, but the syntax goes in the closure expression and not in a function type. This is because property wrapper attributes are declaration attributes, not type attributes, so they have to be attached to a parameter declaration directly.

Yes.

How is it intended to guide API consumers to use the wrapped version versus the boxed version?

If the current syntax for declaring things is func foo<Bar>(_ bar: Bar, _ apply: (Wrapped<Bar>) -> Void) then it would be often used as the un-addorned closure which would not be the wrapped variant. I could see it to be quite useful to have the application of the property wrapper to be enforced to the consumer of foo in this case and that would allow for some nifty advances of the syntax for wrappers.

E.g. if I had a weak box wrapper perhaps I would want an API to primarily be used as a wrapper and rarely ever as it's boxing type:

@propertyWrapper
public struct Weak<Object: AnyObject> {
  public weak var wrappedValue: Object?
  public init(wrappedValue: Object?) {
    self.wrappedValue = wrappedValue
  }
}

func withWeakReference<Object: AnyObject>(_ object: Object, _ apply: (Weak<Object>) -> Void) {
  let weakRef = Weak(wrappedValue: object)
  apply(weakRef)
}

class Foo { 
  func bar() {
    print("bar")
  }
}

let f = Foo()
withWeakReference(f) { (@Weak obj) in
  
}

This comes into play when the closure becomes asynchronous. It would be highly useful for the ownership model to allow detached tasks to not cause references to their containers of whom is holding onto the task itself (which most likely many other APIs would rather this). So that means that it is more desirable that the common currency is the wrapped version of the variable. Rather than to force folks to access the .wrappedValue each time.

Obviously this does not apply in all cases but in some it could be leveraged quite well that the least amount of stuff to type is the intended use case. Whereas today with the proposal as it stands the $0 will require folks to add .wrappedValue all over the place until they get they can add the @Wrapper before their parameter.

This was also suggested in the previous review. I don't think there's anything in the current proposal preventing this in the future:

I also think it's out of scope for this proposal:

Wait, wait...

Perhaps I'm not understanding this correctly. @Lantua noted that his understanding of the proposal is that whether a property wrapper has API significance is based on what methods the property wrapper itself implements. This is consistent with that determination being a replacement for the previously proposed @propertyWrapper versus @propertyWrapper(api) distinction, which is also declared on the property wrapper itself.

If I read your reply correctly, whether a property wrapper has API significance also comes down to the point of use in the function's declaration and the same property wrapper type could have API significance for one argument but not for another?!

Yes. This was already the case with @autoclosure because init(wrappedValue:) can have overloads, which meant that the parameter type could impact the API decision. I had that noted in the proposal and again several times throughout this thread. Although, I've taken your suggestion and simply removed @autoclosure from the API model specifically to make the API decision much more consistent with how ad-hoc protocols are checked today (I did this late last night, and was planning to comment here today to notify everyone that I've made that change).

Arguments in the wrapper attribute constantly exhibit this phenomenon where they behave differently based on the way you write the wrapper attribute when applying it to a property. Consider this code:

@propertyWrapper
struct Clamping<Value: Comparable> {
  var wrappedValue: Value {
    get
    set
  }

  init(wrappedValue: Value, min: Value, max: Value)
}

struct S {
  @Clamping var value: Int
}

struct T {
  @Clamping(min: 0, max: 10) var value: Int = 4
}

struct U {
  @Clamping(wrappedValue: 4, min: 0, max: 10) var value: Int
}


S(value: 10) // error: cannot convert value of type 'Int' to expected type 'Clamping<Int>'

T(value: 10) // okay

U(value: 10) // error: cannot convert value of type 'Int' to expected type 'Clamping<Int>'

This difference in behavior also happens for a plain init(wrappedValue:) with no additional arguments when you provide the wrappedValue in the attribute versus via = or omission.

I think the way to solve this issue (not in this proposal) is to remove wrapper attribute arguments from property wrapper initialization altogether, and make them per-declaration static storage that is shared across each property wrapper instance. Of course, this would have to be a new capability of property wrappers, and the old model would still need to be supported for source compatibility.

If the inconsistency is deemed unacceptable for parameters, one alternative is to simply ban arguments in the wrapper attribute from API wrappers on the basis that they are expected to never change, and in the interest of consistency.

2 Likes

The error for S(value: 10) intuitively makes sense in that, well, the bounds of Clamping have to come from somewhere.

I do worry about the API significance of a property wrapper on a parameter being subject to similar differences in behavior, though. Perhaps it's overcautious, but I think the ban that you mention is justifiable for the time being (it can always be loosened later, say when the static storage feature is implemented later).