Galapagos Tech Blog

株式会社ガラパゴスのメンバーによる技術ブログです。

Swift Macrosを使ってみよう

こんにちは、お久しぶりです。 気づけば入社して一年が経過していました。サカパ・iOSチームの東です。 筋トレを始めたので体がどんどん大きくなっていきます💪
WWDC2023で発表されたSwift Macrosを触ってみたので記事にします。

Swift Macors

今回はCodingKeysのマクロを元に記事にしていきたいと思います。 普段馴染みのないSwiftSyntaxの知識も少し必要になります!

マクロパッケージの作り方

File -> New -> Package... -> Swift Macro

の流れです。NewからProjectを選択するとSwift Macroが選択できないので注意してください。

フォルダ構成

.
├── Package.resolved
├── Package.swift
├── README.md
├── Sources
│   ├── Test
│   │   └── Test.swift
│   ├── TestClient
│   │   └── main.swift
│   └── TestMacros
│       ├── CodableKey.swift
│       └──  CustomCodable.swift
└── Tests
    └── TestTests
        └── TestTests.swift

Swift Macrosの基本(簡易)

Macrosの種類

自立型と付属型の2種類があります。

// 自立型
@freestanding(引数)

//付属型
@attached(引数)

それぞれ引数にマクロの種類が入ります。 引数によってマクロの動きが変わってきます。

freestanding

引数として受け取った値を使用する。関数のようにどこにでも記述可能

変数 意味
declaration 宣言マクロ。struct, var, func...
expression 式マクロ。グローバル関数とほぼ同じ意味
codeItem 宣言も式も生成できるマクロ。

attached

宣言が対象です。class, struct, var等

変数 意味
accessor プロパティ宣言に付与可能。アクセサスコープを出力できる
member 色々な型宣言に付与可能。スコープ内に別の宣言を出力可能
memberAttribute 色々な型宣言に付与可能。スコープ内の変数や関数にattributeを出力する
peer 色々な宣言に付与可能。付与した宣言と同じスコープに別の宣言を出力する
extension 型宣言に付与可能。extensionを出力します。

SwiftSyntax

swift-ast-explorer.com

こちらのサイトより、Swiftの構文解析を行うことがきでます。
これで、Memberへのアクセスや、継承されている名前の参照を簡単に行えるようになります。

CodingKeysのマクロを作成する

マクロの宣言

Test/Test.swift

@attached(peer)
public macro CodableKey(name: String) = #externalMacro(module: "TestMacros", type: "CodableKey")

@attached(member, names: arbitrary)
public macro CustomCodable() = #externalMacro(module: "TestMacros", type: "CustomCodable")

moduleに指定するのは、typeに指定した構造体が存在するフォルダです。
CodableKeyの場合、TestMacrosフォルダ内のCodableKeyを使用するという意味です。

マクロの実装

TestMacros/CodableKey.swift

import SwiftSyntax
import SwiftSyntaxMacros

public struct CodableKey: PeerMacro { // ①
    public static func expansion(of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
        []
    }
}

CodingKeysで書き換える対象を判別するだけのものなのでCodableKeyの実装はありません。

コード解説

PeerMacroを継承していますが。これはマクロ宣言時のattachedの引数によって変わります。
memberならMemberMacro、accessorならAccessorMacroになります。

TestMacros/CustomCodable.swift

import SwiftSyntax
import SwiftSyntaxMacros

public struct CustomCodable: MemberMacro {
    public static func expansion(of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
        let memberList = declaration.memberBlock.members // ①
        let types = declaration.inheritanceClause?.inheritedTypes // ②
        guard let types = types,
              !types.map({ $0.as(InheritedTypeSyntax.self)?.type.as(IdentifierTypeSyntax.self)?.name.text == "Codable" }).filter({ $0 == true }).isEmpty
        else {
            throw CustomError.message("Codableは必須です") // ③
        }
        // ④
        let cases = memberList.compactMap({ member -> String? in
            guard let propertyName = member.decl.as(VariableDeclSyntax.self)?.bindings.first?.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else { return nil }
            // ⑤
            if let customeKeyMacro = member.decl.as(VariableDeclSyntax.self)?.attributes.first(where: { element in
                element.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.description == "CodableKey"
            }) {
                // ⑥
                guard let customeKeyValue = customeKeyMacro.as(AttributeSyntax.self)!.arguments!.as(LabeledExprListSyntax.self)!.first?.expression.as(StringLiteralExprSyntax.self)?.segments.first?.as(StringSegmentSyntax.self)?.content.text
                else { return nil }
                return "case \(propertyName) = \"\(customeKeyValue)\""
            } else {
                return "case \(propertyName)"
            }
        })
        
        // ⑦
        let codingKeys: DeclSyntax = """
        enum CodingKeys: String, CodingKey {
            \(raw:  cases.joined(separator: "\n"))
        }
        """
        return [codingKeys]
    }
}

