[회고] 신입 iOS 개발자가 되기까지 feat. 카카오 자세히보기

🍎 Apple/Swift

[Swift][문서의역] Macro

inu 2023. 7. 15. 22:38
반응형

본 포스트는 스위프트 공식문서의 매크로에 대한 설명을 의역한 것이며
편의를 위해 생략 혹은 추가된 내용이 있을 수 있습니다.


Macro

https://docs.swift.org/swift-book/documentation/the-swift-programming-language/macros

  • 컴파일 타임에 코드를 만들 수 있는 기능
  • Macro는 컴파일할 때 소스코드를 변환해서 반복적인 코드를 최소화할 수 있도록 돕는다
  • 컴파일시간에 Swift는 코드를 빌드하기 전에, 코드 안에 존재하는 모든 매크로를 확장(Expands)한다.

매크로의 특징

  • 매크로의 코드 확장 작업은 추가적인 작업으로, 기존 코드를 수정하거나 삭제하지는 않는다.
  • 매크로 코드와 매크로 확장 결과 코드 모두에서 유효한 Swift 코드 여부를 확인한다.
  • 매크로에 전달하는 값과 매크로에 의해 생성된 값들 모두 타입 유효성을 체크한다.
  • 어쨌든 컴파일 아주 충실하게 잘한다는 이야기들이 적혀있음ㅎ

매크로의 종류

매크로의 종류에는 2가지가 있다.

  • Freestanding macros : 혼자 자체적으로 표시되며, 선언에 첨부되지 않음 (Freestanding macros appear on their own, without being attached to a declaration.)
  • Attached macros : 첨부되어서 해당 선언을 수정함 (Attached macros modify the declaration that they’re attached to.)

cf. ‘선언(declaration)’은 말그대로 ‘클래스 선언’, ‘함수 선언’할 때 그 선언.

어쨌든 둘다 매크로 확장을 위한 동일한 모델을 기반으로 만들어졌고 동일한 접근 방식을 사용해 사용하면 된다. 두 매크로의 차이 및 정의에 대해서는 나중에 자세히 알려준다니까 일단 쭉 읽어보자.

Freestanding Macros

Freestanding Macro를 호출하려면 매크로 이름 앞에 # 을 쓰고 매크로 이름뒤 괄호안에 매크로에 대한 인수를 입력한다.

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

#function은 Swift 표준 라이브러리 내에 구현되어 있는 function macro를 호출한다. 이 코드를 컴파일하면 #function 부분을 현재 함수의 이름으로 대체하는 Swfit는 Macro의 구현부를 호출한다.

 

#warning은 표준 라이브러리의 warning macro를 호출하여 사용자 컴파일 타임 경고를 생성시킨다.

 

Freestanding Macros는 #function처럼 값을 생성하거나 #warning처럼 컴파일 타임에 특정 작업을 수행할 수 있도록 도와주는 매크로라고 생각하면 된다.

Attached Macros

Attached Macros를 호출하려면 매크로 이름 앞에 @ 기호를 쓰고 매크로 이름 뒤의 괄호 안에 매크로에 대한 인수를 입력한다. Attached Macros는 첨부된 선언을 수정한다. 새로운 메서드를 정의하거나 protocl에 적합하도록 변경해주는 등 해당 선언에 코드를 추가하는 동작을 하는 것이다.

매크로의 적용 예시

일단 아래 매크로를 안쓴 아래 코드를 먼저 살펴보자.

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안에 옵션들(nuts, cherry, fudge)들을 반복적이고 수동적으로 초기화하고 있다. 근데 매크로를 쓰면?

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

이렇게 간단해진다. @OptionSet 은 Swift 표준 라이브러리에서 OptionSet 매크로를 호출해준다. 이 매크로는 private enum에서 각 case들을 읽어서 각 Option들에 대한 상수목록을 만든다. 그리고 이를 해당하는 선언에 추가하여 OptionSet protocol을 지키도록 해준다.

내부적으로 컴파일되면서 확장된 코드는 다음과 같다.

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 { }

private enum 이 후의 모든 코드는 매크로를 기반으로 생성된 것이다. 오히려 코드 읽기가 쉬워진 것을 알 수 있다.

매크로 선언 (Macro Declaration)

