• 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이 들어갈 수 있는 어느 곳에서도 쓸 수 있다는 것을 의미한다.

      • #문자로 시작한다.
      • 반대로 다른 선언에 붙어서 사용되는 attached 매크로도 있다.
    • 매크로 인자가 검사를 통과했다면 실제로 확장이 일어난다.

      • 각 매크로는 컴파일러 플러그인 형태로 구현된다.

      • 컴파일러는 매크로 표현식 전체를 플러그인으로 보낸다.

      • 플러그인은 받은 표현식을 Syntax Tree로 파싱한다.

        스크린샷 2023-06-17 오전 11.07.15.png

      • 플러그인 자체도 Swift로 작성된 프로그램으로, 이 Syntax tree를 자유롭게 변환할 수 있다.

        스크린샷 2023-06-17 오전 11.08.47.png

      • 이렇게 변환된 Syntax Tree는 직렬화되어 컴파일러로 다시 보내지고 컴파일러는 매크로 표현식을 이로 대체한다.

  • Create a macro

    • 새로운 패키지 → Swift Macro 템플릿을 선택한다.

    • 매크로의 정의를 보자.

      • externalMacro로 특정 모듈의 매크로 타입을 지정하고 있다.
      @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
          

          스크린샷 2023-06-17 오후 1.01.52.png

        • 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로 남겨놓은 부분을 채우자.

      • enum이 아닌 케이스에 적용되면 에러다.
    • 테스트 케이스부터 작성해보자.

      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 프로토콜을 채택한 어떤 것이던 될 수 있다.

      • attribute말고 다른 곳에 에러 메시지를 보여주고 싶거나, 경고를 띄우고 싶거나, fix 버튼을 띄워주고 싶으면 context 매개변수의 addDiagnostic 메소드를 활용할 수 있다.
      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
      
  • 위 예제를 일반화해보자.

  • 일반화를 위해서는 제네릭 인자의 타입을 가져와야 한다.