SE-0293: Extend Property Wrappers to Function and Closure Parameters

This would be neat! I agree that this behavior more closely captures the 'traffics-in-wrapped-type' APIs that are not meant to be enabled by this feature.

Given our discussion, though, I'm not sure that I really consider this a feature. If argument wrappers are only intended to be used when you would normally pass the storage type, why is it a 'win' to make the call and declaration sites look as though they are accepting the wrapped type? Passing the wrapped type is not really "how to call the function" if the function is actually meant to traffic in the storage type—it's just a sugar to make initializing the storage type more convenient.

The more I think about this, the more I wonder if it's dangerously close to allowing the expression of certain implicit conversions (from the wrapped type to the storage type) in function argument position. That's why I think there's a lot of value in having some sort of signifier at the call site that something weird is going on.

Right, the $ syntax can really only have one meaning in this position, and it's certainly not obvious that my proposed meaning is the 'correct' one. I'm also not 100% sold in general on overloading $identifier to mean "insert some sort of property-wrapper magic here," but it does seem better than any alternative syntaxes I've seen suggested or come up with myself... :thinking: It's certainly a benefit that many more novice users who have never defined a property wrapper themselves likely already think of $ as being some sort of magical property wrapper operator (and indeed, I've seen it described as such even in official documentation!).

1 Like

I consider this a 'win' because it fits into the existing property wrapper initialization model (which IMO has issues, and I would probably design it differently if that were a possibility, but this is what we have to work with). Assigning a wrapped property to a wrapped value at the declaration or in init is not really "how to initialize the property". It's really initializing the backing wrapper, and the wrapped property itself is computed. It's really, really important to understand how this works because it impacts which code from the wrapper is actually called between init(wrappedValue:) and the wrappedValue setter. With parameters, at least there aren't two different things that could be happening (at the call-site, at least), but you still do need to understand that you're initializing a property wrapper with that line of code.

I get that this is kind of a crappy argument ("this is how it's always been, so it's okay!" :neutral_face:). I do like the idea to have some kind of syntax to make it obvious that property wrapper initialization is happening, but I don't think it's a good idea to have such a syntax for parameters and a different syntax (the regular syntax) for properties. Consistency with the existing design is equally important, in my opinion.

I do wish there were more semantics attached to $ for property wrappers. I think this would allow us to enforce more for property wrappers on parameters, and it might address some of the concerns in this thread, like the access control problem. For example, if $ is used as a mechanism to publicly expose the backing wrapper (which is super common), we might want to only allow wrapper attributes on parameters if the wrapper exposes itself publicly via $. This would also make the most sense for the $ calling syntax in order to not conflate the backing wrapper with the projection, unless they really mean the same thing.

1 Like

That is the point of scenarios 2 (and 3). The function argument wrapper caters to the utility wrapper, while the closure parameter wrapper caters to the (shared-) storage wrapper. It feels a lot more like two halves of two different features trying to make a single whole.

I deem this undesirable (and ignited this whole adventure ;). If we look at the function-closure pairs:

// Matching call-site
func foo1(@Wrapper a: Value) { ... }
let foo2 = { (a: Value) in
  @Wrapper var a = a
}

// Matching unapplied function reference
func foo3(a aTmp: Wrapper<Value>) {
  @Wrapper a: Value
  _a = aTmp
}
let foo4 = { (@Wrapper a: Int) in ... }

These parings/transformations hardly make senses. This is not a mental gymnastic I want to do on a regular basis.

It'd be much better if we have a simpler pairing:

// Matching call-site & UFR, whatever they are
func foo5(@Wrapper a: Value) { ... }
let foo6 = { (@Wrapper a: Value) in }

This feature could meet that criterion with some adjustment.

They sure can, but this feature aims to make it convenient. Right now I don't think there's any wrapper that would benefit from the function argument syntax. Storage wrappers surely don't, and I don't think utility wrappers do either (with the unapplied reference shenanigan).

Why is that desirable? Shouldn't UFR simply be a way to stow away functions for later usage? Why should it allow for more expressivity?


Maybe we can add support for those that are pass-by-storage first (e.g. proxy wrappers). They bypass the initialization process anyhow, and it should be common due to the currency proxy Binding. If the rule is:

  • Always pass-by-storage, and don't generate init(wrappedValue:) calling.

It would make function and closure much more consistent, make this feature look more like a single whole, and leave us with enough room for iteration if we want to add support for pass-by-wrapped (in both functions and closures).


On an overarching story, I think it's best if the decision on argument passing is done in three steps:

  • The wrapper author decides the supported traffic types via init.
  • The function author decides the exact type of init allowed for a particular argument. This argument can only use init(wrappedValue:), for example. We can put this in the function name: foo(a:) vs foo($a:), or just use annotations.
  • The user chooses, among the allowed init, the initializer to be used via type checking.

I think this should work pretty well.

The second step is quite crucial since I don't want to discount the possibility that a wrapper type can well support multiple traffic types (though I couldn't find any). It also solves the open-world problem too; when someone added more supported traffic types, causing ambiguity at the call site. If we include step 2, it'd be great if we can have uniform convention across functions, closures, subscripts, etc.

