Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Swift Macros - Macro Polo - A new generation of...

Swift Macros - Macro Polo - A new generation of code generation

These are the slides from a talk about Swift Macros I gave in Swift Heroes 2024 and Waze's iOS Meetup in Tel Aviv, Israel

Shai Mishali

December 10, 2024
Tweet

More Decks by Shai Mishali

Other Decks in Technology

Transcript

  1. Senior iOS Tech Lead @ monday.com Open Source ❤ Hackathon

    fan and winner of some International Speaker About Me @freak4pc
  2. What are Macros ? “Macros transform your source code when

    you compile it, letting you avoid writing repetitive code by hand.” The Swift Programming Language Book https://docs.swift.org/swift-book
  3. What are Macros? In essence, instead of writing “boilerplate code”

    over and over, you create a reusable Macro which can generate new code by analyzing your own code Swift Compiler % Your code, with attached or standalone macros Super-powered code with Expanded macro &
  4. What are Macros? Consider this simple closure-based function which performs

    an API call - you might have many of these in your app class API { func getMemes( count: Int, completion: @escaping (Result<[Meme], Error>) -" Void ) { /$ ..& } }
  5. What are Macros? Consider this simple closure-based function which performs

    an API call - you might have many of these in your app class API { func getMemes( count: Int, completion: @escaping (Result<[Meme], Error>) -" Void ) { /$ ..& } } What about async/await? $
  6. What are Macros? Consider this simple closure-based function which performs

    an API call - you might have many of these in your app class API { func getMemes( count: Int, completion: @escaping (Result<[Meme], Error>) -" Void ) { /$ ..& } } What about async/await? $ @AddAsync
  7. What are Macros? We gain compile-time access to the context

    of our existing code, and can derive new code based on it, from a single reusable implementation class API { func getMemes( count: Int, completion: @escaping (Result<[Meme], Error>) -" Void ) { /$ ..& } func getMemes(count: Int) async throws -> [Meme] { // Generated code which calls out to the closure-based // implementation and bridges to async/await } } @AddAsync
  8. What are Macros? We gain compile-time access to the context

    of our existing code, and can derive new code based on it, from a single reusable implementation class API { func getMemes( count: Int, completion: @escaping (Result<[Meme], Error>) -" Void ) { /$ ..& } func getMemes(count: Int) async throws -> [Meme] { // Generated code which calls out to the closure-based // implementation and bridges to async/await } } @AddAsync
  9. What are Macros? We gain compile-time access to the context

    of our existing code, and can derive new code based on it, from a single reusable implementation class API { func getMemes( count: Int, completion: @escaping (Result<[Meme], Error>) -" Void ) { /$ ..& } func getMemes(count: Int) async throws -> [Meme] { // Generated code which calls out to the closure-based // implementation and bridges to async/await } } @AddAsync
  10. What are Macros? We gain compile-time access to the context

    of our existing code, and can derive new code based on it, from a single reusable implementation class API { func getMemes( count: Int, completion: @escaping (Result<[Meme], Error>) -" Void ) { … } func addMeme( _ meme: [Meme], completion: @escaping (Result<Meme, Error>) -" Void ) { … } /$ etc etc… @AddAsync @AddAsync
  11. How does it work? Macros are made from two primary

    pieces: A macro declaration and a compiler plugin Your code using macros I want to use the @AddAsync macro @AddAsync func getMemes(…) { ..# } Macro declaration (Definition) Yes, this macro exists and it can do these things, but I don’t have its implementation @attached(peer, names: arbitrary) public macro AddAsync() = #externalMacro( module: "ShaisMacros", type: "AddAsyncMacro" ) Macro Compiler Plugin (Actual implementation) I hold the actual macro implementation and generate new code based on existing code in a separate process public struct AddAsyncMacro: PeerMacro { /% Macro implementation }
  12. How does it work? A macro definition can either live

    in your main app or in a separate module, and it has several pieces @attached(peer, names: arbitrary) public macro AddAsync() = #externalMacro( module: "ShaisMacros", type: "AddAsyncMacro" )
  13. How does it work? A macro definition can either live

    in your main app or in a separate module, and it has several pieces @attached(peer, names: arbitrary) public macro AddAsync() = #externalMacro( module: "ShaisMacros", type: "AddAsyncMacro" ) Name specifiers { Role { Type {
  14. How does it work? A macro definition can either live

    in your main app or in a separate module, and it has several pieces @attached(peer, names: arbitrary) public macro AddAsync() = #externalMacro( module: "ShaisMacros", type: "AddAsyncMacro" ) Name and access level Module containing the implementation Name of the struct implementing the macro
  15. How does it work? The implementation accepts what’s called an

    Abstract Syntax Tree (AST) representing how Swift “sees” the syntax tree, and lets you peek in and generate code based on your existing code Apple provides a package called Swift Syntax which is a source-accurate tree representation of Swift source code, meaning the di!erent pieces of Swift codes are represented as actual Swift types on their own https://github.com/apple/swift-syntax
  16. How does it work? func getMemes( count: Int, completion: @escaping

    (Result<Memes, Error>) -> Void ) -> Void { // ... } The implementation accepts what’s called an Abstract Syntax Tree (AST) representing how Swift “sees” the syntax tree, and lets you peek in and generate code based on your existing code
  17. How does it work? ├─name: identifier("getMemes") ├─signature: FunctionSignatureSyntax │ ├─parameterClause:

    FunctionParameterClauseSyntax │ │ ├─leftParen: leftParen │ │ ├─parameters: FunctionParameterListSyntax │ │ │ ├─[0]: FunctionParameterSyntax │ │ │ │ ├─attributes: AttributeListSyntax │ │ │ │ ├─modifiers: DeclModifierListSyntax │ │ │ │ ├─firstName: identifier("count") │ │ │ │ ├─colon: colon │ │ │ │ ├─type: IdentifierTypeSyntax │ │ │ │ │ ╰─name: identifier("Int") │ │ │ │ ╰─trailingComma: comma │ │ │ ╰─[1]: FunctionParameterSyntax │ │ │ ├─attributes: AttributeListSyntax │ │ │ ├─modifiers: DeclModifierListSyntax │ │ │ ├─firstName: identifier("completion") │ │ │ ├─parameters: TupleTypeElementListSyntax │ │ │ │ ╰─[0]: TupleTypeElementSyntax │ │ │ │ ╰─type: IdentifierTypeSyntax │ │ │ │ ├─name: identifier("Result") │ │ │ │ ╰─genericArgumentClause: GenericArgumentClauseSyntax │ │ │ │ ├─leftAngle: leftAngle │ │ │ │ ├─arguments: GenericArgumentListSyntax │ │ │ │ │ ├─[0]: GenericArgumentSyntax │ │ │ │ │ │ ├─argument: IdentifierTypeSyntax │ │ │ │ │ │ │ ╰─name: identifier(“Memes”) │ │ │ │ │ │ ╰─trailingComma: comma │ │ │ │ │ ╰─[1]: GenericArgumentSyntax │ │ │ │ │ ╰─argument: IdentifierTypeSyntax │ │ │ │ │ ╰─name: identifier("Error") │ │ │ │ ╰─rightAngle: rightAngle func getMemes( count: Int, completion: @escaping (Result<Memes, Error>) -> Void ) -> Void { // ... } The implementation accepts what’s called an Abstract Syntax Tree (AST) representing how Swift “sees” the syntax tree, and lets you peek in and generate code based on your existing code
  18. How does it work? The implementation accepts what’s called an

    Abstract Syntax Tree (AST) representing how Swift “sees” the syntax tree, and lets you peek in and generate code based on your existing code ├─name: identifier("getMemes") ├─signature: FunctionSignatureSyntax │ ├─parameterClause: FunctionParameterClauseSyntax │ │ ├─leftParen: leftParen │ │ ├─parameters: FunctionParameterListSyntax │ │ │ ├─[0]: FunctionParameterSyntax │ │ │ │ ├─attributes: AttributeListSyntax │ │ │ │ ├─modifiers: DeclModifierListSyntax │ │ │ │ ├─firstName: identifier("count") │ │ │ │ ├─colon: colon │ │ │ │ ├─type: IdentifierTypeSyntax │ │ │ │ │ ╰─name: identifier("Int") │ │ │ │ ╰─trailingComma: comma │ │ │ ╰─[1]: FunctionParameterSyntax │ │ │ ├─attributes: AttributeListSyntax │ │ │ ├─modifiers: DeclModifierListSyntax │ │ │ ├─firstName: identifier("completion") │ │ │ ├─parameters: TupleTypeElementListSyntax │ │ │ │ ╰─[0]: TupleTypeElementSyntax │ │ │ │ ╰─type: IdentifierTypeSyntax │ │ │ │ ├─name: identifier("Result") │ │ │ │ ╰─genericArgumentClause: GenericArgumentClauseSyntax │ │ │ │ ├─leftAngle: leftAngle │ │ │ │ ├─arguments: GenericArgumentListSyntax │ │ │ │ │ ├─[0]: GenericArgumentSyntax │ │ │ │ │ │ ├─argument: IdentifierTypeSyntax │ │ │ │ │ │ │ ╰─name: identifier(“Memes”) │ │ │ │ │ │ ╰─trailingComma: comma │ │ │ │ │ ╰─[1]: GenericArgumentSyntax │ │ │ │ │ ╰─argument: IdentifierTypeSyntax │ │ │ │ │ ╰─name: identifier("Error") │ │ │ │ ╰─rightAngle: rightAngle func getMemes(count: Int) async throws -> Memes
  19. Types and Roles There are two types of Macros ✌

    Freestanding Attached Modify the declaration they’re attached to Start with @ i.e. @Observable, @AddAsync Standalone, similar to a free function Start with a # i.e. #Preview, #warning, #line
  20. Types and Roles Each type has di!erent roles which determine

    what the macro can do Freestanding Attached @freestanding(expression) Creates a piece of code that returns a value @freestanding(declaration) Creates one or more declarations @attached(peer) Adds new declarations on the same scope as the symbol @attached(accessor) Adds get/set accessors to a property @attached(memberAttribute) Adds attributes to a type / extension @attached(member) Adds new declaration inside a type / extension @attached(extension) Adds a conformance to a type / extension
  21. Types and Roles Each role requires an implementation conforming to

    a di!erent dedicated protocol Freestanding Attached member - Adds new declaration inside a type / extension expression - ExpressionMacro Creates a piece of code that returns a value declaration - DeclarationMacro Creates one or more declarations peer - PeerMacro Adds new declarations on the same scope as the symbol accessor - AccessorMacro Adds get/set accessors to a property memberAttribute - MemberAttributeMacro Adds attributes to a type / extension extension - ExtensionMacro Adds a conformance to a type / extension MemberMacro
  22. Types and Roles Each macro implementation is responsible for returning

    the newly generated code, which is strictly-checked by Swift Syntax: public struct AgendaMacro: MemberMacro { public static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { return [ """ enum Topics: String { case what = "What are Macros?" case how = "How do they work?" case typesAndRoles = "Types & Roles" case testingDebugging = "Testing & Debugging" case liveCoding = "Your first Macro" case examples = "Useful examples" case limitations = "Limitations & Downsides" case tools = "Tools & Resources" } """ ] } }
  23. Testing, Debugging & Diagnostics Remember - Macros are NOT a

    black box. You can always expand macros and see exactly what code they’re generating, for example - #Preview:
  24. Testing, Debugging & Diagnostics Remember - Macros are NOT a

    black box. You can always expand macros and see exactly what code they’re generating, for example - #Preview:
  25. Testing, Debugging & Diagnostics Sometimes your macro will need to

    notify the user when they’re doing something wrong, the simplest way is throwing an error enum AgendaMacroError: Error, CustomStringConvertible { case tooAwesome var description: String { switch self { case .tooAwesome: "This lecture is awesome, it doesn't need an agenda!" } } } public struct AgendaMacro: MemberMacro { public static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { // ... throw AgendaMacroError.tooAwesome
  26. Testing, Debugging & Diagnostics You can go even deeper with

    what’s called a Diagnostic, where you can customize the severity, add fix-its, and even notes and highlights enum AgendaMacroDiagnostic: String, DiagnosticMessage { var message: String { switch self { case .tooAwesome: "This lecture is awesome, it doesn't need an agenda!" } } var diagnosticID: MessageID { MessageID(domain: "com.freak4pc.app", id: rawValue) } var severity: DiagnosticSeverity { switch self { case .tooAwesome: .warning } } case tooAwesome } public struct AgendaMacro: MemberMacro { public static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { // .. context.diagnose( Diagnostic( node: node, message: AgendaMacroDiagnostic.tooAwesome, highlights: […], notes: […], fixIts: […] ) ) return []
  27. Testing, Debugging & Diagnostics You can go even deeper with

    what’s called a Diagnostic, where you can customize the severity, add fix-its, and even notes and highlights
  28. Testing, Debugging & Diagnostics Macros are inherently pure functions, so

    writing tests around them is a great way to confirm that given a syntax tree, the expected piece of code is generated assertMacro part of pointfreeco/swift-macro-testing assertMacroExpansion part of apple/swift-syntax
  29. Useful Real-Life Examples @EnvironmentValue https://github.com/Wouter01/SwiftUI-Macros /// Creates an unique EnvironmentKey

    for the variable and adds getters and setters. /// The initial value of the variable becomes the default value of the EnvironmentKey. @attached(peer, names: prefixed(EnvironmentKey_)) @attached(accessor, names: named(get), named(set)) public macro EnvironmentValue() = #externalMacro( module: “SwiftUIMacrosImpl", type: “AttachedMacroEnvironmentKey" )
  30. Useful Real-Life Examples @Codable https://github.com/SwiftyLab/MetaCodable @attached( extension, conformances: Decodable, Encodable,

    names: named(CodingKeys), named(DecodingKeys), named(init(from:)), named(encode(to:)) ) @attached( member, conformances: Decodable, Encodable, names: named(CodingKeys), named(init(from:)), named(encode(to:)) ) @available(swift 5.9) public macro Codable() = #externalMacro(module: "MacroPlugin", type: "Codable")
  31. Limitations & Downsides Macros are purely additive, they CANNOT remove

    or modify your code. They are also sandboxed to prevent disk & network access, which overall makes them safe citizens in the Swift Ecosystem. .
  32. Limitations & Downsides The sandbox does not prevent you from

    accessing outside information such as Date, identifiers, etc - such use is highly discourage since it can lead to unexpected behaviors for your macro If you need a random, safe-to-use name for a generated type, you can simply use makeUniqueName context.makeUniqueName(“AmazingNewType") -> __macro_local_14AmazingNewTypefMu_ /
  33. Limitations & Downsides Macros have a lot of heavy dependencies,

    such as Swift Syntax - when you import a macro, all these dependencies can heavily impact your build time
  34. Sourcery https://github.com/krzysztofzablocki/Sourcery • The OG of AST-Based Code Generation (Released

    2016), uses Swift Syntax as well • Can do most of what Swift Macros can, but uses Stencil templates instead of Swift code • Does not incur the build time penalty since it runs outside of the Xcode Build System as a CLI • Tooling is not as tight-knit with Xcode & Swift as Apple’s solution, can’t bundle as dependency /$ Found {{ types.all.count }} Types /$ {% for type in types.all %}{{ type.name }} {% endfor %} Template.stencil /$ Found 2 Types /$ AppDelegate ViewController Template.generate.swift Tools & Resources
  35. Keep learning Expand on Swift Macros - WWDC 23 #10167

    https://developer.apple.com/videos/play/wwdc2023/10167 Write Swift Macros - WWDC 23 #10166 https://developer.apple.com/videos/play/wwdc2023/10166 Tools and Resources