SE-0539: Enable Macros to Grant `self` Access for Property Initializers

A macro automatically produces code that could be written by hand. It is quite common to write a computed property where we need access to self or instance members. I find it a real limitation of accessor macros that they can move the initializer into a getter, but self access is artificially forbidden.

As for the SwiftUI macro example: I over-enthusiastically wrote it down before I could glance at its expansion. It's true that the init expression is being wrapped in a closure in the current implementation, which prevents my proposal from working for this use case. For a property wrapper this approach is a perfect fit. A macro produces code and is therefore more flexible. Maybe there is an alternative approach, but that's a decision for the SwiftUI team.

We could add an underscore to the name if consensus is that the proposed feature is experimental.

I am not sure if the new API is much of a burden. It's only for accessor role declarations, and opt-in for those who want to grant self access. Everybody can ignore it safely. It does not pollute the rest of the Swift namespace, because there is a bespoke method that parses macro role parameters. A macro role attribute is not really Swift code. initialization and selfAvailable won't be Swift keywords. In fact, the alternative true|false spelling only looks like a boolean, but isn't.

@Lazy was just the simplest example I could come up with. Its use-case is already well understood because we have lazy var properties as a language feature. Other macros may be more useful. How about one that logs access as a side effect? Or one that uses a different backing store, such as a dictionary or UserDefaults. Or something like @Clamped that limits values to a given range...

2 Likes

I can think of two more examples of "effectful" getters:

  • An LRU cache that returns a value after moving it to the end of some list.
  • A "spy" test double that records the number of times this property was accessed.

What I have a hard time thinking of is a practical example of a new effectful getter that would most naturally be expressed with an InitializerClauseSyntax defined by the product engineer using the macro. The only example I can think of so far is Lazy

Any of those macros could be also lazy, or have a lazy variant. In the Alternatives Considered I mentioned that we cannot add a macro to a lazy var. We have to add laziness to the macro implementation. Currently we cannot fully replicate lazy var semantics in a macro. The missing piece is to allow self access.

A common reason for a lazy property is to delay or avoid computationally intensive initialization. Let's say we want to have a @Thumbnailed macro[1]:

  • Creates a thumbnail of a given image
  • Caches the thumbnail

Let's use it:

struct Article {
    let heroImage: UIImage

    // Currently does not compile (`self` not available)
    @Thumbnailed var thumbnail = heroImage
}

To avoid creating a thumbnail wastefully, it postpones that work to the first read access. At that moment, it checks if there is an existing thumbnail for that image in the cache. If not, it creates the thumbnail, caches it and returns it[2].

The pattern "lazy evaluation & caching" is quite useful if initialization is non-trivial and if the number of values is reasonably small, for example DateFormatters.


  1. We could use a lazy var with an initializer expression that does the image processing and caching. It's not too bad if the heavy lifting is provided by a free function:

    lazy var thumbnail =
        cachedThumbnail(for: heroImage)
    

    Disadvantage: there is a mutating get, which may be OK if the enclosing instance can be mutated. If not, a macro could be a great solution. ↩︎

  2. Note: The cache should ideally have reference semantics so we don't need mutating get. ↩︎

2 Likes

Side note: A getter is not the only way to allow access to instance members. A macro could move the initializer expression into an init accessor if it knows the properties where access is needed. Those have to be added to the accesses list of @storageRestrictions.

1 Like

So we say that the missing piece right now is to allow self access. And that is fair. But what about another missing piece in the future?

  • An elephant?
  • A bicycle?

Starting from Swift 6.8 our initialization: selfAvailable is no longer enough to replicate lazy semantics… we actually need:

initialization: selfAvailable, elephantAvailable, bicycleAvailable

Initially, the proposed spelling was initialization: lazy|eager. It matched the precedent of the lazy keyword nicely, but did not adequately describe what is actually happening during type-checking. The same reasoning applies to the alternative initialization: deferred|immediate spelling.

I'm a little more interested to hear why lazy|eager or deferred|immediate are no longer on the table. It's "the what not the how" instead of "the how not the what". There is not going to be one right answer here… but I do think communicating this API in terms of what we want to enable gives us a little more freedom in the future to determine how we want to enable that.

Ha! Now it's your turn to come up with practical examples :wink:

I admit that I do want elephants on bicycles, but not during property initialization. And I'd like to keep it private and not tell the compiler about it...

Any of those would be fine for me. Maybe delayed would be a bit more clear than deferred because the latter has a second meaning. I'll defer to those who preferred selfAvailable to elaborate on their reasoning.

1 Like

I tried to be very careful about avoiding source-breaking changes when I decided to add the initialization: selfAvailable parameter to the macro role attribute, which defaults to selfUnavailable. But maybe that's a bit too conservative? I have some concerns that this setting will be largely ignored by the macro community. If that's the case, we end up with a bunch of macros that could allow self access, but don't. Quickly brainstorming three ideas:

  1. Diagnose warning if initialization: parameter is missing
    This will nudge macro authors to at least think about it and explicitly declare selfUnavailable if needed, or decide to ignore/suppress the warning.

  2. Compiler setting instead of role attribute parameter
    ACCESSOR_MACRO_SELF_AVAILABLE_FOR_INITIALIZER=true[1]
    This should be the default setting for new macro projects[2]. Old projects can opt in. No fine-grained control for different accessor macros in the same package.

  3. Change default behavior with new Swift version
    A macro compiled with Swift 6.4 keeps the existing behavior. When a newer compiler is used, selfAvailable becomes the default, potentially breaking client code in some rare edge cases. With this approach we could argue that selfUnavailable is not an option, and the initialization: ... parameter should be removed from the proposal.


  1. That's a mouth full, bike shedding needed if this idea is chosen. ↩︎

  2. If blissfully ignored by a macro author, and initializer is re-contextualized where self is in fact unavailable: no big deal, it will be diagnosed by the type checker in the new context. ↩︎

1 Like
Side Question about Compatibility

The WIP implementation attached to the proposal is a diff on swift itself. Did this also need any corresponding diff on swift-syntax?

Is the idea that once a macro is built from a new toolchain then this effectively "back-deploys" to any version of swift-syntax? Or are there any version limitations there to know about?

swift-syntax does not need to change. It is not involved at all. In my draft, an "initializerContext" field is added to the MacroRoleAttribute in AST. The compiler uses it to decide which way to type-check.

For a macro that opts in for selfAvailable, both macro and client code need to be compiled with a new toolchain to get the new feature. In any other case, we get the current behavior where self access is not allowed[1].

Client Old Toolchain Client New Toolchain
Macro Old TC :cross_mark: no self :cross_mark: no self
Macro New TC :cross_mark: no self :white_check_mark: self access allowed

  1. Note to self: I'll have to check if my current code screws up macroRoleAttr memory layout for backwards compatibility and fix it if needed. ↩︎

1 Like

I believe this was discussed in the proposal:

Enable self for all Subsumed Initializers in General

It could be argued that subsumed property initializers should always be allowed to access self and its instance members in general, since invalid access would be diagnosed in their new context later.

This approach would introduce source-compatibility problems where the meaning of a reference changes if self becomes available.

  • A static member has the same name as an instance member:
    struct S {
      let foo = 0
      static let foo = 17
    
      // Used to be initialized with the static member.
      // Now it's initialized with the instance member.
      @Lazy var x = foo
    }
    
  • There is an instance member named self. For example, classes deriving from NSObject have a self() method:
    class C: NSObject {
      // Used to be an unapplied reference to the inherited method "`self`() -> Self"
      // Now it's an instance of `C`
      @Lazy var x = self
    }
    

The proposed solution addresses this concern by giving macro authors control over granting self access. Making selfAvailable for existing macros should be carefully considered and documented as potentially source-breaking by macro vendors.

Those would be the breaking changes? Are there more potential breaking changes we would expect for?

Was this pattern of falling back to a static variable always intended? Or was this not completely intended but happened anyway?

struct S {
  static let x = 1
  let x = 0
  
  let y = x
  lazy var z = x
}

var s = S()
precondition(s.y == 1)
precondition(s.z == 0)

Was there ever a time when this code would have been broken? Was something fixed that made code like this possible?

Allowing accessor type checking in a context with self allows macro authors to write new init accessor expansions, which seems like a natural use case for this feature. Tweaking the example to be @Cached instead of @Lazy

