[Pitch #4] SE-0293: Extend Property Wrappers to Function and Closure Parameters

FWIW, I don't actually think this is that big of a footgun, which is why I'm also comfortable with the synthesized witnesses in the same-name case. I wanted to question what it is that we're protecting against with the synthesized witness that isn't also a problem for the "simple forwarding wrapper."

I guess the reason it seems more pertinent in this revision is because we're attempting to address the protocol conformance situation head-on. I was happy with the previous proposal, with the expectation that somewhere down the line, when we addressed protocol conformances with wrapped-argument functions, I'd likely be advocating for the same solution I am now.

My interpretation was that this revision presents the following (complete) answer for the protocol conformance question: functions with implementation-detail argument wrappers may satisfy protocol requirements without wrappers, but otherwise the wrappers must match. Is that accurate?

Are you talking about the same-name or different-name case here? In the different-name case, writing a wrapper function is exactly the first step I'd expect someone to take when implementing a conformance. In the same-name case, yeah, the first step is probably to write extension S: P {}, then get an error about "argument wrapper mismatch," but the very next attempt (I expect) would be to write out the requirement signature (without wrappers), at which point the user would get the "invalid redeclaration" error, and then what?

I see, so am I understanding the line of thought here correctly as "even if we synthesize the witness, if @Foo is a wrapper with no init(wrappedValue:) (but with an init(projectedValue:)) then we'd still have a situation where the user cannot (easily) write out a conformance by hand"?

I think I'm missing what solution this revision provides for such property wrappers. After all, like you said, the "new thing" in this revision is the implementation-detail wrapper which AFAICT must support init(wrappedValue:).

If I understand correctly, your argument won the day; there was broad acceptance that a design predicated on this notion was acceptable and the core team has accepted this in principle.

Right, hence the note from the core team that one possible way to compose this proposal with protocol conformance would be to allow functions to fulfill requirements with arguments of the wrapped type.

I guess I'm not seeing here why consideration of this--which would be consistent with your argument--prompted this current revision that jettisons the argument wholesale and places the responsibility of determining whether property wrappers have API significance on users of the feature.

By the same token, then, we could achieve parameter-level control of "API-ness" without foisting the responsibility on property wrapper authors, and abolish @propertyWrapper(api) as a user-facing concept entirely, by sinking @propertyWrapper(api) into a stdlib-internal underscored @propertyWrapper(_api) feature and vending only a single @API property wrapper defined as you illustrate above.

I don't think it's a fatal flaw that not all property wrappers can be composed with protocol requirements. I agree that it's basically saying that only those with init(wrappedValue:) work, but if that's inherent to a cogent model, then so be it. There are already various rules about what functions can be protocol witnesses, and users will be able to understand that a parameter's wrapper must be initializable from a wrapped value in order for a function to fulfill a protocol requirement. Certainly from that standpoint it will be less arbitrary than telling the user that the author of a property wrapper did or did not choose to write (api).

As proposed for now, protocol requirements on actors must be fulfilled by nonisolated (aka @actorIndependent) functions. I have not thought through the implications of how that would compose with this feature, but in a real sense, that is in no small part properly up to the authors of the actor proposal.


I want to return to my sandwich bag analogy. I really like it:

The sandwich is the motivating use case. The bag is what we're proposing as the design. The point I want to make here is that a sandwich bag is the way it is, not because it's adequate at holding all sandwiches, but because it's well suited to holding most sandwiches. That a sandwich bag can't fit a footlong sub doesn't make it a bad sandwich bag, not at all. In fact, sandwich bags would be strictly worse if they were all so big as to fit footlong subs.

It's certainly reasonable for the core team to want a story for how property wrappers for parameters will compose with protocol conformance. But a wholesale redesign of the proposal in order to accommodate how parameters for unsafe transfer of otherwise non-ConcurrentValue arguments to actor methods which are also meant to fulfill protocol requirements which haven't themselves been declared with the same unsafe transfer wrapper: that's redesigning the sandwich bag to fit the footlong sub.

2 Likes

Honestly, I think part of the issue here is miscommunication. When I talked through the core team feedback with John, what I took away was that the protocol conformance issue was a fundamental modeling issue with the signature of a function with wrapped parameters. Perhaps my mistake was not having that conversation directly on the decision thread. I would appreciate some clarification and guidance here from @John_McCall and @Chris_Lattner3

To clarify, this revision has nothing to do with this use case. It isn't even one of the motivating examples for this feature. It was just an example that popped into my mind for init(wrappedValue:) wrappers that aren't necessarily supposed to be implementation detail, and as John mentioned, it's probably not a good use case for this feature anyway.

2 Likes

I'm not sure why you've spent so many words calling out UnsafeTransfer here, because UnsafeTransfer is a wrapper which clearly needs caller cooperation and is therefore adequately modeled by previous versions of the proposal. Some wrappers do not need that sort of cooperation to function properly, e.g. because they simply transform or observe the argument value. Many of the examples given in previous discussions fall into that category (although there are certainly counter-examples like SwiftUI's Binding). There is no reason for such a wrapper to be anything other than an implementation detail of the function; it certainly should not change the function's type or ABI or limit its usefulness in protocols.

We are creating a syntax here for programmers to modify their parameters with an attribute. Programmers will take advantage of this for all sorts of things, both more complex and more trivial than we imagine. We should aim to minimize the amount of unexpected surprise it introduces. No one should have to reason like, "if you use this attribute here, it's going to end up affecting how calls work, not because it actually needs to for this attribute, but just because the attribute is implemented using a feature that always does that." That is a trap for the unwary.

1 Like

Which, I think, is reflected in the design as reviewed previously and the discussion above. And for the exact reason you articulate, a type-level annotation for property wrappers which changes how calls work for a function with a wrapped parameter raises the trap you mention above. We seem to agree entirely; what am I missing?

How do the implementation-detail wrappers work at call sites? Do we still get call site overload resolution for them? I think the best way is to still use call site resolution, even for implementation-detail wrappers, but convert them to "local wrapper sugar" when witnessing requirements.


Given that the API wrappers don't witness requirements, would anything change for its unapplied method reference? They have all the same questions about function boundaries and stuff. It would be peculiar if I can't witness a protocol because of its incorrect API boundary, but I can grab just about the same kind of closure without any problem.

From the proposal:

func insert(@Logged text: String) { ... }

The above code is sugar for:

func insert(text: String) {
 @Logged var text = text
}

which the compiler further desugars to generate:

func insert(text: String) {
  var _text: Logged<String> = Logged(wrappedValue: text)

  var text: String { _text.wrappedValue }
}

I.e., implementation detail wrappers really are totally internal to the function.

This is a good point. It does seem odd that we'd be okay with easily discarding the API in one circumstance, but not another.

1 Like

That's what I'd suspect, though I'd like a confirmation since I do have a fair amount of opinion on that, and I'd like to make sure we're not talking past each other (as I do).

That that’s not how the feature worked before, and that using a parameter wrapper actually always impacted the ABI and the ability of the function to satisfy protocol requirements.

That is correct.

2 Likes

I think losing parts of the API through the protocol interface, which contains every part of the API, versus through a higher-order function, which loses every part of the API except for its type, are very different. The problem I brought up was changing the context in which the wrapping is done based on how the function is called. This isn't an issue for the unapplied function reference, because the wrapping is still done in the context of the caller. You can also use an unapplied reference in a way which takes in the projected value type. You cannot do this with the suggested protocol witness approach.

Like I said to @Jumhyn above, it might help to find a real example where you think this is an issue, because I think most of this is stemming from the poor example I brought up earlier. That example is also special, because if you try to circumvent the wrapper you simply cannot make the code compile without making the wrapper unnecessary anyway.

1 Like

If the UFR is called immediately after being created, sure. But what's the practical difference between this:

func writeStuff(using: (String) -> Void) { ... }

struct NonEmptyPrinter {
  func write(@Asserted(.nonEmpty) _ value: String) { print(value) }
}

let printer = NonEmptyPrinter()

writeStuff(using: printer.write)

and this

protocol Writer {
  func write(_: String)
}

struct NonEmptyPrinter {
  func write(@Asserted(.nonEmpty) _ value: String) { print(value) }
}

func writeStuff(using writer: Writer) { ... }

extension NonEmptyPrinter: Writer {}

writeStuff(using: NonEmptyPrinter())

?

You're right, that doesn't seem like it causes a problem. The problem we talked about earlier was changing concurrency domains, but again, I don't want to get caught up on that example.

I'm going to reiterate my main pushback against the approach you're suggesting.

  • It achieves the same thing as the implementation-detail property wrapper as pitched here, but with a huge implementation cost. This approach really feels like a hack around a fundamental modeling issue. The implementation-detail property wrapper basically comes for free. There's also an increased code size with no benefit that I can see.
  • Most API property wrappers are API because they opted into projectedValue. I expect many (but not all) of these wrappers to also adopt init(projectedValue:). I think it's totally reasonable to want such a wrapper in a protocol requirement and enforced on conformances, and allow all callers to pass the projected value.

Yes, the second part depends on another proposal, but the formalized distinction between implementation-detail wrappers and API wrappers gives that proposal a nice path forward, ending the "are property wrappers implementation-detail or API?" debate once and for all.

I would also like to reiterate that I think most property wrappers are implementation detail, and would fall under the exact semantics that you all would like with respect to protocol witnesses.

3 Likes

Point taken—it's a valid position that the implementation complexity of the "witness-by-wrapped" semantics is simply too high for whatever benefits it gives us. I think you've made a decent case against that strategy, even if it comes down to us simply valuing the tradeoffs differently. Unsurprisingly, as someone who isn't working on the implementation of this feature, implementation cost has ranked fairly low on my list. :slight_smile:

I don't really have anything new to say in favor of the "witness-by-wrapped" strategy, so I'm going to drop that thread for now and focus instead on the semantics of these "API wrappers".

I don't really view the introduction as @propertyWrapper(api) as "free" from the standpoint of the language model. Introducing two kinds of property wrappers with subtly different behavior in certain edge cases introduces its own complexity and explainability burden. I'd really love if we could come up with a way to make this distinction based on the semantics the property wrapper actually implements. I think error messages like:

Error: 'S.foo' cannot satisfy requirement 'P.foo' because wrapper '@Wrapper' does not implement 'init(wrappedValue:)'.

are immensely better and more explainable than

Error 'S.foo' cannot satisfy requirement 'P.foo' because wrapper '@Wrapper' is an API-level property wrapper.

Now, I'm sure you've spent considerably more time thinking about this proposal that I have, and so if you've determined that there's really no suitable proxy for "API-ness" short of forcing the property wrapper to specify explicitly, I'm inclined to accept that.

FWIW, I think it's also totally reasonable for a protocol to tag a an argument of a requirement with an implementation detail wrapper like, say, @Logged, and have that wrapper applied at the call site regardless of whether the conforming type provides that wrapper, so it's not obvious to me that API-ness is even a suitable proxy for "should this be allowed in protocol requirements".

Which is another concern I have about introducing an attribute parameterization like this. I appreciate that there's been some thought put into how it might be applicable to future situations, but I I'm skeptical that we've done a thorough exploration of the potential future design space for property wrappers. Will we find another use case that will require yet another parameterization? Would that use case influence our design of api if we could anticipate it now?

The proposal obviously can't do the work of considering every possible future extension of property wrappers, but to me, introducing a new parameterization on an attribute is a relatively "heavy" solution so I would personally want a bit more confidence that it will be generally applicable in multiple likely future directions in order to feel like it will pull its weight in the long run. Given that the main future direction called out in the proposal for @propertyWrapper(api) is one that I have concerns about, it's a bit hard for me to envision how else the api parameterization could be useful.

2 Likes

I'm aware of the protocol conformance shortcomings which were not dealt with in the previous version of the proposal. As I have said, I certainly agree with the feedback to address that issue. (Regarding ABI, more below.)

Regarding aspects of the user-facing model that were included in the previously considered version, though, great effort was made precisely to align with this notion that you have articulated. For instance, that version made a change so that an unapplied function reference to func log<Value>(@Traceable value: Value) has type (Value) -> Void, requiring a compiler-generated thunk.

As I have said before, I felt that this was definitely an improvement, and the core team's feedback (by my read) called out approvingly how the proposal adhered to exposing such a user-facing model. The core team feedback indicated (again, by my read) that the task remaining was to articulate how the proposed feature (now renamed) would dovetail with protocol conformance.

Where I'm lost now is how that effort (to which my simplemindedness lends itself to inquiring: is the solution not just the right synthesized thunks?) leads to the pitch presented here. As far as I understand, in this version:

  • The feature we worked to design over two previous iterations is now renamed as @propertyWrapper(api). Which is to say, it is no longer about extending the use of existing property wrappers to function parameters: existing property wrappers, when used in that scenario, will no longer behave in the way that we spent the previous two iterations of this proposal discussing.
  • That feature which we previously designed still doesn't dovetail with protocol conformance and still has an ABI impact. And although much effort was made to eliminate user surprise exactly in line with the principles we agreed on (for example, by changing how unapplied function references work, as discussed above), the feature is now supposed to have API impact, even as it still retains those changes we made above (for example, it still uses a thunk to hide the API impact in the case of unapplied function references).
  • An entirely new feature, which is syntactic sugar for callee-side wrapping that was explicitly argued against in each prior version of the proposal, now takes over as what happens when a user makes use of an existing property wrapper in function parameters.

The concerns about ABI weren't mentioned in the core team feedback, to my knowledge. The proposal has always made arguments in favor of caller-side wrapping, which after all is in line with caller-side default argument evaluation. If we accept those arguments (and I had thought the core team did in fact accept them), then I think it naturally leads to that was arrived at in terms of impact on ABI.

If the argument is that limitations that arise because of caller-side wrapping are too surprising to the user, this revision of the proposal does not do away with the need for users to understand it. In fact, by having two different flavors of property wrapper which are spelled identically at the function declaration site, users will actively have to reason about which ones are wrapped on the caller's side and which on the callee's side.

1 Like

I wonder if you could elaborate on this a bit here. In the proposal, you write:

Is it not sufficient for the compiler also to synthesize the following at the point of conformance (recognizing, of course, that that still requires implementation work)?

func insert(test: String) { ... }
1 Like

If you take a look at the Alternatives Considered section which argues against this (I haven't touched this section yet so it's still there), you'll see that most of the arguments don't apply to implementation-detail wrappers.

  • You cannot pass a projected value. Okay, that's fine -- it's been brought up in this thread that if a property wrapper has var projectedValue / init(projectedValue:), it's probably supposed to be at the API level. I've been thinking about potential warnings in situations where the compiler may be able to detect that a property wrapper is adding API.
  • Attribute arguments become resilient. Who cares? Clients don't know about those attribute arguments anyway, because the entire wrapper is implementation detail to the library.

The last point is one that I've been questioning anyway, and it's overload resolution. @Lantua brought up the issue that overload resolution is inconsistent between these two models. I was already thinking that overload resolution should be done at the declaration site anyway in all cases, because it's a simpler model and it leaves the function author in control of which initializer is called.

I did not say it's not sufficient or possible. I said it has a huge implementation cost, which it does, and increased code size for no perceivable benefit to me. By implementation cost, I meant the code in the compiler that I need to write to implement this :slightly_smiling_face: in my experience, special cases like these are a red flag that I got the model wrong.

3 Likes

I think this is good to be concerned about, but it feels like the @propertyWrapper(api) addition "solves" this issue by simply surfacing the special case into the language model. If we're concerned that the feature results in too much special casing—we should strive for a design that eliminates that complexity entirely, not one that foists it onto the programmer to understand.

1 Like

That's fair. An alternative answer to the core team's question would be that property wrappers on functions compose with protocol requirements in that users who need the protocol conformance can write these themselves.

The compiler would need to treat a parameter @Wrapper foo: Int as distinct from foo: Int for the specific purpose of redeclaration checking, but I'd argue that it would be reasonable anyway to do this independent of protocol conformances under the model described in the previous iteration of the proposal.

If we allow overloads based on argument wrappers, then AFAICT, there's no good way to specify which declaration gets called:

struct S {
  func foo(@Wrapper arg: Int) {}
  func foo(arg: Int) {}
}

S().foo(arg: 0)

I can't come up with any good way for a user to disambiguate which foo gets called here. Even if we unconditionally prefer one of these declarations based on some explainable rule (and how would that apply to functions with multiple wrapped arguments, etc.?), how would the user specify that they actually intended to call one of the other functions? I don't think it's satisfactory to say that the other declarations can only be called if they're a witness for a protocol requirement...