Universal escaping attribute

As macros arrive to swift, I would like to discuss a topic about code generation that annoyed me for some time.

The main problem is that currently @escaping modifier might be applied only to function types, but, on syntax level, there is no way reliably understand that type is function type or not.

Suppose I want to write a macro that generates memberwise public initializer:

enum Visibility { case `private`, `internal`, `public` }

@attached(member)
public macro initializer(_ visibility: Visibility = .internal)

@initializer(.public)
struct Foo {
  var a: A
  var b: B
  /* Macro generates:
  public init(a: A, b: B) {
    self.a = a
    self.b = b
  } */
}

It's all well and good, before discovering that A or B is actually a function type.

And, afaik, there is no good way for doing that, other than explicitly specify to macro which types are function types, something like:

@attached(member)
public macro initializer(_ visibility: Visibility = .internal, funcTypes: Any.Type...)

@initializer(.public, funcTypes: Callback.self)
struct Foo {
  typealias Callback = () -> Void
  var a: A
  var b: Callback
  /* Macro generates:
  public init(a: A, b: @escaping Callback) {
    self.a = a
    self.b = b
  } */
}

Problem with this is that it's ugly and makes hard to understand and follow compilation errors. Imagine adding new field to struct and it yells somewhere that it's escapes, and to fix that you need to go to third place where macro is called.

May be we could consider that @escaping attribute can be applied to any type that actually escapes? Or make another attribute with this meaning.

7 Likes

I see no problem in writing a macro that detects function types and makes them escaping in the initializer. Here is a quick and dirty macro implementation that can do it:

enum InitializerMacro: MemberMacro {
    static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        guard
            case let .argumentList(arguments) = node.argument,
            let visibilityModifier = arguments.first?.expression.as(MemberAccessExprSyntax.self)?.name.trimmed,
            let structDecl = declaration.as(StructDeclSyntax.self)
        else {
            return []
        }

        let memberVariables = structDecl.memberBlock.members.compactMap {
            $0.decl.as(VariableDeclSyntax.self)
        }.filter { !$0.hasModifier("static") }

        guard memberVariables.allSatisfy({ $0.bindings.count == 1 }) else {
            return []
        }

        let variables: [(name: TokenSyntax, type: TypeSyntax)] = memberVariables.compactMap { variable in
            guard
                let binding = variable.bindings.first,
                let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier,
                let type = binding.typeAnnotation?.type
            else {
                return nil
            }

            return (name, type)
        }

        guard variables.count == memberVariables.count else {
            return []
        }

        let parameterList = variables.map { name, type in
            let type: TypeSyntax = if let functionType = type.as(FunctionTypeSyntax.self) {
                "@escaping \(functionType)"
            } else {
                type
            }

            return "\(name): \(type)"
        }.joined(separator: ", ")

        return [
            try .init(InitializerDeclSyntax(
                "\(visibilityModifier) init(\(raw: parameterList))"
            ) {
                for (name, _) in variables {
                    ExprSyntax("self.\(name) = \(name)")
                }
            }).with(\.leadingTrivia, .newlines(2))
        ]
    }
}

extension WithModifiersSyntax {
    func getModifier(_ modifier: TokenSyntax) -> DeclModifierSyntax? {
        self.modifiers?.first(where: { $0.name.tokenKind == modifier.tokenKind })
    }

    func hasModifier(_ modifier: TokenSyntax) -> Bool {
        self.getModifier(modifier) != nil
    }
}
public enum Visibility { case `private`, `internal`, `public` }

@attached(member, names: named(init))
public macro initializer(_ visibility: Visibility = .internal) = #externalMacro(
    module: "MacroTestImpl",
    type: "InitializerMacro"
)

This converts the following code:

@initializer(.public)
struct Foo {
    var a: Int
    var b: String
    var c: (Int) -> ()
    var d: (Int) -> (String)
}

into:

struct Foo {
    var a: Int
    var b: String
    var c: (Int) -> ()
    var d: (Int) -> (String)

    public init(a: Int, b: String, c: @escaping (Int) -> (), d: @escaping (Int) -> (String)) {
        self.a = a
        self.b = b
        self.c = c
        self.d = d
    }
}

That relies on the argument type being syntactically a function type, and doesn't work if it's a typealias for a function type.

4 Likes

While that's true, typealiases are a problem for basically every macro. In many of the Macros I've written so far, I had to detect simple types by comparing their identifier with a string. That breaks down as well when people are using typealiases.

So the actual answer to this problem is: don't use typealiases in macros yet. At some point we will hopefully get a mechanism to query the true type of an expression/declaration, but until then it's better to avoid this problem.