// declaration
struct Earth {
    let mice: Int
    @Cached var theAnswer = mice * 2
}

    // Expansion:
    private var _theAnswer: Int? = nil
    private var _theAnswerRecipe: () -> Int
    
    var theAnswer: Int {
        @storageRestrictions(initializes: _theAnswerRecipe, accesses: mice)
        init {
            _theAnswerRecipe = { [mice] in mice * 2 } // ✅ no error
        }

        mutating get {
            if let value = _theAnswer {
              return value
            } else {
              _theAnswer = _theAnswerRecipe()
              return _theAnswer!
            }
        }

        set {
            _theAnswer = newValue
        }
    }

By providing an accesses: mice on the init accessor, the above example compiles and works as intended.

While it's easy to write this out by hand, it's challenging for a macro to figure out which instance properties are accessed in the initial value expression. A macro could do it with enough string parsing with the proposed design, but lexical analysis would be inaccurate and brittle. For example:

@Cached var theAnswer = mice * 2 * globalVariable

// expands to
@storageRestrictions(
    initializes: _theAnswerRecipe, 
    acessess: mice, globalVariable) 
                 // ^ ❌ Cannot find type 'globalVariable' in scope
init {
    _theAnswerRecipe = { [mice, globalVariable] in mice * 2 * globalVariable }
}

Given the aims of the proposal to grant self Access for Property Initializers, the init-accessor use case seems worth considering as part of the design, and there should be a way for macro authors to query the list of accessed instance properties.

5 Likes

Let's try to actually implement the @Lazy macro.

@attached(peer, named: prefix(_))
@attached(accessor, initialization: selfAvailable)
public macro Lazy<Wrapped>() = ...

Since the accessor macro does not generate the backing variable, we'll need a peer macro. The proposal correctly points out that peer macro needs to provide the type for the backing variable:

@Lazy var foo = self.mice * 2

// expanded by the PeerMacro conformance:
private var _foo: /* what does the peer macro put here? */

The peer macro must take the original expression self.mice * 2, generate another valid Swift expression there that convinces the complier that the type of the expression is Int?. There's no other mechanism with macros to derive _foo's type.

The problem is self.mice now needs to appear in the scope of a type declaration without a macro.

private var _foo = someThingAbout(self.mice)

And this still isn't valid.

But don't give up yet! If self.mice isn't allowed here, could it be allowed in another macro, equipped with a new language feature that grants the macro author the option to (re)move it to some valid place?

/// expands to nil
@freestanding(expression)
macro nil<T>(of ignored: T) -> T? = ...

let mice: Int
private var _foo /*: inferred to be Int*/ = #nil(of: self.mice * 2)

So perhaps we should grant freestanding macro the same privilage?

@freestanding(expression, initialization: selfAvailable)
macro nil<T>(of ignored: T) -> T? = ...

Without something like this, I don't think the @Lazy macro syntax as envisioned in the proposal could be implemented.

2 Likes

Today, the initializer expression of a instance property is typed checked in the static scope.

struct ReferenceToStaticProperty {
    let instanceProperty = 42
    static let staticProperty = 42
    
    // compiles  
    static let anotherStaticProperty = staticProperty
    let instanceProperty = staticProperty
    
    // errors: Cannot use instance member 'instanceProperty' within property initializer...
    let anotherInstanceProperty = instanceProperty
    static let anotherStaticProperty = instanceProperty
}

Since we are lifting the self-access restriction in the initial expression for instance properties, I'm wondering how such a macro would interact with that of a static property?

    // will start to compile
    @anInitializationSelfAvailableMacro
    let anotherStaticProperty = instanceProperty

    // what about this?
    @anInitializationSelfAvailableMacro
    static let anotherStaticProperty = instanceProperty
}

If it's allowed and typed checked the same way as for an initial expression of a instance property,

I imagine the backing storage of a Lazy macro expansion could do something like this:

@attached(accessor, initialization: selfAvailable)
public macro nilOf() = ...

@Lazy var foo = mice * 2

// @Lazy expands to:
var _foo = Self.__foo
@nilOf
public static var __foo = mice * 2

// @nilOf expands to
public var __foo/*: Int? */ {
   get { nil }
}

However, I believe applying an initialization: selfAvailable macro on a static variable or a global variable should not be banned.

@dohle But the exact behavior is not specified in the proposal. Some clarification would be ideal.

2 Likes

