swiftswift-macro

Why "Circular reference expanding peer macros" here


I'm making a macro like this:

@attached(peer, names: arbitrary)
public macro Lazify(name: String, lock: Protectable) = #externalMacro(module: "MacroModule", type: "LazifyMacro")

It should be used to attach to a function declaration, to generate some other variables.

But I got the error Circular reference expanding peer macros on <function_name>, the code is below:

class SomeClass {
  
  var _internal_lock: NSLock = .init()
  static let classLock: NSLock = .init()
  
  // Error: Circular reference expanding peer macros on 'createLazyVariable()'
  @Lazify(name: "internalClassName", lock: SomeClass.classLock) 
  func createLazyVariable() -> String {
    return "__" + NSStringFromClass(type(of: self))
  }
}

If I change the lock: Protectable to lock: String then it's ok, but it won't have type check when using the macro.

And further if I use a instance property lock _internal_lock and the error will change to Instance member '_internal_lock' cannot be used on type 'SomeClass'; did you mean to use a value of this type instead?

So I wonder why I can't use the classLock or the _internal_lcok here?


If I use a global instance lock then it can compile:

let globalLock: NSLock = .init()

class SomeClass {
  @Lazify(name: "internalClassName", lock: globalLock)
  func createLazyVariable() -> String {
    return "__" + NSStringFromClass(type(of: self))
  }
}

Solution

  • This is a circular reference because to resolve the name SomeClass.classLock, Swift must first expand Lazify. Why? Because Lazify can introduce arbitrary names, so the meaning of SomeClass.classLock might be different after @Lazify is expanded. @Lazify can't affect names outside of its scope, so if you use a name that is declared in a different class, or global let, it works. I'm not sure about this explanation though - it might as well be a bug, perhaps related to this.

    In any case, it seems like Lazify would add a declaration with the name of whatever string is passed to its name parameter. This is not a good design, since, at least, it would be difficult to rename this using the "Refactor -> Rename" option in Xcode.

    Instead of generating whatever name the user wants, you can use the suffixed or prefixed options in @attached(peer, names: ...), and always generate a name from the name of the declaration to which the macro is attached. For example, use:

    @attached(peer, names: suffixed(_lazify)) 
    // you can write multiple "suffixed(...)" here, if you need
    

    Then,

    @Lazify(lock: SomeClass.classLock)
    func createLazyVariable() -> String { ... }
    

    should be implemented to generate createLazyVariable_lazify.


    You can't use instance properties here for a similar reason to why something like this doesn't work:

    class Foo {
        let x = 1
        let y = x // "self" is not available here!
    }
    

    I think this is checked before the macro expansion, so this is not a problem of your macro, per se.

    To allow instance members, you can add an overload like this:

    public macro Lazify<T, P: Protectable>(lock: KeyPath<T, P>) = ...
    

    Usage:

    @Lazify(lock: \SomeClass._internal_lock)
    
    // in the macro implementation, you would generate something like this:
    self[keyPath: \SomeClass._internal_lock]
    // to access the lock.
    

    From your previous question, it seems like a better design would be to just use a property wrapper:

    @propertyWrapper
    struct Lazify<T> {
        let lock: Protectable
        let initialiser: () -> T
        
        var wrappedValue: T {
            mutating get { 
                if let x = lazy {
                    return x
                }
                return lock.around {
                    if let x = self.lazy {
                        return x
                    }
                    let temp = initialiser()
                    self.lazy = temp
                    return temp
                }
            }
    
            set { lazy = newValue }
        }
        
        private(set) var lazy: T?
    }
    

    If you really want a macro, you can make Lazify an accessor macro as well. Declare the internalClassName property yourself and pass the code in createLazyVariable to the macro as a closure.

    @attached(peer, names: prefixed(_lazy_))
    @attached(accessor, names: named(get), named(set))
    public macro Lazify<T>(lock: Protectable, initialiser: () -> T) = ...
    
    @Lazify(lock: SomeClass.classLock) {
        return "__" + NSStringFromClass(type(of: self))
    }
    var internalClassName: String
    
    // expands to
    private(set) var _lazy_internalClassName: String? = nil
    var internalClassName: String {
        get {
            if let exsist = self._lazy_internalClassName {
                return exsist
            }
            return SomeClass.classLock.around { [weak self] in
                guard let self else {
                    fatalError()
                }
                if let exsist = self._lazy_internalClassName {
                    return exsist
                }
                let temp = "__" + NSStringFromClass(type(of: self))
                self._lazy_internalClassName = temp
                return temp
            }
        }
    
        set { _lazy_internalClassName = newValue }
    }