Пишем свой Swift макрос без лида, ИТ-курсов и кредитов

от автора

Привет, Хабр! Меня зовут Сергей, я 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) }

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

Итог

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/828888/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *