Hello everyone,
The following is a pitch for allowing let declared accessor macros in the Swift language. We welcome any feedback or questions about this pitch!
[Pitch] Allow Accessor Macros on Let Declarations
- Proposal: SE-NNNN
- Authors: Amritpan Kaur, Pavel Yaskevich
- Review Manager: TBD
- Status: Awaiting Implementation
Introduction
SE-0389 Attached Macros provided a way to add extensibility to declarations and introduced the peer
and accessor
macro roles that can be used on properties. However, these roles can only be attached to var declared properties. This proposal builds on the original vision by expanding attached macro usage to include let
declared properties.
Motivation
With SE-0400 Init Accessors, a property wrapper is an accessor
macro and a peer
macro.
Allowing let
declared property wrappers has been discussed on the Swift Forum as a way to allow nonisolated
wrapped properties or to simplify property wrapper usage where the backing type is a reference type.
Nonisolated properties
Actor methods and properties, which are inherently isolated to that actor, can be marked nonisolated
to allow them to be accessed from outside their actor context. At this time, the nonisolated
keyword cannot be used on property wrappers because var
declared property wrappers generate a var
declared backing property that is not safe from race conditions and so cannot be nonisolated
.
@propertyWrapper
struct Wrapper {
var wrappedValue: Int { .zero }
var projectedValue: Int { .max }
}
@MainActor
class C {
@Wrapper nonisolated var value // error: `nonisolated` is not supported on `var` declared property wrappers
}
Instead, a property wrapper declared with a let
can allow us to write nonisolated
getter-only computed properties as the backing property will also be a let
:
@MainActor
class C {
@Wrapper nonisolated let value: Int
nonisolated func test() {
_ = value
_ = $value
}
}
Backing reference types
A let
wrapped property could be useful for reference types like a property wrapper class. Typically property wrappers are written for value types, but occasionally a protocol like NSObject
may require the use of a class. For example, WrapperClass
is a property wrapper of a reference type:
@propertyWrapper
class WrapperClass<T> : NSObject {
var wrappedValue: T
init(wrappedValue: T) {
self.wrappedValue = wrappedValue
}
}
WrapperClass
could be declared as a let
instance, assigned to once, and prevent any future unintentional changes to the property wrapper class instance in this context.
class C {
@WrapperClass let value: Int
init(v: Int) {
value = v
}
}
SE-0400 Init Accessors models an accessor
macro that is read-only but cannot be applied to let declarations and solve some of the existing user issues with property wrappers.
Now that attached macros can model property-wrapper-like transformations, allowing let
declared accessor
and peer
macros can improve Swift language consistency and expressivity.
Proposed solution
We propose to allow the application of accessor macros to let
declared properties, which will permit the macro to abstract away the storage of the property while enforcing that it be initialized only once.
For example, consider the following implementation for a @BoilingPointWrapper
property wrapper:
@propertyWrapper
struct BoilingPointWrapper<T> {
init(wrappedValue: T) {
self.wrappedValue = wrappedValue
}
var wrappedValue: T
}
Let’s declare BoilingPointWrapper
as an attached macro:
@attached(accessor)
@attached(peer, names: prefixed(_))
macro BoilingPoint<T>() = #externalMacro(module: "MyMacros", type: "BoilingPointMacro", wrapper_name: BoilingPointWrapper)
And attach it to a let
property:
struct Temperature {
@BoilingPoint let water: Double = 373.1
}
After the let
declared property has been initialized, any attempt to reassign it will result in an error.
temperature.water = 400 // error: cannot assign to property: 'water' is a 'let' constant
Detailed design
SE-0400 Init Accessors details how an accessor macro can transform a var
declared stored property into a computed property while still allowing initialization of stored properties through the newly-computed property. Let’s imagine a @Conversion
attached accessor macro that can be applied to the stored properties in struct Angle
:
struct Angle {
var degrees: Double
@Conversion var radians: Double
}
The @Conversion
macro transforms radians
into a computed property with a getter, setter, and init accessor:
struct Angle {
var degrees: Double
var radians: Double {
@storageRestrictions(initializes: degrees)
init(initialValue) {
degrees = initialValue * 180 / .pi
}
get { degrees * .pi / 180 }
set { degrees = newValue * 180 / .pi }
}
init(degrees: Double) {
self.degrees = degrees
}
}
Now let’s imagine the @Conversion
macro was attached to a let
property:
struct Angle {
var degrees: Double
@Conversion let radians: Double
}
Applying an accessor macro to a let
declared stored property could also transform it into a computed property with an init accessor and a getter, but would not generate a setter. The getter-only radians
can be assigned to only once.
struct Angle {
var degrees: Double
let radians: Double {
@storageRestrictions(initializes: degrees)
init(initialValue) {
degrees = initialValue * 180 / .pi
}
get { degrees * .pi / 180 }
}
init(radiansParam: Double) {
self.radians = radiansParam // calls init accessor for 'self.radians', passing 'radiansParam' as the argument
self.radians = radiansParam // error: cannot assign to property: 'radians' is a 'let' constant
}
}
Additionally, if the accessor macro implementation includes both a getter and a setter, then its setter will be ignored when it is attached to a let
declaration.
To summarize,
- an accessor can be applied to a stored property declared with
let
; - the accessor macro expansion can transform the
let
constant into a computed property; - the accessor macro must produce an
init
accessor and a getter; - and if the accessor macro produces a setter, it will not be added to the computed property.
Effect on ABI stability/API resilience
This is an additive change that does not impact source compatibility, does not compromise ABI stability or API resilience, and requires no runtime support for back deployment.
Alternatives considered
A let constant’s immutability
Theoretically, a let
declared property wrapper would generate a backing storage initialized only once via a getter-only computed property. While there is concern that this could break the immutability premise of a let
constant, there has also been discussion of whether property wrappers should be limited to var declarations as a consequence of being computed properties when the Swift language allows other value types with computed properties to be declared with a let
.
This proposal does not conflict with a let
declaration’s inherent promise of immutability because, like any other let
declared property, a let
declared property with an accessor or peer macro can only be initialized once and cannot be reassigned. While read-only accessor macros are now possible, writing accessor macros directly on let declarations also solves recurring Swift language pain points.
Acknowledgments
Thank you to Holly Borla for providing formative feedback on this pitch and to Pavel Yaskevich for modeling how to test the existing attached macro implementation. Thank you to both for helping me understand how macros work.