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

I think inference of API-level wrappers will at least help users more easily understand diagnostics. For example, if a user finds out about the differences between wrappers like @Logged and @Traceable through diagnostics they'll grasp –– albeit superficially –– that the quality of being "traceable" entails special semantics that more "pure" wrappers such as @Logged and @Clamped neither have nor require.

Building on this principle, the compiler will now enforce what constitutes API wrappers. This way, unassuming wrapper authors won't be able to simply see @propertyWrapper(api) in a random post and use it carelessly. All this is not to say that explicitly marking wrappers API-level in the future will be dangerous, it will require, however, great care in how it is advertised to users.

I don't see how some wrappers could be "argument wrappers", as they require API-level capabilities. If understand the feature you propose correctly, either authors of normally API-level wrappers would have to provide some way for these wrappers to act as implementation-detail ones or the compiler would emitted an error if said capability is unsupported.

2 Likes

I think this would be a pretty unfortunate restriction. Minimizing the differences between the models is desirable, but this actually seems like a misinterpretation of that. The key difference between the models is whether the wrapper type is part of the signature; there's a general access restriction about the types that appear in the signature, and of course that restriction applies differently in the two models because the set of types that appear in the signature is different.

2 Likes

I don't want to derail the conversation too much if this is a direction that the authors are uninterested in, but this idea is building off of my earlier post (and I believe I mentioned something similar in one of the pitch threads):

Rather than using the wrapper position as a signal about the wrapper type's "API-ness," we could use it as the source of the "API-ness" for that wrapper instance. So all of the above would become well-formed. If a user writes:

func foo(@Wrapper arg: Int) { ... }
foo(0)

then they've declared an "argument wrapper." We do all the usual API-wrapper transformations and synthesize:

func foo(arg _arg: Wrapper<Int>) {
  var arg: Int {
    get { _arg.wrappedValue }
    set { _arg.wrappedValue = newValue }
  }
  ...
}
foo(Wrapper(wrappedValue: 0))

this would also enable things like init(projectedValue:), @autoclosure arguments, etc.

OTOH, if the user writes:

func foo(arg @Wrapper arg: Int) { ... }

then they've declared a "parameter wrapper," and we do the implementation-detail transformation:

func foo(arg arg: Int) {
  @Wrapper let arg: Int = arg
  ...
}

and @Wrapper is hidden from the interface etc.

This ends up being a pretty fine distinction, so it might be too subtle to surface at the per-wrapper-instance level. I also don't love that the "default" version likely ends up being the API-level one, rather than the implementation-detail one.

ETA: and, if the particular feature set of a given wrapper doesn't support usage in a particular position (e.g., no init(wrappedValue:)) then an appropriate diagnostic would be emitted at the wrapper use-site.

Makes sense. This is along the lines of what I said earlier that the restriction on API wrappers only is easily explainable - if the wrapper has an external effect on the argument, of course it needs to match the access level of the function because it needs to be accessible from all callers. The restriction on property wrappers today doesn't make much sense to me (unless the wrapper type is accessible via projection).

I clarified in the proposal that this restriction only applies to API property wrappers.

2 Likes

Yeah, this has always been strange to me too, especially since the wrapper doesn’t show up in the generated interface. I’d be in favor of allowing it (in a separate proposal, of course :slightly_smiling_face:).

1 Like

I want to make a few observations regarding the relationship between API and Impl wrappers:

  • The main difference between the two is how they react when wrapping arguments. Wrappers that are not to be passed around, e.g., @State and most of what I called entity-bounded wrappers, could live their whole lives not knowing (nor caring) what exactly they are.
  • An Impl wrapper can evolve into an API wrapper. It can gain more functionality over time, to the point where interfacing with the wrapper becomes significant. For example, Atomic could be Impl wrapper that passes in initial value. It could then become API wrapper that accepts an already-atomic values.
  • Wrong decision for whether a wrapper is API or Impl is impactful, changing between them is a breaking change. There is no "default" choice that a "don't care" wrapper can choose. The wrapper author needs to choose them (wisely) early on.
  • Every API wrapper can become an Impl wrapper using local variable wrappers. It's the Impl wrappers that can't become API wrappers, not without a more sophisticated workaround, like creating a new wrapper type.

These observations hint toward my conclusion that the relationship between API and Impl wrappers isn't that of a dichotomy but a subdivision. Every wrapper is an Impl wrapper, some of which are also API wrappers. I think we should treat API wrappers as a special case of Impl wrappers, not a distinct part from it.

If we look through subdivision lens, we can see why there is a lot of friction unifying this feature. Two distinct features are hiding underneath (as many already probably grok). Now, there's nothing wrong with adding multiple features at once. We could argue that SE-0269 has two components. The problem with this one is that the two are intruding on the same syntactical space, trying to impose different interpretations. The Impl part is sugar for every local variable wrapper, while the API part introduces a notion of caller cooperation through it–they are incongruent. It is evident from the fact that we need to change the type checking rule just to make them look remotely similar. Even then, the two features desugar to entirely different codes, have different interpretations, and even result in different ABIs.

