Pitch #2: Extend Property Wrappers to Function and Closure Parameters

Sure, but a) not all types offer synthesized memberwise inits, b) the memberwise init will not expose the backing wrapper if an appropriate init(wrappedValue:) exists, c) in cases where the memberwise init does expose the backing wrapper, it's only exposed as internal and not necessarily at the visibility level of the struct/property itself, and d) if this behavior is undesirable then the struct author can define their own initializer which does not expose the backing wrapper, and they don't have to worry about the memberwise init.

Conversely, with function argument wrappers, a-b) the backing wrapper exposed for every wrapped function argument, c) the backing wrapper is exposed at the visibility of the function itself, and d) there is no way for a function author to override either of these behaviors if they find them undesirable.

I started writing out a longer response, but then I scrolled back up and realized I was basically reiterating all of my arguments about embedding wrappers in the ABI, and instead reframing them in terms of API. Unfortunately, I was convinced on the ABI front on the basis that it was consistent with how property wrappers already behave. Unless you can help me reframe things mentally, I'm not sure that the same arguments apply (for the reasons I sketched out just above).

Agree that consistency is important, and that this would be... somewhat troubling. But to me, this isn't a question of "inconsistency vs. no inconsistency," it's a question of "inconsistency between functions and closures, or inconsistency between property wrappers and function argument wrappers."

In particular, I think it's an incredibly important difference that functions with wrapped arguments are called with the wrapped type, while closures are called with the wrapper type. E.g.,

let foo = { (@Wrapper arg: Int) -> String in ... }
func bar(@Wrapper _ arg: Int) -> String { ... }

foo(Wrapper(wrappedValue: 0))
bar(0)

func transform(_ arg: Int, by transform: (Int) -> String) -> String { transform(arg) }

transform(0, by: bar) // error: cannot convert '(Wrapper<Int>) -> String' to '(Int) -> String'

// I have to do this, why isn't it the same?
transform(0, by: { bar($0) })

In other words, I think that clients of functions with wrapped arguments will think of those functions as having the type of the wrapped values, not the backing wrapper. This doesn't apply to closures, so at some level the inconsistency is already there.

Since it's prohibited to use a wrapper with initializer arguments on a function argument, I would argue it is unexpected. I think it's a very natural leap from "huh, I can't use a wrapper with init arguments here" to "the wrapper passed in is always created via init(wrappedValue:)." In fact, given the explanation of the transformation, I think it would take a very careful reader to determine that that was not in fact the case. This feels like an inevitable sharp corner that will always have to be mentioned whenever anyone reaches for this feature.

With your idea of the future foo($arg: 0) syntax in mind, IMO a natural corollary is that you would be able to reference the unapplied version of that function as foo($arg:), and in that world it seems perfectly reasonable that the foo(arg:) form would give you the 'unwrapped' (Int) -> Void version of the function.

I believe that the question of unapplied references to functions with wrapped parameters may be better left unanswered as part of this proposal, and deferred until we can see a little more usage of this feature in the wild. That would allow us to examine things at least a bit more empirically when considering the question of whether users expect unapplied references to take on the wrapped type, or the wrapper type.

On an unrelated note: it's been a couple weeks since I read through this thread, so I forget if it was already mentioned, but I think the Motivation section could definitely be bolstered. For one, the solution-as-proposed doesn't actually solve the problem posed with Percentage—the comments complain, "Unfortunately, we can't use '@Clamped(to: 0 ... 100)' [on the function argument]," but with the proposed solution, we still can't use @Clamped(to: 0 ... 100)!

Additionally, I think that the overloads of adding(_:) should be updated (per @Lantua's post) to what I think is a fairer representation of the status quo:

  mutating func adding(_ offset: Int) {
    adding(Clamped(wrappedValue: offset, to: 0...100))
  }

  mutating func adding(_ offset: Clamped<Int>) {
    @Clamped(to: 0...100) var offset: Int = offset.wrappedValue
    // ... or, '_offset = offset'
    percent += offset
  }
2 Likes