I'm totally with you until we run up against what is still the major sticking point for me: public functions. Neither of the issues you identify here are relevant across module boundaries. Direct initialization of a stored property is particularly straightforward, IMO, since it can only happen within the type declaration itself, and is immediately associated with the @Wrapper attribute.

But even property initialization in init always has to happen in-module (since initializers defined in out-of-module extensions have to delegate to an in-module initializer before accessing self). So we simply don't have a case today where library clients have to worry about the init(wrappedValue:)/wrappedValue setter distinction with regards to library types which have wrapped properties. The backing storage can basically be considered an implementation detail (modulo any API vended via projectedValue of course...).

Is this meant to be an argument in favor of the as-proposed call behavior, or am I misunderstanding? In my view, the proposal falls short on this point (hence my suggested explicit syntax for pass-by-wrapped calls).

I think I'm less troubled by this than you. I've mentioned above why I think the situation with direct initialization is unconcerning to me, but I'm also not worried about differences with the current situation for init arguments. If a type with some @Wrapped var foo: Int property exposes an init which takes a parameter foo: Int, why is it not an implementation detail that this init happens to create a new instance of Wrapped within its body? I could easily modify my type to drop the @Wrapped attribute altogether and the init could remain the same.

To me, that's a very different situation than what this proposal gives us, where an entire initializer call is injected at the call site, and overload resolution, etc. can be influenced locally. This is already a significant departure from the status quo, IMO. We'd basically be introducing a limited version of C++'s converting constructors at function-argument boundaries.

This sounds nice, but is it achievable in practice? I think all we would really be able to tell is that the property wrapper type vends something with the same type as Self—whether we actually return self or some other instance would be an implementation detail. But maybe having the same type for projectedValue would be sufficient for most 'normal' cases? (As a not-too-far-fetched example, imagine a Bool wrapper which vends Wrapper(!self) via projectedValue.)

Nice to see this proposal progressing; thanks for all your hard work on it! I think I mostly understand it and it seems like a +1, but I have a few questions:

  1. The proposal sez:

    (comment mine). Where did that Int come from? I may not really understand property wrappers yet, but I'd have thought that should have been Reference<Lowercased>.

  2. The Mutability of composed wrappedValue accessors section is a little unclear.

    • The algorithm is described in terms of an iteration over the composed property wrappers, but doesn't say whether it's inner-to-outer or vice-versa. If the direction doesn't actually matter, I wager the whole thing could be more simply and understandably described as rules in terms of the words “all” and “any”. If I'm wrong about that, I fear for users' ability to reason about nested property wrappers. That said, if we can't come up with simpler rules, could we have a walkthrough of some examples (and put it in the proposal for posterity)?
    • The first bullet seems to be describing an interaction between three property wrappers: the “next” one, the “previous” one, and the “composed” (which is presumably the current?) one. That seems really unlikely to be the correct interpretation. Can you clear that up?
    • The first bullet looks like it's unintentionally used the word “getter” where maybe it meant “accessor?”
  3. The use case I'm really concerned with is making imported C++ references ergonomic to work with. Specifically,

    struct X { var a, b: Int }
    func returnsReference() -> UnsafeCXXReference<X> { ... }
    func takesReference1(@UnsafeCXXReference _ y: X) {
     print(y.a) // prints the “a” property of some X instance
     takesReference2($y)
    }
    func takesReference2(@UnsafeCXXReference _ y: X) {
     print(y.b) // prints the “b” property of some X instance
    }
    takesReference1(returnsReference())
    

    If I understand correctly, what's in this proposal could “in spirit” (modulo any syntax mistakes I've made) be used to make everything but the last line work. I realize it's out of scope for this proposal, but would you be willing to discuss the possibility of extending the proposed feature, sometime in the future, to cover that last line? I just want to make sure that something like it isn't ruled out.

