Link Search Menu Expand Document

Макроси

⚠️🛠️ Увага! Цей підрозділ наразі перекладається, тому наразі пропонуємо вашій увазі неповторний оригінал 🛠️⚠️

Макроси генерують код під час компіляції.

Макроси трансформують ваш вихідний код при його компіляції, що дозволяє уникнути написання повторюваного коду вручну. Під час компіляції, Swift розгортає кожен макрос у вашому коді перед звичайною компіляцією вашого коду.

Діаграма, що зображає розгортання макросу. Зліва – стилізована репрезентація коду Swift. Справа – цей же код з кількома рядками, які додались за допомогою макросу.

Розгортання макросу є завжди адитивною операцією: макроси додають новий код, але вони ніколи не видаляють і не змінюють вже наявний код.

Як вхідні дані макросу, так і результат розгортання макросу перевіряються, щоб гарантувати синтаксичну правильність цього коду на Swift. Аналогічно перевіряється коректність типів значень, що ви передаєте до макросу, та значення у коді, згенерованому макросом. На додачу, якщо реалізація макросу під час розгортання макросу стикається з помилкою, компілятор вважає це помилкою компіляції. Ці гарантії дозволяють легше розмірковувати про код, що використовує макроси, а також дозволяють легше ідентифікувати проблеми на кшталт некоректного використання макросу, чи реалізації макросу, що містить баг.

У Swift є два види макросів:

  • Окремо стоячий макрос з’являється в коді самостійно, без прив’язки до оголошення.
  • Прив’язаний макрос змінює оголошення, до якого він прив’язаний.

Прив’язані та окремо стоячі макроси викликаються дещо по-різному, але обидва ці види макросів слідують одній і тій же моделі розгортання макросів, і їх реалізовують за допомогою одного і того ж підходу. Наступні розділи детально описують кожен вид макросів.

Окремо стоячі макроси