コード解説

構造体のメンバをリストで取得しています。
中身はMemberBlockItemSyntaxという構造体です。

CustomCodableをつけた構造体に継承されているものを取得しています。

guard節でCodableが継承されているかチェックし、ビルド前にエラーを表示させています。
今回の例ではCodingKeysを使用するのでCodableを必須にしています。以下のように即座にエラーが表示されます。

①で取得したmembersをCodingKeysのメンバに変換しています。

ここで、先ほど作成したCodableKeyが関係してきます。
attributeに指定されているかどうかをチェックし、変換を行うかを判断しています。

CodableKeyの引数の値を取得します。
今回は引数が1つなので単純に.arguments!.as(LabeledExprListSyntax.self)!.firstとしていますが、引数が複数ある場合は、適切に処理をしなければなりません。(引数名を取得する等)

最終的に結果として返す部分です。
④では、構造体のメンバ分の数の文字列の配列が作成されるので、改行文字列で結合して返します。

Plugin

TestMacros/Plugin.swift

import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct TestPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        CustomCodable.self,
        CodableKey.self
    ]
}

こちらに型を登録しないと利用できないので注意が必要です。

マクロのテスト

マクロについても簡単にテストを書くことが可能です。 プロジェクトを作成した時点でTestsフォルダが作成されているかと思います。

testMacrosにMacroを登録する

TestTests/TestTests.swift

let testMacros: [String: Macro.Type] = [
    "CodableKey": CodableKey.self,
    "CustomCodable": CustomCodable.self
]

こちらで登録を行わないとテスト実行時にマクロの実行ができないので忘れずに行ってください。

テストを書く

マクロのテストを書く際は、assertMacroExpansionを使用します。
(文字列ベースなので誤字、脱字などに注意して下さい)
今回のテスト例

  • 正しくCodingKeysが展開されるか
  • Codableが継承されていない時にエラーが表示されるか

正しくCodingKeysが展開されるか

TestTests/TestTests.swift

    func testCodableKey() throws {
#if canImport(TestMacros)
        assertMacroExpansion(
            """
            @CustomCodable
            struct Test: Codable, Equatable {
                @CodableKey(name: "OtherName1")
                var propertyWithOtherName1: String
                var propertyWithSameName: String
                @CodableKey(name: "OtherName2")
                var propertyWithOtherName2: String
            }
            """,
            expandedSource: """
            struct Test: Codable, Equatable {
                var propertyWithOtherName1: String
                var propertyWithSameName: String
                var propertyWithOtherName2: String
            
                enum CodingKeys: String, CodingKey {
                    case propertyWithOtherName1 = "OtherName1"
                    case propertyWithSameName
                    case propertyWithOtherName2 = "OtherName2"
                }
            }
            """,
            macros: testMacros
        )
#else
        throw XCTSkip("macros are only supported when running tests for the host platform")
#endif
    }

第一引数は実際のコードを書きます。
expandedSourceには、実際にマクロが展開された時に期待されるコードを書きます。
macorsにはtestMacrosにMacroを登録するで宣言したものを指定します。

Codableが継承されていない時にエラーが表示されるか

TestTests/TestTests.swift

    func testCodableKeyFiailureNotInheritedCodable() throws {
#if canImport(CustomMacroLibMacros)
        assertMacroExpansion(
            """
            @CustomCodable
            struct Test: Equatable {
                @CodableKey(name: "OtherName1")
                var propertyWithOtherName1: String
                var propertyWithSameName: String
                @CodableKey(name: "OtherName2")
                var propertyWithOtherName2: String
            }
            """,
            expandedSource: """
            struct Test {
                var propertyWithOtherName1: String
                var propertyWithSameName: String
                var propertyWithOtherName2: String
            }
            """,
            diagnostics: [
                DiagnosticSpec(message: "message(\"Codableは必須です\")", line: 1, column: 1)
            ],
            macros: testMacros
        )
#else
        throw XCTSkip("macros are only supported when running tests for the host platform")
#endif
    }

エラーが表示されるかを確認するには、diagnostics引数を使用します。

まとめ

Swift Macrosはまだ発表されたばかりで、情報も少ないですが使ってみるとかなり興味深いものだと思いました。
上手に使えば、開発の効率を上げることができますが、使いすぎるとかえってコードの可読性が悪くなってしまうので、取り扱いには気をつけたいところです。

いつもの

弊社ではいっしょに働くアプリエンジニアを募集しています! ご興味のある方はぜひご応募いただけますと嬉しいです。

hrmos.co