Thanks again,
Dave

2 Likes

Yea, that should be the type of Reference<Lowercased>.projectedValue, which is Reference<Lowercased>. Looks like a typo.

I'm pretty sure we can essentially remove the mutability section. Mutability of property wrappers should already be "well understood" since SE-0258 (Property Wrapper) whether or not it was explicitly defined there. Otherwise we wouldn't be able to use them on non-mutating struct.

I think we can even just say that the wrapper storage is immutable, and that should cover enough ground.

Argument wrapper has been compared to other declaration attributes a lot (understandably). However, among those attributes, argument wrapper doesn't have an escape hatch. You can easily bypass defaulted value & builder:

func foo(@Wrapper a: Int, b: Int = 0, c: @Builder () -> ())

foo(a: ...) { /* Builder */ } // Use default value & builder
foo(a: ..., b: 1) { return } // Don't use default value & builder

That is not the case for argument wrapper. The closest escape hatch is to use UFR, but that's a clean removal of all all declaration attributes. That's cheating:

let bar = foo
bar(..., 1) { return } // Use no attribute

If we want to treat them on equal footing, we should also have an escape hatch.

You wouldn't be able to declare takeReference at all.

Declaring takeReference needs UnsafeCXXReference to have init(wrappedValue:).

struct UnsafeCXXReference<Value> {
  init(wrappedValue: Value) { ... }
}

and you need to pass X, not UnsafeCXXReference<X>, to takeReference which is likely not what you want.

I've been figuring out what kind of wrapper will benefit from this proposal, and shared-storage wrapper doesn't seem to be the one.

I'm in fairly strong disagreement with this view, if I understand what you're saying correctly. It is not enough to rely on the idea that there must be broad and sufficient understanding of how the feature works because, after all, people use it. The rules need to be written down somewhere in a way that can be understood and analyzed, and if they currently aren't, I commend the proposal authors for trying (even if in an ideal world, it should be considered out of scope for this particular proposal)! Without a written explanation of the rules, for example, how can we ever resolve whether some part of the behavior is a bug?

As for your remarks about my point #3, thanks for the feedback, but to clarify, I am aware (I think) that the proposal as written does not cover that use case well. I am asking the proposal authors to speculate about what could be done, with extensions to what's being proposed here, to support it.

3 Likes

I agreed with that, but I don't think that this proposal is the right place to lay down the rule since it's not adding that mutation behaviour but will be relying on the behaviour designed (and implemented) prior.

Maybe, we can add an addendum to SE-0258 :thinking:. Of course, we need an accurate description of the rule first (which is what you’ve been trying to figure out).

1 Like

Whoops, this is a typo. You're right that it should be Reference<Lowercased>. Thanks for catching this!

The iteration over the wrapper composition chain is outer-to-inner. The direction does matter, because mutability of the outer accesses can be propagated inward.

Yes, I share this fear. It even took me a while to grasp how this works, and I certainly haven't yet figured out how to explain it to Swift users. I think walking through a few examples is a good idea; I can put this in the proposal for now, and we can get this documentation somewhere more visible later.

Let me try to rephrase and elaborate on my description of the algorithm.

The algorithm is computing the mutability of the synthesized accessors for a wrapped parameter (or property) with N attached property wrapper attributes. Attribute 1 is the outermost attribute, and attribute N is the innermost. The accessor mutability is the same as the mutability of the N th .wrappedValue access, e.g. _param.wrappedValue1.wrappedValue2. [...] .wrappedValueN

