Макросы

Используйте макросы для генерации кода во время компиляции.

07 июля 2023

Макросы преобразуют ваш исходный код при его компиляции, позволяя вам избежать написания повторяющегося кода вручную. Во время компиляции Swift расширяет все макросы в вашем коде, прежде чем создавать ваш код как обычно.

A diagram showing an overview of macro expansion.  On the left, a stylized representation of Swift code.  On the right, the same code with several lines added by the macro.

Расширение макроса всегда является аддитивной операцией: макросы добавляют новый код, но они никогда не удаляют/не изменяют существующий код.

Как входные данные макроса, так и выходные данные макроса проверяются, чтобы убедиться, что они являются синтаксически корректным кодом Swift. Аналогично, значения, которые вы передаете макросу, и значения в коде, сгенерированном макросом, проверяются, чтобы убедиться, что они имеют правильные типы. Кроме того, если реализация макроса обнаруживает ошибку при расширении этого макроса, компилятор обрабатывает это как ошибку компиляции. Эти гарантии облегчают анализ кода, использующего макросы, и облегчают выявление таких проблем, как неправильное использование макроса или реализация макроса с ошибкой.

В Swift есть два вида макросов:

  • Автономные макросы отображаются сами по себе, без привязки к объявлению.
  • Прикрепленные макросы изменяют объявление, к которому они прикреплены.

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

Автономные макросы

Чтобы вызвать автономный макрос, вы пишете знак ‘#’ перед его именем и записываете любые аргументы макроса в круглых скобках после его имени. Например:

func myFunction() {
    print("Currently running \(#function)")
    #warning("Something's wrong")
}

 

В первой строке #function вызывает макрос функции из стандартной библиотеки Swift. Когда вы компилируете этот код, Swift вызывает реализацию этого макроса, которая заменяет #function именем текущей функции. Когда вы запускаете этот код и вызываете myFunction(), он выводит “Currrently running myFunction()”. Во второй строке #warning вызывает макрос warning(_:) из стандартной библиотеки Swift для создания пользовательского предупреждения во время компиляции.

Автономные макросы могут выдавать значение, как это делает #function, или могут выполнять действие во время компиляции, как это делает #warning.

 

Прикрепленные макросы

Чтобы вызвать подключенный макрос, вы пишете знак ‘@’ перед его именем и записываете любые аргументы макроса в круглых скобках после его имени.

Прикрепленные макросы изменяют объявление, к которому они прикреплены. Они добавляют код к этому объявлению, например, определяют новый метод или добавляют соответствие протоколу.

Например, рассмотрим следующий код, который не использует макросы:

struct SundaeToppings: Option Set {
    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
    }
    type alias 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 { }

 

Весь код после частного перечисления берется из макроса @Options. Версию SundaeToppings, которая использует макрос для генерации всех статических переменных, легче читать и поддерживать, чем предыдущую версию, закодированную вручную.

 

Объявление макроса

В большинстве Swift-кодов, когда вы реализуете символ, например функцию или тип, отдельного объявления нет. Однако для макросов декларация и реализация являются отдельными. Объявление макроса содержит его имя, параметры, которые он принимает, где его можно использовать и какой код он генерирует. Реализация макроса содержит код, который расширяет макрос путем генерации Swift-кода.

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

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


В первой строке указывается имя макроса и его аргументы — имя OptionSet, и оно не принимает никаких аргументов. Вторая строка использует макрос externalMacro(module:type:) из стандартной библиотеки Swift, чтобы сообщить Swift, где находится реализация макроса. В этом случае модуль Swift Macros содержит тип с именем OptionSetMacro, который реализует макрос @OptionSet.

Поскольку OptionSet является вложенным макросом, в его названии используется верхний регистр camel, как и в названиях структур и классов. Автономные макросы имеют имена в нижнем регистре, такие как имена переменных и функций.

Примечание

Макросы всегда объявляются как общедоступные. Поскольку код, который объявляет макрос, находится в модуле, отличном от кода, который использует этот макрос, нет места, где вы могли бы применить непубличный макрос.

Объявление макроса определяет роли макроса — места в исходном коде, где этот макрос может быть вызван, и виды кода, которые макрос может генерировать. У каждого макроса есть одна или несколько ролей, которые вы записываете как часть атрибутов в начале объявления макроса. Вот еще немного описания для @OptionSet, включая атрибуты для его ролей:

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


Атрибут @attached появляется в этом объявлении дважды, по одному разу для каждой роли макроса. Первое использование, @attached(member), указывает на то, что макрос добавляет новые элементы к типу, к которому вы его применяете. Макрос @OptionSet добавляет инициализатор init(rawValue:), который требуется протоколом OptionSet, а также некоторые дополнительные элементы. Второе использование, @attached(соответствие), сообщает вам, что @OptionSet добавляет одно или несколько соответствий протоколу. Макрос @OptionSet расширяет тип, к которому вы применяете макрос, чтобы добавить соответствие протоколу набора параметров.

