Property wrapper requirements in protocols

@tanner0101 and I have written a draft proposal for adding the ability to require that a property requirement specified by a protocol be declared with a specific property wrapper. This proposal is purposely limits its scope to exposing the property wrapper's projected value, if any, via the protocol.

Latest version is available at swift-evolution/NNNN-property-wrapper-protocol-requirements.md at prop-wrapper-protocols · gwynne/swift-evolution · GitHub

Property wrapper requirements in protocols

Introduction

It is sometimes desirable for a protocol to require that a conforming type use a property wrapper in the declaration of a required property. This allows consumers of the protocol to access the wrapper's projected value and properties, not just the wrapped value.

Motivation

Currently the only way to generically access the projected value of a property wrapper is to use Mirror to reflect the containing object and cast the appropriately-labeled descendant to the property wrapper's type:

extension Model {
    var _$id: ID<IDValue> {
        self.anyID as! ID<IDValue>
    }

    var anyID: AnyID {
        guard let id = Mirror(reflecting: self).descendant("_id") as? AnyID else {
            fatalError("id property must be declared using @ID")
        }
        return id
    }
}

This approach turns out to be extremely slow at runtime, and requires a runtime check and use of fatalError() to verify a requirement the compiler could otherwise have enforced.

Proposed solution

We propose allowing a subset of property wrapper syntax to appear in protocol declarations:

protocol Model {
    associatedtype IDValue: Codable, Hashable

    @ID var id: IDValue? { get set }
}

Only property wrapper names may appear in a protocol declaration, and initializer parameters are not permitted. A property wrapper attached to a protocol requirement has two effects:

  1. Conforming types must use the specified property wrapper on their declaration of the property.
  2. If the property wrapper has a projectedValue, the protocol provides access to it via the usual $ prefix.

This has the effect of making the projected value of the wrapper available via the protocol, without affecting how backing storage works. For this proposal, we do not consider specification of nested property wrappers in a protocol

Detailed design

Given the above declarations, the effect of the @ID wrapper on the protocol requirement is roughly semantically equivalent to:

@propertyWrapper
class ID<T: Codable & Hashable> {
    var wrappedValue: T
    var projectedValue: Self { self }
}

protocol Model {
    associatedtype IDValue: Codable, Hashable

    var _id: ID<IDValue?> { /* private */ }
    var id: IDValue? { get set }
    var $id: ID<IDValue?> { get }
}

The implicit backing store property does not exist as an actual protocol requirement; it serves the semantic purpose in this design of forcing the use of the property wrapper by conforming types even if the wrapper offers no projected value. In the case that the wrapper does have a projected value, the implicit $-prefixed property is a strict requirment of the protocol. Conforming types may not provide an actual declaration of the projection property; it is provided by the property wrapper; its presence as a requirement of the protocol is for the specific purpose of exposing the projection via the protocol. The presence or absence of a setter on the implicit requirement is determined entirely by the property wrapper's declaration and can not be influenced by the declaration in the protocol (e.g. a get-only property may still have a settable projected value and vice versa).

Source compatibility

No source compatibility issue is raised by this proposal; the proposed syntax is purely additive and was previously a compile error.

Effect on ABI stability

The authors are aware of no ABI stability concerns raised by this proposal.

Effect on API resilience

The authors are aware of no API resilience concerns raised by this proposal.

Alternatives considered

No alternative designs for this functionality have yet been considered or proposed at the time of this writing.

57 Likes

I am a huge +1 on this. I have been feeling its absence of late, due to the following motivating example.

I have a small react-style Observable library, for which I have written the following property wrapper:

@propertyWrapper public struct ObservableProperty<Value> {
    public var wrappedValue: Value {
        didSet { observable.next(wrappedValue) }
    }

    private let observable = Observable<Value>()

    public let projectedValue: Observer<Value>

    public init(wrappedValue: Value) {
        self.wrappedValue = wrappedValue

        projectedValue = observable.observer()
    }
}

If I want to have a protocol with an observable property, I have to define it as such:

@protocol ContainsObservable {
    var property: PropertyType { get }
    var propertyObserver: Observer<PropertyType> { get }
}

With this pitch, I'd be able to simply define:

@protocol ContainsObservable {
    @ObservableProperty var property: PropertyType { get }
}
12 Likes

+1 but,

Why this restriction:

Only property wrapper names may appear in a protocol declaration, and initializer parameters are not permitted

Any way to avoid it?

Initializer parameters for property wrappers are disallowed in protocols because:

  1. The value of them being available is dubious at best. I couldn't find a case where it didn't make more sense for types conforming to the protocol to provide the initialization.
  2. It would massively complicate the syntax and implementation.

