Klein Mioke
Klein Mioke

Reputation: 1352

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))
  }
}

Upvotes: 0

Views: 648

Answers (1)

Sweeper
Sweeper

Reputation: 270708

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 }
}

Upvotes: 1

Related Questions