The mutability of the N th access is defined as follows:

  • If N = 1, the mutability of the access is the same as the mutability of the wrappedValue accessor in the 1st property wrapper.
  • Otherwise:
    • If the wrappedValue accessor in the N th property wrapper is nonmutating, then the N th access has the same mutability as the N - 1 th get access.
    • If the wrappedValue accessor in the N th property wrapper is mutating, then the N th access is mutating if the N - 1 th get or set access is mutating.

I think it's more natural to describe the algorithm recursively. Let me know whether this helps.

getter is intentional here. If the N th wrappedValue accessor is nonmutating, the mutability of the N th access only depends on the N - 1 th get access.

Yes, the intention is to extend the proposed feature in the future to support passing the backing wrapper directly - we just need to figure out an appropriate mechanism/syntax. The original idea was to use something similar to init(wrappedValue:) (e.g., init(projectedValue:)) but folks pointed out in the pitch thread that this wouldn't work for reference type wrappers. Another idea is to use $ with an argument label when calling the function as a "special" calling syntax for passing the backing wrapper directly.

Thank you for your constructive criticism on the proposal - this has been a great exercise for me in improving my technical writing! I'll work on getting these clarifications into the proposal.

3 Likes

Right - I don't think the type information is enough. By "more semantics attached to $," I meant something additional that the user had to explicitly write on var projectedValue, e.g. an attribute.

1 Like

I had a few more questions come to mind over the past few days.

  1. What effect (if any) do parameter wrappers have on the overloading/overload resolution behavior of the function itself? I'd expect there to be no effect (i.e., the addition of a wrapper attribute does not enable the ability to define any new overloads, nor affect how any existing overloads are resolved), but just want to make sure.

  2. This proposal has no effect on the requirement that function type signatures be fully-specified, right? I.e., this would be invalid:

@propertyWrapper
struct Wrapper { var wrappedValue: Int }

func foo(@Wrapper arg) {}

?

  1. Calling back to an exchange I had with @hborla in the pitch thread:

It seems like this restriction doesn't actually apply to init(wrappedValue:) overloads in extensions (maybe a bug?). Thus, the following produces a possibly unexpected result:

@propertyWrapper
struct Wrapper {
  var wrappedValue: Double
}

extension Wrapper {
  init(wrappedValue: Int) { // no error!
    self.init(wrappedValue: Double(wrappedValue + 1))
  }
}

struct S {
  @Wrapper
  var x: Double = 0
}

print(S().x) // 1.0

Presumably, this would also cause trouble in a situation like:

func foo(@Wrapper arg: Double)
foo(arg: 0)

which would result in the literal being inferred to have type Int, even though it's passed to a function which ostensibly takes a Double. Am I missing something?

1 Like

That's right - wrapper attributes don't add anything to the solver score nor are they factored into solution ranking.

Yes, that's right, you still have to specify the type of the parameter for functions. Only closures allow this inference.

Ah, yeah, this is because the code that does these checks on init(wrappedValue:) only looks directly inside the property wrapper declaration. I consider this a bug. And yes, it means that for this code:

@Wrapper var x: Double = 0

that literal is inferred as Int. Same goes for the function argument example. This is fixable, but I hope nobody is depending on this behavior...

1 Like

Thanks for your quick response, Holly!

Even if we find that there's too much existing code that relies on this behavior (yikes), I think we should probably still prohibit it at the call site of a wrapped-argument function, and require that the argument type be equal to the type of the wrappedValue property. For wrapped-property initialization, the initialization expression is at least attached directly to the wrapper attribute, so there's a bit more indication that something weird could be going on.

I agree, though, that if it's still an option to just prohibit such init(wrappedValue:) overloads in extensions, that's the preferable solution.