2 Likes

"Just don't use A to use B" where A and B are useful or widely used language features is technically an answer, but not very good one.

3 Likes

You're holding it wrong :slight_smile:

Btw, there are some underscored attributes for marking an argument that escapes swift/docs/ReferenceGuides/UnderscoredAttributes.md at main · apple/swift · GitHub
But, firstly, they are underscored, secondly, as far as I understand, it's for optimisation purposes, and it doesn't solve described problem.

Perhaps I'd found a solution. It compiles fine!

struct Foo {
  var a: () -> Int
  var b: (String) -> ()
  /* Macro generates */
  public init<A, B>(a: A, b: B) where A == () -> Int, B == (String) -> () {
    self.a = a
    self.b = b
  }
}

// let foo = Foo(a: { 42 }, b: { print($0) })

I know that it isn't a good answer. But unfortunately macros are still pretty much an experimental feature, so it's to be expected that they don't play nicely together with some other language features.

If you say so...

I haven't tried it yet, but I would be surprised if you could use typealiases even with Apple's own macros like @Model from SwiftData.

Let's get more technical on the original proposal.
All non-function arguments can be treated as "escaping" as there is no way to express non-escaping behavior for them. But the introduction of the explicit @escaping attribute would imply that there should be @noescape as well.
What would @noescape mean for non-function arguments?
Escaping function type supports not very many operations on it: own it, lose it, pass it as an escaping or non-escaping argument, and call it. Making it non-escaping denies owning and passing it as an escaping argument. This also implies that the context is either not exists or is unowned by the callee.
So, a non-escaping argument should follow the following rules:

  • It's unowned. The lifetime during the invocation is guaranteed by the caller. This is opposed to the borrow/consume semantics.
  • It cannot be owned.
  • It cannot participate in operations that may allow it to escape.

And an escaping argument is just a regular argument just like all of them now.

But for non-escaping arguments to be any useful we should also provide a way to specify that functions operating on self do not allow self to escape. Something similar to the borrowing/consuming keywords in front of a function.

class Foo {
  private var _isGood = true
  nonescaping var isGood: Bool { _isGood }
}

func bar(foo: @noescape Foo) {
  if foo.isGood { // call is allowed because it's marked as `nonescaping`
    print("good")
  }
}
1 Like

Even though this is a brilliant workaround, it produces warnings like: "same-type requirement makes generic parameter 'A' non-generic; this is an error in Swift 6". For those of us who live with -warnings-as-errors this is not an option. So the issue is still relevant.

1 Like

That is strange. I don't know any proposal which decided to introduce this change.
I believe this is then a breaking change which abolishes language capability without any explicit proposal.
Is it possible in Swift Evolution process...?

I found a PR that introduced this diagnostic [RequirementMachine] Diagnose type parameters that are made concrete by a same-type requirement. by hborla · Pull Request #42257 · apple/swift · GitHub

1 Like

Just to clarify, my PR did not introduce the diagnostic, my PR only implemented that diagnostic in the new implementation of the generic signature type checker (called the Requirement Machine). That diagnostic has been around in Swift for a very long time (predating the Swift evolution process, I think?), and I had to make it a warning-until-Swift-6 in the Requirement Machine because the new implementation is more precise than the old implementation (called the Generic Signature Builder) so it diagnosed more code than before.

EDIT: This commit introduced the diagnostic back in 2014: Enable same-type concrete constraints, e.g. <T: P where T.Assoc == Int> · apple/swift@9f12e2e · GitHub

5 Likes

Thank you!

What do you think about the issue discussed here?


I came up another workaround; perhaps this will be feasible in Swift 6;

struct Foo {
  var a: () -> Int
  var b: (String) -> ()
  /* Macro generates */
  public init(a: @autoclosure () -> (() -> Int), b: @autoclosure () -> ((String) -> ())) {
    self.a = a()
    self.b = b()
  }
}
// let foo = Foo(a: { 42 }, b: { print($0) })

It is sad that it will not conform protocols which requires non-@autoclosure inits, though.

1 Like

That's really smart! I never thought about autoclosures as implicit wrappers, cool!

Unfortunately, there are some drawbacks, so we can't call it a complete solution to the problem. Autoclosures are adding a bit of overhead both to speed and size of generated code, especially in public module interface where compiler cannot optimise it out (overhead is small, but it can add up if used widely in codebase). One could potentially solve it generating an inlinable initialiser, but then you need to mark all non-public properties with @usableFromInline and make them internal.

So the problem is still relevant.