매크로 선언의 특징

  • 대부분의 Swift 코드에서 함수, 클래스같은 심볼을 구현할 때는 선언과 구현을 따로 할 필요가 없었음. (예를 들어 class A { } 라고 해놓고 다른 곳에서 내부 구현을 한다던가 하진 않았음)
  • 하지만 매크로는 선언과 구현이 분리되어 있음.
  • 매크로의 선언(Declaration)에는 매크로의 이름, 매크로에 필요한 매개변수, 매크로가 사용될 수 있는 위치, _매크로가 생성하는 코드의 종류_가 포함됨
  • 매크로의 구현에는 매크로 호출부에서 Swift 코드를 생성해 코드를 확장하는 로직이 포함될 것이다.

macro 키워드를 통해 매크로를 선언한다. 아까 예제에서 본 @OptionSet 의 선언부는 아래와 같다.

public macro OptionSet<RawType>() =
        #externalMacro(module: "SwiftMacros", type: "OptionSetMacro")
  • 첫번째 줄에서는 매크로의 이름, 파라미터를 지정했다. (=이름은 OptionSet, 파라미터는 없음)
  • 두번째 줄은 Swift 표준 라이브러리의 externalMacro를 이용해 매크로의 구현 위치를 Swift에 알려주었다. (=SwiftMacros라는 모듈에 해당 매크로의 구현부인 OptionSetMacro type이 포함되어 있음)
    • cf. externalMacro : 그냥 보여지는대로 매크로의 모듈이랑 이름을 지정하면 해당 구현 불러와주는 매크로

일반적으로 Attached Macro는 구조체, 클래스처럼 Upper Camel Case를 사용하며, 일반적으로 FreeStanding Macros에는 lower Camel Case를 사용한다.

 

Note) 매크로는 항상 public으로 선언함. 매크로를 선언하는 코드는 해당 매크로를 사용하는 코드와 다른 모듈에 위치하기 때문에 private, internal로 선언해도 쓸 수 있는 곳이 없다.

매크로 선언부의 역할

매크로 선언부는 매크로의 역할을 정의해준다. 역할을 정의한다는 것은 곧 해당 매크로가 어느 정도 범위의 능력을 가지는지 미리 알려준다는 것이다. (소스 코드에서 해당 매크로를 호출할 수 있는 위치, 매크로가 생성할 수 있는 코드의 종류 등) 모든 매크로에는 하나 이상의 역할이 있기 때문에 매크로의 선언 시작 부분에 역할을 작성해주어야 한다.

  • 다음 코드가 @OptionSet 의 선언을 더 정확히 보여줌
@attached(member)
@attached(conformance)
public macro OptionSet<RawType>() =
        #externalMacro(module: "SwiftMacros", type: "OptionSetMacro")

처음 입력된 @attached(member) 는 해당 매크로가 매크로대상에게 새로운 멤버를 추가한다는 뜻이다.(@OptionSet 매크로는 OptionSet 프로토콜에 필요한 init(rawValue:)를 포함해 몇가지 멤버변수를 추가하고 있었다. 이 역할을 여기서 알려주는 것) 그 다음 입력된 @attached(conformance) 는 해당 매크로가 매크로 대상에게 하나 이상의 프로토콜을 채택하도록 만든다는 뜻이다. (@OptionSet 매크로는 OptionSet 프로토콜을 따르도록 만들었으니 당연히 이게 필요할 것이다.)

  • cf. conformance 뜻 : 적합성

FreeStanding 매크로는 @freestanding 속성을 작성해 매크로 역할을 지정한다.

@freestanding(expression)
public macro line<T: ExpressibleByIntegerLiteral>() -> T =
        /* ... location of the macro implementation... */
  • #line 매크로는 expression이라는 역할을 가진다. expression 역할을 가진 매크로는 특정한 값(value)를 만들어내거나 경고 생성과 같은 컴파일 타임 작업을 수행한다.

매크로의 선언부는 매크로의 역할 외에도 매크로가 생성하는 심볼의 이름들의 정보도 제공한다. 매크로의 선언부에 심볼 이름의 목록을 제공해주면 코드 내부에 해당 이름을 사용하는 심볼들만 생성되도록 보장된다.

 

다음 코드가 @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 가 생성하는 심볼들이 표현되어있다. (@OptionSet 매크로는 RawValue, rawValue, init 심볼을 추가함) 왜냐하면 그 이름들은 미리 알 수 있기 때문에 선언에 명시적으로 나열해놓는것이다.

 

