Macro gotcha/bug: accessor macros can execute the wrong code

In developing a macro that, like @Observable, swaps properties out for accessors that point to private storage, I encountered a pretty surprising bug, and macro authors should probably be aware of it.

For example, this expansion:

 struct Foo {
-  @MyMacro
   var bar: () -> Bool = { false }
+  {
+    get { _bar }
+    set { _bar = newValue }
+  }
+  private var _bar: () -> Bool = { fatalError("TODO") }
 }

Would lead me to believe that calling Foo().bar() would go through the new computed get and fatal error when invoking the default private storage. However:

let foo = Foo()
print(foo.bar())  // prints 'false'

Setting a breakpoint in the get does catch when accessing the property:

   get {
🔵   _bar
   }

But invoking _bar seems to invoke { false } for some strange reason, and not the value assigned by the macro.

I've filed an issue here that folks can track.

2 Likes

It looks like the thing is there are two initial assignments happening - one for _bar with the fatalError version, and one for bar with the false version. I don't know if the order of those is supposed to be defined, but I don't think I'd want to depend on it.

The behavior should definitely be defined and not something we should worry about depending on. According to the proposal, initial values should be removed when accessor macros are applied:

The expansion of an accessor macro that does not specify one of willSet or didSet in its list of names must result in a computed property. A side effect of the expansion is to remove any initializer from the stored property itself; it is up to the implementation of the accessor macro to either diagnose the presence of the initializer (if it cannot be used) or incorporate it in the result.

swift-evolution/proposals/0389-attached-macros.md at main · apple/swift-evolution · GitHub

So I believe this is a bug that should be fixed.

For a more realistic example, say you have a macro that takes the initial value and instruments it:

 struct Analytics {
-  @Log
   var track: (String) -> Void = { LiveAnalytics.track($0) }
+  {
+    get { _track }
+    set { _track = newValue }
+  }
+  private var _track: (String) -> Void = {
+    print("Tracking \($0)")
+    LiveAnalytics.track($0)
+  }
 }

This will fail to execute the print line.

2 Likes

Now I see. It looks like this bug has already been filed: accessor macro expansion doesn't remove initializer from stored property · Issue #2310 · apple/swift-syntax · GitHub

That is a separate (but seemingly related) issue in SwiftSyntax according to the discussion there.