SE-0382: Expression Macros

I finally came around to working some more on the result builder macro. Here are the results:

As I see it, there are two types of result builders:

  • Type-erasing ones that always return the same type
  • Ones that derive the result type from the code structure such as SwiftUI's ViewBuilder

The feasibility of creating a macro that takes a type-erasing result builder type and a closure and produces a transformed closure has already been proven in this and this post. It is pretty simple and works as expected.

Doing the same for ViewBuilder-style result builders is more challenging. However, I have managed to implement a very rough proof-of-concept that does exactly this and works pretty well:

let very = true
let long = true

let viewClosure = #apply(resultBuilder: ViewBuilder.self) {
    Text("This")
    Text("is")
    Text("a")
    if very {
        Text("very")
    }
    if long {
        Label("long", systemImage: "arrow.left.and.right")
        Text("(really long)")
    } else {
        let shortWord = "short"
        Text(shortWord)
    }
    let lastWord = "sentence"
    Text(lastWord)
}

print(type(of: viewClosure()))
// prints TupleView<(Text, Text, Text, Optional<Text>, _ConditionalContent<TupleView<(Label<Text, Image>, Text)>, Text>, Text)>

The macro does not depend on nor is it specialized for SwiftUI or ViewBuilder (with one little caveat I will come to later). This means it should work for other result builder types. As far as I know, the macro produces the same function calls as the built-in feature.

Full implementation

In MacroExamplesLib:

@expression public macro apply<R>(resultBuilder: R.Type, to closure: () -> Void) -> (() -> any View) = #externalMacro(module: "MacroExamplesPlugin", type: "ResultBuilderMacro")

In MacroExamplesPlugin:

public struct ResultBuilderMacro: ExpressionMacro {
    public static func expansion(
        of node: MacroExpansionExprSyntax, in context: inout MacroExpansionContext
    ) throws -> ExprSyntax {
        guard
            let resultBuilderSelfExpr = node.argumentList.first?.expression.as(MemberAccessExprSyntax.self),
            let resultBuilderName = resultBuilderSelfExpr.base?.withoutTrivia().description,
            let originalClosure = node.argumentList.dropFirst().first?.expression.as(ClosureExprSyntax.self) ?? node.trailingClosure
        else {
            throw SomeError()
        }
        
        let originalStatements: [CodeBlockItemSyntax] = originalClosure.statements.map { $0.withoutTrivia() }
        return "{ () -> any View in\n\(raw: rewrittenStatements(forOriginalStatments: originalStatements, finalCallPrefix: "return ", resultBuilderName: resultBuilderName, context: &context))\n}"
    }
    
    private static func rewrittenStatements(forOriginalStatments originalStatements: [CodeBlockItemSyntax], finalCallPrefix: String, finalCallSuffix: String = "", resultBuilderName: String, context: inout MacroExpansionContext) -> String {
        var localNames: [String] = []
        var newStatements: [String] = []
        
        for statement in originalStatements {
            switch statement.item {
            case .expr(let expr):
                let localName = context.createUniqueLocalName().description
                newStatements.append("let \(localName) = \(expr);")
                localNames.append(localName)
            case .stmt(let stmt):
                let localName = context.createUniqueLocalName().description
                if let ifStmt = stmt.as(IfStmtSyntax.self) {
                    if case .codeBlock(let elseBody) = ifStmt.elseBody {
                        let tempIfName = context.createUniqueLocalName()
                        let tempElseName = context.createUniqueLocalName()
                        newStatements.append("""
                            let \(localName) = {
                                if false {
                                    \(rewrittenStatements(forOriginalStatments: ifStmt.body.statements.map { $0.withoutTrivia() }, finalCallPrefix: "let \(tempIfName) = ", resultBuilderName: resultBuilderName, context: &context))
                                    \(rewrittenStatements(forOriginalStatments: elseBody.statements.map { $0.withoutTrivia() }, finalCallPrefix: "let \(tempElseName) = ", resultBuilderName: resultBuilderName, context: &context))
                                    return true ? \(resultBuilderName).buildEither(first: \(tempIfName)) : \(resultBuilderName).buildEither(second: \(tempElseName))
                                }
                                if \(ifStmt.conditions) {
                                    \(rewrittenStatements(forOriginalStatments: ifStmt.body.statements.map { $0.withoutTrivia() }, finalCallPrefix: "return \(resultBuilderName).buildEither(first: ", finalCallSuffix: ")", resultBuilderName: resultBuilderName, context: &context))
                                } else {
                                    \(rewrittenStatements(forOriginalStatments: elseBody.statements.map { $0.withoutTrivia() }, finalCallPrefix: "return \(resultBuilderName).buildEither(second: ", finalCallSuffix: ")", resultBuilderName: resultBuilderName, context: &context))
                                }
                            }()
                        """)
                        localNames.append(localName)
                        
                    } else {
                        newStatements.append("""
                            let \(localName) = {
                                if \(ifStmt.conditions) {
                                    \(rewrittenStatements(forOriginalStatments: ifStmt.body.statements.map { $0.withoutTrivia() }, finalCallPrefix: "return \(resultBuilderName).buildIf(", finalCallSuffix: ")", resultBuilderName: resultBuilderName, context: &context))
                                }
                                return \(resultBuilderName).buildIf(nil)
                            }()
                        """)
                        localNames.append(localName)
                    }
                } else {
                    newStatements.append(stmt.description)
                }
            default:
                newStatements.append(statement.description)
            }
        }
        
        newStatements.append("\(finalCallPrefix)\(resultBuilderName).buildBlock(\(localNames.joined(separator: ", ")))\(finalCallSuffix)")
        
        let joinedStatements = newStatements.joined(separator: "\n")
        return joinedStatements
    }
}

