[Pitch] Allow Accessor Macros on Let Declarations

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

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.

13 Likes

What happens if a macro only provides a mutating get? Or a get throws or get async?

Thank you for reworking your pitch around accessor macros!

I'm not sure how to interpret this example. I can certainly see how, with your feature and Wrapper being an accessor + peer macro that generates a nonisolated let behind the scenes, this example works. I think it makes sense to present it that way, rather than start off the pitch with property wrappers.

It might even be best to search for and remove every instance of "property wrapper" in your pitch because, e.g., "Backing reference types" also talks through property wrappers. Then only bring them back in a section later if you have something specific you want to say about them.

The @attached(accessor) should document that it produces an init, e.g., @attached(accessor, names: named(init), named(get)).

I think it's valuable here to show the expansion (which will have the init and the get), then note that the expanded property has the right semantics.

Interesting. We could, alternately, require that the macro implementation not produce a setter when applied to a let, but it seems like that would lead the macros that accidentally don't support let properties. I think you have the most user-friendly behavior, though.

Missing "macro" after "accessor".

Please document what happens with observers (willSet and didSet), and the cases the other poster asked about (such as mutating get). I assume all of these are prohibited.

I don't really agree with this rationale, because the generated getter could read any mutable state anywhere (from other properties, global state, through reference types, etc.) as part of producing the value it returns. The inherent promise of a let property (of value type) is not only that you can't change the value yourself, which you note, but that it can't change behind your back [*]. If you've read a value from a let property once, that's it's value forever, which is a strong semantic guarantee. With this proposal, let on value types will no longer make the same promises it did before.

It's possible that there is a design that could allow this feature without removing that promise. It would mean significant restrictions on what code could be written in the init and get accessors that are applied to a let. For example, it would only be able to read other let values (recursively) and their parameters (e.g., newValue), and only call functions that have the same restrictions (similar to the "pure functions" from past discussions).

Your pitch as written lays out a reasonable design for this feature, and I have no concerns about the ability to implement it as specified. I think the chief question for this proposal is whether the expressivity gain it provides is worth weakening the promise of let properties.

Doug

[*] For folks with a C++ background, C++ const implies the first property (I can't change it) but not the second (someone else can still change it!).

2 Likes

@Douglas_Gregor Maybe this is a sign that Swift should somehow incorporate the concept of "let requirement"?
There is an orthogonal design flaw about "vars" and "lets" that might benefit from it. There is no such thing as a let property in protocols. We have to use a "get-only" property in this case, even though in almost all cases the property in the implementation is a let one. This implies the optimizer can't cache the value of a protocol's property and always have to invoke the getter.

A rough sketch

protocol Foo {
  let bar: Int
  let buz: Int
}

class FooImpl: Foo {
  let bar: Int = 24 // stored let property

  let buz: Int { // computed let property
    unsafe get { // with a promise that the getter always returns the same value
      42
    }
  }
}
1 Like

Yes, that's right. We could permit let requirements in protocols today, and require that they be satisfied by a let constant. In the current language, one would have to introduce storage to satisfy the requirement, which makes retroactive and conditional conformance impossible, so it's not a great feature by itself.

But as you note, if we did have a way to write computed let properties safely, we could have let requirements.

Doug

2 Likes

TL;DR: unsafe is important.

I think it will never be possible to deduce if a computed let property is "safe". There are easy cases, obviously, like a computation involving only compile time constants and other let properties (classic examples are translation between Celsius and Fahrenheit, or Degrees/Radians from the pitch).

But apart from "unchangability" let properties also promise to have no side effects. And this is where Swift also has a room for improvement. For example we don't have a notion for a function to be "pure". For example, how can we be sure operator * has no side effects and its result depends only on the arguments. The optimizer sometimes can reason about it, for example for Dead Object Elimination, but to do so the implementation of the function should be visible. If the function is in another module and not marked with an inlinable/transparent attribute we should assume it has all sorts of side effects.

But even if one day Swift will support all these advanced notions for defining code as "pure", there still will be two major cases, in which we never can deduce if they are "safe" or not:

  • Calls to C functions. It just will never the case we can deduce if a C function is "safe" to be called from a let getter.
  • Code that is semantically pure, but the implementation isn't. The easiest example is the Lazy pattern, we can't observe the underlying storage, so from the outside it's a perfectly "safe" let property, but the implementation should store the value somewhere upon the first get.

It all might sound like I want to discourage everyone from supporting let computed properties, but that's not true. Actually I want this feature, and I'd like to promote a possible roadmap.
I guess implementing even the easiest analysis for "safe" computed properties will be a nightmare. So instead at first, IMO, it would be wise to delegate the "safeness" check to a programmer, with a clear syntax that the compiler doesn't check the safeness(i.e. "unsafe", "unchecked").
And then over the years slowly improve the compiler's ability to deduce the "safeness".