Уменьшаем 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
Типы макросов определяют, будет ли макрос прикреплен к какому-то месту в коде или его можно развернуть везде.
Подтипы или роли макросов определяют характер и разворачивания макроса в 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()
//...
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
}
}
}
Плюсы:
Минусы:
Какие бывают макросы
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-кода. Теперь препроцессор может читать наш код и конвертировать его в связанные объекты, на основе которых мы можем создавать новые объекты и внедрять их в код. Так каждый объект может быть описан соответствующей сущностью.
Например,
и так далее для функций, классов.
Синтаксис этого модуля интуитивно понятен.
Для решения нашей задачи напишем 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. Если у вас есть примеры использования, или вы хотите обсудить прочитанное — милости просим в комментарии!