Щоб викликати окремо стоячий макрос, слід написати знак решітки (#) перед його назвою, і слід також вказати всі його аргументи, якщо вони є, у дужках після його назви. Наприклад:

func myFunction() {
    print("Наразі виконується \(#function)")
    #warning("Щось не так")
}

У першому рядку, запис #function викликає макрос function зі стандартної бібліотеки Swift. Під час компіляції цього коду, Swift викликає реалізацію цього макросу, котра замінює вираз #function назвою поточної функції. Якщо запустити цей код та викликати функцію myFunction(), вона надрукує “Наразі виконується myFunction()”. На другому рядку, вираз #warning викликає макрос warning(_:) зі стандартної бібліотеки Swift, що створює ваше власне попередження компілятора.

Окремо стоячі макроси можуть створювати значення, як робить макрос #function, або вони можуть виконувати дію під час компіляції, як робить макрос #warning.

Прив’язані макроси

Щоб викликати прив’язаний макрос, слід написати знак (@) перед його назвою, та вказати аргументи макроса, якщо вони є, у дужках після його назви.

Прив’язані макроси змінюють оголошення, до якого вони прив’язані. Вони додають код до цього оголошення, на кшталт додавання нового методу або додавання підпорядкування до протоколу.

Наприклад, розглянемо наступний код, що не використовує макросів:

struct SundaeToppings: OptionSet {
    let rawValue: Int
    static let nuts = SundaeToppings(rawValue: 1 << 0)
    static let cherry = SundaeToppings(rawValue: 1 << 1)
    static let fudge = SundaeToppings(rawValue: 1 << 2)
}

У цьому коді, оголошення кожної опції із множини опцій SundaeToppings містить виклик ініціалізатора, котрий є повторюваним кодом, написаним вручну. У такому коді легко зробити помилку при додаванні нової опції, набравши неправильне число наприкінці рядка.

Ось версія цього коду, що натомість використовує макрос:

@OptionSet<Int>
struct SundaeToppings {
    private enum Options: Int {
        case nuts
        case cherry
        case fudge
    }
}

Ця версія SundaeToppings викликає макрос @OptionSet зі стандартної бібліотеки Swift. Цей макрос вичитує список елементів приватного перечислення, генерує список констант для кожної опції, та додає підпорядкування до протоколу OptionSet.

Для порівняння, ось як виглядає цей код після розгортання макросу @OptionSet. Вам не доведеться писати такий код, і ви його побачите лише якщо ви спеціально попросите Swift показати розгортання макросу.

struct SundaeToppings {
    private enum Options: Int {
        case nuts
        case cherry
        case fudge
    }

    typealias RawValue = Int
    var rawValue: RawValue
    init() { self.rawValue = 0 }
    init(rawValue: RawValue) { self.rawValue = rawValue }
    static let nuts: Self = Self(rawValue: 1 << Options.nuts.rawValue)
    static let cherry: Self = Self(rawValue: 1 << Options.cherry.rawValue)
    static let fudge: Self = Self(rawValue: 1 << Options.fudge.rawValue)
}
extension SundaeToppings: OptionSet { }

Весь код після приватного перечислення з’являється з макросу @OptionSet. Версія SundaeToppings, що використовує макрос для генерації всіх статичних змінних, є простішою для читання та підтримки, аніж попередня версія, оголошена повністю вручну.

Оголошення макросів

У більшості коду Swift, якщо вам треба оголосити символ, такий як функцію чи тип, вам не потрібне окреме його оголошення. Однак, для макросів, оголошення та реалізація є розділеними. Оголошення макросу містить його назву, параметри, що він приймає, місце, де його можна застосувати, та якого виду код він генерує. Реалізація макросу містить код, що розгортає макрос, генеруючи код мовою Swift.

Оголошення макросу вводиться за допомогою ключового слова macro. Наприклад, ось частина оголошення макросу @OptionSet, що використовувався у попередньому прикладі:

public macro OptionSet<RawType>() =
        #externalMacro(module: "SwiftMacros", type: "OptionSetMacro")

Перший рядок визначає назву макросу та його аргументи: назвою є OptionSet, і він не приймає жодних аргументів. Другий рядок використовує макрос externalMacro(module:type:) зі стандартної бібліотеки Swift, і за допомогою його вказує Swift, де буде знаходитись реалізація цього макросу. У цьому випадку, модуль SwiftMacros містить тип на ім’я OptionSetMacro, що реалізовує макрос @OptionSet.

Оскільки макрос OptionSet є прив’язаним, його назва використовує ВерхнійВерблюдячийРегістр, так само як назви структур та класів. Окремо стоячі макроси мають нижнійВерблюдячийРегістр, як назви змінних та функцій.

Примітка:

Макроси завжди оголошуються публічними (public). Оскільки код, що оголошує макрос, та код, що його використовує, знаходяться у різних модулях, непублічні макроси неможливо застосувати будь-де.

Оголошення макросу визначає його роль – місця у вихідному коді, в яких можна викликати цей макрос, і різновиди коду, що може згенерувати цей макрос. Кожен макрос має одну або декілька ролей, які записуються як частина атрибутів на початку оголошення макросу. Ось ще трошки оголошення макросу @OptionSet, включно з атрибутами, що визначають його ролі:

@attached(member)
@attached(conformance)
public macro OptionSet<RawType>() =
        #externalMacro(module: "SwiftMacros", type: "OptionSetMacro")

Атрибут @attached використовується у цьому оголошенні двічі, по одному разу на кожну роль макросу. У першому використанні, @attached(member) вказує, що макрос додає нові члени до типу, на якому його застосовують. Макрос @OptionSet додає ініціалізатор init(rawValue:), наявність якого вимагається протоколом OptionSet, так само як і додаткові члени. Друге використання – @attached(conformance) – вказує, що @OptionSet додає одне або декілька підпорядкувань протоколу. Макрос @OptionSet розширює тип, до якого він застосовується, додаючи до нього підпорядкування протоколу OptionSet.

Для окремо стоячого макросу, слід писати атрибут @freestanding, щоб вказати його роль:

@freestanding(expression)
public macro line<T: ExpressibleByIntegerLiteral>() -> T =
        /* ... місцезнаходження реалізації макроса... */

Макрос #line вище має роль expression, тобто він є виразом. Макроси-вирази продукують значення, або виконують дії часу компіляції, на кшталт генерації попередження.

На додачу до ролі макросу, оголошення макросу надає інформацію про назви символів, котрі генерує макрос. Коли оголошення макросу має список імен, гарантується, що він створить лише оголошення, що використовують ці імена. Це допомагає зрозуміти та зневадити згенерований код. Ось повне оголошення макросу @OptionSet:

@attached(member, names: named(RawValue), named(rawValue), 
        named(`init`), arbitrary)
@attached(conformance)
public macro OptionSet<RawType>() =
        #externalMacro(module: "SwiftMacros", type: "OptionSetMacro")

У оголошенні вище, атрибут @attached(member) включає аргументи після мітки named: для кожного символу, що генерує макрос @OptionSet. Макрос додає оголошення для символів з назвами RawValue, rawValue, та init: оскільки ці імена є відомими наперед, оголошення макросу перечислює їх явно.

Оголошення макросу також включає arbitrary після списку назв, що дозволяє макросу генерувати оголошення, чиї назви є невідомими до використання макросу. Наприклад, коли макрос @OptionSet застосовується до структури SundaeToppings вище, він генерує властивості типу, що відповідають елементам перечислення nuts, cherry, та fudge.

Детальніше зі списком ролей макросу можна ознайомитись у секціях attached та freestanding розділу Атрибути.

Розгортання макросів

Коли Swift збирає код, що використовує макроси, компілятор викликає реалізацію макросу для його розгортання.

Діаграма ілюсрує чотири кроки розгортання макросів. Входом є вихідний код Swift. Він стає деревом, що представляє структуру коду. Реалізація макросу додає гілки до дерева. Результатом є додатковий вихідний код Swift.

Зокрема, Swift розгортає макрос наступним способом:

  1. Компілятор читає код, створюючи репрезентацію синтаксису у пам’яті.
  2. Компілятор надсилає частину репрезентації синтаксису до реалізації макросу, котра розгортає макрос.
  3. Компілятор заміняє виклик макросу його розгорнутою формою.
  4. Компілятор продовжує збірку, використовуючи розгорнутий вихідний код.

Щоб пройтись конкретними кроками, розглянемо наступне:

let magicNumber = #fourCharacterCode("ABCD")

Макрос #fourCharacterCode приймає рядок довжиною у чотири символи та повертає беззнакове 32-бітне ціле, що відповідає значенням ASCII у рядку, поєднаних разом. Деякі файлові формати використовують цілі числа на кшталт цього для ідентифікації даних, оскільки вони є компактнішими, але їх все ще можна прочитати у дебагері. Розділ Реалізація макросів нижче пояснює, як реалізувати цей макрос.

Щоб розгорнути макрос у коді вище, компілятор читає файл Swift та створює репрезентацію цього коду у пам’яті, відому як абстрактне синтаксичне дерево, або АСД. АСД робить структуру коду явною, що дозволяє простіше писати код, що взаємодіє з цією структурою – наприклад, код компілятора або реалізації макросу. Ось репрезентація АСД для коду вище, трошки спрощена за кошт опускання зайвих деталей.

Діаграма, що зображує дерево, із кореневим елементом constant. Константа має назву magic number, та значення. Значенням константи є виклик макросу. Виклик макросу має назву, fourCharacterCode, та аргументи. Єдиним аргументом є рядковий літерал, ABCD.

Діаграма вище ілюструє, як структура цього коду представляється у пам’яті. Кожен елемент АСД відповідає частині вихідного коду. Елемент АСД “Constant declaration” містить два дочірні елементи, котрі репрезентують дві частини оголошення константи: її назву та її значення. Елемент “Macro call” має два дочірні елементи, що репрезентують назву макросу та список аргументів, що передаються до макросу.

У ході побудови АСД, компілятор перевіряє, що вихідний код є коректним кодом на Swift. Наприклад, #fourCharacterCode приймає єдиний аргумент, що має бути рядком. При спробі передати цілочисельний аргумент, або якщо забути поставити лапки (") наприкінці рядкового літерала, ми отримаємо помилку на цьому етапі процесу компіляції.

Компілятор знаходить місця у коді, де викликається макрос, і завантажує зовнішній модуль, у якому реалізовується цей макрос. Для кожного виклику макросу, компілятор передає частину АСД до реалізації цього макросу. Ось репрезентація цієї частини АСД:

Діаграма, що зображує дерево, із викликом макросу як кореневий елемент. Виклик макросу має назву, fourCharacterCode, та аргументи. Аргументом є рядковий літерал, ABCD

Реалізація макросу #fourCharacterCode вичитує цю частину АСД як свій вхід при розгортанні макросу. Реалізація макросу опрацьовує лише частину АСД, котру отримує на вході, що означає, що макрос завжди розгортається в один і той же спосіб незалежно від того, який код йде перед ним або після нього. Це обмеження допомагає зробити розгортання макросу легшим для розуміння, і допомагає вашому коду збиратися швидше, оскільки Swift може уникнути розгортання макросу, котрий не змінився.

Swift допомагає авторам макросу уникнути випадкового вичитування інших вхідних даних, обмежуючи код, що реалізовує макрос.

  • АСД, що передається до реалізації макросу, містить лише ті елементи АСД, що представляють лише сам макрос, але не стосуються жодного коду перед ним або після нього.
  • Реалізація макросу виконується у середовищі пісочниці, що запобігає доступу до файлової системи та мережі.

На додачу до цих механізмів безпеки, автор макросу є відповідальним за те, щоб не вичитувати або модифікувати будь-що за межами входу макросу. Наприклад, розгортання макросу не повинно залежати від поточного часу доби.

Реалізація макросу #fourCharacterCode генерує нове АСД, котре містить розгорнутий код. Ось що цей код повертає до компілятора:

Діаграма, що зображає дерево з єдиним елементом, що є цілочисельним літералом 1145258561.

Коли компілятор отримує це розгортання, він заміняє елемент АСД, що містить виклик макросу, елементом, що містить розгортання макросу. Після розгортання макросу, компілятор знову перевіряє код розширення, щоб упевнитись, що програма все ще є синтаксично коректним кодом на Swift, і що всі типи є коректними. Це продукує фінальне АСД, що тепер може бути скомпільовано, як зазвичай:

Діаграма, що зображає дерево з константою у корені. Константа має назву magic number, та значення. Значення константи є цілочисельним літералом зі значенням 1145258561

Це АСД відповідає коду на кшталт цього:

let magicNumber = 1145258561

У цьому прикладі, вихідний код на початку містить лише один макрос, але у реальній програмі може бути декілька екземплярів одного макросу, і декілька викликів до різних макросів. Компілятор розгортає макроси по одному за раз.

Якщо один макрос з’являється всередині іншого, зовнішній макрос розгорнеться першим – це дозволяє зовнішньому макросу змінити внутрішній макрос до того, як він розгорнеться.

Реалізація макросів

Щоб реалізувати макрос, слід створити два компоненти: тип, що виконує розгортання макросу, та бібліотеку, що оголошує макрос та надає його як свій API. Ці частини збираються окремо від коду, що використовує макрос, навіть якщо ви працюєте над макросом та його клієнтами разом, тому що реалізація макросу виконується як частина збірки клієнтів макросу.

Щоб створити новий макрос за допомогою Swift Package Manager, слід запустити команду swift package init --type macro: це створить декілька файлів, включно з шаблоном для реалізації та оголошення макросу.

Щоб додати макрос до наявного проєкту, слід додати таргет з реалізацією макросу, та таргет для бібліотеки макросу. Наприклад, можна додати щось схоже на цей приклад коду до вашого файлу Package.swift, змінюючи назви, щоб вони відповідали вашому проєкту:

targets: [
    // Реалізація макросу, що виконує трансформації вихідного коду.
    .macro(
        name: "MyProjectMacros",
        dependencies: [
            .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
            .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
        ]
    ),

    // Бібліотека, що надає макрос як частину свого публічного API.
    .target(name: "MyProject", dependencies: ["MyProjectMacros"]),
]

Код вище визначає два таргети: MyProjectMacros, котрий містить реалізацію макросу, та MyProject, що робить цей макрос доступним.

Реалізація макросу використовує модуль SwiftSyntax для взаємодії з кодом Swift у структурований спосіб, використовуючи АСД. Якщо ви створили новий пакет з макросом за допомогою Swift Package Manager, згенерований файл Package.swift автоматично включає залежність на SwiftSyntax. Якщо ви додаєте макрос до наявного проєкту, слід додати залежність на SwiftSyntax до вашого файлу Package.swift:

dependencies: [
    .package(url: "https://github.com/apple/swift-syntax.git", from: "some-tag"),
],

Замініть some-tag у коді вище на тег Git для версії SwiftSyntax, з якою ви хочете працювати.

В залежності від ролі вашого макросу, слід обрати відповідний протокол з модуля SwiftSystem, до котрого має бути підпорядкована реалізація макросу. Наприклад, розглянемо макрос #fourCharacterCode із попереднього розділу. Ось структура, що реалізовує цей макрос:

public struct FourCharacterCode: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression,
              let segments = argument.as(StringLiteralExprSyntax.self)?.segments,
              segments.count == 1,
              case .stringSegment(let literalSegment)? = segments.first
        else {
            throw CustomError.message("Необхідний статичний рядок")
        }

        let string = literalSegment.content.text
        guard let result = fourCharacterCode(for: string) else {
            throw CustomError.message("Некоректний чотирьохсимвольний код")
        }

        return "\(raw: result)"
    }
}

