Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to me as the review manager by DM. When contacting the review manager directly, please put "SE-0539" in the subject line.
What goes into a review?
The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:
What is your evaluation of the proposal?
Is the problem being addressed significant enough to warrant a change to Swift?
Does this proposal fit well with the feel and direction of Swift?
If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
How much effort did you put into your review? A glance, a quick reading, or an in-depth study?
Review manager's note: This proposal was originally pitched with the title "Lazy accessor macros", but the title was changed pre-review to better reflect the actual change being proposed.
So I do think this pitch proposal has some good ideas overall worth discussing. But for this specific SwiftUI example I am not sure that trying to read from Environmentbefore computing a view component body is ever officially supported. If the assumption is this "lazy" variable is only read at the time a view component body is computed then maybe it looks safe… but still feels a little "brittle" to me.
I would offer another solution built on DynamicProperty and update which is explicitly documented to run before our view component body is computed:
@Observable
final class Storage {
private var modelContext: ModelContext? = nil
init() {
}
func update(modelContext: ModelContext) {
if self.modelContext !== modelContext {
self.modelContext = modelContext
...
}
}
}
struct ViewModel: DynamicProperty {
@Environment(\.modelContext) private var modelContext
@State private var storage = Storage()
func update() {
self.storage.update(modelContext: self.modelContext)
}
}
Yes, maybe I should have left the link to that idea out of the proposal. It could only work if the SwiftUI dataflow team decided to support this use case. Any updates to @State and how it interacts with the environment are not part of this proposal.
I would suggest discussing this example in the original pitch thread to keep this proposal review focused.
Side Discussion
Thanks for the neat idea of putting the environment into a DynamicProperty. From reading the docs on DynamicProperty and environment values I would have never guessed this was possible. I always had assumed the environment wrapper needs to be in a struct conforming to View, otherwise there would be a warning like, "Accessing an Environment value outside of being installed on a View". I'll try it out though!
Add an optional initialization: parameter to the accessor macro role declaration. Valid arguments are selfAvailable or selfUnavailable.
Hmm… an initialization parameter sounds kind of generic and "big".
The intitialization: parameter is still open for expansion to other options if the need arises in the future, since it is not limited to a boolean true|false.
If there were some future arguments we wanted to support under the initialization umbrella we might be kind of boxing together some different concepts that might not be totally related other than they have something to do with "initialization". That's not necessarily a Bad Thing… but it might affect what direction you want to take your pitch proposal.
A boolean selfAvailable: true|false was considered. Here it is unclear whereself is available/unavailable (the initializer expression).
What about selfAvailableOnInitialization: true|false? Just spell out exactly what this is for? Were there more arguments against selfAvailable[OnInitialization]: true|false that make it not a good idea?
I was mostly uncomfortable with selfAvailable: true|false because it's not crystal clear that it's referring to the initializer expression. selfAvailableOnInitialization: true|false should be fine in that regard. It's a bit longer than initialization: self[Un]Available, but that's probably OK. The boolean spelling pretty much closes the door on future extension. But how likely is that extensibility going to be needed?
I chose the proposed naming[1] because it seems clear enough as a starting point for discussion. It's not set in stone yet.
There was valuable feedback during the pitch phase steering me away from lazy and to use initialization: rather than initializer:. ↩︎
I think this is a useful feature. However, selfAvailable sounds quite arbitrary. IMO, the underlying mechanism is type-checking the initial expression as an autoclosure. So what about initializeAsAutoclosure: true|false? Autoclosures follow the precedent established by property wrappers, which are required to use autoclosures to get initialized with "access to self".
Hi Filip,
The Autoclosure topic came up a few times in private messages I received. To be honest, I was a little confused, so I spent some time researching, but didn't find the answer...
Property Wrappers & Autoclosures
I had no idea that property wrappers grant self access to the property initializer expression if an autoclosure is used. Maybe I am doing something wrong here. Let's try it out:
@propertyWrapper
struct Autoclosed<Value> {
private var storedValue: Value?
var wrappedValue: Value {
mutating get {
guard let value = storedValue else {
let newValue = makeValue()
storedValue = newValue
return newValue
}
return value
}
}
private var makeValue: () -> Value
init(wrappedValue: @autoclosure @escaping () -> Value) {
makeValue = wrappedValue
}
}
The initializer expression is wrapped in a closure and evaluated later. Great if initialization is expensive! But we still cannot access self, because @autoclosure creates a closure that captures self or in this case an instance member, and that's not possible when the wrapper is created because it would violate Definite Initialization of the enclosing self. To illustrate:
struct Foo {
let number = 42
@Autoclosed var bar = number
// ^ Cannot use instance member 'number' within property initializer; property initializers run before 'self' is available
}
My understanding is @autoclosure creates a closure like this, where we need self access to capture number:
@Autoclosed var bar = { [number] in return number }
The closure is created immediately, regardless when it is actually evaluated.
Workaround (unrelated to discussion)
Instead of an autoclosure, we can require an explicit factory function that takes self as an argument. At the use site:
@NotAutoclosed var bar = { (self: Self) in return self.number }
Not beautiful.
The wrapper would be implemented using the not-quite-official subscripts where we have access to the enclosing self via KeyPaths.
Is there any magic kind of special autoclosure that does not immediately capture self or instance members? Do you know of any property wrappers where we can do this?
This Proposal & Autoclosures
AFAIK, accessor macros do not @autoclose over the property init expression. The macro author is free to do anything with the initializer; of course they can wrap it in a closure in the expanded code. In fact, that's what the new @State macro in this year's beta releases is currently doing.
Macro authors have an additional superpower: They can defer creation of the closure until later, when selfis available. But that does not help us when the original init expression is type-checked in its original location[1].
For my prototype, I just repurposed the existing special-casing when type-checking the initializer of lazy var properties. For those properties, the compiler just inserts an "implicit self declaration"[2]. I didn't find anything that creates an Autoclosure in that code, maybe that happens somewhere further down the line?
Remember, this check is needed for type inference ↩︎
See PatternBindingInitializer::getImplicitSelfDecl() in lib/AST/Decl.cpp as a starting point ↩︎
The macro uses the inferred type during expansion:
private var _foo: Int?
// ^ Macro uses inferred type here
var foo: Int {
get {
if let value = _foo {
return value
}
let newValue = 42
// ^ Macro has re-contextualized the initializer here.
// It will be checked again in this context to make sure
// the expansion is valid.
_foo = newValue
return newValue
}
set { _foo = newValue }
}
Small nit here but I think we have to make this a mutating get on struct containers.
I am not sure if it's a good idea to name something after its implementation detail in the compiler
If I put myself into the shoes of the macro author and encounter the error [...] property initializers run before 'self' is available, my first thought would be, "Oh, I need to somehow make self available to the initializer!". initializeAsAutoclosure would require mounting another level of abstraction. In fact, I would never have thought of an autoclosure as a solution (as you can surely tell by chuckling about my utter confusion in my post above )
// Declaration of the `@Lazy` macro
//
// We promise to use the initializer in a context where `self` is available:
@attached(accessor, initialization: selfAvailable, names: named(get), named(set))
@attached(peer, names: prefixed(_))
public macro Lazy() = #externalMacro( ... )
// This macro uses the initializer in an `init` accessor, where `self` access
// would be invalid.
@attached(accessor, initialization: selfUnavailable, names: named(init))
public macro SomeEagerMacro() = #externalMacro( ... )
Hmm… I'm not really sure I understand example number one here. Example number two is explicitly declaring itself as generating an init accessor which is what the proposal is addressing. Correct? But example number one does not explicitly declare itself as generating an init accessor. What effect does selfAvailable have at that point?
Ok… so I think I see where I am getting confused. This proposal currently proposes adding a new initialization parameter. I mentally coupled the proposal to the named(init) parameter. But that actually is not what the proposal is meant to change. Correct?
// Macro declaration:
@attached(accessor, initialization: selfAvailable, names: named(get), named(init))
@attached(peer, names: prefixed(_))
public macro NotLazy() = #externalMacro( ... )
// Macro usage:
struct Earth {
let mice = 21
@NotLazy var theAnswer = mice * 2
}
// Expansion:
struct Earth {
let mice = 21
private var _theAnswer: Int
var theAnswer: Int {
@storageRestrictions(initializes: _theAnswer)
init {
_theAnswer = mice * 2
// ^ 🛑 cannot use instance member 'mice' within property initializer; property initializers run before 'self' is available
}
get { _theAnswer }
}
}
I think what is happening here is in the original example:
struct Earth {
let mice = 21
@Lazy
var theAnswer = mice * 2
// ^ 🛑 cannot use instance member 'mice' within property initializer; property initializers run before 'self' is available
}
Our macro expansion repurposes the "initializer" as the new get property accessor. Correct? We seem to be coupling our proposal to a concrete example where a macro author makes the pledge that attaching this specific macro to a property transforms the initializer passed to that property into a get accessor. Correct? I think then a more general way to think of this proposal is less about enabling a new initialization behavior and more about enabling a new way to express stateful side effects. Correct?
In the code block above, you can see the expansion of the @Lazy macro. It does not produce an init accessor. Instead, it uses the initializer in the getter[1]. The getter can only be evaluated after the enclosing type has been fully initialized[2]. That's why self access including instance members is safe and legal in this context. However, this is not possible until now, because an error is diagnosed when the initializer is checked in its original location.
The first example is a declaration that lifts this limitation. It promises to move the initializer to a context where self access is available. The type checker uses this information when checking the initializer in the original location, allowing use of the enclosing self.
In other words, this proposal is actually about the first example. The second example is the current behavior, where the compiler diagnoses an error. The hypothetical SomeEagerMacro may use the initializer expression in the init accessor it generates. This is a context where self is not available.
This can be confusing, because there are multiple "inits":
The initializers of the enclosing type (T.init(...))
An init accessor of a property
The initializer expression of a property
This proposal is about number 3 in the list above:
@Foo var foo = <# initializer expression #>
Number 2, an init accessor, may be an implementation detail of a macro, and the reason why the macro should not declare selfAvailable. The proposed feature actually does not need to know about it.
In fact, computed properties can only be used after Definite Initialization, even if the property actually only accesses fully initialized members of a partially initialized object. ↩︎
I think another way to express my confusion is about the direction the new initializer is moving.
If there existed some hypothetical decorator a product engineer could use on their initializer before our macro expansion:
struct Earth {
let mice = 21
@Lazy
var theAnswer = { @SelfAvailable mice * 2 }()
}
Then our problems today would already be fixed. Correct? The macro author building Lazy needs no more help at that point. Correct?
So I think what was confusing me about the proposal was that the initializer parameter seems to be addressing the input to the macro. But other parameters like names are addressing the output of the macro.
Which is not to say it is not an impactful change. It just feels like we are expanding the role these attached parameters are for. Are there other examples of attached parameters that are more about the input than the output?
Actually, the macro author only pledges that whatever is happening to the initializer expression, self access will be allowed after expansion. How and why is an implementation detail of the macro. It's correct that the @Lazy example achieves this by using it in the context of the get accessor.
I don't think so in the general case. At this point it's just a tool to give access to self. A silly, contrived example could be a macro that completely ignores the initializer expression. It may implement a getter that just returns some static value without side effects. Since the initializer expression is removed after expansion, it would be OK to allow self access[1].
Can you think of a reason why we would want the compiler to know that there will be stateful side effects? The compiler should already have what it needs by consuming the expanded code, right?
Bad idea in practice. People who use such macro would be confused why the initializer is never evaluated, and why the property ignores the inferred type. Better: Diagnose the presence of the initializer expression as a warning. ↩︎
Something like that could be an alternative way to fix the problem: give the user control. The advantage is that we can retroactively do this in client code without the macro author having to do anything. The downside is that it looks busier in client code, and users of a macro have to look into its implementation to find out if this annotation is legal.
It's a bit tricky too, because we would only want it for properties where the initializer is actually subsumed.
The way I see this is that the macro is a compiler plugin. The parameters of the role declaration tell the compiler some details that it needs. For example the names: they can be used to look up those symbols efficiently before the macro is expanded. As for the initialization parameter: it tells the compiler before macro expansion that it's OK to allow self access.
This is not really a client-facing property. It's a dialogue between the macro author and the compiler.
You're totally right, I misremembered the autoclosure behavior.
I'm just concerned this feature might be too specific to a @Lazy macro or niche SwiftUI macros, resulting in needless language complexity. Namely, if the language doesn't allow closures to capture self in property initializers, why should the SwiftUI macro in the Motivation section be an exception?
If the only strong motivating example is an @Lazy macro, then this macro can simply use an underscored version of the proposed selfAvailable property. This way, we don't have to commit to a public API for all macro developers.
These are not super important blockers… but more like nice to have improvements that might help the proposal if you have time:
And that is fair and I see how this can solve a real world problem. What could be good then to document in the proposal is if there is any prior art in this space. Is this "the first" proposal to add a new public API for macro authors to work around existing limitations in the compiler? Or have previous proposal reviews covered a similar ground? What could we learn from those previous proposal reviews?
There is a small mention in the proposal of:
Other macros such as @Lazy may result in an expansion where self access would be valid for the re-contextualized initializer. Currently, this fact is unknown during the initial check; therefore, self access is assumed to be illegal.
Do have the ability to brainstorm what those "other" macros might look like? Could we think of at least one practical example other than Lazy? It does not have to be a long discussion… just identifying one more practical use case could be helpful here.