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

This was the one section of the revised pitch that I found confusing. Thanks for clarifying here.

Do I understand correctly that generic Itself could be overloaded to propagate the wrapper’s overloading to caller code?

func generic(value: Void) {}

generic(value: ()) // now calls LateInitialized’s “Value == Void” constrained initializer

If so, it would be great to extend the example with that. That is, after the current example add something like “Author’s can select between overloaded initializers by providing similar overloads in their declarations.”

—-
Stepping back, thanks for all the iteration on this! I’m thrilled with where this is ending up.

Yep, that's right! I'll add an example of this to the proposal.

I'm still not sold that supporting argument wrapper for Impl wrappers is a good idea, especially when local var wrapper has just been rolled out. Nonetheless, that's a disagreement I can (reluctantly) work with.


Have we considered other categorization heuristic? Given that we know exactly which init is possible at function decl (only the corresponding init(wrapppedValue:) and init(projectedValue:)), we could choose between Impl wrapper and API wrapper at func decl site. That would double the type checking required (not exponentiating it thankfully), but depending on how one spin it, it could be either a footgun or a feature.


I'm glad that we now infer the init from the decl instead of call site. I've been thinking that the old behaviour is weird and unintuitive all this time.


Property wrapper attributes can only be used on parameters in overridden functions or protocol witnesses if the original has the same property wrapper attributes.

This should be restricted to only API wrapper.


How does it work with implementation-detail wrapper w/ #line in the arguments? Do they just use the line of function decl? Maybe we should treat them as API wrapper, but Assert is not making this easy!


That's largely the reason we remove var in SE-0003. It gets confused w/ inout semantic. And well, supporting wrapped inout is also in the future direction.

Regardless, I think it's more inline with wrapping non-mutating let local variables, which doesn't exist (I'm sure this time!).

3 Likes

Just to clarify, for init(wrappedValue:) and init(projectedValue:), you're suggesting to 1) type check in the context of the defining module of the property wrapper to determine whether or not the wrapper can be API, and then 2) type check in the context of the defining module of the function to determine the actual initializer that will get called? I thought about that briefly. One issue that I thought of is if overload resolution chooses an @autoclosure init(wrappedValue:) in the module defining the wrapper but overload resolution chooses a regular init(wrappedValue:) in the module defining the function.

EDIT: This also sounds like a really complicated type checking rule to think through as a user.

Agreed. I'll clarify in the proposal.

Yeah, the magic literals like #file and #line will use the context of the function declaration. It's certainly possible to look for magic literals in default arguments to the wrapper initializers, but promoting a wrapper to API based on default arguments feels like it could be unexpected...

§Restrictions on API-level property-wrapper parameters

Non-instance methods cannot use property wrappers that require the enclosing self subscript.

Is this referring to the static subscript(instanceSelf:wrapped:storage:), which was a future direction of SE-0258?

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).