- Authors: Pavel Yaskevich
- Implementation: PR#60345, PR#60864, PR#60865
Contents
- Introduction
- Motivation
- Proposed solution
- Detailed design
- Source compatibility
- Effect on ABI stability
- Effect on API resilience
- Alternatives considered
- Future directions
Introduction
There are some code patterns that require access to storage go through a centralized or externally managed location (e.g. for mocking, proxying, or introducing additional data transformations/effects). This proposal introduces a mechanism called type wrappers to achieve just that. A type wrapper abstracts over the declared stored properties of the type it is applied to (the wrapped type) where all access to those properties goes through a central operation in the type wrapper instance.
Motivation
The existing language tools are insufficient when a use-case requires some collective action on a set of properties of a type. To demonstrate, let’s build a type that keeps track of the number of modifications that are performed to an instance’s properties by storing a generation count.
Let's start by declaring the type that we want to track the modifications to:
class Document {
let title: String
var content: String? = nil
}
Document
’s title
is immutable, so generation count applies only to its content
modifications:
// generation 0
var document = Document(name: "Draft #1")
// generation 1
document.content = "Lorem ipsum"
Generation tracking could be implemented in a couple of different ways that introduce a fair amount repetitive boilerplate and/or additional storage which is not always acceptable. Let’s look at a few of the most prevalent solutions:
- Hide the storage and turn
content
into a computed property:
class Document {
private var generation: Int = 0
let title: String
var content: String? {
get { _value }
set {
_content = newValue
generation += 1
}
}
private var _content: String? = nil
}
The publicly exposed content
is referring to the hidden _content
to access the storage. This does the job but when a type has more than one property (i.e. with introduction of timestamp
) the effect from the boilerplate code associated with tracking increases exponentially and can lead to errors where some of the properties are not tracked.
- Use SE-0252
@dynamicMemberLookup
feature with hidden storage:
@dynamicMemberLookup
class Document {
var generation: Int = 0
let title: String
struct _Storage {
var content: String?
}
private var storage: _Storage
init(title: String, content: String? = nil) {
self.title = name
self.storage = _Storage(content: content)
}
subscript<U>(dynamicMember keyPath: WritableKeyPath<_Storage, U>) -> U {
get { storage[keyPath: keyPath] }
set {
storage[keyPath: keyPath] = newValue
generation += 1
}
}
}
This is a more significant refactoring that requires a separate _Storage
type, splitting the state between Document
and _Storage
, and re-implementation for every type. On the other hand, this approach allows removing most of the boilerplate because the access is routed through a subscript, which is big improvement over the first approach.
- Use a property wrapper:
@propertyWrapper
struct GenerationTracked<Value> {
var value: Value
var generation: Int = 0
init(wrappedValue: Value) {
self.value = wrappedValue
}
var projectedValue: Self { return self }
var wrappedValue: Value {
get { self.value }
set {
self.value = newValue
generation += 1
}
}
}
GenerationTracker
is going to keep a generation count per property, and could be used like this:
class Document {
let title: String
@GenerationTracked var content: String? = nil
}
GenerationTracker
property wrapper greatly improves readability at the declaration site of each property comparing to both previous approaches and doesn’t require code duplication per-type - this kind of repetitive pattern is what inspired property wrapper introduction to the language!
But there is a catch - with each property comes additional storage which adds up pretty quickly for types with a large number of properties, and more importantly, exposing the "current generation value" becomes a more complex operation as number of tracked properties grows:
class Document {
let title: String
@GenerationTracked var content: String? = nil
@GenerationTracked var timestamp: UInt64
}
The generation value is a sum of content
and timestamp
:
extension Document {
var generation: Int {
$content.generation + $timestamp.generation
}
}
This also means that with addition of every new property generation
would have to be updated as well which could be error prone.
Another interesting example is an anti-pattern which is very easy to reach for because there is, currently, no better way to express the use-case in the language. Let’s say that instead of trying to track changes made to a Document
, the developer wants to synchronize access to its mutable properties using a queue. They’d like to have a type confined to a single queue which is going to make all access to the properties serial. For this example we are going to skip over the computed properties and @dynamicMemberLookup
approaches because they look exactly the same ditto var generation
is now var queue: DispatchQueue
and jump directly to the property wrapper approach instead:
@propertyWrapper
struct Confined<Value> {
var value: Value
private var queue = DispatchQueue(label: "com.example.confined.\(Value.self)")
init(wrappedValue: Value) {
self.value = wrappedValue
}
var projectedValue: Self { return self }
var wrappedValue: Value {
get {
self.queue.sync { self.value }
}
set {
self.queue.sync { self.value = newValue }
}
}
}
Newly added @Confined
could be attached to every mutable property of the Document
just like in our previous example with the GenerationTracker
:
class Document {
let title: String
@Confined var content: String? = nil
@Confined var timestamp: UInt64
}
But unlike @GenerationTracker
, that only adds additional storage, @Confined
has more serious implications for performance, because now every property is handled by a dedicated queue which is not a light-weight mechanism and access pattern to the type itself is not serial!
Using either a property wrappers or @dynamicMemberLookup
is the best answer for Document
class but each comes with some significant drawbacks. Nevertheless the best parts of these approaches could be combined into a type wrapper concept that could be used to cover all mutable stored properties of a type.
Proposed solution
Type wrappers provide a way to abstract a type’s storage so that it can be managed in some other manner. Let's convert the example from Motivation to use a type wrapper.
The type wrapper will be defined with the @typeWrapper
attribute, like this:
@typeWrapper
struct GenerationTracker<S> {
/// The instance of the underlying storage the wrapper operates on.
var underlying: S
/// The generation count that gets increased after every modification.
var generation: Int = 0
init(memberwise storage: S) {
self.underlying = storage
}
subscript<V>(storageKeyPath path: WritableKeyPath<S, V>) -> V {
get {
return underlying[keyPath: path]
}
set {
underlying[keyPath: path] = newValue
generation += 1
}
}
}
The type wrapper can be used as a custom attribute on the definition of a Document
:
@GenerationTracker
class Document {
let title: String
var content: String? = nil
var timestamp: UInt64
}
The @GenerationTracker
attribute applies the type wrapper transformation to the Document
. This transformation does several things:
- It creates a new struct
$Storage
that contains the mutable stored properties that are declared in theDocument
class. The struct would look like this:
struct $Storage {
var content: String?
var timestamp: UInt64
}
- Then, it replaces the declared stored properties in the
Document
with a single stored property of typeGenerationTracker<$Storage>
, like this:
class Document {
// synthesized
private var $_storage: GenerationTracker<$Storage>
... see below
}
- And then turns each of the declared stored properties into a computed property that goes through the storage subscript, like this:
var content: String? {
get { $_storage[storageKeyPath: \$Storage.content] }
set { $_storage[storageKeyPath: \$Storage.content] = newValue }
}
Since $_storage
is accessible from the wrapped type, it's pretty easy to add a computed property to surface the current generation value of a Document
:
extension Document {
var generation: Int { $_storage.generation }
}
Now, let’s take a look at @Confined
example from the previous section. Type wrappers can help to synchronize access to the Document
using a single queue because they wrap the whole type:
@typeWrapper
struct Confined<S> {
/// The instance of the underlying storage the wrapper operates on.
var underlying: S
/// A single queue to sync access to all of the properties
private var queue = DispatchQueue(label: "com.apple.example.confined.\(S.self)")
init(memberwise storage: S) {
self.underlying = storage
}
subscript<V>(storageKeyPath path: WritableKeyPath<S, V>) -> V {
get {
self.queue.sync { underlying[keyPath: path] }
}
set {
self.queue.sync { underlying[keyPath: path] = newValue }
}
}
}
Applied to Document
it looks like this:
@Confined
class Document {
let title: String
var content: String? = nil
var timestamp: UInt64
}
With type wrapper a single queue would be used to manage access to all properties of the Document
.
Type wrappers address all drawbacks associated with property wrappers for this particular use-case:
- Avoid duplicate storage.
- Provides access indirection to all mutable stored properties (with a possibility to explicitly opt-out).
- Remove boilerplate through access centralization.
- Type wrapper state (i.e. value of
generation
) is easily accessible from the wrapped type.
Detailed design
Type Wrapper types
A type wrapper type is a type that can be used to manage access to the storage of the wrapped type. There are a few requirements for a type wrapper type:
-
The type wrapper type must be defined with the attribute
@typeWrapper
. The attribute indicates that the type is meant to be used as a type wrapper type, and provides a point at which the compiler can verify any other consistency rules. -
The type wrapper type must have:
- A single generic parameter that represents a type of underlying storage (the
$Storage
of a particular type a wrapper is applied to). -
init(memberwise: <#T#>)
- to initialize underlying storage property. -
subscript<V>(storageKeyPath path: WritableKeyPath<#T#>, V>) -> V
- to access the underlying storage given a key path.
- A single generic parameter that represents a type of underlying storage (the
Custom attributes
Type wrappers are a form of custom attribute, where the attribute syntax is used to refer to entities declared in Swift. Grammatically, the use of type wrappers is described as follows:
attribute ::= '@' type-identifier
The type-identifier must refer to a type wrapper type, which cannot include generic arguments. It could be beneficial to extend this definition to support generic arguments, which is a possible future direction.
Applying type wrapper to a type
Introducing a type wrapper to a type makes all of its mutable stored properties computed (with a getter/setter) and adds:
- A member struct
$Storage
that mirrors all the stored properties of a type. - A stored property
$_storage
- instance of type wrapper type which is used for all storage access. The name is chosen to avoid a potential clash with a user-defined variablestorage
with a (projection capable) property wrapper which is going to get$storage
synthesized for it.
The transformation from stored to computed properties happens as follows:
- A new member is introduced to
$Storage
which uses the name and type of the original property. - The original property is marked as computed and the compiler synthesizes:
- A getter:
get { $_storage[storageKeyPath: \$Storage.<name>] }
- A setter:
set { $_storage[storageKeyPath: \$Storage.<name>] = newValue }
- A getter:
- The default value of a property (if any) is moved to a parameter of implicitly synthesized initializer or injected into a user-defined initializer, see Initialization section for more details.
Properties with property wrappers
When property has one or more property wrappers, the type wrapper transformation applies only to the compiler synthesized backing property because it represents the underlying storage of the wrapped property.
To demonstrate this in action, let's consider the following example:
@GenerationTracker
struct Person {
@Wrapper var favoredColor: Color
}
Applying @Wrapper
to favoredColor
results in the following transformation (for more details please see property wrapper proposal):
@GenerationTracker
struct Person {
var favoredColor: Color {
get { _favoredColor.wrappedValue }
set { _favoredColor.wrappedValue = newValue }
}
/// !!! - exists only if `Wrapper` supports projection.
var $favoredColor: Wrapper<Color> {
get { _favoredColor.projectedValue }
}
/// Backing property is stored
private var _favoredColor: Wrapper<Color>
}
Both favoredColor
and $favoredColor
are computed properties which means that type wrapper only applies to _favoredColor
and routes it through the $_storage
:
@GenerationTracker
struct Person {
struct $Storage {
internal var _favoredColor: Wrapper<Color>
}
var $_storage: GenerationTracker<$Storage>
/// !!! - Backing property is now routed through the `$_storage`
private var _favoredColor: Wrapper<Color> {
get { $_storage[storageKeyPaht: \$Storage._favoredColor] }
set { $_storage[storageKeyPath: \$Storage._favoredColor] = newValue }
}
var favoredColor: Color {
get { _favoredColor.wrappedValue }
set { _favoredColor.wrappedValue = newValue }
}
// Note: only if `Wrapper` supports projection.
var $favoredColor: Wrapper<Color> {
get { _favoredColor.projectedValue }
}
}
This composes well with multiple property wrappers and means that type wrapper is applied before any property wrapper operation can occur. There are some interesting considerations regarding how such properties are initialized which are discussed in Initialization section.
Initialization
Memberwise initializer for stored properties
If there are no user-defined initializers, instead of the usual memberwise initializer, the type wrapper transformation will synthesize a special initializer that covers all of the stored properties in the following fashion:
- Any stored properties not handled by a type wrapper are initialized by direct assignment -
self.<name> = <name>
- All other properties are initialized through
$_storage
initialization via callinginit(memberwise:)
and passing an instance of fully initialized$Storage
to it. - If a property has a default value it’s going to be used as a default value expression:
@GenerationTracker
struct Person {
let name: String
/// `= 30` is subsumed by the type wrapper
var age: Int = 30
/// synthesized init
init(name: String, age: Int **= 30**) {
self.name = name
self.$_storage = .init(memberwise: $Storage(age: age))
}
}
Let’s return to our Person
example with a couple more properties:
@GenerationTracker
struct Person {
let name: String
var age: Int = 30
@Wrapper(arg: 42) var favoredColor: Color
}
The compiler would synthesize the following initializer for Person
:
init(name: String, age: Int = 30, **@Wrapper(arg: 42)** favoredColor: Color) {
self.name = name
self.$_storage = .init**(memberwise: $Storage(age: age, favoredColor: favoredColor))**
}
For properties without property wrappers synthesis is straightforward - synthesize a parameter with a default expression (if property had one) but with property wrappers it becomes a bit more complicated because the type of the parameter depends on the capabilities of the wrapper as outlined in Memberwise Initialization section of the property wrapper proposal.
If it’s possible to use a wrapped type then the corresponding parameter is going to get the wrapper attribute (@Wrapper(arg: 42)
in our example) as well to make it easier to synthesize a backing variable _favoredColor
that is passed to the $Storage
initializer.
If use of wrapped type is not allowed, parameter is going to have a wrapper type:
init(name: String, age: Int = 42, favoredColor _favoredColor: Wrapped<Color>) {
self.name = name
self.$_storage = .init(memberwise: $Storage(age: age, favoredColor: _favoredColor))
}
Handling user-defined initializers
All of the user-provided designated initializers are going to be transformed by the compiler to inject initialization of the $_storage
property. Convenience initializers are required to ultimately call a designated initializer which would make sure that $_storage
is always initialized.
The initializer transformation involves:
- A new local variable for
$Storage
→_storage
. This variable collects initial values of all type wrapper managed properties. - A transformation of initial user-written assignments to
self.<name>
to use_storage
instead untilself.$_storage
could be fully initialized and used.- If a property has a property wrapper then type wrapper transformation happens after property wrapper has already been applied.
To demonstrate how the transformation could look like in the surface language, let’s consider the following example:
@<typeWrapper>
struct Person {
let name: String
var age: Int
var favoriteColor: Color
init(name: String = "Arthur Dent", age: Int = 30) {
self.name = name
self.age = age
self.favoriteColor = .yellow
self.age = 42
print(self.age)
}
}
Assignments to age
and favoredColor
have to happen on a temporary variable that represents partially initialized $Storage
type because $_storage
could only be initialized using init(memberwise:)
after all of the properties managed by a type wrapper are initialized:
init(name: String = "Arthur Dent", age: Int = 30) {
// !!! - injected by the compiler
var _storage: $Storage
// `name` is not accessed via type wrapper so it's ignored
self.name = name
// !!! - transformation of `self.age = age`
_storage.age = age
// !!! - transformation of `self.favoriteColor = .yellow`
_storage.favoriteColor = .yellow
// !!! - since `_storage` is now completely initialized
// (via initializing both `age` and `favoredColor`) the
// compiler can inject `$_storage` initialization.
self.$_storage = .init(memberwise: _storage)
// !!! - As soon as `$_storage` is initialized all access
// to type wrapper managed properties can happen using
// their getter/setter.
self.age = 42
print(self.age)
}
Compiler injects $_storage = .init(memberwise: _storage)
as soon as _storage
is fully initialized which means that all access to managed properties is always going to go through the type wrapper type instance which is consistent with compiler synthesize default initializer.
Access control
-
$Storage
is aninternal
type because it could be used as a type witness as described in the following section. -
$_storage
variable hasinternal
access level because it’s intended to be used only by the wrapped type and as a witness to a protocol requirement just like$Storage
. - Synthesized memberwise initializer has
internal
access level just like standard memberwise initializers.
Inference of type wrapper attributes
A type wrapper attribute could be applied to a protocol as a mechanism to infer a type wrapper and add extra APIs to the conforming type:
@GenerationTracker
protocol GenerationTracked {
}
Such use implies the following:
- Types that conform to
GenerationTracked
at the primary declaration automatically infer@GenerationTracker
attribute. If the conformance is stated in an extension, type wrapper inference is disabled and it must be explicitly written at the primary declaration. - A type can only conform to one such protocol because type wrapper attributes do not compose.
- The compiler would add following declarations to the protocol declaration:
-
associatedtype $Storage
; and var $_storage: GenerationTracker<Self.$Storage> { get }
-
This is how transformed GenerationTracked
declaration looks like:
@GenerationTracker
protocol GenerationTracked {
// synthesized by the compiler
associatedtype $Storage
// synthesized by the compiler
var $_storage: GenerationTracker<Self.$Storage> { get }
}
Going back to our original example from the Motivation section this inference mechanism allows us to declare var generation: Int { ... }
in a protocol extension instead of having to re-define it for every @GenerationTracker
type:
extension GenerationTracked {
var generation: Int {
get { $_storage.generation }
}
}
Now instead of using @GenerationTracker
attribute directly, Document
could declare a conformance to GenerationTracked
protocol:
struct Document : GenerationTracked {
let title: String
var content: String? = nil
}
The conformance is going to add @GenerationTracker
attribute for Document
and allow it to access generation
property.
Opting out properties from type wrapper transformation
It could be beneficial to opt-out certain, otherwise supported, properties from the type wrapper transformation. It should be done by applying @TypeWrapperIgnored
attribute to each declaration to be ignored.
struct Document : GenerationTracked {
let title: String
// Property is managed by the GenerationTracker
var content: String? = nil
// Property is managed by the Document
@TypeWrapperIgnored var timestamp: UInt64
}
Restrictions on type wrappers
-
A protocol cannot be a type wrapper.
-
Type wrapper cannot be applied to enums, and extensions because they don't have stored properties.
-
A type cannot have more than one type wrapper associated with it (type wrappers do not compose).
-
Type wrappers are not applied to the following:
- A computed property.
- A
let
property. - A property that overrides another property (if type wrapper is associated with a class).
- A
lazy
,@NSCopying
,@NSManaged
,weak
, orunowned
property.
Source compatibility
This is an additive feature that doesn't impact existing code.
Effect on ABI stability
No ABI impact since this is an additive change.
Effect on API resilience
Adding and removing a type wrapper to a type is a resilient change unless the wrapped type is marked @frozen
.
Alternatives considered
Support for let
properties
Type wrappers could be allowed to manage let
properties as well, but this would mean that the underlying value of the immutable property could change across accesses, which goes against the existing behavior of let
declarations. This would also complicate type wrapper types by having to support subscript(storageKeyPath:)
overloading based on the key path argument.
Future Directions
Using an actor as at type wrapper
It may be possible to use a type wrapper as an actor:
@typeWrapper actor MyActor { ... }
However, applying this type wrapper to another type needs to reconcile the isolation of the type wrapper. If the type wrapper subscript is nonisolated
, then the type wrapper transformation can be applied as usual. However, if the subscript is isolated to the actor, then all stored properties in the wrapped type need to be (implicitly or explicitly) async
or isolated to the type wrapper. Determining how actor type wrappers interact with actor isolation is out of the scope of, but not prohibited by, this proposal.