4 июля 2023

ВРЕМЯ ПРОЧТЕНИЯ — 5 МИН

Макросы в Swift

Всем привет! Меня зовут Никита Тархов, я iOS‑разработчик CleverPumpkin. Сегодня поговорим про макросы: что это такое, зачем нужны и где они применяются.
How to upload an app to the App Store and not get rejected
Введение
Макрос — один из инструментов, позволяющий сократить время на совершение рутинного и повторяющегося набора действий. Макросы встречаются как в специализированном ПО (например, в Photoshop и Excel), так и в языках программирования. В обоих случаях макросы преследуют одну цель: облегчить жизнь пользователю или программисту.

С макросами в программировании можно встретиться, например, в языке C. Макрос объявляется директивой #define. На этапе обработки препроцессора все использованные макросы заменяются на его выражение.

Например, создадим макрос с константой:

#define MAX_NUMBER_OF_APPLES	(3)
#define TRUE					(1)
#define FALSE					(0)
#define SUCCESSFUL_EXIT_CODE	(0)
#define UNSUCCESSFUL_EXIT_CODE	(1)
...
int check_apples_count(int count) {
	if (count < MAX_NUMBER_OF_APPLES) {
		return TRUE;
	}
		
	printf("Maximum apples count is: %d", MAX_NUMBER_OF_APPLES);
	return FALSE;
}
...
if check_apples_count(2) == TRUE {
	exit(SUCCESSFUL_EXIT_CODE);
} else {
	exit(UNSUCCESSFUL_EXIT_CODE);
}
После обработки препроцессором макросы развернутся, и компилятору наш код
передастся в таком виде:

int check_apples_count(int count) {
	if (count < (3)) {
		return (1);
	}
		
	printf("Maximum apples count is: %d", (3));
	return (0);
}
...
if check_apples_count(2) == (1) {
	exit((0));
} else {
	exit((1));
}
Как можно заметить, код с макросами выглядит более читаемым. А если нам потребуется изменить максимальное число яблок, то это делается в одном месте. Но макросы в C имеют и обратную сторону медали, поэтому с ними нужно работать аккуратно.

Давайте напишем макрос, который возводит число в квадрат:

#define square(x) 		x * x
...
int value = square(2 + 2);
С первого взгляда на square(2 + 2) ожидается, что результат операции должен быть равен 16. Но это не так. Наш код расширяется иначе:

#define square(x) 		x * x
...
int value = 2 + 2 * 2 + 2;
Результат налицо: value будет 8. Чтобы исправить ситуацию, нужно расставить скобки в выражении макроса:

#define square(x) 		((x) * (x))
...
int value = square(2 + 2); // Расширится в ((2 + 2) * (2 + 2))
Но даже с этими недостатками макросы остаются одним из самых полезных
инструментов программиста, так как убирают тонны повторяющегося кода.
В языке Swift изначально не было макросов, но все изменилось с версии языка 5.9, в котором и появилась возможность их добавлять.
Макросы в Swift 5.9
Макросы в Swift это не те макросы, которые мы знаем из C. Макросы Swift – полноценные расширения для компилятора, которые позволяют «разгуляться» намного больше, т.к. могут работать непосредственно с синтаксисом Swift.

Как было сказано ранее, макрос — это полноценная программа-расширение
компилятора, написанная на языке Swift. Структура макроса состоит из
интерфейсной части и исполнительной части.

Пример интерфейсной части:


@attached(member, names: named(init))
public macro AutoInit() = #externalMacro(module: "PumpkinMacrosStorage", type: "AutoInitMacro")

Тут PumpkinMacrosStorage – название модуля, в котором располагается
исполнительная часть макроса, а AutoInitMacro – название типа,
ответственного за макрос AutoInit.

Пример исполнительной части:


public struct AutoInitMacro: MemberMacro {

	public static func expansion(
		of node: AttributeSyntax,
		providingMembersOf declaration: some DeclGroupSyntax,
		in context: some MacroExpansionContext
	) throws -> [DeclSyntax] {
		// Тут генерируется код для развертывания
		...
	}
}
...
@main
struct PumpkinMacrosPlugin: CompilerPlugin {

	let  providingMacros: [Macro.Type] = [
		AutoInitMacro.self
	]
}

