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

Well, in the same-name case I figured that implementation-detail wrappers would have to be written as:

func doStuff(arg @Logged arg: Int)

which is admittedly un-ideal, but IMO preferable to having no distinction in the signature between the implementation-detail and API cases.

1 Like

This issue where adding or removing a property wrapper can break API or ABI without you realizing already exists today. I don't think it's that big of an issue because it's not that common for wrappers to have that kind of effect. In most code bases, it's going to be really obvious if you break source due to the addition or removal of a wrapper attribute on a parameter or on a property. As far as ABI goes, the vast majority of Swift programmers don't need to worry about that. For the few programmers who do, there are tools like the ABI checker to help catch ABI breakage.

1 Like

Yeah, fair enough. I guess I'm just reaching for a generic "different things should look different" argument—if the API/implementation-detail distinction has important impacts on how the declaration behaves, then IMO we should make it apparent to those writing and reading the declaration which case they're dealing with.

(ETA: and, if we think the impacts aren't important enough that the distinction should be easily visible, then perhaps we should reconsider how important it is to formalize the distinction to begin with... :slight_smile:)

1 Like

It's important to formalize with explicit syntax because I don't think it's possible to infer. There are some indications that the wrapper is adding or changing API in some way, but I haven't been able to come up with a set of rules that cover all of the cases the wrapper needs to have an external effect on the function.

EDIT: What I'm trying to argue is not that the formalization doesn't matter, but that most programmers won't really need to reason about it. Some programmers will, of course, and those programmers are probably the ones with an ABI, and they need to have a deeper understanding of language features anyway.

I also still think it makes sense to only allow wrappers with an external effect in protocol requirements. Protocols don't care about implementation details, including internal parameter names. Protocols do care about things that require caller cooperation.

I also think that some of these subtleties exist with the suggested "synthesized witness" approach. In that case, the distinction isn't based on which wrapper you use, but rather how you call the function. I think that's a lot harder to reason about than what we're proposing here. With what we're proposing here, all you need to do is look at the wrapper type. We could even get that information into QuickHelp. I don't totally understand why there is so much push back against the formalized distinction, yet everyone is fine with the other approach.

My point here was less about @propertyWrapper(api) versus inferring the "API-ness", and more about the fact that we introduced a distinction between API and implementation-detail wrappers at all because it has (apparently) important impacts on the programming model for users who attach wrappers to their function arguments. Namely, it has a potentially large impact on the ability of that function to witness a given protocol requirement.

If that difference is important enough to explicitly categorize property wrapper types in the language model, why isn't it important enough to make visible to the users of function argument wrappers?

Yeah, that's a reasonable position. My preference here is pretty weak.

For sure, that approach has plenty of its own drawbacks. I've tried to avoid bringing that alternative up again, since, like I said, I don't really have anything new to say in its favor.

Sorry, the way I phrased my previous post was a bit cheeky. I definitely understand the value in this distinction, I just don't think I'm 100% convinced that it outweighs the increased cognitive load on users of "splitting the world" of property wrappers, so to speak. I'm mostly looking for ways to tweak the language model here to reduce that cognitive load, and one of those is to make it clear at the use site of the property wrapper which of an API/implementation-detail property wrapper is being used.

ETA: Also, one of the reasons I am slightly more comfortable with the "witness-by-wrapped" approach is that protocol conformances and witness matching rules are already so subtle, it doesn't seem like a huge marginal increase in complexity to say "oh yeah, and there's also some weirdness when dealing with argument wrappers." OTOH, introducing a whole new category of property wrappers is, naĂŻvely, doubling the complexity of the feature in terms of the number of special cases that users will have to think about.

1 Like

I feel like this is just a danger of allowing libraries to define the semantics of an attribute. It seems to me that the purpose of the custom attribute is to not invent new syntax every time we want new semantics. Let me also demonstrate the existing issue in a slightly different way:

@propertyWrapper
struct Lazy<Value> {
  init(wrappedValue: @autoclosure: () -> Value) { ... }

  var wrappedValue: Value {
    mutating get { ... }
  }
}

protocol P {
  var test: Int { get }
}

struct S: P { // error: type 'S' does not conform to protocol 'P'
  @Lazy var test: Int
}

It's not clear from the use site of @Lazy that this isn't going to work as a witness. I'm not sure whether or not people are often confused by this in practice.

1 Like

I'm starting to think that it might be better to define a set of rules where api/the fact that the wrapper has an external effect is inferred by the compiler. At least then there would be no surprises (well, if you know the rules :slightly_smiling_face:). You wouldn't be able to use it for some of the wrappers that we presented as motivation, like @Asserted, but that seems like an okay compromise. For parameters, we could limit it to the case where the wrapper has init(projectedValue) or if init(wrappedValue:) needs an autoclosure, which means the caller can pass a different type of argument. If we wanted to extend this to properties, this could be inferred if the wrapper changes the type of the accessors or if the wrapper adds a projection. We could probably implement some tooling to help users easily figure out if a composed wrapper has an external effect, because that could be hard to figure out from looking at the use site

5 Likes

I think allowing implementation-detail wrappers on protocols is in some ways similar to how protocols can define parameter names –– by acting merely as a suggestion to conforming types.

protocol Dog { 
  func bark(for seconds: Int) 
  func age(@Clamped(1 ... .max) by years: Int)
}

struct MyDog { 
  func bark(for myParamterName: Int) { ... } // OK
  func age(@Clamped(myCustomRange) by years: Int) { ... } // OK
}

But then how will users know when a wrapper in a protocol requirement is api-level or implementation-detail? That would be hard to determine, since changing the wrapper of a witness of a requirement that contains an api-level wrapper would be prohibited. Of course, users can just view that wrapper's declaration. That, though, would be problematic if cases where a witness uses a non-placeholder wrapper become popular, and users start getting confused about the api-level vs implementation-detail wrappers on witnesses distinction.

1 Like

I think @Asserted is an important category of api-level wrappers, so through discussion on alternative implementations or future directions should definitely take place before we dismiss the current api-level wrappers' semantics.

Other than that I think that aligning compiler inference of api-level wrappers with such wrapper's semantics will help with user intuition and protect users from bad API design. That said, taking other external effects into account, such as file literals, would also be useful. One downside, though, is that introducing file literals and other non-obvious external effects could take away from the intuition aspect of this design.

As for actually determining what actually constitutes an API-level wrapper, I'm not entirely certain that auto closure on the init(wrappedValue:) initializers or a projected value initializer are appropriate. Before making any assumptions, I am curious as to why a projected value initializer results in api-level classification, as I'm not sure that "projecting" wrappers necessarily have external effects.

Only allowing the compiler to determine which wrappers are API certainly does not cut out a future direction to add the explicit spelling. We can always add the explicit spelling later. If we add it now, we cannot take it back if it turns out to be confusing. I'm inclined to go with the simple model, at least for now.

Both of these are cases which would cause the function to need to take a different type of argument, meaning the type of the function needs to change. The type of the function changing is the "external effect" I'm talking about. For wrappers that support projected-value initialization (as in, they implemented init(projectedValue) for the purpose of passing a projected value argument), there is no possible way to allow the caller to pass a wrapped value or a projected value without either changing the type of the function or emitting several functions (I think at this point everyone knows I am personally not a fan of compiler synthesized overloads :slightly_smiling_face:).

1 Like

I think that if we can recognize that an init(wrappedValue:) takes an autoclosure, we recognize that it takes #file / whatever arguments.

That said, I'm not totally convinced that Asserted triggering its assertion in the caller is valuable enough that it should be designed as an API wrapper.

3 Likes

Perhaps I wasn’t that clear; let me explain what I meant.

I too was worried that with API wrappers the proposal was getting too large, applying to both closures and functions. Therefore, making function wrapping simpler and more intuitive is great (if not necessary for this proposal to move forward), especially when it doesn't compromise on wrappers with API-level "capabilities".

EDIT: I got slightly confused here so the following argument makes no sense.

At the same time, I am worried about preventing any potentially useful future directions. Hence, my worrying about wrappers that may be using projected-value initialization not for external effects per se, but rather as configuration points:

func takeValue(
  @DefaultingToLowerBoundClamping(1 ... 3) value: Int
) {
  print(value) 
}

takeValue($value: 1 ... 5)  // 1

The above example is not one of good API design. Nevertheless, users may be surprised this approach to determining API wrappers. Furthermore, there might be wrappers we haven’t thought of that use projected-value initialization correctly and effectively, but that are erroneously marked API-level.


Good point, I'll add it as a future direction.

I am not proposing that @Asserted-like wrappers be enabled in version one of this feature; just that future versions accommodate these wrappers.

1 Like

I agree that this should not be allowed. Passing a projection is prohibited when the wrapper attribute has an argument, so there is no reason for this wrapper to affect the function externally.

2 Likes

Sure, we should definitely leave room for adding API wrappers that don’t fall into the autodetection cases, because it’s probably useful at times. I’m just making the point that the decision to make a wrapper API shouldn’t be casual, and for @Asserted in particular it seems like the wrong decision to me.

3 Likes

If you're confident that we can come up with a reasonable set of rules that captures most of the situations we care about (at least to start with), I think I agree that this is a better direction. It doesn't help with my complaints about visibility at the use site, but I like that it makes the API/implementation-detail distinction a bit more explainable beyond "that's what the wrapper author wrote :man_shrugging:."

I still have a personal affinity for letting the function author decide if the wrappers are "argument" or "parameter" wrappers in order to make the API/implementation-detail distinction, but I get why that might be a bit too subtle to expose in the language model.

There's one last thing I was wondering about up-thread that I didn't see a response to:

(If I missed an answer somewhere in the subsequent posts, very sorry for making you repeat yourself!)

1 Like

Ah sorry, I had started to write out an answer to this and then I got side tracked with the new direction. I don't see any reason why this wouldn't be feasible. I do want to be careful to minimize the number of restriction differences between the two models, but I think there could be a logical diagnostic along the lines of cannot use <lower access level, e.g. private> wrapper on a <higher access level, e.g. public> function because the wrapper has an external effect with a note about what that effect is (e.g. passing a projected value, capturing the argument in an autoclosure, whatever else)

1 Like

Another axis to consider is the differences introduced between property wrappers and argument wrappers. Property wrappers must be at least as visible as the properties they wrap, so maybe we'd want to preserve that aspect when extending the feature to function arguments.

I thought this was the case when I started to write the above answer and I was about to use it as an argument against doing this, but I just tried this code in Swift 5.4 and it works:

public struct S {
  @Wrapper public var value = 10

  @propertyWrapper
  private struct Wrapper {
    var wrappedValue: Int
  }
}

EDIT: Maybe this is just a bug? The code in SR-12506 still does not compile. Ah well, let's ban it on parameters for now and we can always lift the restriction later.

1 Like

Heh, I just spent a few minutes digging into this on a playground because I was surprised that the code you posted compiled. It looks like it compiles without a type annotation on value, but if you write value: Int then we get the error!

1 Like