Deep Dive Into Macros

Swift 5.9 (WWDC23) introduced Macros to make your codebase more expressive and easier to read. In this article, I'll go over why swift macros exist, how they work and how you can easily set up one of your own.

Who likes writing boring repetitive code? Nobody right?

Swift 5.9 (WWDC23) introduced Macros to make your codebase more expressive and easier to read. In this article, I'll explain why Swift macros exist, how they work, and how you can easily set up one of your own.

What is a Macro?

Macros aren't a new concept in programming. It first became popularised in languages such as C and even existed before that in languages like Assembly.

Take a look at this excerpt from The Art of Assembly by Randall Hyde [1]:

Macros are objects that a language processor replaces with other text during compilation. Macros are great devices for replacing long, repetitive sequences of text with much shorter sequences of text.

However, Swift Macros are a little different. When you're calling a macro in your code and adding a type that is incompatible with the macro (you add a String to an argument instead of an Int) when you compile, you will see an error being thrown. If you did the same approach with a macro in C, you wouldn't get a throw error since the pre-processor stage evaluates C macros before type-checking [2].

Before we dive in, what are the different types of macros that exist in Swift?

Freestanding Macro

  • Are used to stand in place of something else in your code [4].
  • To call a freestanding macro, you use the pound (#) before its name.
  • They can produce a value, or they can act at compile time.

Example:

The #warning(_:) macro that you use in Swift is a freestanding macro.

Attached Macros

  • Are used as attributes on declarations in your code [4].
  • They always start with an at (@).
  • They modify the declaration they're attached to and add code to that declaration, such as defining a new method or adding conformance to a protocol.

Example:

The @Observable macro is a type of attached macro.

Using the symbols helps detect whether what you're calling is a macro or not. So you can be certain that if you don't see @ or #, you're not using a macro.

But isn't this the same as a function?!

But I know what you're thinking, what is the difference between a macro and a standard function in your code? Aren't they the same thing? Well, no.

When you implement a function in your code, there isn't a separation between them. Usually, this function exists inside a class or even in another module. However, with macros, the declaration in your code and the implementation are separate [3].

How do Macros Work?

Since we got the basics laid down we can talk about how do macros work. As I stated before you can think of macros as independent blocks of code that are separate from your code base.

Let's take the following example: Imagine that you have a list of operations that you want to present on the screen. You want the user of your app to do basic calculations and check if they are right or wrong.

You would have a very repetitive list of something like this:

let calculations = [
  (1+2, "1+2"),
  (2+3, "2+3"),
  ...
]

Besides the obvious fact that is repetitive we can't check for certain that the String value matches the calculation (error prone). In this case since we want to produce a value we can use the #stringify macro. This macro will return a tuple of (Int, String). Which is exactly what we need. We would do the following substitution:

let calculations = [
  #stringify(1+2),
  #stringify(2+3),
  ...
]

But what is happening under the hood? How does the Swift compiler handle this?

First the Swift compiler detects a macro in your code and extracts it.

It then sends it to a special compiler plugin that contains the implementation of this macro. This implementation will run in a separate process in a secure sandbox, and the plugin will return a new fragment of code created by the macro.

After that, the Swift compiler will compile your code and the expansion together, and at this stage, it looks like you wrote the expansion of said macro.

This might sound a little bit intimidating, so let's imagine this scenario.

Imagine that you're baking a cake and this is not your run of the mill cake, it needs more complex components like frosting. Something like a birthday cake.

When you look at the recipe book, you see that the frosting should be done in a very specific way and can be done by you or the Frosty Company (fictitious name, of course). You can do it yourself, but that would take a lot of work.

So you decide to call the Frosty Company to have them do the frosting instead.

They agree and arrange to deliver the frosting when you start baking your cake. The Frosty Company also gives you a list of ingredients and the recipe they use so you can add it to your recipe book.

When you actually start the cake, one of the Frosty workers comes by and delivers the frosting. Everyone is happy; you saved time and resources and got the exact frosting that the cake needed.

And when you take a look at the whole process,, you can see the full list of instructions to bake the cake, including the frosting, even though you weren't the one that did it.

Macros work similarly. The compiler (you) can do a task that is tedious and repetitive (frosting), or you can call the macro (frosting company) to do it instead. The macro works in a separate environment (the frosting company would have their kitchen with their resources to do it). The macro can also be expanded when is compiled (the company gives the exact recipe with the ingredients that they use).

Your First Macro

Now to the fun part, we're going to check how to create a macro. Before we start I would like to briefly go over the situations where you shouldn't implement macros.

One of the first cases where a macro could be created is to give the exact time and date. Even though this, in theory, looks like a situation where a macro could be helpful, we shouldn't create one. Here's why.

In the video Expand on Swift Macros [4], Becca explains that since macros run in a very controlled sandbox environment your compiler is assuming that the implementation of the macro is a pure function.

This means that macros should:

  • With the same input, they should deliver the same output (deterministic).
  • Not deliver any side effects.
  • Not modify any variables outside of the function.

If you create a macro that delivers the current time, you are violating this notion. The result is not deterministic, so this is not a good case for implementing a macro.

Create the Package in XCode

I'm using Xcode 15.3 so these steps might change in the future.

  • First, open Xcode and select a new package.
  • On the Multiplatform tab, select Swift Macro.

After giving a name and setting the location of the project you may notice two main files.

  • The definition of the macro.
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "CodingWithVeraMacroExampleMacros", type: "StringifyMacro")
  • The expansion of the macro.
public struct StringifyMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression else {
            fatalError("compiler bug: the macro does not have any arguments")
        }

        return "(\(argument), \(literal: argument.description))"
    }
}

@main
struct CodingWithVeraMacroExamplePlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        StringifyMacro.self,
    ]
}

You can also check the definition and expansion of the macro when your calling it by right-clicking on it.

You can also check the tests to see how you can (and should) test your macros.

This is just an overview of the template that comes with the package when you select a macro. If you're interested in learning more, keep an eye out for more articles from me. Also, if you want to learn more about Swift macros, take a look at this GitHub repository with a curated list of community-created macros.

Conclusion

Swift Macros was first introduced in WWDC 23, even though it's not a new concept in programming it's innovative. Macros lets you customize Swift language based on your needs and allows you to distribute them to other developers without modifying the compiler code.

I also highly recommend that you check Swift's official documentation, especially the two WWDC sessions that Becca and Alex gave in the 2023 edition.

References

[1] Macros and the HLA Compile-Time Language: 9.8 Macros (Compile-Time Procedures), page 573, The Art of Assembly by Randall Hyde

[2] Write Swift Macros: WWDC23 https://developer.apple.com/videos/play/wwdc2023/10166

[3] Macros: Macro Declarations, Swift Official Documentation https://docs.swift.org/swift-book/documentation/the-swift-programming-language/macros/#Macro-Declarations

[4] Expand on Swift Macros: WWDC23 https://developer.apple.com/videos/play/wwdc2023/10167/