Essentially the arguments are the same as default method arguments also being forbidden in protocol requirements.

2 Likes

Thank you. I misunderstood. Reading more carefully it appears that property wrappers with parameters are supported, just specified by only their name in the protocol declaration.

My head spins a little, I don't believe this can work without compiler magic. Property wrappers on the other hand are not magical, nor is the new $ prefixed namespace which is reserved for projected values. The proposal however in my eyes attempts to introduce a few magical special cases.

Consider this simple example:

@poropertyWrapper 
struct W {
  let wrappedValue: Int
} 

protocol P {
  @W var value: Int { get }
}

// this protocol *should* only desugar to
protocol P {
  var value: Int { get }
}

There is basically no way here without compiler magic to enforce that P protocol its value property has to be wrapped by W property wrapper.


The only way to solve it without compiler magic would require us to introduce access control for property wrapper backing storage, and also require partial access control inside protocol declaration. This does imply that in the end the protocol will need to expose the backing storage to be able to enforce it. However it would simplify things as it means that the proposed syntax is again just like property wrappers themselves pure sugar for boilerplate code.

protocol P {
  @W 
  internal(storage) var value: Int { get }
}

// de-sugars to
protocol P {
  var _value: W { get set }
  var value: Int { get }
}
1 Like

@DevAndArtist Can you elaborate on what you're calling "compiler magic" here, and how it differs from just a new compiler feature? Of course this isn't possible today, that's why it's a pitch for a language enhancement! I'm a bit unclear on what your objection is to the idea as-pitched.

3 Likes

I'm simply not convinced that properties can require wrappers, which probably would be encoded in the type-system. It doesn't make any sense to me. The protocol in the original posts says that we know that the conforming type has a backing storage of type ID, but it also says that we don't know it because it discards it as a requirement.

The extend on this, I think the motivation of this proposal seeks for the wrong solution. Honestly I think we should lift the restriction of the $ prefixed identifiers and allow those in protocol requirements. The user can decide on his own how the property is backed. The only extra requirement would be that he provides a projected value.

6 Likes

The proposed syntax is also not correct in my opinion.

This protocol, if it was valid:

protocol Model {
  associatedtype IDValue: Codable, Hashable

  @ID 
  var id: IDValue? // notice no `get / set`
  // this is derived from `ID<...>.wrappedValue` accessors
}

Should desugar to:

protocol Model {
  associatedtype IDValue: Codable, Hashable
   
  var _id: ID<Value?> { get set }
  var id: IDValue? { get set }
  var $id: ID<Value?> { get }
}

Which would also require the implementor to use currently unavailable access control such as access_level(storage):

public struct Foo: Model {
  public typealias IDValue = ...
  @ID
  public public(storage) var id: IDValue?
}

// desugars
public struct Foo: Model {
  public typealias IDValue = ...
  public var _id: ID<IDValue?>
  public var id: IDValue? { 
    get {
      _id.wrappedValue
    }
    set {
      _id.wrappedValue = newValue
    }
  }
  public var $id: ID<IDValue?> {
    _id.projectedValue
  }

  init(id: IDValue?) {
    self._id = ID<IDValue?>(wrappedValue: id)
  }
}

Here is a more concrete example. What benefit would you get from a protocol such as the following?

import SwiftUI
protocol ViewWithState: View {
  @State
  var value: Int
}

If it's only about $value then the above protocol is wrong, as in my opinion it would require you to expose the backing storage.

Instead we might allow protocols with projected values:

protocol ViewWithBindingToValue: View {
  var value: Int { get nonmutating set }
  var $value: Binding<Int> { get }
}

This is by far more flexible, because you can bake the value property however you want and still get access to the projected value.

Instead of State I could use my own property wrapper which has the same API surface as State does: swiftui_helper_property_wrapper.swift · GitHub

3 Likes

I think an important question here is whether the fact that a property has a particular wrapper is considered part of the API, or just an implementation detail. In addition to potentially providing projected values, property wrappers convey important semantic information about the behavior of a property. To borrow from the Swift book's section on property wrappers, there's the non-projecting @TwelveOrLess wrapper which clamps Ints to be less than or equal to twelve. It seems useful to me to be able to do something like:

protocol SmallNumberCounter {
    @TwelveOrLess var value: Int
}

func count<T: Sequence>(items: T, with counter: SmallNumberCounter) {
    counter.value = 0
    for item in items {
        counter.value += 1
    }

    assert(counter.count <= 12, "Count is too big to handle!!")
}

If I control the TwelveOrLess wrapper and the SmallNumberCounter, then I can guarantee that the implementation of count(items:with:) won't crash, since I know that any conforming type to SmallNumberCounter has to wrap its value in @TwelveOrLess. If I just required the var value: Int, then I lose control over whether conformers obey the contract of SmallNumberCounter. If I make the requirement var value: TwelveOrLess, then conforming types lose the utility that property wrappers provide.

Simply desugaring a property wrapper requirement to its constituent parts as you suggest loses the semantic link between the storage and the wrapped property. E.g., this would be valid:

struct EvilCounter: SmallNumberCounter {
    var _value: TwelveOrLess = TwelveOrLess()
    var value: { get { return 10000 } set { } }
}
9 Likes

FWIW, I mostly agree with you when it comes to projections, with a single caveat. When (if?) we eventually have access-level specification for property wrappers, what happens with the following?

// A.swift
struct MyView: View {
    @State private internal(projection) var value: Int
}

// B.swift
extension MyView: ViewWithBindingToValue {
    var value: Int { get { return 10 } set { } }
}

We now have a conformance of MyView to ViewWithBindingToValue that misrepresents the semantics of the protocol—$value is not a binding to value! Such a situation wouldn't be possible if the requirement were declared as @State var value: Int.

With respect, but nonsense code is always possible.

func returns42() -> Int { 0 }

It shouldn't be the type-systems burden to enforce the correctness of this. I think the right question we should ask is "what generic code does one design allow you to write what the other design doesn't?"!

I'm contending a counterpoint to this, that this feature allows protocol authors to explicitly disallow a certain type of nonsense, which enables them to write verifiably correct code without having to worry about guarding against bad-faith (or just plain buggy) conformers. Is there a hole in the SmallNumberCounter example above that would allow the assertion to fail?

Why not, if it's able to? Moreover, why should the type system support preventing this type of nonsense:

var value: Int { return "Not an Int!" }

and not this type:

var returns42: Int { return 0 }

?

This was somewhat implied at the beginning of this post, but it enables you to more stringently enforce the semantics of conforming types, rather than having to write code to defensively guard against buggy implementations.

4 Likes

In that sense every existing protocol conformance which has a customization point for some properties is considered buggy as it‘s not enforced somehow to follow specific semantics.

Sorry for answering with a counter-question, but why should it support this? This particular example is slightly different and I don‘t want to dive deep into it and potentially derail the conversation. That said, the type system should not be responsible for evaluating the value produced by the implementation the user provides. Only the types should match and that‘s it. In this example however you want to encode semantical information into the property name and have the compiler figure out if your implementation is correct. How would you do that, or better yet, why?

I understand what you‘re at, but I‘m simply not convinced by the proposal as written. I guess I said my opinion loud and clear now and for now I have nothing more to add. We should let other people express their opinions as well.

One more thing though, as for my personal experience with property wrappers (I used the, a lot), I personally haven‘t had the need to expose the projected value like this through a protocol nor wanted to enforce a certain type to have a property backed by a wrapper. Even though I kinda understand the appeal in how it sounds, I‘m not yet convinced it‘s the right way to do it.


Aside from the alternative pure sugar option I presented upthread, I think this proposal is overoptimisitic. It presents a new type of constraint, but it lacks in expressiveness and user flexibility. It wants to introduce a new type of property constraints, but it does not explore that area at all.

What about classes? Why shouldn‘t a super-class not be able to express that some certain property is backed by a wrapper, yet still hide the backing storage? If the sub-class doesn‘t know that then by the upthread mentioned conversation it would imply that we consider the super-class‘s property as potential dangerous and have to code defensively?

That said, if we really want something like this to happen then we should look at the bigger picture here and explore future directions, as well as other potential property constraints use cases, not only the re-enforcement of the backing storage.

I respectfully disagree.
This reminds me of our friend NonEmptyCollection. It allows for lifting expectations into the type-system where it can be checked by the compiler. Allowing protocols to require property wrappers falls into the same category I'd argue.

2 Likes

Can you explain how this is lifted into the type system? The non-emptyness is an API contract. The compiler does not check for non-emptyness.

Again as mentioned upthread already. I understand that some people want a 'property constraint', which is an interesting idea and I‘m not opposed of it, but I personally do not think the proposed solution is the way to start that. Remember how property wrappers all started. The first design was inflexible.

struct NonEmptyCollection<Element> {
    var head: Element
    var tail: [Element]
}

extension NonEmptyCollection: Collection {
    // implementation details
}
1 Like

Again, how is this problem lifted into the type system? I understand how it can be implemented.

Maybe I misunderstood the question? This implementation makes it impossible to construct an NonEmptyCollection with zero elements, and that requirement is guaranteed by the type system.

6 Likes