private func fourCharacterCode(for characters: String) -> UInt32? {
    guard characters.count == 4 else { return nil }

    var result: UInt32 = 0
    for character in characters {
        result = result << 8
        guard let asciiValue = character.asciiValue else { return nil }
        result += UInt32(asciiValue)
    }
    return result.bigEndian
}
enum CustomError: Error { case message(String) }

Макрос #fourCharacterCode є окремо стоячим макросом, що продукує вираз (expression), тому тип FourCharacterCode, що його реалізує, підпорядковується до протоколу ExpressionMacro. Протокол ExpressionMacro містить єдину вимогу, а саме метод expansion(of:in:), що розгортає АСД. Зі списком ролей макросів та їх відповідних протоколів у SwiftSystem, можна ознайомитись у секціях attached та freestanding розділу Атрибути.

Щоб розгорнути макрос #fourCharacterCode, Swift надсилає АСД для коду, що використовує цей макрос, що бібліотеки, що містить реалізацію макросу. Всередині бібліотеки, Swift викликає метод FourCharacterCode.expansion(of:in:), і передає до нього АСД та контекст як аргумент. Реалізація методу expansion(of:in:) спершу шукає рядок, який було передано як аргумент до макросу #fourCharacterCode, та вираховує відповідний цілочисельний літерал.