В примере интерфейса можно заметить такую вещь, как @attached. Этот атрибут говорит о типе макроса.
На данный момент в языке существует 2 типа макросов:

  1. freestanding – это макросы, которые можно расположить произвольно по коду. Применение такого макроса возможно через символ решетки: ( #macro).
  2. attached – это макросы, которые присоединяются к какому либо объявлению. Применение такого макроса возможно через символ «собаки»: ( @Macro).

Начнем с freestanding макросов. Они имеют 2 роли:

  1. freestanding(expression) – макрос разворачивается в выражение.
  2. freestanding(declaration) – макрос разворачивается в объявление, например, объявляет структуру.
freestanding(expression)
Рассмотрим freestanding(expression) на примере валидирования константных URL во время компиляции.

Наверное, многие сталкивались с подобным хранением константных URL:


enum Constants {
	static let validUrl = URL(string: "https://cleverpumpkin.ru")!
	static let invalidUrl = URL(string: "https://url with mistake")!
}

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

Напишем макрос, который будет защищать нас от этого в compile time:


@freestanding(expression)
public macro URL(_ value: String) -> URL = #externalMacro(module: "PumpkinMacrosStorage", type: "URLMacro")

...

public struct URLMacro: ExpressionMacro {
	
	public static func expansion(
		of node: some FreestandingMacroExpansionSyntax,
		in context: some MacroExpansionContext
	) throws -> ExprSyntax {
  
  		guard
  			let firstExpression = node.argumentList.first?.expression,
  			let stringLiteralExprSyntax = firstExpression.as(StringLiteralExprSyntax.self),
  			case .stringSegment(let literalSegment) = stringLiteralExprSyntax.segments.first
  		else {
  			throw URLMacroError.invalidMactoArgument
  		}
  
  		guard let _ = URL(string: literalSegment.content.text) else {
  			throw URLMacroError.invalidUrl
  		}
  
  		return "URL(string: \(firstExpression))!"
	}
}

Теперь ссылка может проверяться в compile time и XCode будет сигнализировать нам об ошибке.
А если ошибок нет, то наш код развернется в прежнее состояние.
freestanding(declaration)
В отличие от freestanding(expression) , freestanding(declaration) разворачивается в объявления. Это может быть структура, класс, функция, переменная и так далее из этого списка.

Реализуем создание однотипных структур при помощи макроса. Для простоты будем создавать структуру, которая содержит в себе N одинаковых параметров (вектор):


@freestanding(declaration, names: arbitrary)
public macro vector(_ value:  Int) = #externalMacro(module: "PumpkinMacrosStorage", type: "VectorMacro")

...

public struct VectorMacro: DeclarationMacro {
	
    public static func  expansion(
		of node: some FreestandingMacroExpansionSyntax,
		in context: some MacroExpansionContext
	) throws  -> [DeclSyntax] {
		guard
			let firstExpression = node.argumentList.first?.expression,
			let integerLiteral = firstExpression.as(IntegerLiteralExprSyntax.self),
			let integerValue = Int(integerLiteral.digits.text),
			integerValue >  0
		else {
			throw VectorMacroError.invalidMactoArgument
		}
		
		let header: PartialSyntaxNodeString =  "public struct Vector\(integerLiteral): Equatable"
		let structDeclSyntax =  try  StructDeclSyntax(header) {
		
			for index in  0..
Итог работы макроса:
attached макросы
Главное отличие attached макросов от freestanding в том, что они захватывают тот контекст определения, к которому присоединяются. Например, при присоединении к структуре такой макрос получает доступ ко всем определениям внутри этой структуры.

attached имеют 5 ролей:
  • attached(peer) может быть присоединен к любому определению. Данная роль позволяет добавлять определения на тот же уровень, где уже находится определение с присоединенным макросом.
  • attached(accessor) позволяет генерировать get и set к параметрам‑сокращениям с доступом к хранимым параметрам типа.
  • attached(memberAttribute) позволяет добавлять атрибуты к определениям, к которым присоединен макрос.
  • attached(member) позволяет добавлять определения внутри типа или extension, к которому присоединен.
  • attached(conformance) позволяет добавлять элементы в цепочку наследования в типе или extension, к которому присоединен.

Создание такого типа макроса аналогично freestanding. Достаточно создать исполнительный тип, который удовлетворяет одному или комбинации протоколов:
  • PeerMacro
  • AccessorMacro
  • MemberAttributeMacro
  • MemberMacro
  • ConformanceMacro

Для примера рассмотрим задачу:

У нас есть модель, которая получается из FireStore путем декодинга данных в структуру с использование стандартных типов, а так же модель может отправляться в FireStore , но уже с использованием типа FieldValue:



struct User: Codable {
	let name: String
	let registrationDate: Date
	let friendsIds: [String]
}

Мы хотим зарегистрировать пользователя и сообщить FireStore, чтобы в
качестве registrationDate выставлялось серверное значение. Для этого в FireStore под ключом registrationDate нужно передать FieldValue.serverTimestamp(). Аналогичная ситуация и с friendsIds: мы
хотим получать массив, а серверу сообщать об изменениях этого массива, если такие имеются.

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


updates = [
	"name": "Nikita"
	"registrationDate": FieldValue.serverTimestamp(),
	"friendsIds": FieldValue.arrayUnion(["1", "2", "3"])
]

Или можно было создать структуру‑клон, но с измененными типами:


struct User: Codable {
	var name: String?
	var registrationDate: FieldValue?
	var friendsIds: FieldValue?
}

Оба способа нуждаются в ручном обновлении каждый раз, когда меняется исходная структура User. C макросом же этот процесс можно автоматизировать:


@attached(member, names: named(Editable))
public macro FirebaseEditable() = #externalMacro(module: "PumpkinMacrosStorage", type: "FirebaseEditableMacro")

...

public struct FirebaseEditableMacro: MemberMacro {

	public static func expansion(
		of node: AttributeSyntax,
		providingMembersOf declaration: some DeclGroupSyntax,
		in context: some MacroExpansionContext
	) throws -> [DeclSyntax] {

		guard declaration.is(StructDeclSyntax.self) else {
			
			// Выводим ошибку в XCode средствами Diagnostic
			let errorDiagnose = Diagnostic(
				node: Syntax(node),
				message: MistakeDiagnostic.notAStruct
			)

			context.diagnose(errorDiagnose)

			return []
		}

		//  Получаем список пропертей
		let variables = declaration.memberBlock.members.compactMap {
			$0.decl.as(VariableDeclSyntax.self)
		}

		// Создаем определение структуры
		let editableStructure = try StructDeclSyntax("struct Editable: Codable") {
		
			// Заполняем ее пропертями
			for variable in variables {
				variable.byReplacingTypeToFieldValue
			}
		}

		return [
			DeclSyntax(editableStructure)
		]
	}
}

// MARK: - FirebaseEditableMacro

private extension FirebaseEditableMacro {

	enum MistakeDiagnostic: String {
		case notAStruct
	}
}

// MARK: - Diagnostic.DiagnosticMessage

extension FirebaseEditableMacro.MistakeDiagnostic: DiagnosticMessage {

	var message: String {
		switch self {
		case .notAStruct:
			return  "'@FirebaseEditable' can only be applied to a 'struct'"
		}
	}

	var diagnosticID: MessageID {
		.init(domain: "PumpkinMacros", id: rawValue)
	}

	var severity: DiagnosticSeverity {

    	switch self {
    	case .notAStruct:
    		return .error
    	}
  	}
}

// MARK: - VariableDeclSyntax+Convenience

private extension VariableDeclSyntax {

	var byReplacingTypeToFieldValue: Self {
		var editable =  self
		let typesToReplace: Set = ["date"]
		
		// Заменяем типы typesToReplace на FieldValue
		let updatedBindings = editable.bindings.map { binding -> PatternBindingSyntax in

			var binding = binding

			guard var typeAnnotation = binding.typeAnnotation else {
				return binding
			}

			if shouldReplace(type: typeAnnotation.type, typesToReplace: typesToReplace) {
				
                typeAnnotation = .init(
					type: SimpleTypeIdentifierSyntax(name: "FieldValue")
				)
			}

			if typeAnnotation.type.is(OptionalTypeSyntax.self) == false {

				typeAnnotation = .init(
					type: OptionalTypeSyntax(
						wrappedType: typeAnnotation.type,
						questionMark: .postfixQuestionMarkToken()
					)
				)
			}

			binding.typeAnnotation = typeAnnotation

			return binding
		}

		editable.bindings = .init(updatedBindings)
		editable.bindingKeyword = "var"

		return editable
	}
	
	// MARK: - Private methods
  
	private  func  shouldReplace(type: TypeSyntax, typesToReplace: Set) ->  Bool {

		if  let simpleTypeIdentifier = type.as(SimpleTypeIdentifierSyntax.self) {
			return typesToReplace.contains(simpleTypeIdentifier.name.text.lowercased())
		
        } else  if type.is(ArrayTypeSyntax.self) {
			return true
		
        } else  if  let optionalType = type.as(OptionalTypeSyntax.self) {
			return shouldReplace(type: optionalType.wrappedType, typesToReplace: typesToReplace)
		
        } else {
			return false
		}
	}
}

Итог:
Помимо вышеуказанного примера, есть много мест, где можно применить такие макросы. Например макрос, генерирующий публичный инициализатор, кастомный генератор CodingKeys и так далее.

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

Сам макрос запускается внутри песочницы. Это не дает ему доступа к файлам на диске и доступа к сети. Apple также запрещают использовать некоторые системные методы API внутри макроса (вызвать их можно, но строго не рекомендовано). Кроме того, не рекомендуется использовать макросы для определения даты компиляции.

Макросы могут храниться как в одном пакете, так и в разных. При этом пакеты подключаются к различным проектам средствами SPM (Swift Package Manager), поэтому вы можете переиспользовать готовые макросы между разными проектами. В целом, у Apple получился достаточно мощный инструмент, который сможет найти применение в любом проекте.

```jsx ```
Другие статьи по теме: