Уменьшаем 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-кода. Теперь у разработчиков больше поводов использовать аннотации.

ccf3825532589a214067c2e146d51618.png

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

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

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

47fd3de657592e122d11a5f5082f94df.png

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

Подтипы или роли макросов определяют характер и разворачивания макроса в 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

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

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

d3a62bd73562d231f8b4b8938ff46d22.png

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).

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

  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. Если у вас есть примеры использования, или вы хотите обсудить прочитанное — милости просим в комментарии!

© Habrahabr.ru