Unlocking the Power of Macros in Swift 5.9

Jun 19, 2023#swift

In programming languages, macros are a feature that allows developers to define reusable code snippets or patterns. Macros are essentially a way to perform code transformations or code generation at compile-time or pre-processing stage.

Macros are commonly used in situations where code repetition can be reduced, such as implementing design patterns, creating domain-specific languages, or defining shorthand notations for complex code structures.

Swift 5.9 includes a new macro system that can be used to eliminate boilerplate and provide new forms of expressive APIs:

Expression Macros

Expression macros are used as expressions in the source code (marked with #) and are expanded into expressions. They can have parameters and a result type, much like a function, which describes the effect of the macro expansion on the expression without requiring the macro to actually be expanded first.

As a simple example, let’s consider a stringify macro that takes a single argument as input and produces a tuple containing both the original argument and also a string literal containing the source code for that argument. The type signature of a macro is part of its declaration, which looks a lot like a function.

@freestanding(expression)
macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "MyMacros", type: "StringifyMacro")

This macro could be used in source code as:

#stringify(x + y)

and would be expanded into:

(x + y, "x + y")

Macro arguments are type-checked against the parameter types of the macro prior to instantiating the macro. If they are ill-formed, the macro will never be expanded.

Freestanding Declaration Macros

The feature extends the notion of freestanding macros introduced in expression macros to also allow macros to introduce new declarations. Like expression macros, freestanding declaration macros are expanded using the # syntax, and have type-checked macro arguments. However, freestanding declaration macros can be used any place that a declaration is provided, and never produce a value.

The #warning can be described as a freestanding declaration macro as follows:

/// Emits the given message as a warning, as in SE-0196.
@freestanding(declaration) 
macro warning(_ message: String) = #externalMacro(module: "MyMacros", type: "WarningMacro")

and can be used anywhere a declaration can occur:

#warning("unsupported configuration")

Attached Macros

Attached macros provide a way to extend Swift by creating and extending declarations based on arbitrary syntactic transformations on their arguments. They make it possible to extend Swift in ways that were only previously possible by introducing new language features, helping developers build more expressive libraries and eliminate extraneous boilerplate.

Attached macros are identified with the @attached attribute, which also provides the specific role as well as any names they introduce. For example, the aforemented macro to add a completion handler would be declared as follows:

@attached(peer, names: overloaded)
macro AddCompletionHandler(parameterName: String = "completionHandler")

The macro can be used as follows:

@AddCompletionHandler(parameterName: "onCompletion")
func fetchAvatar(_ username: String) async -> Image? { ... }

The use of the macro is attached to fetchAvatar, and generates a peer declaration alongside fetchAvatar whose name is overloaded with fetchAvatar. The generated declaration is:

/// Expansion of the macro produces the following.
func fetchAvatar(_ username: String, onCompletion: @escaping (Image?) -> Void) {
  Task.detached {
    onCompletion(await fetchAvatar(username))
  }
}

Package Manager Support for Custom Macros

Macro definitions would be provided in a separate package that performs a syntactic transformation. SwiftPM builds each macro as an executable for the host platform, applying certain additional compiler flags. Macros are expected to depend on swift-syntax using a versioned dependency that corresponds to a particular major Swift release.

Similar to package plugins, macro plugins are built as executables for the host (i.e, where the compiler is run). The compiler receives the paths to these executables from the build system and will run them on demand as part of the compilation process. Macro executables are automatically available for any target that transitively depends on them via the package manifest.

A minimal package containing the implementation, definition and client of a macro would look like this:

import PackageDescription
import CompilerPluginSupport

let package = Package(
    name: "MacroPackage",
    dependencies: [
        .package(url: "https://github.com/apple/swift-syntax", from: "509.0.0"),
    ],
    targets: [
        .macro(name: "MacroImpl",
               dependencies: [
                   .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
                   .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
               ]),
        .target(name: "MacroDef", dependencies: ["MacroImpl"]),
        .executableTarget(name: "MacroClient", dependencies: ["MacroDef"]),
        .testTarget(name: "MacroTests", dependencies: ["MacroImpl"]),
    ]
)

If the above macro package contains definition and implementation of the #fontLiteral macro, which is similar in spirit to the built-in expressions #colorLiteral, #imageLiteral, etc. You can use it like this:

import MacroDef

struct Font: ExpressibleByFontLiteral {
  init(fontLiteralName: String, size: Int, weight: MacroDef.FontWeight) {
  }
}

let _: Font = #fontLiteral(name: "Comic Sans", size: 14, weight: .thin)