보면 named 목록말고 arbitray라는 것도 보이는데, 매크로를 직접 사용하기 전까지 이름을 알 수 없는 것을 생성할 수 있다. (예를들면 위에 @OptionSet 매크로에서는 private enum 안에 있는 nuts, cherry, fudge같은것)

 

더 자세한 정보는 아래 문서를 참고하자.

More about attached & freestanding

Macro Expansion 자세히 알아보기

앞서서도 좀 봤듯이 매크로를 쓰면 컴파일러가 매크로를 기반으로 코드를 확장(Expansion)한다.

컴파일러가 이를 수행하는 것은 다음과 같은 단계를 밟는다.

  1. 컴파일러가 코드를 읽고 문법의 in-memory representation을 생성 (cf. in-memory representation : 프로그램의 경우에도 메모리에 저장되어야 실행될 수 있습니다. 프로그램은 일련의 명령어로 구성되며, 이러한 명령어는 메모리에 특정한 형식으로 표현되어야 합니다. 프로그램의 in-memory representation은 메모리에 저장된 명령어의 형식과 구조를 의미합니다. (from. chatGPT))
  2. 컴파일러가 Macro Implementation에게 in-memory representation의 일부를 전송해 필요한 부분의 확장을 진행
  3. 컴파일러는 매크로의 호출부를 확장된 폼으로 대체
  4. 컴파일러가 이렇게 확장된 코드를 사용해 컴파일을 계속해서 진행
let magicNumber = #fourCharacterCode("ABCD")

이런 코드가 있다고 치자.

 

#fourCharacterCode는 4글자의 문자열을 받아 해당 문자열의 아스키 코드를 기반으로 32비트 정수를 생성해주는 매크로이다.

  • 일부 파일 형식에서는 이런 정수를 활용해 데이터를 식별한다. 이렇게해도 압축만 되고 디버거는 값을 정상적으로 읽을 수 있기 때문이다.

해당 매크로를 확장하기 위해 컴파일러는 Swift 파일을 읽고 해당 코드의 inmemory-representation인 abstract syntax tree(AST)을 생성함

  • AST는 코드 구조를 더 명시적으로 만들어서 현재 코드와 상호작용해야 하는 것들이 더 쉽게 정보를 주고받도록 함. (컴파일러, Macro Implementation)

아래는 #fourCharacterCode 의 AST를 단순화하여 표현한 것이다.

이 그림은 메모리에서 위 코드의 구조가 어떻게 표현되는지 보여준다. 위의 AST의 각 요소는 실제 소스코드의 일부에 해당한다. Constant declaration 요소 아래에 두개의 하위요소가 있다. 이는 상수 선언의 이름, 값 두 부분을 나타낸다. Macro call 요소에는 매크로의 이름 및 매크로에 전달되는 인수목록을 나타내는 하위 요소가 있다.

 

컴파일러는 코드에서 매크로를 호출하는 곳을 찾은 다음, 해당 매크로를 구현한 외부 바이너리를 로드한다. 매크로를 호출할 때마다 컴파일러는 AST의 일부를 해당하는 Macro Implementation에게 전달한다.

 

다음은 전달되는 AST 중 일부를 간략하게 표현한 것

#fourCharacterCode 를 구현한 Macro Implementation에서는 위와 같은 AST를 입력으로 받는다는 것이다. 그래서 이렇게 입력으로 받은 AST만 확장하는 것이다. (그 이외에는 영향X) 불필요한 확인, 변경이 필요없으니 이해하기도 쉽고 빌드도 꽤 빠르다. 파일 시스템이나 네트워크에는 접근할 수 없도록 샌드박스가 적용된 환경에서 실행되기 때문에 외부에 영향이 갈 위험도 없다.

  • Sandbox 시스템? 컴퓨터 시스템 내에서 실행되는 소프트웨어나 코드를 격리된 환경에서 실행하고 제한된 권한으로 동작하도록 하는 보안 기술입니다 (from Chat GPT)

Macro Implementation에 의해 확장이 완료되면 새 AST를 만든다. (위 사진은 그렇게 컴파일러에 반환하는 AST이다.)

받고 나면 컴파일러는 이런식으로 매크로 부분을 대체해서 넣는다. 그리고 나머지 컴파일을 진행해 현재 구문에 유효한 유형인지같은 것을 체크한다.

 

코드로 표현하면

let magicNumber = 1145258561

이런 느낌이다.

 