SwiftUI's@State doesn't require the value to be initialized lazily e.g.:

struct MyView: View {
    @State var myState: Int
    init() {
        _myState = .init(initialValue: 1)
    }
    var body: some View {}
}

but today, lazy properties must have an initializer e.g.:

struct MyStruct {
    lazy var int: Int // error: Lazy properties must have an initializer
    init() {
        int = 1
    }
}

If SE-0539 requires this as well it would not be able for @State to adopt it without breaking source.

Would it be possible to elaborate in the proposal if an assignment is required before the macro is invoked or not?

2 Likes

The proposal mentioned lifting the restriction in general. I just wanted to open it up for discussion again with the subtle difference in having some limited control by choosing an older Swift version when compiling the macro. The solution discussed in "Alternatives Considered" would allow self access no matter how the macro was compiled. Each approach has its pros and cons:

  1. Allow self access in general
  • :white_check_mark::white_check_mark: Least complexity in compiler[1]
  • :white_check_mark::white_check_mark: most adoption
  • :white_check_mark: No technical debt in compiler
  • :cross_mark: No control
  • :cross_mark: source-breaking in rare cases
  1. Allow self access when macro compiled with new toolchain
  • :white_check_mark: Low complexity in compiler[2]
  • :white_check_mark::white_check_mark: Good adoption, increasing over time
  • :warning: Limited technical debt in compiler
  • :white_check_mark: Some control initially
  • :cross_mark: Source-breaking in rare cases
  1. Give macro authors control
  • :cross_mark: Higher complexity in compiler[3]
  • :cross_mark: Lower adoption
  • :cross_mark: More technical debt in compiler
  • :white_check_mark::white_check_mark: Fine-grained control
  • :warning: Source-breaking in rare cases only if enabled by macro author

The source braking issue should be quite rare, and should be easy to fix. Personally, I would be OK when an approach with less control is chosen (Option one or two above). However, these source-breaking issues can be very subtle and may be easy to miss.

Quoted Proposal Section

Yes. No other breaking changes are expected.


  1. During type-checking, we just have to check if an accessor macro is attached ↩︎

  2. We need to add a flag when compiling the macro with a current toolchain. During type-checking in client code, we check if an accessor macro is attached with the flag set. ↩︎

  3. We need to parse the new parameter in the macro role declaration. We use that info to add a flag. During type-checking in client code, we check if an accessor macro is attached with the flag set. ↩︎

1 Like

Hi Apollo,
Thanks for bringing up the topic of init accessor expansions!

I agree this is a natural use case, briefly mentioned it before. I was handwaving about it by saying, "if the macro knows which members need to be accessed". This is tricky in the general case as you have shown in your example where the initializer expression references both a member property and a global property.

I am not sure if the macro could figure out which one of the two properties is a member. Both are of type DeclReferenceExprSyntax in the syntax tree. I don't think heroic string comparison is even an option, because the accessor macro does not have access to peers.

It's much easier if the initializer expression explicitly uses self like this:

@Cached var theAnswer = self.mice * 2 * globalVariable * staticVariable

Here, the macro gets a MemberAccessExprSyntax for self.mice[1], and that's exactly what we need to expand an init accessor, isn't it?

I wonder if there was a way to get something similar when self is omitted. I'll do some experimentation later when I have some time.

Side Question

For this particular example, wouldn't it be easier to use the initializer expression directly without wrapping it in a closure? Or do you want to make sure the values for mice and globalVar are captured during initialization of the enclosing type Earth? In my expansion below the values are taken when the initializer expression is evaluated—on first read access if _theAnswer==nil. The values may have changed since initialization of Earth if those properties are vars:

// declaration
var globalVariable = 17

struct Earth {
    var mice: Int
    @Cached var theAnswer = mice * 2 * globalVariable
}

    // Expansion:
    private var _theAnswer: Int? = nil
    
    var theAnswer: Int {
        mutating get {
            if let value = _theAnswer {
              return value
            } else {
              // current values used, they may be
              // different than original values during initialization
              _theAnswer = mice * 2 * globalVariable
              return _theAnswer!
            }
        }

        set {
            _theAnswer = newValue
        }
    }

  1. The others are still DeclReferenceExprSyntax for "staticVariable" and "globalVariable", plus an IntegerLiteralExprSyntax. ↩︎