매크로 지원의 필요성
Swift는 보일러 플레이트 코드를 줄이기 위한 다양한 기능을 제공하지만, 이러한 기능들로도 해결되지 않는 경우가 발생할 수 있습니다.
이때, 개발자가 Swift 컴파일러를 수정하는 방법도 있습니다. Swift가 오픈 소스로 제공되기 때문에 컴파일러 수정이 가능하긴 하지만, 이 방법은 복잡하고 유지보수가 어렵습니다.
매크로를 활용하면 컴파일러를 수정할 필요 없이 패키지에 포함된 형태로 보일러플레이트를 제거할 수 있습니다.
이는 개발자들이 원하는 기능을 쉽게 구현할 수 있도록 도와줍니다.
매크로는 명확해야 한다
Swift 컴파일러는 #
과 @
기호를 매크로로 인식하여 확장성을 추가합니다.
매크로는 두 가지 유형이 있습니다.
- 독립형 매크로: 항상
#
기호로 시작합니다. - 첨부 매크로: 코드 선언부에 작성되며
@
기호로 시작합니다.
이외의 기호는 매크로로 인식되지 않습니다.
매크로에 전달된 코드와 반환된 코드는 완전해야 하며, 유효성 검증과 타입 체크가 이루어져야 하고, 코드 전개 방식이 예측 가능해야 합니다.
매크로는 기존 코드를 변경하는 것이 아니라 새로운 코드를 추가만 할 수 있, 추가된 코드에 대해 디버깅과 break point 설정도 가능합니다.
매크로의 실제 작동 방식
Swift 컴파일러는 코드에서 매크로 호출을 인식하고, 이를 추출하여 특수한 컴파일러 플러그인에 전달합니다.
이 플러그인은 샌드박스 내에서 별도의 프로세스를 거쳐 입력에 맞는 적절한 매크로 코드를 생성하고 반환합니다.
이후 Swift 컴파일러는 이 코드를 프로그램에 추가하여 함께 컴파일합니다.
매크로 선언과 역할
매크로 호출은 매크로 선언을 통해 인식됩니다.
매크로 선언은 매크로를 위한 API를 제공하며, 함수 선언처럼 파라미터, 리턴 타입, 한 개 이상의 역할을 정의할 수 있습니다.
독립형 매크로의 역할
표현식 매크로
결과를 실행하고 산출하며, 코드의 의미와 안전성 사이에서 균형을 맞춥니다.
예를 들어, 옵셔널 결과의 unwrapping
을 처리하는 매크로가 있습니다.
@freestanding(expression)
macro unwrap<Wrapped>(_ expr: Wrapped?, message: String) -> Wrapped
let image = #unwrap(downloadedImage, message: "was already checked")
// Begin expansion for "#unwrap"
{ [downloadedImage] in
guard let downloadedImage else {
preconditionFailure(
"Unexpectedly found nil: ‘downloadedImage’ " + "was already checked",
file: "main/ImageLoader.swift",
line: 42
)
}
return downloadedImage
}()
// End expansion for "#unwrap"
선언 매크로
함수나 변수, 타입 선언처럼 사용되며, 복잡한 선언을 간단하게 처리할 수 있습니다.
/// Declares an `n`-dimensional array type named `Array<n>D`.
/// - Parameter n: The number of dimensions in the array.
@freestanding(declaration, names: arbitrary)
macro makeArrayND(n: Int)
#makeArrayND(n: 2)
// Begin expansion for "#makeArrayND"
public struct Array2D<Element>: Collection {
public struct Index: Hashable, Comparable { var storageIndex: Int }
var storage: [Element]
var width1: Int
public func makeIndex(_ i0: Int, _ i1: Int) -> Index {
Index(storageIndex: i0 * width1 + i1)
}
public subscript (_ i0: Int, _ i1: Int) -> Element {
get { self[makeIndex(i0, i1)] }
set { self[makeIndex(i0, i1)] = newValue }
}
public subscript (_ i: Index) -> Element {
get { storage[i.storageIndex] }
set { storage[i.storageIndex] = newValue }
}
}
// End expansion for "#makeArrayND"
#makeArrayND(n: 3)
#makeArrayND(n: 4)
#makeArrayND(n: 5)
첨부 매크로의 역할
peer
선언 옆에 새로운 선언을 추가합니다.
예를 들어, async-await
메서드와 completionHandler
를 자동으로 생성할 수 있습니다.
/// Overload an `async` function to add a variant that takes a completion handler closure as
/// a parameter.
@attached(peer, names: overloaded)
macro AddCompletionHandler(parameterName: String = "completionHandler")
/// Fetch the avatar for the user with `username`.
@AddCompletionHandler(parameterName: "onCompletion")
func fetchAvatar(_ username: String) async -> Image? {
...
}
// Begin expansion for "@AddCompletionHandler"
/// Fetch the avatar for the user with `username`.
/// Equivalent to ``fetchAvatar(username:)`` with
/// a completion handler.
func fetchAvatar(
_ username: String,
onCompletion: @escaping (Image?) -> Void
) {
Task.detached {
onCompletion(await fetchAvatar(username))
}
}
// End expansion for "@AddCompletionHandler"
accessor
변수와 그에 따른 get
, set
접근자를 추가할 수 있습니다.
/// Adds accessors to get and set the value of the specified property in a dictionary
/// property called `storage`.
@attached(accessor)
macro DictionaryStorage(key: String? = nil)
struct Person: DictionaryRepresentable {
init(dictionary: [String: Any]) { self.dictionary = dictionary }
var dictionary: [String: Any]
@DictionaryStorage var name: String
// Begin expansion for "@DictionaryStorage"
{
get { dictionary["name"]! as! String }
set { dictionary["name"] = newValue }
}
// End expansion for "@DictionaryStorage"
@DictionaryStorage var height: Measurement<UnitLength>
// Begin expansion for "@DictionaryStorage"
{
get { dictionary["height"]! as! Measurement<UnitLength> }
set { dictionary["height"] = newValue }
}
// End expansion for "@DictionaryStorage"
@DictionaryStorage(key: "birth_date") var birthDate: Date?
// Begin expansion for "@DictionaryStorage"
{
get { dictionary["birth_date"] as! Date? }
set { dictionary["birth_date"] = newValue as Any? }
}
// End expansion for "@DictionaryStorage"
}
memberAttribute
타입이나 확장에 첨부되어 멤버 속성에 추가할 수 있습니다.
/// Adds accessors to get and set the value of the specified property in a dictionary
/// property called `storage`.
@attached(memberAttribute)
@attached(accessor)
macro DictionaryStorage(key: String? = nil)
@DictionaryStorage
struct Person: DictionaryRepresentable {
init(dictionary: [String: Any]) { self.dictionary = dictionary }
var dictionary: [String: Any]
// Begin expansion for "@DictionaryStorage"
@DictionaryStorage
// End expansion for "@DictionaryStorage"
var name: String
// Begin expansion for "@DictionaryStorage"
@DictionaryStorage
// End expansion for "@DictionaryStorage"
var height: Measurement<UnitLength>
@DictionaryStorage(key: "birth_date") var birthDate: Date?
}
member
기존 멤버에 속성을 추가하는 대신 완전히 새로운 멤버를 추가할 수 있습니다.
/// Adds accessors to get and set the value of the specified property in a dictionary
/// property called `storage`.
@attached(member, names: named(dictionary), named(init(dictionary:)))
@attached(memberAttribute)
@attached(accessor)
macro DictionaryStorage(key: String? = nil)
@DictionaryStorage struct Person: DictionaryRepresentable {
// Begin expansion for "@DictionaryStorage"
init(dictionary: [String: Any]) {
self.dictionary = dictionary
}
var dictionary: [String: Any]
// End expansion for "@DictionaryStorage"
var name: String
var height: Measurement<UnitLength>
@DictionaryStorage(key: "birth_date") var birthDate: Date?
}
extension
타입이나 확장에 프로토콜을 채택할 수 있습니다.
해당 세션에서는 네이밍이 conformance
였지만, 논의를 통해 extension
으로 변경되었다고 합니다.
/// Adds accessors to get and set the value of the specified property in a dictionary
/// property called `storage`.
@attached(extension)
@attached(member, names: named(dictionary), named(init(dictionary:)))
@attached(memberAttribute)
@attached(accessor)
macro DictionaryStorage(key: String? = nil)
struct Person
// Begin expansion for "@DictionaryStorage"
: DictionaryRepresentable
// End expansion for "@DictionaryStorage"
{
var name: String
var height: Measurement<UnitLength>
@DictionaryStorage(key: "birth_date") var birthDate: Date?
}
매크로 구현 방법
매크로를 구현하려면 #externalMacro
를 통해 플러그인과 그 플러그인 속의 타입 이름을 지정해야 합니다. 매크로는 해당 플러그인을 통해 생성된 코드를 프로그램에 추가합니다. 매크로 구현은 각 역할에 맞는 프로토콜을 준수해야 하며, 커스텀 오류 메시지를 추가할 수도 있습니다.
@freestanding(expression)
macro stringify<T>(_ expr: T) -> (T, String) = #externalMacro(
module: "MyLibMacros",
type: "StringifyMacro"
)
매크로 구현 예제 분석
SwiftSyntax, SwiftSyntaxMacros, SwiftSyntaxBuilder 패키지는 매크로 구현을 위한 도구들을 제공합니다.
예를 들어, DictionaryStorageMacro
는 MemberMacro
역할을 수행하며, 특정 선언이 구조체인지 확인하고, 맞지 않을 경우 커스텀 에러 메시지를 출력합니다.
import SwiftSyntax
import SwiftSyntaxMacros
import SwiftSyntaxBuilder
struct DictionaryStorageMacro: MemberMacro {
static func expansion(
of attribute: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard declaration.is(StructDeclSyntax.self) else {
let structError = Diagnostic(
node: attribute,
message: MyLibDiagnostic.notAStruct
)
context.diagnose(structError)
return []
}
return [
"init(dictionary: [String: Any]) { self.dictionary = dictionary }",
"var dictionary: [String: Any]"
]
}
}
올바른 매크로 사용
매크로 작성 시, context.makeUniqueName()
을 사용하여 다른 매크로와 충돌하지 않는 고유한 이름을 생성할 수 있습니다.
또한, 역할의 names
매개변수로 지정자를 넘겨 명확성을 높일 수 있습니다.
예를 들어, overloaded
, prefixed
, suffixed
, named
, arbitrary
와 같은 지정자를 사용할 수 있습니다.
잘못된 매크로 생성 방지
매크로는 오로지 컴파일러가 제공하는 정보만 사용해야 합니다.
컴파일러는 매크로 함수를 순수 함수로 간주하기 때문에 날짜와 같은 외부 정보를 사용하면 안 됩니다.
이를 방지하기 위해, 컴파일러 플러그인은 샌드박스 환경에서 실행되어 디스크 파일을 읽거나 네트워크에 접근하지 못하도록 제한됩니다.
매크로 테스트
SwiftSyntaxMacrosTestSupport
의 assertMacroExpansion
을 사용하여 매크로가 올바르게 작동하는지 테스트할 수 있습니다.
예를 들어, @DictionaryStorage
매크로의 올바른 확장이 이루어졌는지 확인할 수 있습니다.
import MyLibMacros
import XCTest
import SwiftSyntaxMacrosTestSupport
final class MyLibTests: XCTestCase {
func testMacro() {
assertMacroExpansion(
"""
@DictionaryStorage var name: String
""",
expandedSource: """
var name: String {
get { dictionary["name"]! as! String }
set { dictionary["name"] = newValue }
}
""",
macros: ["DictionaryStorage": DictionaryStorageMacro.self])
}
}