컴파일러는 매크로 하나를 이런식으로 확장한다.

 

cf. 참고로 한 매크로가 다른 매크로 안에 있으면 바깥쪽 매크로가 먼저 확장된다고함. 이는 바깥쪽 매크로가 안쪽 매크로를 수정할 수도 있다는 것을 의미함.

Implementing a Macro (매크로의 구현)

매크로를 구현하려면 두가지 컴포넌트를 만들어야한다.

  • 매크로 확장을 직접적으로 수행하는 것(Macro Implementation) + 매크로를 선언하여 API로 노출해주는 라이브러리(Macro Library)

SPM(Swift Package Manager)를 통해 새 매크로를 만들려면,

swift package init --type macro

이 명령어를 입력하면 됨 (당연히 swift 5.9부터 될듯?)

 

기존 프로젝트에 매크로를 추가하려면 Macro Implementation을 위한 타겟과 Macro Library를 위한 타겟을 추가해야한다. 예를 들면 Package.swift 에 아래같은 내용을 추가하는 식이다.

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

    // Macro Library
    .target(name: "MyProject", dependencies: ["MyProjectMacros"]),
]

Macro Implementation은 SwiftSyntax라는 모듈을 통해 AST를 기반으로하여 Swift 코드와 상호 작용을 한다.

  • SwiftSyntax : Swift 소스 코드 구문 분석, 검사, 생성을 해주는 라이브러리. (일단 걍 AST 같은거 만들어주는 도구 정도로 이해)

SPM을 통해 새로운 Macro Package를 생성한 경우, 생성된 Package.swift 파일 안에 SwiftSyntax에 대한 종속성이 자동으로 포함된다. (기존 프로젝트에 매크로를 추가하고 싶으면 Package.swift 파일에 SwiftSyntax에 대한 종속성을 추가해줘야함)

dependencies: [
    .package(url: "https://github.com/apple/swift-syntax.git", from: "some-tag"),
],
// some-tag는 사용하려는 SwiftSyntax의 버전에 대한 Git의 태그로 바꾸셈

매크로의 역할에 따라 Macro Implementation이 준수하는 프로토콜이 있다.

 

예를 들면 #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) }

일단 freestanding macro라서 ExpressionMacro라는 프로토콜을 준수해야한다. (expression 역할 : 특정한 값(value)를 만들어내거나 경고 생성과 같은 컴파일 타임 작업을 수행함) 해당 프로토콜은 AST를 확장해주는 함수인 expansion(of: in:) 메서드의 구현을 요구한다. 해당 매크로 확장을 위해 Swift에서는 AST를 Macro Implementation으로 전달한다.

 

위의 예시 분석

  • 첫번째 가드문으로부터 AST에 존재하는 문자열 리터럴을 추출하고, 이를 string으로 저장함
  • 그리고 fourCharacterCode(for:)을 호출해서 결과를 리턴해줌 (아래에 따로 구현되어 있는 함수)
  • 중강중간 에러체크도 해줌
  • 결과적으로는 SwiftSyntax에 존재하는 타입인 ExprSyntax라는 타입의 인스턴스를 반환함. 이는 StringLiteralConvertible 프로토콜을 준수하기 때문에 경량화를 위해 문자열 리터럴을 사용함.
    • StringLiteralConvertible : 쉽게말하면 문자열로 특정 타입을 초기화할 수 있다는 것.
    • StringLiteralConvertible
  • 이 예시말고도 다른 Macro Implementation에서 반환하는 SwiftSyntax 내부의 타입은 StringLiteralConvertible을 준수함

Developing and Debugging Macros

매크로는 테스트에 매우 유용하다. 왜냐면

  • 외부 상태를 변경하지않고 AST → AST 로 변환함
  • 문자열 리터럴로 syntax nodes를 생성할 수 있어서 테스트 입력이 쉬움.
  • AST에 존재하는 description property를 읽어서 기대값과 일치하는지 확인할 수도 있음

아래는 테스트코드 예시

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)
반응형

'🍎 Apple > Swift' 카테고리의 다른 글

[Swift] Attributes  (2) 2023.07.29
[Swift] AVFoundation 기초  (0) 2023.07.15
[Swift] Mirror  (0) 2023.05.18
[Swift] autoreleasepool  (2) 2022.05.27
[Swift] UNUserNotificationCenter 살펴보기  (0) 2022.05.26