Here are the things I have learned along the way:

  • With the built-in result builders feature, closures are able to return an opaque result type, i.e. some View. With the current implementation, macros are not allowed to return opaque result types so the macro is forced to return a closure producing any View. I suppose this is due to the fact that the macro is type-checked before being expanded. @Douglas_Gregor: Is there any chance that macros could return an opaque result type? I think this feature would be very valuable.

  • The biggest problem in the macro implementation was to satisfy the type checker when transforming if statements. This is because a macro has no way of getting the type of subexpressions of the expression passed to the macro. I work around this by putting the generated if statement into a closure to use the automatic return type inference. I am not sure if putting the code into a closure can be a problem in some situations.

    For simple if statements, this is pretty straight-forward. For ones that also have an else clause, things get pretty messy. This is because ViewBuilder-style result builders have two methods:

    public static func buildEither<TrueContent, FalseContent>(first: TrueContent) -> _ConditionalContent<TrueContent, FalseContent>
    public static func buildEither<TrueContent, FalseContent>(second: FalseContent) -> _ConditionalContent<TrueContent, FalseContent>
    

    This means the return type depends on both branches. Having no access to the type of subexpressions, the macro has to use a hack to use the automatic type inference. It looks like this in pseudo code:

    let result = {
        if false {
           ... // Code that builds result of if body
           ... // Code that builds result of else body
           return true ? ViewBuilder.buildEither(first: /*Result of if body*/)
                       : ViewBuilder.buildEither(second: /*Result of else body*/)
        }
        if long  {
            ... // Code that builds result of if body
            return ViewBuilder.buildEither(first: /*Result of if body*/)
        } else {
            ... // Code that builds result of else body
            return ViewBuilder.buildEither(second: /*Result of else body*/)
        }
    }()
    

    The if false { ... } is obviously never executed and only there to satisfy the type checker. It means that the whole code is duplicated. Of course, this is very inefficient. It could lead to problems if both if and else branch declare variables of the same name. This could probably be mitigated by renaming the variables but it feels like a brittle solution.

    Possible solutions could be:

    1. Giving macros access to type information. I think for this case, it would not be enough to provide the type of subexpressions, but to give access to the function signatures of the result builder types (via static reflection) or alternatively allow macros to query the type of arbitrary expressions.
    2. Introducing multi-statement if and case expressions that are smart enough to piece together the return type from both branches.
    3. Making type inference smart enough to infer the return type of the closure if the if false { ... } is dropped.

    The current macro implementation neither supports if statements with else ifs nor switch statements. I think that it could be extended to support them. However, the type inference hacks would probably need to get even worse.

  • If a user of the macro makes a mistake, the error produced is not so great. One example would be the error Static method 'buildBlock' requires that 'Int' conform to 'View'. The message can be helpful if the user knows a little about result builders. However, with the current implementation the error does not point to any code location. So the user must look through every line of code to find the error. I think the compiler could be improved to point to the location of the macro. However, it could not know, which subexpression is at fault.

    To provide a good error message, the macro itself would need to diagnose the error. However, to do that, again, the macro would need access to (a) the type of subexpressions and (b) static reflection of the result builder type (that includes static functions).

  • Currently, writing code using the result builder macro generates warnings such as Result of 'Text' initializer is unused, because in the original closure, the expression results are unused. I think the compiler should generate such warnings only after macro expansion.

  • This is the caveat I was alluding to earlier: The current implementation produces an error if a macro wants to return a closure whose return type depends on a generic argument:

    @expression public macro apply<R: ResultBuilder>(simpleResultBuilder: R.Type, to closure: () -> Void) -> (() -> R.FinalResult) = #externalMacro(module: "MacroExamplesPlugin", type: "SimpleResultBuilderMacro")
    
    public protocol ResultBuilder {
        associatedtype FinalResult
    }
    
    Full error
    checked decl cannot have error type
    (macro_decl range=[/Users/kocki/workspace/swift-macro-examples/MacroExamplesLib/Macros.swift:33:20 - line:33:208] "apply(simpleResultBuilder:to:)" <R : ResultBuilder> interface type='(R.Type, () -> Void) -> <<error type>>' access=public)
    Please submit a bug report (https://swift.org/contributing/#reporting-bugs) and include the crash backtrace.
    Stack dump:
    0.	Program arguments: /Library/Developer/Toolchains/swift-DEVELOPMENT-SNAPSHOT-2023-01-07-a.xctoolchain/usr/bin/swift-frontend -frontend -emit-module -experimental-skip-non-inlinable-function-bodies-without-types /Users/kocki/workspace/swift-macro-examples/MacroExamplesLib/Macros.swift -target arm64-apple-macos12.0 -Xllvm -aarch64-use-tbi -enable-objc-interop -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk -I /Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Products/Debug -F /Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Products/Debug -no-color-diagnostics -enable-testing -g -module-cache-path /Users/kocki/Library/Developer/Xcode/DerivedData/ModuleCache.noindex -swift-version 5 -enforce-exclusivity=checked -Onone -D DEBUG -enable-experimental-feature Macros -load-plugin-library /Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Products/Debug/libMacroExamplesPlugin.dylib -serialize-debugging-options -enable-bare-slash-regex -empty-abi-descriptor -resource-dir /Library/Developer/Toolchains/swift-DEVELOPMENT-SNAPSHOT-2023-01-07-a.xctoolchain/usr/lib/swift -enable-anonymous-context-mangled-names -Xcc -I/Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Intermediates.noindex/MacroExamples.build/Debug/MacroExamplesLib.build/swift-overrides.hmap -Xcc -iquote -Xcc /Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Intermediates.noindex/MacroExamples.build/Debug/MacroExamplesLib.build/MacroExamplesLib-generated-files.hmap -Xcc -I/Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Intermediates.noindex/MacroExamples.build/Debug/MacroExamplesLib.build/MacroExamplesLib-own-target-headers.hmap -Xcc -I/Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Intermediates.noindex/MacroExamples.build/Debug/MacroExamplesLib.build/MacroExamplesLib-all-target-headers.hmap -Xcc -iquote -Xcc /Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Intermediates.noindex/MacroExamples.build/Debug/MacroExamplesLib.build/MacroExamplesLib-project-headers.hmap -Xcc -I/Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Products/Debug/include -Xcc -I/Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Intermediates.noindex/MacroExamples.build/Debug/MacroExamplesLib.build/DerivedSources-normal/arm64 -Xcc -I/Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Intermediates.noindex/MacroExamples.build/Debug/MacroExamplesLib.build/DerivedSources/arm64 -Xcc -I/Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Intermediates.noindex/MacroExamples.build/Debug/MacroExamplesLib.build/DerivedSources -Xcc -DDEBUG=1 -Xcc -working-directory/Users/kocki/workspace/swift-macro-examples -module-name MacroExamplesLib -disable-clang-spi -target-sdk-version 13.1 -emit-module-doc-path /Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Intermediates.noindex/MacroExamples.build/Debug/MacroExamplesLib.build/Objects-normal/arm64/MacroExamplesLib.swiftdoc -emit-module-source-info-path /Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Intermediates.noindex/MacroExamples.build/Debug/MacroExamplesLib.build/Objects-normal/arm64/MacroExamplesLib.swiftsourceinfo -emit-objc-header-path /Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Intermediates.noindex/MacroExamples.build/Debug/MacroExamplesLib.build/Objects-normal/arm64/MacroExamplesLib-Swift.h -serialize-diagnostics-path /Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Intermediates.noindex/MacroExamples.build/Debug/MacroExamplesLib.build/Objects-normal/arm64/MacroExamplesLib-master-emit-module.dia -emit-dependencies-path /Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Intermediates.noindex/MacroExamples.build/Debug/MacroExamplesLib.build/Objects-normal/arm64/MacroExamplesLib-master-emit-module.d -parse-as-library -o /Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Intermediates.noindex/MacroExamples.build/Debug/MacroExamplesLib.build/Objects-normal/arm64/MacroExamplesLib.swiftmodule -emit-abi-descriptor-path /Users/kocki/Library/Developer/Xcode/DerivedData/MacroExamples-esfyllwzbxrrazbckyixrjqgygek/Build/Intermediates.noindex/MacroExamples.build/Debug/MacroExamplesLib.build/Objects-normal/arm64/MacroExamplesLib.abi.json
    1.	Apple Swift version 5.8-dev (LLVM d0e30b7f831ad7b, Swift 1b92f52a9a0714a)
    2.	Compiling with the current language version
    Stack dump without symbol names (ensure you have llvm-symbolizer in your PATH or set the environment var `LLVM_SYMBOLIZER_PATH` to point to it):
    0  swift-frontend           0x00000001074072b4 llvm::sys::PrintStackTrace(llvm::raw_ostream&, int) + 56
    1  swift-frontend           0x0000000107406538 llvm::sys::RunSignalHandlers() + 128
    2  swift-frontend           0x00000001074078f4 SignalHandler(int) + 304
    3  libsystem_platform.dylib 0x000000018d1042a4 _sigtramp + 56
    4  libsystem_pthread.dylib  0x000000018d0d5cec pthread_kill + 288
    5  libsystem_c.dylib        0x000000018d00f2c8 abort + 180
    6  swift-frontend           0x0000000107860a80 (anonymous namespace)::Verifier::verifyChecked(swift::VarDecl*) (.cold.1) + 0
    7  swift-frontend           0x0000000103d32234 (anonymous namespace)::Verifier::verifyChecked(swift::ValueDecl*) + 284
    8  swift-frontend           0x0000000103d2c31c (anonymous namespace)::Verifier::walkToDeclPost(swift::Decl*) + 4344
    9  swift-frontend           0x0000000103d33af4 (anonymous namespace)::Traversal::doIt(swift::Decl*) + 288
    10 swift-frontend           0x0000000103d339c8 swift::Decl::walk(swift::ASTWalker&) + 32
    11 swift-frontend           0x0000000103eeb0fc swift::SourceFile::walk(swift::ASTWalker&) + 236
    12 swift-frontend           0x0000000103d21ff4 swift::verify(swift::SourceFile&) + 96
    13 swift-frontend           0x0000000103ff0ca4 swift::TypeCheckSourceFileRequest::cacheResult(std::__1::tuple<>) const + 76
    14 swift-frontend           0x0000000103b41bd4 llvm::Expected<swift::TypeCheckSourceFileRequest::OutputType> swift::Evaluator::getResultCached<swift::TypeCheckSourceFileRequest, (void*)0>(swift::TypeCheckSourceFileRequest const&) + 160
    15 swift-frontend           0x0000000103b3f974 swift::TypeCheckSourceFileRequest::OutputType swift::evaluateOrDefault<swift::TypeCheckSourceFileRequest>(swift::Evaluator&, swift::TypeCheckSourceFileRequest, swift::TypeCheckSourceFileRequest::OutputType) + 44
    16 swift-frontend           0x0000000102caf204 bool llvm::function_ref<bool (swift::SourceFile&)>::callback_fn<swift::CompilerInstance::performSema()::$_7>(long, swift::SourceFile&) + 16
    17 swift-frontend           0x0000000102cab0d0 swift::CompilerInstance::forEachFileToTypeCheck(llvm::function_ref<bool (swift::SourceFile&)>) + 160
    18 swift-frontend           0x0000000102cab010 swift::CompilerInstance::performSema() + 76
    19 swift-frontend           0x0000000102b52c50 withSemanticAnalysis(swift::CompilerInstance&, swift::FrontendObserver*, llvm::function_ref<bool (swift::CompilerInstance&)>, bool) + 60
    20 swift-frontend           0x0000000102b45cb4 swift::performFrontend(llvm::ArrayRef<char const*>, char const*, void*, swift::FrontendObserver*) + 3204
    21 swift-frontend           0x00000001029b1f14 swift::mainEntry(int, char const**) + 3304
    22 dyld                     0x000000018cdabe50 start + 2544
    

    Therefore, I have to currently hard-code the return type any View into the macro. I suppose this is only a bug in the implementation. If not, this would be an unfortunate limitation.

  • Overloading macros does not seem to work in this case:

    @expression public macro apply<R: ResultBuilder>(resultBuilder: R.Type, to closure: () -> Void) -> (() -> String) = #externalMacro(module: "MacroExamplesPlugin", type: "ResultBuilderMacro")
    @expression public macro apply<R>(resultBuilder: R.Type, to closure: () -> Void) -> (() -> String) = #externalMacro(module: "MacroExamplesPlugin", type: "ResultBuilderMacro2")
    

    It produces the error Invalid redeclaration of 'apply(resultBuilder:to:). @Douglas_Gregor: Is this the expected behavior or just a limitation of the current implementation? I would expect overloading macros to work just like overloading functions.

All in all, I think the general directions laid out in this proposal is great and I am surprised that I came this far with the proposed feature. What I missed where (a) type information about subexpressions and (b) static reflection. I fear that without these features users of macros (macro users, not macro implementors) could face a very rough user experience because the macro often cannot anticipate errors. It must leave it up to the compiler to generate an error message that might or might not be helpful to the user.


I think it's a good first step. It doesn't bring us the whole way, but it bridges the gap until macros gain an introspection API, wich is inevitable in my opinion.

10 Likes