У прикладі вище, перший блок guard витягує рядковий літерал з АСД, присвоюючи цей елемент АСД константі literalSegment. Другий блок guard викликає приватну функцію FourCharacterCode(for:). Обидві ці блоки викидують помилку, якщо макрос було використано неправильно: повідомлення про помилку стає помилкою компіляції, що відобразиться у місці вжитку помилкового коду. Наприклад, якщо ви спробуєте викликати макрос як #fourCharacterCode("AB" + "CD"), компілятор покаже помилку “Необхідний статичний рядок”.

Метод expansion(of:in:) повертає екземпляр ExprSyntax, типу з модуля SwiftSyntax, що репрезентує вираз у АСД. Оскільки цей тип підпорядковано до протоколу StringLiteralConvertible, реалізація макросу використовує рядковий літерал як спрощений синтаксис для створення свого результату. Усі типи в модулі SwiftSyntax, що можна повертати у реалізації макросів, є підпорядкованими до протоколу StringLiteralConvertible, тому ви можете використовувати цей підхід при реалізації будь-якого макросу.

Розробка та зневадження макросів

Макроси дуже добре підходять для розробки через тести: вони трансформують АСД в інше АСД, не залежачи від жодного зовнішнього стану, і без внесення змін до жодного зовнішнього стану. Крім того, ви можете створювати елементи АСД прямо з рядкових літералів, що спрощує налаштування вхідних даних для тесту. Ви також можете читати властивість description елементів АСД, щоб порівнювати рядок з очікуваним значенням. Наприклад, ось тести для макросу #fourCharacterCode з попередніх розділів:

let source: SourceFileSyntax =
    """
    let abcd = #fourCharacterCode("ABCD")
    """

let file = BasicMacroExpansionContext.KnownSourceFile(
    moduleName: "MyModule",
    fullFilePath: "test.swift"
)

let context = BasicMacroExpansionContext(sourceFiles: [source: file])

let transformedSF = source.expand(
    macros:["fourCharacterCode": FourCC.self],
    in: context
)

let expectedDescription =
    """
    let abcd = 1145258561
    """

precondition(transformedSF.description == expectedDescription)

У прикладі вище макрос тестується за допомогою передумови precondition, але ви можете використовувати натомість будь-який фреймворк для тестування.