(Of course... if we required some syntax to make the pass-by-wrapped explicit, this wouldn't be as much of a concern... :innocent:)

1 Like

I think we can still use static functions, like passAsArgument(_:).

Though since a wrapper could have both init(wrappedValue:) and passAsArgument*, we'd need to be explicit somewhere between function declaration and call site which ones we want to use. We could rely on type checker for this, but that feels so wrong.

* And I still want pass-by-wrapper to come for free. It looks like a very common use case.

2 Likes

Agreed. I'm in favor of explicit syntax at the call-site. I also agree that relying on the type checker is a bad idea, both for performance reasons and because I think it would be confusing to the use the same argument label for pass-by-wrapped and pass-by-wrapper. This begs the question of what to do when there isn't an argument label, but we could always require a label if you want to be able to pass both. I think that's pretty natural, because today you use variations of the declaration name to specify which part of the property wrapper you want to use.

Agree here as well.

2 Likes

but then we need to annotate at the function declaration which one is allowed for nameless argument. That does sound like a pretty complex feature—to have annotations for both the func decl and call-site for a single decision (which one to pass).

Maybe we can bend the name a little bit. Say, if we ends up with prefix <prefix>a vs a, then we could use <prefix>:. Like foo(x) and foo($: x)

I'd even go so far to say that pass-by-wrapper should receive the highest affordance. That it is the "default" passing type, and have others passing type be annotated instead.

Ah well, so long as we can figure out highly ergonomic syntaxes.

1 Like

+1

I'm late to the discussion, and haven't got time to read through everything up-thread. Apologies if the following point has already been raised:

I think the proposed design is confusing to new learners and surprising to experts.

func postUrl(@Lowercased urlString: String) {
  guard let url = URL(string: urlString) else { return }
  ...
}

postUrl(urlString: "mySite.xyz/myUnformattedUsErNAme")

I believe that to many people, especially those who don't follow this proposal closely, postURL looks like of type String -> (). Because there is nothing at the call site that suggests otherwise. The only way for them to find the correct type is if they go to the function declaration directly, which could be in a 3rd party library and/or generated by a GYB file. Not every IDE is capable of providing a quick help like Xcode does.

The declaration site is also confusing, but to a lesser degree.

It can be much more clear if the property wrapper annotation is moved to the call site.

func postUrl(urlString: Lowercased) {
  guard let url = URL(string: urlString.wrappedValue) else { return }
  ...
}

postUrl(urlString: Lowercased(wrappedValue: "mySite.xyz/myUnformattedUsErNAme"))

postUrl(@Lowercased urlString: "mySite.xyz/myUnformattedUsErNAme")

It will reintroduce the problem of having to access wrapped value manually in the function body. But I believe the clarity at function call site outweighs the inconvenience in the function body.

3 Likes

I see the point of confusion, and in fact this feature has already been discussed in detail throughout the thread, so feel free to read the previous posts for more information. As @Jumhyn pointed out from the begging there are three substantial solutions: keeping the current behavior, banning unapplied function references, or changing the type of unapplied function references.

Retaining the current behavior has the notable advantage of having a simple mental medal once one learns about the property-wrapper transformation model. However, as you pointed out, it is somewhat confusing that despite the signature of a transformed function containing the storage/wrapper type, the call-site exposes just the property-wrapped type.

What would save us from all that complexity is banning unapplied references for now and providing a fix-it message as @Lantua proposed:

Evidently, a disadvantage of this direction is the restring of users and library authors, who may need to easily access the function type of a function with wrapped parameters.

As for the last case, changing unapplied references to exert a similar behavior to that of directly calling a function, seems like a viable alternative. Namely, since property-wrapper attributes on functions are already baked into the ABI, a similar transformation to that of said function calls could be made to also apply to unapplied references:

func postUrl(@Lowercased urlString: String) {
  guard let url = URL(string: urlString) else { return }
  ...
}

postUrl(urlString: "mySite.xyz/myUnformattedUsErNAme")

let unappliedReference = postUrl 

which would become:

func postUrl(urlString _urlString: Lowercased) {
  var urlString: String {
    get { _urlString.wrappedValue }
  }

  guard let url = URL(string: urlString) else { return }
  ...
}

postUrl(urlString: Lowercased(wrappedValue: "mySite.xyz/myUnformattedUsErNAme"))

let unappliedReference: (String) -> Void { 
  postUrl(urlString: Lowercased(wrappedValue: $0))
} 

This direction results in a more unified system in regards to where the storage type is being exposed.

1 Like