Сегодня с вами Никита Коробейников, 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

Типы макросов определяют, будет ли макрос прикреплен к какому-то месту в коде или его можно развернуть везде.
Подтипы или роли макросов определяют характер и разворачивания макроса в 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).
Краткий гайд по созданию макроса
Создание проекта типа Swift Macros.
Определение макроса в *Definition.swift.
Объявление макроса в *Plugin.swift.
Написание тестов.
Реализация макроса.
Дополнение тестов для нестандартных ситуаций.
Отладка макроса в main.swift.
Заключение
Появление Swift Macros — это определенно шаг в светлое будущее, в котором любая прикладная задача выполняется на одном языке — Swift, будь то верстка, работа с хранилищами, логика сервера или генерация этого самого кода.
Такой подход снижает порог входа в разработку и расширяет возможности разработчиков. Однако не стоит забывать про legacy-проекты, в которых использовались другие инструменты кодогенерации (liquid, sourcery и другие). Не все кейсы можно заменить с помощью Swift Macros.
Мы были рады рассказать вам, что такое Swift Macros. Если у вас есть примеры использования, или вы хотите обсудить прочитанное — милости просим в комментарии!
Больше полезного про iOS — в телеграм-канале Surf iOS Team. Публикуем кейсы, лучшие практики, новости и вакансии Surf. Присоединяйтесь>>
