[Pitch] Type Wrappers

It's a very thought-out proposal. I'd echo the comments below that this seems to occupy a narrow niche between dynamic member lookup and property wrappers, and therefore one does wonder whether elevating this as another type of wrapper can carry its own weight.


Before I forget, though, I'd like to jot down a few detail-oriented thoughts:

Ah, but then there's a potential clash with a user-defined variable named _storage with a projection-capable property wrapper, is there not?

Given naming conventions observed in the standard library itself and likely by many others, it's almost as likely or even more likely that storage (which name implies internal-only usage) would be given an underscore than not.

This is a very thoughtful accommodation here but perhaps too clever by half: I'd suggest either $Storage and $storage or $_Storage and $_storage. Type wrappers as proposed here impose other limits (more on that below) that are much more limiting than this variable naming clash, so I think consistency here gives predictability.

I'm not sure what purpose the word memberwise: is serving here, and if we ever want to use that word for explicit synthesized memberwise initializers, or bring memberwise conformances formally into the language (it was proposed for @memberwise Differentiable, as I recall), then having the terminology used here may cause issues if there are subtle behavior differences (more on that question later). Instead, it seems that it's perfectly fine for the type wrapper initializer not to have a label here, or to name this init(storage:) instead.

It's worth noting by comparison how the design you propose accepts any name for the sole generic parameter to be used as the storage type (and we don't support labeled generic parameters in Swift yet, of course), so it's hard to see why a label here for init is necessary for clarity when it isn't for the underlying storage type.

As you'll recall, there are subtle differences with respect to initial values and side effects, which the core team has regarded as a bug:

Here, you propose a synthesized "special" initializer, which presumably will behave like Bar.init in the example above that the core team considers buggy? It would be good to spell out the behavior here and whether attaching a type wrapper to a type would change whether a side effect is observable.

Separately from the above issue, it would be important to spell out whether an optional member var foo: Int? is considered to have an implicit nil default value for the purposes of synthesizing the intiializer—i.e., init(foo: Int? = nil) {...} as opposed to init(foo: Int?)—and also mention whether this is consistent with the behavior implemented for SE-0242.

It is unclear whether you're saying that a conformance can be stated in an extension but the stored members of the extended type just wouldn't be wrapped (e.g., I could get all the default implementations of some really useful protocol but none of my stored properties would be wrapped by the protocol's annointed type wrapper), or if you're proposing to make this an error to write.

On the one hand, it seems plausible that there could be useful protocols that happen to use a type wrapper attribute but to which types can conform and do useful things without wrapping any of their properties. In that case, making it an error to state any such conformance in an extension is too severe. On the other hand, it seems plausible that there would be protocols that one couldn't conform to in a semantically sound way without wrapping properties in the way that the protocol wants all conforming types to do; then, allowing conformance without wrapping properties would weaken the guarantees of the protocol.

That said, later on, you discuss a feature to opt out members from the type wrapper (more on that later). This means that users can conform to a protocol that uses a type wrapper attribute while opting all of the members out of type wrapping. This would be isomorphic to allowing a user to declare a protocol conformance in an extension, so I don't see why it should be forbidden if the same thing can be accomplished only in a clunkier way.

This doesn't entirely make sense as a limitation.

It makes sense to allow only one type wrapper attribute as an implementation-level decision, but it seems like a type could perfectly sensibly want to doubly-wrap its storage. To use your example, it seems perfectly sensible that I could want to track generations of my Document and also confine all access to my Document's properties to a single queue. More commentary on whether this is logically impossible or whether it's a limitation of the current implementation is warranted.

Second, even if we accept the one type wrapper rule, this doesn't translate allowing conformance only to a single protocol: infinitely many protocols can specify one and the same type wrapper attribute, and a type could conform to all of them correctly without having to support composition of type wrappers. I see no reason why this would be prohibited.

I understand this point. However, let me ask an unrelated but protocol-related question: can I use a protocol which has a type wrapper attribute as an existential type? What is the behavior of accessing properties of that type-wrapped existential type?

For that matter, in your write-up, the only example of a protocol with a type wrapper attribute has no requirements. Should the use of type wrapper attributes be restricted to protocols with no other requirements, like a marker protocol except with the synthesized associated type and storage member requirement?

We have said here often that protocols exist to enable useful generic algorithms. What useful generic algorithms can be written for protocols that have a type wrapper attribute using its synthesized $_storage property, which might differ from protocol to protocol depending on its other requirements? Or if you anticipate that users might be able to write generic algorithms that make use of $_storage but don't contemplate any other protocol requirements being in the mix, should this be, simply, a single actual protocol named TypeWrapper, just like Actor is a protocol, and then users can refine it?

In what circumstances "could [it] be beneficial" to allow this opt-out—is this hypothetical or do you have a use case in mind? Wouldn't this defeat the purposes of any guarantees of a protocol?

If the desired design is that users must conform to a protocol in the primary declaration to wrap all stored members, there is value (put another way, you're putting power in the protocol author's hands, which we like to do) in saying my protocol definitely requires wrapping all conforming types' stored members with a certain wrapper. This invariant would be broken if users could just opt out some or even all of the stored members of their type.

If a user really wanted to do this, they could have an explicit nested type that conforms to the protocol and an outer type with the properties that aren't managed by the type wrapper. That way, the protocol conformance is actually affixed to a type that contains solely properties that are wrapped.

To extend your example, if I had a Document type that had a property content and not just timestamp but also >20 other metadata properties, it's pretty misleading for a user to see the declaration spelled struct Document: GenerationTracked if I can opt out every property except content from generation tracking. Instead, if opt-outs are not supported, I'd be guided to write a separate type struct Document.Content: GenerationTracked inside my type Document, which itself wouldn't and couldn't be declared as conforming to GenerationTracked; this would be much less misleading and more self-documenting.

Built-in attributes aren't capitalized; this should be @typeWrapperIgnored (although I'd hope we can come up with a better name).

However, more saliently, if we are to support opt-outs it would be great to support multiple, mutually exclusive type wrappers even if we don't support composition, and to have a syntax to label which properties are wrapped by which type wrappers.

To use the example above, let's say I want generation tracking for my document content and, separately, a single queue for accessing all of my metadata fields. If it's thought important not to require users to write separate nested types, then I ought to be allowed to use multiple type wrappers on my Document type as long as the properties being wrapped are mutually exclusive.

12 Likes