Overview
다음과 같이 반복적이고 실수하기 쉬운 코드가 있다고 해보자.
let calculations = [
(1 + 1, "1 + 1"),
(2 + 3, "2 + 3"),
(7 - 3, "7 - 3"),
(5 - 2, "5 - 2"),
(3 * 2, "3 * 2"),
(3 * 5, "3 * 5")
]
매크로를 쓰면 이를 단순화 할 수 있다.
let calculations = [
#stringify(1 + 1),
#stringify(2 + 3),
#stringify(7 - 3),
#stringify(5 - 2),
#stringify(3 * 2),
#stringify(3 * 5)
]
어떻게 돌아가는건가? 매크로 정의를 보자. 함수처럼 생겼다.
@freestanding(expression)
macro stringify(_ value: Int) -> (Int, String)
인자 타입이 안맞거나, 매크로 자체에서 타입 체크가 실패하면 컴파일러가 오류를 던진다.
전처리 단계에서 처리되어 타입 검사를 할 수 없는 C 매크로와는 다르다.
덕분에 Swift 함수에서 쓸 수 있는 강력한 기능인 제네릭 등도 쓸 수 있다.
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String)
freestanding(expression) 이라는 attribute는 이 매크로가 expression이 들어갈 수 있는 어느 곳에서도 쓸 수 있다는 것을 의미한다.
매크로 인자가 검사를 통과했다면 실제로 확장이 일어난다.
각 매크로는 컴파일러 플러그인 형태로 구현된다.
컴파일러는 매크로 표현식 전체를 플러그인으로 보낸다.
플러그인은 받은 표현식을 Syntax Tree로 파싱한다.
플러그인 자체도 Swift로 작성된 프로그램으로, 이 Syntax tree를 자유롭게 변환할 수 있다.
이렇게 변환된 Syntax Tree는 직렬화되어 컴파일러로 다시 보내지고 컴파일러는 매크로 표현식을 이로 대체한다.
Create a macro
새로운 패키지 → Swift Macro 템플릿을 선택한다.
매크로의 정의를 보자.
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "WWDCMacros", type: "StringifyMacro")
StringifyMacro 정의
freestanding(expression) role에 해당하는 ExpressionMacro 프로토콜을 채택하고 있다.
public struct StringifyMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) -> ExprSyntax {
// 인자가 하나 들어오는 것을 검사한다.
guard let argument = node.argumentList.first?.expression else {
fatalError("compiler bug: the macro does not have any arguments")
}
// string interpolation을 통해서 tuple syntax tree를 만든다.
// 첫번째는 인자 자체, 두번째는 인자로 들어온 소스코드의 string literal
return "(\\(argument), \\(literal: argument.description))"
}
}
매크로는 펼쳐보기 전까지는 실제 코드가 안보이기 때문에 테스트가 필요하다.
매크로 자체에는 사이드 이펙트가 없고, 결과 비교도 쉽기 때문에 유닛테스트를 작성하기 좋다.
매크로 템플릿에 이미 테스트가 포함된다.
final class WWDCTests: XCTestCase {
func testMacro() {
assertMacroExpansion(
"""
#stringify(a + b)
""",
expandedSource: """
(a + b, "a + b")
""",
macros: testMacros // 소스코드에서 사용되는 매크로를 넘겨준다.
)
}
}
let testMacros: [String: Macro.Type] = [
"stringify": StringifyMacro.self
]
Macro roles
전체 role 설명은 Expand on Swift Macros 영상 참조
여기서는 attached macro에 집중해서 구현 예시를 보여준다.
ex. 초보자용 slope
/// Slopes in my favorite ski resort.
enum Slope {
case beginnersParadise
case practiceRun
case livingRoom
case olympicRun
case blackBeauty
}
/// Slopes suitable for beginners. Subset of `Slopes`.
enum EasySlope {
case beginnersParadise
case practiceRun
init?(_ slope: Slope) {
switch slope {
case .beginnersParadise: self = .beginnersParadise
case .practiceRun: self = .practiceRun
default: return nil
}
}
var slope: Slope {
switch self {
case .beginnersParadise: return .beginnersParadise
case .practiceRun: return .practiceRun
}
}
}
목표: 생성자와 계산 프로퍼티를 자동으로 만들어주고 싶다.
과정(TDD 스타일로)
attached(member) 매크로 선언
/// Defines a subset of the `Slope` enum
///
/// Generates two members:
/// - An initializer that converts a `Slope` to this type if the slope is
/// declared in this subset, otherwise returns `nil`
/// - A computed property `slope` to convert this type to a `Slope`
///
/// - Important: All enum cases declared in this macro must also exist in the
/// `Slope` enum.
@attached(member, names: named(init))
public macro SlopeSubset() = #externalMacro(module: "WWDCMacros", type: "SlopeSubsetMacro")
빈 매크로 구현체 만들기
/// Implementation of the `SlopeSubset` macro.
public struct SlopeSubsetMacro: MemberMacro {
public static func expansion(
of attribute: AttributeSyntax, // 매크로 attribute
providingMembersOf declaration: some DeclGroupSyntax, // 매크로가 붙은 선언
in context: some MacroExpansionContext
) throws -> [DeclSyntax] { // 반환값은 새로 추가하려는 선언 목록
return [] // 어떻게 구현할지 막막하니 일단은 빈 배열을 반환한다
}
}
만든 구현체를 컴파일러 플러그인에 등록
@main
struct WWDCPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
SlopeSubsetMacro.self
]
}
테스트 케이스 작성
지금은 매크로가 아무것도 추가하지 않으니, 그대로 나와야 할 것이다.
테스트 함수에 사용하려는 매크로 구현을 알려줘야 한다.
let testMacros: [String: Macro.Type] = [
"SlopeSubset" : SlopeSubsetMacro.self,
]
final class WWDCTests: XCTestCase {
func testSlopeSubset() {
assertMacroExpansion(
"""
@SlopeSubset
enum EasySlope {
case beginnersParadise
case practiceRun
}
""",
expandedSource: """
enum EasySlope {
case beginnersParadise
case practiceRun
}
""",
macros: testMacros
)
}
}
실제로는 생성자를 추가해주기를 원하니 테스트 케이스에 명시한다. 이러면 테스트가 실패할거다.
let testMacros: [String: Macro.Type] = [
"SlopeSubset" : SlopeSubsetMacro.self,
]
final class WWDCTests: XCTestCase {
func testSlopeSubset() {
assertMacroExpansion(
"""
@SlopeSubset
enum EasySlope {
case beginnersParadise
case practiceRun
}
""",
expandedSource: """
enum EasySlope {
case beginnersParadise
case practiceRun
init?(_ slope: Slope) {
switch slope {
case .beginnersParadise:
self = .beginnersParadise
case .practiceRun:
self = .practiceRun
default:
return nil
}
}
}
""",
macros: testMacros
)
}
}
매크로 구현체 작성
전체 케이스를 가져와야 하기 때문에, Enum 선언인지를 확인한다.
guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
// TODO: Emit an error here
return []
}
전체 케이스를 가져오기 위해서는 구조를 디버깅해볼 필요가 있는데, 매크로도 Swift프로그램이기 때문에 똑같이 브레이크포인트 걸어서 디버깅이 가능하다.
po enumDecl
member 목록을 가져온다.
let members = enumDecl.memberBlock.members
실제 노드로 변환
let caseDecls = members.compactMap { $0.decl.as(EnumCaseDeclSyntax.self) }
case는 한번에 여러 개를 선언할 수 있기 떄문에, flatMap으로 펼쳐준다.
let elements = caseDecls.flatMap { $0.elements }
생성자에 해당하는 Syntax tree를 만들어야 한다.
만들려는 코드의 Syntax tree를 출력해서 보거나, swift-syntax 공식 문서를 보자.
let initializer = try InitializerDeclSyntax("init?(_ slope: Slope)") {
try SwitchExprSyntax("switch slope") {
for element in elements {
SwitchCaseSyntax(
"""
case .\\(element.identifier):
self = .\\(element.identifier)
"""
)
}
SwitchCaseSyntax("default: return nil")
}
}
이제 이를 반환한다.
return [DeclSyntax(initializer)]
앱에 매크로 통합
패키지 의존성 추가 후 매크로 사용
/// Slopes suitable for beginners. Subset of `Slopes`.
@SlopeSubset
enum EasySlope {
case beginnersParadise
case practiceRun
var slope: Slope {
switch self {
case .beginnersParadise: return .beginnersParadise
case .practiceRun: return .practiceRun
}
}
}
Diagnostics
매크로가 잘못 사용되고 있다면, 이를 알려줘야 한다.
아까 TODO로 남겨놓은 부분을 채우자.
테스트 케이스부터 작성해보자.
func testSlopeSubsetOnStruct() throws {
assertMacroExpansion(
"""
@SlopeSubset
struct Skier {
}
""",
expandedSource: """
struct Skier {
}
""",
diagnostics: [
DiagnosticSpec(message: "@SlopeSubset can only be applied to an enum", line: 1, column: 1)
],
macros: testMacros
)
}
매크로의 에러는 Swift Error 프로토콜을 채택한 어떤 것이던 될 수 있다.
enum SlopeSubsetError: CustomStringConvertible, Error {
case onlyApplicableToEnum
var description: String {
switch self {
case .onlyApplicableToEnum: return "@SlopeSubset can only be applied to an enum"
}
}
}
에러를 던지면, 테스트가 통과한다.
throw SlopeSubsetError.onlyApplicableToEnum
위 예제를 일반화해보자.
@attached(member, names: named(init))
public macro EnumSubset<Superset>() = #externalMacro(module: "WWDCMacros", type: "SlopeSubsetMacro")
일반화를 위해서는 제네릭 인자의 타입을 가져와야 한다.
attribute에서 그 정보를 가져올 수 있다.
guard let supersetType = attribute
.attributeName.as(SimpleTypeIdentifierSyntax.self)?
.genericArgumentClause?
.arguments.first?
.argumentType else {
// TODO: Handle error
return []
}
전환(디테일한 부분은 스킵)
let initializer = try InitializerDeclSyntax("init?(_ slope: \\(supersetType))") {
try SwitchExprSyntax("switch slope") {
for element in elements {
SwitchCaseSyntax(
"""
case .\\(element.identifier):
self = .\\(element.identifier)
"""
)
}
SwitchCaseSyntax("default: return nil")
}
}