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