While I don't think it's impossible to marry two entirely different features, it wouldn't be without sizable compromises.


What if we allow argument wrapper only for the API wrappers, marked by @propertyWrapper(api), or @propertyWrapper(visible), or use heuristic as suggested above. Then we raise an error when someone tried to wrap arguments with non-API wrappers while offering a fix-it.

This isn't true, though. For a wrapper to be implementation detail, it needs to support init(wrappedValue:), which is not a requirement for property wrappers.

The argument we're trying to make is that these two kinds of wrappers already exist, and formalizing the distinction will help the compiler and other tooling help programmers better understand the property wrapper feature. It's not a completely new concept. The formalization is new.

Like I said before, this is the point of allowing libraries to define the semantics of an attribute. This is not a new concept with this proposal. You cannot know what a custom attribute does just by knowing it's syntactically a custom attribute. Syntax can't even tell you whether a custom attribute is a property wrapper or a result builder.

I think this is a mischaracterization of the change we made. Like I said, this is exactly how property wrappers are type checked today, and I think it's the right choice for this feature regardless of the API versus implementation detail distinction. It leaves the function author in control of which initializer gets called rather than the client, and it's a simpler mental model for the programmer.

I don't think this addresses the core team feedback. Further, Swift programmers dislike boilerplate when they know the compiler can do it for them. I would be worried about programmers exploiting @autoclosure just to be able to put a wrapper on the parameter instead of having to use a separate local variable.

2 Likes

We're trying to draw a line that wasn't there before, though. So it can be anywhere between a clean separation and a muddy gray area. At least, I don't think that the API vs Imp is a clear choice as is. That's why I'm trying to see what other (relevant) categorizations can be made.

Since they're your changes, I'll take your word for it. Still, the old overload resolution has been advertised as fixing the memberwise-initializer and got past three pitches and two reviews relatively unscathed (well, depending on how you count this one). I'm confused whether this is a feature that got scraped or a design bug that got fixed.

In any case, I've been saying the same thing since pitch#2, so I can't say I dislike the change.

Impl isn't the baseline as you said :thinking::thinking:, but that also applies that not all non-API wrappers are Impl given that they may not even implement any init(wrappedValue) and init(projectedValue). It may be more of a normal wrapper (@propertyWrapper), which supports wrapping local storage. Then Impl wrapper (@propertyWrapper(impl)) and API (@propertyWrapper(api)) sit on top of it.

It might also make sense for API to be the default, and Impl to be opt-in :thinking::thinking::thinking::thinking:.

I do think it's a design bug that got fixed. @xedin brought up to me recently that the previous design would allow somebody to define their own wrapper initializer outside the defining module of the function that uses the wrapper, and potentially circumvent important checks.

FWIW, overload resolution was not my main argument for "fixing" the memberwise initializer. My issue with the memberwise initializer is 1) the argument label when the initializer takes in the backing wrapper type, and 2) it prevents the programmer from passing a wrapped value (even if supported by the wrapper) if the compiler chose default initialization. People get really confused by this when working with wrappers that implement init() when the wrapped value is Optional. All of that said, I'm not sure we actually can adopt property wrappers in synthesized memberwise initializers in many cases because of the source break it would cause (because of the change in argument label).

This is a fair point, and I thought about this too. It certainly would be nice to actually enforce things like init(wrappedValue:) because people often get it wrong and it causes horrible diagnostics when you try to apply the wrapper... but I think it's the wrong default because most property wrappers are implementation detail wrappers that support init(wrappedValue:). The wrappers that don't are much more rare. Also, this is a general default for Swift (e.g., public API is always explicit).

In all seriousness, I think most of the wrappers don't even wrap an argument. Wrappers commonly are entity-bounded, e.g., State, Published, UserDefaults. They aren't suited to being outside of an instance despite implementing init(wrappedValue:), much less as an argument. So only a few wrappers actually benefit from this feature, some more so than others (link Binding). We are already even of opinions whether Assert is API or Impl (I'm on Impl side btw), so it does really get muddied if we're to enforce a dichotomy on all wrappers. Makes one thinks argument wrapper should be disabled by default, and opt-in by the wrapper author.

:thinking::thinking::thinking::thinking::thinking::thinking: I' shoulda asked for clarification long ago. :stuck_out_tongue:

This is exactly why we decided to narrow the scope of which wrappers are part of the function signature. Wrappers can't casually change from one to the other - the compiler decides based on the semantics of the wrapper whether or not the function needs to accept a different type of argument. Aside from eliminating a lot of confusion, this is also consistent with how property wrappers work today. When a wrapper is applied, the compiler decides whether it needs to change the type of the memberwise initializer and the type of the accessors based on the nature of the wrapper.

Also, we do plan to start a new pitch thread (it just takes a bit of time to update the proposal and implementation :slightly_smiling_face:)

2 Likes

Could you elaborate on this, or will it be on the new pitch? I'm assuming you refer to the heuristic that's been discussed recently (as recent as a 4d-old thread can be), but that doesn't sound quite right.

It'll be in the new pitch, but sure. If the init(wrappedValue:) needs an @autoclosure or if the wrapper implements init(projectedValue:) (which is the mechanism to allow callers to pass an instance of the projected value type), then the function cannot simply accept an instance of the wrapped value type because the wrapper has an external effect on the argument at the call-site. So, the property wrapper necessarily impacts the function signature.

  • You mean that any init(wrappedValue:) takes autoclosure, or only the selected one? I believe it should be the latter.
  • I'm not sure the existence of init(projetedValue:) is a clear choice, given that it can retroactively declared, maybe if it is visible from the function declaration?
  • We should also reject those that have neither init(wrappedValue:) nor init(projectedValue). This should help rejecting things like SwiftUI.Environment, but still wouldn't do much against SwiftUI.State.
1 Like

This will work almost exactly the same way that memberwise initializers work today with respect to @autoclosure ("today" is probably specific to 5.4 - I fixed some autoclosure bugs due to overloading a few months ago), but the compiler will only look in the defining module of the wrapper to decide whether it can have an external effect to prevent soundness issues due to differences between the module-of-definition and the module-of-use. In other words, the compiler will make the same decision about whether the wrapper can have an external effect no matter which module the wrapper is applied to a parameter in. However, the type that the wrapper is applied to is a factor in that decision.

This discussion seems to be turning into speculation of the details of the next pitch, so I suggest that we wait for that before we start to pick apart those details. All of the precise semantics will be laid out there.

EDIT: If you're extra curious about how this works, check out the changes to PropertyWrapperBackingPropertyInfo in this commit. Essentially, the compiler will attempt to build an initialization expression using init(wrappedValue:) and, separately, init(projectedValue:) and type check it in the context of the defining module of the wrapper. It uses a PropertyWrapperValuePlaceholderExpr with the type of the parameter in place of the wrapped or projected value argument since it doesn't have an actual value yet. This placeholder will store whether the type checker injected an autoclosure around the argument. These synthesized initializer expressions will get turned into the property wrapper generator function(s), and the placeholder turns into the argument to the generator function.

2 Likes

(We're still discussing the new semantics for the fifth pitch, so the following may not make it to the actual pitch.)

Wrappers can have different generic constraints on overloads of init(wrappedValue:), in which case the API significance ("API-ness") of the wrapper will (likely) be conditional. This means that Wrapper<Int> may be API-level, whereas Wrapper<Void> implementation-detail. We expect that to be pretty rare, but I'm thinking that perhaps future ways of explicitly stating API significance could revolve around the special init(wrappedValue:) and init(projectedValue:). This approach raises a lot of other questions and could very well unimplementable.

My point here is that this dichotomy is drawn only to allow wrappers with special "API-level" capabilities to work properly, neither with surprises nor with inflexible –– to the wrapper author –– semantics. In other words, this proposal merely captures the most common cases of external effects.

2 Likes

That's why I'm trying to say that it should be trichotomy, to include those that are neither. That said, heuristically categorizing them should still be a good idea whether or not we include the third category (then again, if they aren't suitable for either, trying to wrap an argument with it should yield an error via other constraints anyhow).

Sorry, I didn't mean to dismiss that point of yours. I agree that it should be an error if you try to use a wrapper that doesn't support either initialization mechanism on a parameter.

1 Like

I have not followed the current discussion, however my biggest concern is the proposed naming scheme: @propertyWrapper(api)

  • There is an older thread where some ideas from @jrose, @Douglas_Gregor and myself included are floating around regarding of an optimization for PWs by making some of them static. One of the idea was to extend the attribute to @propertyWrapper(static). However the conversation hasn‘t moved since and it seems that the current proposal could block this solution if accepted in its current form.
  • The solution for the protocol related problem seems to require a new attribute, so why can‘t we just add another separate attribute?

So my suggestions for a few more options for consideration:

  • Option 1: parameterize the attribute to keep it extensible in the future: @propertyWrapper(GREAT_LABEL: .api)
  • Option 2: make api OptionSet like value: @propertyWrapper(.api)
    This would allow for future extension such as [.static, .api]
  • Option 3: introduce a standalone but related attribute:
@NEW_ATTRIBUTE
@propertyWrapper

That option would allow to keep the property wrapper functioning for other purposes as well, not only as an api wrapper.