Уменьшаем boilerplate с помощью Swift Macros

от автора

Сегодня с вами Никита Коробейников, iOS Team Lead в Surf. Никита объяснит, что такое Swift Macros, сравнит кодогенерацию от Apple со сторонними решениями: Liquid, Generamba, Sourcery и расскажет, как создать собственный Swift Macros. 

Apple представила Swift Macros на WWDC 2023. Компания обещала, что Swift Macros поможет сократить количество шаблонов в кодовой базе и упростить внедрение сложных функций. Действительно, он сможет сделать код более читаемым, убрать boilerplate-код и избежать ошибок компиляции. 

По сути, Apple взяли propertyWrapper, спрятали в него кодогенерацию и получили фундамент для нового стиля написания Swift-кода. Теперь у разработчиков больше поводов использовать аннотации.

Предназначение макросов — генерация swift-кода внутри swift-кода на этапе перекомпиляции — все, как любит XZibit. Этот процесс ещё называют разворачиванием макроса.

Схема разворачивания макроса на этапе компиляцииИсточник: Apple

Схема разворачивания макроса на этапе компиляции
Источник: Apple

Типы макросов определяют, будет ли макрос прикреплен к какому-то месту в коде или его можно развернуть везде. 

Подтипы или роли макросов определяют характер и разворачивания макроса в swift-код. Один макрос может реализовывать несколько ролей одновременно.

freestanding (#)

Можно развернуть где угодно: внутри класса, внутри функции или в другом месте. 

Его можно сравнить с глобальной функцией, которая доступна отовсюду.

Роль expression

Разворачивается в выражение.

Примером может послужить open-source макрос, дающий короткий конструктор для создания цвета.

// макрос let uiColor = #uiColor("#ff0055") // результат развертывания let uiColor = UIColor(red: 1.0, green: 0.0, blue: 0.333, alpha: 1.0)

Роль declaration

Разворачивается в определение.

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

attached (@)

Можно развернуть, прикрепив к определению объекта: класса или структуры.

Роль peer

Позволяет развернуть код на уровне прикрепленного объекта — по соседству с ним.
Примером может послужить open-source макрос Mockable для генерации моков для написания юнит-тестов. 

@Mockable protocol Test {     func modifyValue(_ value: inout Int) } #if MOCKING final class MockTest: Test, Mockable {     private var mocker = Mocker<MockTest>() //...     func modifyValue(_ value: inout Int) {         let member: Member = .m1_modifyValue(.value(value))         try! mocker.mock(member) { producer in             let producer = try cast(producer) as (Int) -> Void             return producer(value)         }     } //.. }

Стоит добавить ключевое слово, и макрос развернется в мок-класс с реализованными слушателями и функцией сброса состояния. Очень полезно.

Роль accessor

Поможет добавить переменной обработчик событий, таких как didSet, willSet и других.

Роль member

Добавляет функции или переменные в тело класса.

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

Роль memberAttribute

Позволит настроить автогенерацию комментариев к объекту.

Роль extension

Позволяет добавить полноценное расширение (extension) к классу. Поддерживается даже использование generic where для работы с generic параметрами внутри extension.

В начале были они

До появления Swift Macros разработчики не сидели без дела и использовали другие инструменты для решения подобных задач. Так какие же альтернативы и конкуренты уже существовали?

Liquid

Это язык заполнения шаблонов и генератор файлов.

Пример шаблона для Contents.json из imageasset

{   "images" : [ {   "filename" : "{{ name }}-light.pdf",   "idiom" : "universal" }, {   "appearances" : [     {       "appearance" : "luminosity",       "value" : "dark"     }   ],   "filename" : "{{ name }}-dark.pdf",   "idiom" : "universal" }   ],   "info" : { "author" : "xcode", "version" : 1   } }

Запуск генератора с передачей имени в шаблон

@template = Liquid::Template.parse(template) palette = @template.render('name' => icon_name) File.write(output_file, palette)

На одном из проектов мы и сами использовали такой скрипт для быстрого добавления ресурсов из макета в ресурсы приложения.

Плюсы: 

  • не ограничен языком —  сгенерирует хоть Swift, хоть json;

  • можно использовать из командной строки или в скриптах buildPhases.

Минусы:

  • Трудно читаемые шаблоны с большим количеством параметров.

Generamba

Одна из популярных надстроек над liquid, предназначенная для генерации архитектурных модулей.

В репозитории проекта можно найти шаблоны для таких архитектур: 

  • VIPER;

  • MVP;

  • MVVM и других;

Мы в Surf часто используем свою архитектуру SurfMVP и генерацию модулей через Geberamba.

В основе конфигурации — Rambafile и Rambaspec.

В первом перечисляются спецификации доступных модулей.

### Templates templates: - {name: surf_mvp_coordinatable_module} - {name: surf_mvp_coordinatable_alert}

Во втором задается соответствие между результатом и шаблонами, по которым их нужно сгенерировать.

### Presenter layer - {name: Presenter/Presenter.swift, path: Code/Presenter/presenter.swift.liquid} - {name: Presenter/ModuleInput.swift, path: Code/Presenter/module_input.swift.liquid} - {name: Presenter/ModuleOutput.swift, path: Code/Presenter/module_output.swift.liquid}

А вот вызов генератора будет выглядеть так:

bundle exec generamba gen $(modName) surf_mvp_coordinatable_module --module_path 'Flows/$(flow)' --test_path 'UnitTests/$(flow)' --custom_parameters flow:'$(flow)'

Плюсы:

  • узконаправленный. Хорошо выполняет свою задачу;

  • инкапсулирует логику создания файлов.

Минусы:

  • параметры надо прикинуть в Rambafile и только потом — в шаблон liquid.

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

Sourcery

Это, пожалуй, максимально близкий к Swift Macros инструмент. Он позволяет использовать специальные комментарии в качестве аннотаций. А их уже сможет использовать генератор, чтобы создавать новые классы или расширения в отдельном output-файле.

В качестве шаблонов генерации Sourcery использует язык разметки stencil, который похож на liquid, но позволяет прямо в шаблоне обрабатывать проход по массиву или проверку условий.

В качестве примера можем рассмотреть аннотацию AutoMockable. Достаточно добавить ее рядом с определением протокола.

// sourcery: AutoMockable protocol StrategyDropable { func canDrop(from source: IndexPath, to destination: IndexPath) -> Bool }

И в файле AutoMockable.generated.swift будет сгенерирован результат mock реализации этого протокола.

class StrategyDropableMock: StrategyDropable {  //MARK: - canDrop  var canDropFromToCallsCount = 0 var canDropFromToCalled: Bool {     return canDropFromToCallsCount > 0 } var canDropFromToReceivedArguments: (source: IndexPath, destination: IndexPath)? var canDropFromToReceivedInvocations: [(source: IndexPath, destination: IndexPath)] = [] var canDropFromToReturnValue: Bool! var canDropFromToClosure: ((IndexPath, IndexPath) -> Bool)?  func canDrop(from source: IndexPath, to destination: IndexPath) -> Bool {         canDropFromToCallsCount += 1         canDropFromToReceivedArguments = (source: source, destination: destination)         canDropFromToReceivedInvocations.append((source: source, destination: destination))         if let canDropFromToClosure = canDropFromToClosure {             return canDropFromToClosure(source, destination)         } else {         return canDropFromToReturnValue         } }  }

Плюсы:

  • результат на Swift;

  • более гибкие (чем в liquid) шаблоны.

Минусы:

  • файл с результатами генерации может получиться очень большим.

Какие бывают макросы

Open-source решения

Сообщество разработчиков уже создало приличное количество open source макросов, которые можно подключить как SPM пакет и попробовать на своем проекте. Или же использовать в качестве вдохновения.
Тут собраны ссылки на готовые макросы и на полезные материалы для углубленного изучения темы. А мы покажем, что представляет процесс создания своего макроса.

Создание своего макроса 

Задача: есть структура, каждой переменной (var) которой необходимо добавить mutating func для редактирования.

// дано struct SomeStruct {     let id: String = ""     var someVar: Bool = false }
// получить struct SomeStruct {     let id: String = ""     var someVar: Bool = false      mutating func set(someVar: Bool) {        self.someVar = someVar     } }

Задача выглядит простой, но по мере увеличения количества переменных внутри структуры ее сложность будет повышаться. 

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

Создаем макрос 

Создание таргета для макроса

Создание таргета для макроса

Xcode создаст шаблонный SPM таргет с заготовкой под:

  • определение — {target-name}Definition.swift;

  • объявление — {target-name}Plugin.swift;

  • тестирование — {macros-name}MacroTests.swift;

  • реализацию — {macros-name}Macro.swift;

  • отладку — main.swift — наших макросов.

Определение макросов нужно для выбора типа макроса и связью ключевого слова с модулем и файлом реализации.

// MacroDefinition.swift @attached(member, names: named(set)) public macro Mutable() = #externalMacro(module: "Macros", type: "MutableMacro")

Файл {target-name}Plugin.swift нужен, чтобы показать, какие макросы будут доступны вне нашего таргета.

// MacroPlugin.swift import SwiftCompilerPlugin import SwiftSyntaxMacros  @main struct MacroPlugin: CompilerPlugin {     let providingMacros: [Macro.Type] = [     MutableMacro.self,     BuildableMacro.self     ] }

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

// MutableMacroTests.swift import SwiftSyntaxMacros import SwiftSyntaxMacrosTestSupport import XCTest import Macros  final class MutableMacroTests: XCTestCase {      let testMacros: [String: Macro.Type] = [     "Mutable": MutableMacro.self     ]      func testExpansionSucceded_whenAppliedToStruct_withVariablesAndType() {     assertMacroExpansion(         """         @Mutable         struct SomeStruct {             let id: String = ""             var someVar: Bool = false         }         """,         expandedSource:         """          struct SomeStruct {             let id: String = ""             var someVar: Bool = false              mutating func set(someVar: Bool) {                     self.someVar = someVar             }         }         """,         macros: testMacros     )     }

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

Будьте внимательны к табуляции исходного и ожидаемого тела, ибо тест очень чувствителен к этому, что часто становится причиной «падения».

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

func testExpansionFailed_whenAppliedTo_nonStruct() {     assertMacroExpansion(         """         @Mutable         class SomeStruct {             let id: String = ""             var someVar: Bool = false         }         """,         expandedSource:         """          class SomeStruct {             let id: String = ""             var someVar: Bool = false         }         """,         diagnostics: [             .init(message: "onlyApplicableToStruct",                   line: 1,                   column: 1)         ],         macros: testMacros     ) }

В этом тесте ожидаемый результат — отсутствие mutating функции и наличие ошибки компилятора.

Тесты готовы, приступим к реализации.

Модуль Swift Syntax, с помощью которого пишутся макросы, представляет собой типичный DSL (domain specific language), созданный для упорядоченного описания всего Swift-кода. Теперь препроцессор может читать наш код и конвертировать его в связанные объекты, на основе которых мы можем создавать новые объекты и внедрять их в код. Так каждый объект может быть описан соответствующей сущностью.

Например,

  • структура = StructDeclSyntax

  • переменная = VariableDeclSyntax

и так далее для функций, классов. 

Синтаксис этого модуля интуитивно понятен.

Для решения нашей задачи напишем extension для структуры — чтобы найти все переменные.

// поиск определения переменных свойств внутри структуры extension StructDeclSyntax {     var variables: [VariableDeclSyntax] {     return memberBlock.members         .compactMap { $0.decl.as(VariableDeclSyntax.self) }         .filter { $0.bindingKeyword.text == "var" }     } }

Тут перебираются все свойства читаемой структуры и отбрасываются лишние.

Добавляем еще одно extension, чтобы все получилось по красоте.

// чтение имени и типа переменных extension VariableDeclSyntax {      func parseNameAndType() throws -> (name: TokenSyntax, type: TypeSyntax)? {     guard let variableBinding = bindings.first,           let variableType = variableBinding.typeAnnotation?.type.trimmed else {         let variableName = bindings.first?.pattern.trimmedDescription         throw MacroError.typeAnnotationRequiredFor(variableName: variableName ?? "unknown")     }      let variableName = TokenSyntax(stringLiteral: variableBinding.pattern.description)          return (name: variableName, type: variableType)     }  }

И в конечном итоге формируем массив функций, который будет добавлен в тело структуры.

// Формирование mutating func для каждой переменной private extension MutableMacro {       static func prepareEditorDeclarations(for variables: [VariableDeclSyntax]) throws -> [FunctionDeclSyntax] {     try variables.compactMap { variableDecl -> FunctionDeclSyntax? in          guard let variable = try variableDecl.parseNameAndType() else {             return nil         }          return FunctionDeclSyntax(leadingTrivia: .newlines(2),                                   modifiers: .init(itemsBuilder: {             DeclModifierSyntax(name: .keyword(.mutating))         }),                                   identifier: .identifier("set"),                                   signature: .init(input: .init(parameterListBuilder: {             FunctionParameterSyntax(firstName: variable.name,                                     type: variable.type)                      })),                                   body: .init(statementsBuilder: {             ExprSyntax(stringLiteral: "self.\(variable.name.text)=\(variable.name.text)")         })         )     }     }  }

Вот такой лаконичный макрос у нас получился.

// MutableMacro.swift import SwiftSyntax import SwiftSyntaxBuilder import SwiftSyntaxMacros  /// A macro which generates `mutating func` for all variables inside a struct. public struct MutableMacro: MemberMacro {       public static func expansion(of node: SwiftSyntax.AttributeSyntax, providingMembersOf declaration: some SwiftSyntax.DeclGroupSyntax, in context: some SwiftSyntaxMacros.MacroExpansionContext) throws -> [SwiftSyntax.DeclSyntax] {     guard let baseStruct = declaration.as(StructDeclSyntax.self) else {         throw MacroError.onlyApplicableToStruct     }      let variables = baseStruct.variables      let functions = try prepareEditorDeclarations(for: variables)      return functions.compactMap { $0.as(DeclSyntax.self) }     }  }

Отметим, что здесь реализован протокол Member Macro. Он соответствует подтипу attached-макроса, который мы выбрали в определении макроса. 

В определении одного макроса может быть несколько выбранных подтипов. В таком случае, в теле макроса надо будет реализовать несколько протоколов с уникальными expansion функциями, каждая из которых будет отличаться доступными узлами (node).

Краткий гайд по созданию макроса

  1. Создание проекта типа Swift Macros.

  2. Определение макроса в *Definition.swift.

  3. Объявление макроса в *Plugin.swift.

  4. Написание тестов.

  5. Реализация макроса.

  6. Дополнение тестов для нестандартных ситуаций.

  7. Отладка макроса в main.swift.

Заключение

Появление Swift Macros — это определенно шаг в светлое будущее, в котором любая прикладная задача выполняется на одном языке — Swift, будь то верстка, работа с хранилищами, логика сервера или генерация этого самого кода. 

Такой подход снижает порог входа в разработку и расширяет возможности разработчиков. Однако не стоит забывать про legacy-проекты, в которых использовались другие инструменты кодогенерации (liquid, sourcery и другие). Не все кейсы можно заменить с помощью Swift Macros.

Мы были рады рассказать вам, что такое Swift Macros. Если у вас есть примеры использования, или вы хотите обсудить прочитанное — милости просим в комментарии!

Больше полезного про iOS — в телеграм-канале Surf iOS Team. Публикуем кейсы, лучшие практики, новости и вакансии Surf. Присоединяйтесь>>


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


Комментарии

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

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