Привет, Хабр! Меня зовут Сергей, я iOS-разработчик в Альфа-Банке. В повседневной работе я использую множество проверенных инструментов, а в свободное время мне нравится исследовать новые возможности и расширять свои горизонты за пределами используемых в продакшене технологий.

Сегодня я хотел бы рассказать вам о макросах в Swift 5.9, как их можно применять для избавление от бойлерплейта в коде, как их создавать, какие сложности есть с ними и куда всё это движется. Так как я работаю в команде дизайн-системы, мы рассмотрим макросы на примере добавления метода copy для всех моделей UI-компонентов.

Что такое макросы в Swift

Макросы являются программами-расширениями, подключаемыми к вашему проекту и работающими во время компиляции, расширяя функциональность исходного кода до момента её компиляции.

Макросы бывают двух типов, автономные или подключаемые:

  • Автономные макросы Freestanding Macrosin page link (#) — простые программы которые принимают один или больше агрументов и генерируют на их основе код. Чтобы посмотреть пример такого макроса, достаточно просто создать проект‑макрос, там будет автоносный макрос. Данные макросы хорошо подходят для создания системы логирования. Как раз #file, #fucntionc — являются представителями автономных макросов.

  • Подключаемые макросы Attached Macrosin page link (@) — сложные программы которые применяются к какому‑то участку кода и добавляют к нему новую функциональность — новые методы, проперти, инициализаторы и реализация протоколов. Такие макросы дают возможность работать с AST данного куска кода. В статье мы будем подробней рассматривать именно этот тип макросов.

Создаём свой макрос

Прежде чем перейти к практической части, я добавлю немного бэкграунда про работу компонентов дизайн-системы:

  • Модель компонента всегда иммутабельна (неизменяема) и является структурой

  • Модель всегда лежит внутри компонента (UIView) — то есть путь для неё в общем случае будет: Component.Model

  • Модель и все её проперти должны быть public

  • Все проперти по возможности должны быть let, но это не всегда так, потому что некоторые проперти обёрнуты в PropertyWrapper.

  • Модель обязательно должна реализовывать Equatable

Что же мы хотим получить? Нам нужен макрос, который позволит копировать иммутабельную модель с изменением параметров исходной модели. По возможности проверять, что модель собрана по общим правилам, описанным выше, и если это не так, выбрасывать ошибку с указанием, что пошло не так.

Предположим, у нас есть ProfileView, которая состоит из атрибутированного текста и статуса пользователя. Model этого компонента будет выглядеть вот так:

extension ProfileView {
  //@ViewModelCopy(ProfileView.self) - тут хотим применять наш макрос
  public struct Model: Equatable {
    // Отображаемое имя
    @Semantic // Property wrapper позволяющий сравнивать NSAttributedString 
    public private(set) var name: NSAttributedString
    // Статус аккаунта
    public let status: Status
  
    public init(
        name: NSAttributedString,
        status: Status
    ) {
        self.name = name
        self.status = status
    }  
  }
}

А как итог работы макроса мы хотим получить следующий результат:

extension ProfileView {
  public struct Model: Equatable {
    ...
  
  public func copy(build: (inout Builder) -> Void) -> Self {
      var builder = Builder(model: self)
      build(&builder)
      return .init(name: builder.name, status: builder.status)
    }

    public struct Builder {
      // Отображаемое имя
      public var name: NSAttributedString
      // Статус аккаунта
      public var status: Status
      public init(model: ProfileView.Model) {
        name = model.name
        status = model.status
      }
    }
  }
}

Начнём с создания проекта. Запускаем Xcode → File → Package, далее выбираем Swift macros и заполняем все обязательные поля.

Созданный проект
Созданный проект

После этого перед нами открывается проект с заранее созданным примером — stringify. Рассмотрим, что было создано:

  • ViewModelCopy — это объявление нашего макроса, можно сказать, его интерфейс.

  • ViewModelCopyMacro — это реализация нашего макроса.

  • main — это программа‑пример, который мы можем запустить.

  • ViewModelCopyTests — тесты нашего макроса.

Сгенерированный код нам не нужен, поэтому смело его удаляем и пишем нашу реализацию. Начнём с создания интерфейсной части нашего макроса — ViewModelCopy.

@attached(member, names: named(copy), named(Copy))
public macro ViewModelCopy<T>(component: T) = #externalMacro(module: "ViewModelCopyMacros", type: "ViewModelCopyMacro")

Тип нашего макроса подключаемый — @attached, подключается он как member — данный тип позволяет нам использовать макрос для создания новых структур, свойст и методов. Подробнее обо всех возможных типах можно посмотреть здесь.

Теперь самое время написать тесты на наш макрос. Переходим в файл ViewModelCopyTests. Возьмём за основу наш пример выше про ProfileView:

#if canImport(ViewModelCopyMacros)
import ViewModelCopyMacros

let testMacros: [String: Macro.Type] = [
    "ViewModelCopy": ViewModelCopyMacro.self,
]
#endif

final class ViewModelCopyTests: XCTestCase {
    func testMacro() throws {
        #if canImport(ViewModelCopyMacros)
        assertMacroExpansion(
            """
            extension ProfileView {
                @ViewModelCopy(ProfileView.self)
                public struct Model: Equatable {
                    // Отображаемое имя
                    @Semantic
                    public private(set) var name: NSAttributedString
                    // Статус аккаунта
                    public let status: Status
            
                    public init(
                        name: NSAttributedString,
                        status: Status
                    ) {
                        self.name = name
                        self.status = status
                    }
                }
            }
            """,
            expandedSource: """
            extension ProfileView {
                public struct Model: Equatable {
                    // Отображаемое имя
                    @Semantic
                    public private(set) var name: NSAttributedString
                    // Статус аккаунта
                    public let status: Status
            
                    public init(
                        name: NSAttributedString,
                        status: Status
                    ) {
                        self.name = name
                        self.status = status
                    }
            
                    public func copy(build: (inout Builder) -> Void) -> Self {
                        var builder = Builder(model: self)
                        build(&builder)
                        return .init(name: builder.name, status: builder.status)
                    }
            
                    public struct Builder {
                        // Отображаемое имя
                        public var name: NSAttributedString
                        // Статус аккаунта
                        public var status: Status
                        public init(model: ProfileView.Model) {
                            name = model.name
                            status = model.status
                        }
                    }
                }
            }
            """,
            macros: testMacros
        )
        #else
        throw XCTSkip("macros are only supported when running tests for the host platform")
        #endif
    }
}

Запускаем, тесты горят красным — значит пришло время написать реализацию ? Переходим в ViewModelCopyMacro.swift — файл, в котором будет находиться реализация нашего макроса. Удаляем всё, что есть там сейчас, и пишем нашу реализацию:

import ...

public struct ViewModelCopyMacro: MemberMacro {
    public static func expansion(of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
        []
    }
}

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

ViewModelCopyMacro — реализация нашего макроса, в ней будет находиться вся логика. ViewModelCopyPlugin — является точкой входа в наш макрос (на что намекает @main ?). Тут нужно перечислить, какие макросы объявлены в пакете, в нашем случае это только ViewModelCopyMacro.

Swift macros работает с AST, и наша задача — при разработке макроса пройтись по AST, модернизировать и вернуть измененный AST. Работать с AST — хоть и не самое сложное, но довольно монотонное занятие, так что при написании макросов нужно быть к этому готовым.

Самая большая проблема — это то, что ты не видишь сразу все AST. Но нашлись добрые люди в интернетах и написали такое замечательное web-приложение, которое как раз очень поможет нам в изучении AST. Просто вставляем из теста входное значение и получаем AST, с которым и будем работать.

Для обработки неожиданных аргументов макроса или некорректной структуры, да и в целом для любой ошибки, создадим свой набор возможных ошибок:

enum CopyError: Error, CustomStringConvertible {
    case notFoundComponentName // любая ошибка связанная с обработкой агрументов макроса
    
    var description: String {
        switch self {
        case .notFoundComponentName:
            return "Некорректный агрумент макроса. В качастве аргумента необходимо передать название компонента."
        }
    }
}

Получение имени компонента

Теперь напишем функцию, которая достаёт нам название компонента, и если не получилось достать, выбрасывает ошибку notFoundComponentName:

static func getComponentName(of node: AttributeSyntax) throws -> String {
    guard
        let arguments = node.arguments?.as(LabeledExprListSyntax.self),
        let argument = arguments.first,
        let memberAccess = argument.expression.as(MemberAccessExprSyntax.self),
        let declExpr = memberAccess.base?.as(DeclReferenceExprSyntax.self)
    else { throw CopyError.notFoundComponentName }
    return declExpr.baseName.text
}

В качестве аргумента передаём верхнеуровневый AttributedSyntax — именно в нём хранится весь AST самого макроса. Дальше мы начинаем кастить аргументы в конкретные типы. Как узнать, к чему кастить? Во-первых, можно поставить точку остановки и запустить наш тест, а во-вторых, намного удобнее воспользоваться web-приложением и посмотреть, что он нам показывает:

На вкладке Structure показывается всё AST дерево, при наведении на конкретный блок слева подсвечивается, к чему он относится. AttributedSyntax, который является аргументом нашей функции, относится к LabeledExprList, и нам нужно просто скастить к нему.

Дальше из списка аргументов достаём первый элемент (в идеале и единственный, так что можно добавить ещё проверку на количество). Дальше так же кастишь от конкретных значений к конкретным типам. Всегда можно посмотреть составляющие структуры, наведя на неё, например, как это показано для MemberAccessExprSyntax — тут можно увидеть, что дальнейшее значение лежит в свойстве base. Как итог мы получаем название параметра.

В идеале нужно ещё написать тесты. В данной статье я это опущу, и тест будет только один — который мы написали вначале.

Генерация метода copy 

Теперь перейдём к созданию метода copy. Посмотрим, из чего он состоит в нашем тесте:

public func copy(build: (inout Builder) -> Void) -> Self {
    var builder = Builder(viewModel: self)
    build(&builder)
    return Self(
        name: builder.name,
        status: builder.status
    )
}

Первые 4 строки статические, и их легко сгенерировать, а 5 и 6 строчки используют поля, перечисленные в нашей структуре. Как же нам всё это сгенерировать?

public static func expansion(of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
    let componentName = try getComponentName(of: node)
    guard let structDecl = declaration.as(StructDeclSyntax.self) else { throw CopyError.modelIsNotStruct }
    let variables = structDecl.memberBlock.members.compactMap { member in
        return member.decl.as(VariableDeclSyntax.self)
    }
    let copyFuncDeclSyntax = createCopyFunc(with: variables)
    return []
}

static func createCopyFunc(with variables: [VariableDeclSyntax]) -> DeclSyntax {
        let identifiers = variables.compactMap { variable in
            variable.bindings.first?.as(PatternBindingSyntax.self)?.pattern.as(IdentifierPatternSyntax.self)?.identifier
        }
        var returnStr: String = ""
        identifiers.forEach { tokenSyntax in
            returnStr.append("\(tokenSyntax): builder.\(tokenSyntax),")
        }
        if !returnStr.isEmpty { returnStr.removeLast() }
        let copyFuncDeclSyntax = FunctionDeclSyntax(
            funcKeyword: .keyword(.func),
            name: "copy",
            signature: FunctionSignatureSyntax(
                parameterClause: FunctionParameterClauseSyntax(
                    leftParen: .leftParenToken(),
                    parameters: [
                        FunctionParameterSyntax(
                            firstName: "build",
                            type: FunctionTypeSyntax(
                                parameters: [
                                    TupleTypeElementSyntax(
                                        type: AttributedTypeSyntax(
                                            specifier: .keyword(.inout),
                                            attributes: [],
                                            baseType: IdentifierTypeSyntax(name: "Builder")
                                        )
                                    )
                                ],
                                returnClause: ReturnClauseSyntax(
                                    type: IdentifierTypeSyntax(name: "Void")
                                )
                            )
                        )
                    ],
                    rightParen: .rightParenToken()
                ),
                returnClause: ReturnClauseSyntax(arrow: .arrowToken(), type: IdentifierTypeSyntax(name: .keyword(.Self)))
            ),
            body: CodeBlockSyntax(
                statements: [
                    "var builder = Builder(viewModel: self)",
                    "build(&builder)",
                    "return .init(\(raw: returnStr))"
                ]
            )
        )
        return DeclSyntax(copyFuncDeclSyntax)
    }

Для начала проверим, является ли наша модель структурой. Если это не так, выбрасываем ошибку. Дальше соберём все члены модели (переменные, инициализаторы, функции и т.д.) и оставим только переменные. Swift macros позволяет нам не только смотреть, но и создавать AST как обычную структуру. В примере я демонстрирую, как это можно сделать при создании функции copy.

При создании AST дерева мы можем его полностью воссоздавать как структуру, что я делаю на примере декларации самой функции, либо можем пользоваться облегчённым строковым синтаксисом, как у меня при создании body. Мне лично нравится второй подход, так как он намного проще в чтении и последующей поддержке.

Небольшой хак: мы можем воспользоваться Swift AST Explorer, чтобы посмотреть AST для нашей функции. 

Explorer показывает нам всё дерево, которое остаётся просто повторить, а учитывая что синтаксис тут сходится, это в итоге сводится к простой монотонной работе ? 

Пишем Builder

Для нашей задачи осталось написать Builder. Ну что ж, приступим. В целом будем придерживаться примерно такого же алгоритма: смотрим на AST и пытаемся его повторить. 

static func createBuilder(_ variables: [VariableDeclSyntax], componentName: String) throws -> DeclSyntax {
        let memberBindings = variables.compactMap { $0.bindings.first }
        var params: [(name: TokenSyntax, type: TokenSyntax)] = []
        for binding in memberBindings {
            guard
                let paramName = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier,
                let paramType = binding.typeAnnotation?.type.as(IdentifierTypeSyntax.self)?.name
            else { continue }
            params.append((name: paramName, type: paramType))
        }
        let builder = try StructDeclSyntax("public struct Builder") {
            for param in params {
                """
                public var \(param.name): \(param.type)
                """
            }
            try InitializerDeclSyntax("public init(model: \(raw: componentName).Model)") {
                for param in params {
                    """
                    \(param.name) = model.\(param.name)
                    """
                }
            }
        }
        return DeclSyntax(builder)
    }

Из интересного — создание StructDeclSyntax. У нас есть удобный способ создания параметров — кложура, которая возвращает MemberBlockItemListBuilder. Его в свою очередь можно просто создавать через String. Это сильно упрощает код, а главное упрощает его поддержку. Просто сравните, насколько данный способ проще, чем тот, который показан выше в создании func copy.

В конце добавляем вызов нашей функции в теле макроса и возвращаем то, что получилось:

let builderStructDeclSyntax = try createBuilder(variables, componentName: componentName)
return [copyFuncDeclSyntax, builderStructDeclSyntax]

Отлично! Наш макрос готов, осталось запустить тест и убедиться, что он работает корректно:

Оу, как мы видим, забыли добавить комментарии. Первый раз при работе с макросами я столкнулся с проблемой. Наш чудесный сайт не показывает, где хранятся комментарии и как мне их достать. Каждый MemberBlockItem показывает только блок с кодом, а комментарии опускает. Чтобы найти их, нужно перейти в вкладку Trivia, и вуаля, мы нашли наши комментарии, они лежат в leadingTrivia:

Что ж, доработаем метод createBuilder:

static func createBuilder(_ variables: [VariableDeclSyntax], componentName: String) throws -> DeclSyntax {
  let comments = variables.map { // получаем комментарии
      let comment = $0.leadingTrivia.compactMap { triviaPiece in
          switch triviaPiece {
          case let .docLineComment(comment): return comment
          default: return nil
          }
      }.first ?? ""
      return comment
  }
  let memberBindings = variables.map { $0.bindings.first }
  var params: [(name: TokenSyntax, type: TokenSyntax, comment: String)] = []
  for (index, binding) in memberBindings.enumerated() {
      guard
          let paramName = binding?.pattern.as(IdentifierPatternSyntax.self)?.identifier,
          let paramType = binding?.typeAnnotation?.type.as(IdentifierTypeSyntax.self)?.name
      else { continue }
      params.append((name: paramName, type: paramType, comment: comments[index]))
  }
  let builder = try StructDeclSyntax("public struct Builder") {
      for param in params {
          """
          \(raw: param.comment)
          public var \(param.name): \(param.type)
          """
      }
      try InitializerDeclSyntax("public init(model: \(raw: componentName).Model)") {
          for param in params {
              """
              \(param.name) = model.\(param.name)
              """
          }
      }
  }
  return DeclSyntax(builder)
}

Запускаем наш тест — готово!

Итог

В статье я показал, как можно создать простой макрос, а также рассказал немного теории. Надеюсь, вы нашли что-то полезное для себя.

Расскажите в комментариях, приходилось ли вам писать макросы, и если да, то какие?

Комментарии (0)