Для автономного макроса вы пишете атрибут @freestanding, чтобы указать его роль:

@freestanding(expression)
public macro line<T: ExpressibleByIntegerLiteral>() -> T =
        /* ... location of the macro implementation... */


Приведенный выше макрос #line выполняет роль выражения. Макрос выражения выдает значение или выполняет действие во время компиляции, например генерирует предупреждение.

В дополнение к роли макроса, объявление макроса предоставляет информацию об именах символов, которые генерирует макрос. Когда объявление макроса предоставляет список имен, гарантируется, что будут созданы только объявления, использующие эти имена, что поможет вам понять и отладить сгенерированный код. Вот полное объявление @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 — поскольку эти имена известны заранее, в объявлении макроса они перечислены явно.

Объявление макроса также включает произвольные имена после списка имен, что позволяет макросу генерировать объявления, имена которых неизвестны до тех пор, пока вы не используете макрос. Например, когда макрос @OptionSet применяется к приведенным выше SundaeToppings, он генерирует свойства типа, соответствующие вариантам перечисления, nuts, cherry и fudge.

Дополнительные сведения, включая полный список ролей макросов, см. в разделе прикрепленные и автономные в разделе Атрибуты.

 

Расширение макросов

При сборке Swift-кода, использующего макросы, компилятор вызывает реализацию макросов, чтобы расширить их.

Diagram showing the four steps of expanding macros.  The input is Swift source code.  This becomes a tree, representing the code’s structure.  The macro implementation adds branches to the tree.  The result is Swift source with additional code.

В частности, Swift расширяет макросы следующим образом:

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

Чтобы выполнить конкретные шаги, рассмотрите следующее:

let magicNumber = #fourCharacterCode("ABCD")

 

Макрос #fourCharacterCode принимает строку длиной в четыре символа и возвращает 32-разрядное целое число без знака, соответствующее значениям ASCII в строке, объединенным вместе. Некоторые форматы файлов используют целые числа, подобные этому, для идентификации данных, потому что они компактны, но все еще читаемы в отладчике. В разделе "Реализация макроса" ниже показано, как реализовать этот макрос.

Чтобы расширить макросы в приведенном выше коде, компилятор считывает файл Swift и создает в памяти представление этого кода, известное как абстрактное синтаксическое дерево, или AST. AST делает структуру кода явной, что упрощает написание кода, который взаимодействует с этой структурой — например, компилятор или реализация макроса. Вот представление AST для приведенного выше кода, слегка упрощенное за счет опускания некоторых дополнительных деталей:

A tree diagram, with a constant as the root element.  The constant has a name, magic number, and a value.  The constant’s value is a macro call.  The macro call has a name, fourCharacterCode, and arguments.  The argument is a string literal, ABCD.

На приведенной выше диаграмме показано, как структура этого кода представлена в памяти. Каждый элемент в AST соответствует части исходного кода. Элемент AST “Объявление константы” имеет под собой два дочерних элемента, которые представляют две части объявления константы: ее имя и ее значение. Элемент “Вызов макроса” имеет дочерние элементы, которые представляют имя макроса и список аргументов, передаваемых макросу.

Как часть построения этого AST, компилятор проверяет, что исходный код является допустимым Swift. Например, #fourCharacterCode принимает единственный аргумент, который должен быть строкой. Если вы попытаетесь передать целочисленный аргумент или забудете поставить кавычку (") в конце строкового литерала, вы получите сообщение об ошибке на этом этапе процесса.

Компилятор находит места в коде, где вы вызываете макрос, и загружает внешний двоичный файл, который реализует эти макросы. Для каждого вызова макроса компилятор передает часть AST реализации этого макроса. Вот представление этого частичного AST:

A tree diagram, with a macro call as the root element.  The macro call has a name, fourCharacterCode, and arguments.  The argument is a string literal, ABCD.

Реализация макроса #fourCharacterCode считывает этот частичный AST в качестве входных данных при расширении макроса. Реализация макроса оперирует только с частичным AST, который он получает в качестве входных данных, что означает, что макрос всегда расширяется одинаково, независимо от того, какой код появляется до и после него. Это ограничение облегчает понимание расширения макроса и ускоряет сборку вашего кода, поскольку Swift может избежать расширения макросов, которые не изменились.

Swift помогает авторам макросов избежать случайного чтения других входных данных, ограничивая код, реализующий макросы:

  • AST, передаваемый в реализацию макроса, содержит только последние элементы, представляющие макрос, а не какой-либо код, который появляется до или после него.
  • Реализация макроса выполняется в изолированной среде, которая не позволяет ему получить доступ к файловой системе или сети.

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

