Привет, Хабр! Меня зовут Сергей, я 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)
}
Запускаем наш тест — готово!
Итог
В статье я показал, как можно создать простой макрос, а также рассказал немного теории. Надеюсь, вы нашли что-то полезное для себя.
Расскажите в комментариях, приходилось ли вам писать макросы, и если да, то какие?