Реализация #fourCharacterCode генерирует новый AST, содержащий расширенный код. Вот что этот код возвращает компилятору:

A tree diagram with a sigle node, the integer literal 1145258561.

Когда компилятор получает это расширение, он заменяет элемент AST, содержащий вызов макроса, элементом, содержащим расширение макроса. После расширения макроса компилятор снова проверяет, чтобы убедиться, что программа по-прежнему синтаксически корректна Swift и все типы указаны правильно. Это приводит к заключительному AST, который может быть составлен как обычно:

A tree diagram, with a constant as the root element.  The constant has a name, magic number, and a value.  The constant’s value is the integer literal 1145258561

Этот AST соответствует Swift-коду, подобному этому:

let magicNumber = 1145258561

 

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

Если один макрос появляется внутри другого, сначала раскрывается внешний макрос — это позволяет внешнему макросу изменять внутренний макрос перед его расширением.

 

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

Чтобы реализовать макрос, вы создаете два компонента: тип, который выполняет расширение макроса, и библиотеку, которая объявляет макрос, чтобы предоставить его как API. Эти части создаются отдельно от кода, использующего макрос, даже если вы разрабатываете макрос и его клиенты вместе, поскольку реализация макроса выполняется как часть создания клиентов макроса.

Чтобы создать новый макрос с помощью Swift Package Manager, запустите swift package init --type macro — это создаст несколько файлов, включая шаблон для реализации и объявления макроса.

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

targets: [
    // Macro implementation that performs the source transformations.
    .macro(
        name: "MyProjectMacros",
        dependencies: [
            ..product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
            .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
        ]
    ),


    // Library that exposes a macro as part of its API.
    .target(name: "MyProject", dependencies: ["MyProjectMacros"]),
]


Приведенный выше код определяет две цели: MyProjectMacros содержит реализацию макросов, а MyProject делает эти макросы доступными.

Реализация макроса использует модуль SwiftSyntax для структурированного взаимодействия с кодом Swift с использованием AST. Если вы создали новый пакет макросов с помощью 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 для версии синтаксиса Swift, которую вы хотите использовать.

В зависимости от роли вашего макроса, существует соответствующий протокол от 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("Need a static string")
        }
        let string = literalSegment.content.text
        guard let result = fourCharacterCode(for: string) else {
            throw CustomError.message("Invalid four-character code")
        }
        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 - это автономный макрос, который создает выражение, поэтому тип четырехсимвольного кода, который его реализует, соответствует протоколу ExpressionMacro. Протокол ExpressionMacro имеет одно требование - метод расширения (of:in:), который расширяет AST. Список ролей макросов и соответствующих им протоколов SwiftSystem приведен в разделе прикрепленные и автономные атрибуты в разделе Атрибуты

Чтобы расширить макрос #fourCharacterCode, Swift отправляет AST для кода, использующего этот макрос, в библиотеку, содержащую реализацию макроса. Внутри библиотеки Swift вызывает FourCharacterCode.expansion(of:in:), передавая AST и контекст в качестве аргументов методу. Реализация expansion(of:in:) находит строку, которая была передана в качестве аргумента в #fourCharacterCode, и вычисляет соответствующее целочисленное литеральное значение.

В приведенном выше примере первый защитный блок извлекает строковый литерал из AST, присваивая этому элементу AST значение literalSegment. Второй защитный блок вызывает частную функцию FourCharacterCode(for:). Оба этих блока выдают ошибку, если макрос используется неправильно — сообщение об ошибке становится ошибкой компилятора на неправильно сформированном сайте вызова. Например, если вы попытаетесь вызвать макрос как #fourCharacterCode("AB" + "CD"), компилятор выдаст ошибку “Требуется статическая строка”.

Метод expansion(of:in:) возвращает экземпляр ExprSyntax, типа из SwiftSyntax, который представляет выражение в AST. Поскольку этот тип соответствует протоколу StringLiteralConvertible, реализация макроса использует строковый литерал в качестве упрощенного синтаксиса для создания своего результата. Все типы SwiftSyntax, которые вы возвращаете из реализации макроса, соответствуют StringLiteralConvertible, поэтому вы можете использовать этот подход при реализации любого вида макроса.

 

Разработка и отладка макроса

Макросы хорошо подходят для разработки с использованием тестов: они преобразуют один ресурс в другой AST без зависимости от какого-либо внешнего состояния и без внесения изменений в какое-либо внешнее состояние. Кроме того, вы можете создавать синтаксические узлы из строкового литерала, что упрощает настройку входных данных для теста. Вы также можете прочитать свойство description AST, чтобы получить строку для сравнения с ожидаемым значением. Например, вот тест макроса #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)


В приведенном выше примере макрос тестируется с использованием предварительного условия, но вместо этого вы могли бы использовать платформу тестирования.

Оригинал статьи
 

Содержание