👨‍💻

【Swift】Swift で Macro を書いてみる

2024/05/27に公開

初めに

今回は Swift Macros を用いて、マクロを書いてみたいと思います。
先日のGoogle I/O 2024 で Dart の Macros が試験運用版で公開されたので、別分野での実装も参考にしようと思い、今回は Swift Macros について調べて実装してみました。

記事の対象者

  • Swift 学習者
  • Swift Macros について知りたい方
  • Swift で効率的にコードを書きたい方

目的

今回の目的は、Swift における Macros の使い方や実装方法を知ることです。
最終的には、 Dart で公開されていた JsonCodable のマクロを Swift 側でも実装してみたいと思います。
なお、今回実装するコードは以下のリポジトリにおいています。よろしければご覧ください。
https://github.com/Koichi5/json_codable_macro

Swift Macros とは

まずはそもそも Swift Macros とは何なのかについてみてみます。
公式ドキュメント から引用します。

Macros transform your source code when you compile it, letting you avoid writing repetitive code by hand. During compilation, Swift expands any macros in your code before building your code as usual.

マクロは、あなたがそれをコンパイルするときにソースコードを変換し、あなたが手で反復的なコードを書くことを避けることができます。コンパイルの間、Swift は通常通りコードをビルドする前に、コード内のすべてのマクロを展開します。

Expanding a macro is always an additive operation: Macros add new code, but they never delete or modify existing code.

マクロの拡張は常に追加操作である: マクロは新しいコードを追加しますが、既存のコードを削除または変更することはありません。

In addition, if the macro’s implementation encounters an error when expanding that macro, the compiler treats this as a compilation error. These guarantees make it easier to reason about code that uses macros, and they make it easier to identify issues like using a macro incorrectly or a macro implementation that has a bug.

マクロの実装がそのマクロを展開するときにエラーに遭遇した場合、コンパイラはこれをコンパイルエラーとして扱います。これらの保証により、マクロを使用するコードの推論が容易になり、マクロの間違った使用やバグがあるマクロの実装のような問題の特定が容易になります。

Swift has two kinds of macros:
Freestanding macros appear on their own, without being attached to a declaration.
Attached macros modify the declaration that they’re attached to.

Swift には 2 種類のマクロがあります:
自立型マクロは、宣言にアタッチされることなく、それ自身で現れる。
アタッチされたマクロは、それらがアタッチされている宣言を変更します。

それぞれまとめると以下のような内容になるかと思います。

  • マクロを使うことで反復的なコードを書くことを避けることができる
  • マクロにエラーがあった場合、コンパイルエラーとなるため、マクロの誤用やバグの特定が容易
  • Swift には Freestanding macros と Attached macros の2種類がある

この辺りは Dart の Macros と比較しても同じ部分が多いかと思います。
なお、Dart では現状、 Freestanding macros にあたるマクロが見つからない点が異なるかと思います。

実装

次に Macro の実装にはいります。
Macro の実装は以下の手順で進めます。

  1. プロジェクトの作成
  2. Macroコードの実装
  3. 使用できるよう変更
  4. Packageの変更
  5. main.swift の実装
  6. テストの記述

1. プロジェクトの作成

XcodeのFile > New > Package を選択します。
すると以下のように Package を新規作成する画面に移ります。
ここで「Swift Macro」を選択します。

次に作成するマクロの名前、保存先、Git repository を作成するかどうか、別プロジェクトに追加するかどうかを聞かれるので、それぞれ設定します。
今回は「JsonCodable」という名前でプロジェクトを作成しています。

内容を入力して「Next」を押してプロジェクトの作成は完了です。

なお、Swift Macros を作成するためには「SwiftSyntax」パッケージを追加する必要があるので、Xcode > File > Add package dependences... から以下のURLを入力してパッケージを追加しておきましょう。
https://github.com/apple/swift-syntax.git

2. Macroコードの実装

まずは Macro のコードを実装していきます。
作成されたプロジェクトでは[プロジェクト名]Macroという名前のフォルダ、ファイル名になっているファイルを編集していきます。今回はわかりやすいように別のファイルと区別しやすいように「JsonCodableMacroPlugin」という名前に変更しています。
なお、ディレクトリ構造は以下の通りです。

JsonCodableMacroPlugin のコードは以下の通りです。

JsonCodableMacroPlugin.swift
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct JsonCodableMacro: MemberMacro {
    public static func expansion(of node: SwiftSyntax.AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext) throws -> [SwiftSyntax.DeclSyntax] {
        guard declaration.is(ActorDeclSyntax.self) || declaration.is(ClassDeclSyntax.self) || declaration.is(StructDeclSyntax.self) else {
            return []
        }
        
        let variableDecls: [(pattern: IdentifierPatternSyntax, typeAnnotation: TypeAnnotationSyntax)]
        variableDecls = declaration.memberBlock.members.compactMap {
            guard let decl = $0.decl.as(VariableDeclSyntax.self) else { return nil }
            
            guard let binding = decl.bindings.first else { return nil }
            
            guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else { return nil }
            guard let typeAnnotation = binding.typeAnnotation else { return nil }
            
            return (pattern, typeAnnotation)
        }
        let initDecl = DeclSyntax(stringLiteral: 
        """
        init(
            \(variableDecls.map { "\($0.pattern)\($0.typeAnnotation)" }.joined(separator: ",\n"))
        ) {
            \(variableDecls.map { "self.\($0.pattern) = \($0.pattern)" }.joined(separator: "\n"))
        }
        """
        )
        
        let toJsonDecl = DeclSyntax(stringLiteral: """
        public func toJson() -> String? {
            let encoder = JSONEncoder()
            guard let data = try? encoder.encode(self) else {
                return nil
            }
            return String(data: data, encoding: .utf8)
        }
        """)

        let fromJsonDecl = DeclSyntax(stringLiteral: """
        public func fromJson(_ json: String) -> Self? {
            let decoder = JSONDecoder()
            guard let data = json.data(using: .utf8),
                  let object = try? decoder.decode(Self.self, from: data) else {
                return nil
            }
            return object
        }
        """)
        
        let codingKeysCases = variableDecls.map { decl -> String in
            let pattern = decl.pattern.identifier.text
            let snakeCaseName = pattern.toSnakeCase() ?? pattern
            return "case \(pattern) = \"\(snakeCaseName)\""
        }.joined(separator: "\n")

        let codingKeysEnumDecl = DeclSyntax(stringLiteral: """
        public enum CodingKeys: String, CodingKey {
            \(codingKeysCases)
        }
        """)
        
        return [
            initDecl,
            toJsonDecl,
            fromJsonDecl,
            codingKeysEnumDecl
        ]
    }
}

@main
struct JsonCodablePlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        JsonCodableMacro.self,
    ]
}

extension String {
    func toSnakeCase() -> String? {
        guard !isEmpty else { return nil }

        var result = ""
        var shouldAddUnderscore = false

        for character in self {
            if character.isUppercase {
                if shouldAddUnderscore {
                    result += "_"
                }
                result += character.lowercased()
                shouldAddUnderscore = true
            } else {
                result += String(character)
                shouldAddUnderscore = true
            }
        }

        return result
    }
}

それぞれ詳しくみていきます。

以下の部分ではマクロの名前である JsonCodableMacroMemberMacro として定義しています。
MemberMacro は対象に対して新たにメンバーを追加することができるマクロです。MemberMacro は actor, class, struct , enum, extension , protocol のうちいずれかに対して付与することができます。今回は struct に対して付与しています。

public struct JsonCodableMacro: MemberMacro {

以下の部分では expansion メソッドを定義しています。
expansion メソッドは指定されたクラス、構造体、アクターに対して追加のメンバーを生成するために使用されるメソッドです。
返り値として生成されたメンバーの配列を返します。

public static func expansion(
    of node: SwiftSyntax.AttributeSyntax,
    providingMembersOf declaration: some DeclGroupSyntax,
    in context: some MacroExpansionContext
) throws -> [SwiftSyntax.DeclSyntax] {

次に以下の部分ではアノテーションを付与してメンバーを追加する対象の宣言である declaration がアクター、クラス、構造体のいずれかであることを確認しています。 expansion メソッドで処理できるのはアクター、クラス、構造体のいずれかなので、それ以外の場合は空の配列を返すようにしています。

guard declaration.is(ActorDeclSyntax.self) ||
declaration.is(ClassDeclSyntax.self) ||
declaration.is(StructDeclSyntax.self) else {
    return []
}

以下ではJsonCodableアノテーションが付与されたメンバー、クラス、構造体のメンバー変数を解析しています。最終的には、それぞれのメンバー変数の変数名と型アノテーションのセットを抽出しています。複雑なのでさらに詳しく分けてみていきます。

let variableDecls: [(pattern: IdentifierPatternSyntax, typeAnnotation: TypeAnnotationSyntax)]
variableDecls = declaration.memberBlock.members.compactMap {
    guard let decl = $0.decl.as(VariableDeclSyntax.self) else { return nil }
    
    guard let binding = decl.bindings.first else { return nil }
    
    guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else { return nil }
    guard let typeAnnotation = binding.typeAnnotation else { return nil }
    
    return (pattern, typeAnnotation)
}

以下ではプロパティのパターン(変数名)と型アノテーションを含むタプルとして variableDecls を定義しています。

patternIdentifierPatternSyntax 型で、変数名を表します。
typeAnnotationTypeAnnotationSyntax 型で、変数の型情報を表します。つまり、変数名とその型情報がセットで格納されます。

let variableDecls: [(pattern: IdentifierPatternSyntax, typeAnnotation: TypeAnnotationSyntax)]

次に以下の部分です。
declaration.memberBlock.members は、このマクロが適用されるクラスや構造体のすべてのメンバー(メソッド、プロパティ)を含むリストになっています。
compactMap によって、それぞれのメンバーに対して処理を行い、その返り値を variableDecls に格納していきます。

variableDecls = declaration.memberBlock.members.compactMap {
実際のクラスの例

以下のようなクラスに対して今作成している JsonCodable マクロを適用したと考えましょう。

class Person {
    var name: String
    var age: Int
    let birthDate: Date?
}

この時、declaration.memberBlock.members にあたるのは以下のメンバーの集まりになります。

var name: String
var age: Int
let birthDate: Date?

次に以下の部分です。
以下では変数宣言の抽出を行なっています。
各メンバーが VariableDeclSyntax型、つまり変数宣言であることを確認しています。変数宣言でなければ nil を返し、そのメンバーは結果として返却するリストには含まれません。つまり、クラス内に含まれるメソッドなどはここで除外されます。

guard let decl = $0.decl.as(VariableDeclSyntax.self) else { return nil }
実際のクラスの例

Person クラスについて考えてみます。

class Person {
    var name: String
    var age: Int
    let birthDate: Date?
}

この例では var name: String, var age: Int, let birthDate: Date? の三つ全てが変数宣言であるため、全て VariableDeclSyntax になります。

次に以下の部分です。
ここでは先ほど定義した変数宣言から最初のバインディングを抽出しています。
バインディングは変数の最初の定義の部分に当たります。

guard let binding = decl.bindings.first else { return nil }
実際のクラスの例
class Person {
    var name: String
    var age: Int
    let birthDate: Date?
}

この場合におけるバインディングは var name: String, var age: Int, let birthDate: Date? となり、全て単純な変数の定義であるためそのままバインディングになります。

次に以下の部分です。
以下では先ほど定義したバインディングから変数のパターン(名前)を抽出しています。
パターンが IdentifierPatternSyntax型でなければ、nil を返してスキップします。

guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else { return nil }
実際のクラスの例
class Person {
    var name: String
    var age: Int
    let birthDate: Date?
}

この時のバインディングのパターンは変数のパターン、つまり変数名であるため、name, age, birthDate が変数のパターン(IdentifierPatternSyntax)に当たります。

次に以下の部分です。
以下ではバインディングから型アノテーションを抽出しています。
型アノテーションが存在しない場合は、nil を返してスキップします。

guard let typeAnnotation = binding.typeAnnotation else { return nil }
実際のクラスの例
class Person {
    var name: String
    var age: Int
    let birthDate: Date?
}

この時のバインディングの型アノテーションは以下のようになります。
これらが各々 typeAnnotation 変数に代入されていきます。
name: String の型アノテーションは String
age: Int の型アノテーションは Int
birthDate: Date? の型アノテーションは Date?

最後に以下の部分です。
以下ではタプルの作成と返却を行なっています。
パターン(変数名)と型アノテーションをタプルとして返却します。
この結果が variableDecls 変数に格納されるようになります。

return (pattern, typeAnnotation)
実際のクラスの例
class Person {
    var name: String
    var age: Int
    let birthDate: Date?
}

この時、Person クラスのメンバーを順に解析し、変数宣言だけを抽出し、パターン(変数名)と型アノテーションをタプルに格納するため、最終的に、variableDecls には次のようなデータが含まれます。

[
    (pattern: IdentifierPatternSyntax(identifier: "name"), typeAnnotation: TypeAnnotationSyntax(type: "String")),
    (pattern: IdentifierPatternSyntax(identifier: "age"), typeAnnotation: TypeAnnotationSyntax(type: "Int")),
    (pattern: IdentifierPatternSyntax(identifier: "birthDate"), typeAnnotation: TypeAnnotationSyntax(type: "Date?"))
]

これにより、variableDecls には JsonCodableマクロを適用したアクターやクラス、構造体のメンバー変数の変数名と型アノテーションがセットになったタプルが格納されるようになります。

この variableDecls を使って構築したいマクロを記述していきます。
では下のコードに戻ります。

次のコードは以下です。
以下ではすべてのプロパティを初期化する init メソッドのマクロを書いています。先ほど定義した variableDecls を map で展開し、それぞれの変数の変数名と型アノテーションを用いて初期化処理を書いています。

let initDecl = DeclSyntax(stringLiteral: 
"""
init(
    \(variableDecls.map { "\($0.pattern)\($0.typeAnnotation)" }.joined(separator: ",\n"))
) {
    \(variableDecls.map { "self.\($0.pattern) = \($0.pattern)" }.joined(separator: "\n"))
}
"""
)
実際のクラスの例
class Person {
    var name: String
    var age: Int
    let birthDate: Date?
}

Person クラスの初期化メソッドのマクロについて考えてみます。
先ほど示した通り、variableDecls には次のようなデータが含まれます。

[
    (pattern: IdentifierPatternSyntax(identifier: "name"), typeAnnotation: TypeAnnotationSyntax(type: "String")),
    (pattern: IdentifierPatternSyntax(identifier: "age"), typeAnnotation: TypeAnnotationSyntax(type: "Int")),
    (pattern: IdentifierPatternSyntax(identifier: "birthDate"), typeAnnotation: TypeAnnotationSyntax(type: "Date?"))
]

現段階ではまだ JsonCoabele マクロは使えないかと思いますが、マクロの記述をもとに init メソッドを展開すると以下のようになります。

init(
    name: String,
    age: Int,
    birthDate: Date?
) {
    self.name = name
    self.age = age
    self.birthDate = birthday
}

通常時に記述する初期化メソッドと同じ内容になっていることがわかります。

次に以下の部分です。
以下ではインスタンスをJSONにエンコードするための toJson メソッドを生成しています。ここではクラスのメンバ変数などは使用しません。

let toJsonDecl = DeclSyntax(stringLiteral: """
public func toJson() -> String? {
    let encoder = JSONEncoder()
    guard let data = try? encoder.encode(self) else {
        return nil
    }
    return String(data: data, encoding: .utf8)
}
""")

次に以下の部分です。
以下ではJSONからインスタンスをデコードするための  fromJson メソッドを生成しています。ここでもクラスのメンバ変数などを用いずに生成しています。

let fromJsonDecl = DeclSyntax(stringLiteral: """
public static func fromJson(_ json: String) -> Self? {
    let decoder = JSONDecoder()
    guard let data = json.data(using: .utf8),
          let object = try? decoder.decode(Self.self, from: data) else {
        return nil
    }
    return object
}
""")

次に以下の部分です。
以下ではデータベースに保存するときなどに使用する CodingKeys の enum を生成しています。

let codingKeysCases = variableDecls.map { decl -> String in
    let pattern = decl.pattern.identifier.text
    let snakeCaseName = pattern.toSnakeCase() ?? pattern
    return "case \(pattern) = \"\(snakeCaseName)\""
}.joined(separator: "\n")

let codingKeysEnumDecl = DeclSyntax(stringLiteral: """
public enum CodingKeys: String, CodingKey {
    \(codingKeysCases)
}
""")
実際のクラスの例
class Person {
    var name: String
    var age: Int
    let birthDate: Date?
}

Person クラスの CodingKeys のマクロについて考えてみます。
variableDecls をそれぞれ展開しており、その中の処理についてみていきます。
以下では pattern にはそれぞれ name age birthDate がはいります。

let pattern = decl.pattern.identifier.text

以下では snakeCaseName には name age birth_date がはいります。.toSnakeCase() で通常は lowerCamelCase で記述される変数名をスネークケースに変換しています。この実装については後述します。

let snakeCaseName = pattern.toSnakeCase() ?? pattern

そして実際に返却される codingKeysEnumDecl の中身は以下のようになっています。

public enum CodingKeys: String, CodingKey {
    case name = "name"
    case age = "age"
    case birthDate = "birth_date"
}

最後に以下の部分で生成されたコードを返却しています。
これでマクロを生成する処理については完了です。

return [
    initDecl,
    toJsonDecl,
    fromJsonDecl,
    codingKeysEnumDecl
]
実際のクラスの例
class Person {
    var name: String
    var age: Int
    let birthDate: Date?
}

Person クラスに JsonCodable アノテーションを付与した場合、以下のようなコードが生成されるようになります。

init(
    name: String,
    age: Int,
    birthDate: Date?
) {
    self.name = name
    self.age = age
    self.birthDate = birthDate
}

public func toJson() -> String? {
    let encoder = JSONEncoder()
    guard let data = try? encoder.encode(self) else {
        return nil
    }
    return String(data: data, encoding: .utf8)
}

public func fromJson(_ json: String) -> Self? {
    let decoder = JSONDecoder()
    guard let data = json.data(using: .utf8),
          let object = try? decoder.decode(Self.self, from: data) else {
        return nil
    }
    return object
}

public enum CodingKeys: String, CodingKey {
    case name = "name"
    case age = "age"
    case birthDate = "birth_date"
}

以下ではマクロを CompilerPlugin として外部でも使用できるようにしています。

@main
struct JsonCodablePlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        JsonCodableMacro.self,
    ]
}

また、以下は CodingKeys を生成する際に変数名を lowerCamelCase からスネークケースに変換するために使用した String の extension になります。

extension String {
    func toSnakeCase() -> String? {
        guard !isEmpty else { return nil }

        var result = ""
        var shouldAddUnderscore = false

        for character in self {
            if character.isUppercase {
                if shouldAddUnderscore {
                    result += "_"
                }
                result += character.lowercased()
                shouldAddUnderscore = true
            } else {
                result += String(character)
                shouldAddUnderscore = true
            }
        }

        return result
    }
}

これでマクロの作成は完了です。改めて、今回作成したマクロは以下になります。

  • 初期化処理
  • toJson
  • fromJson
  • CodingKeys

3. 使用できるよう変更

マクロの作成は完了したので、次はマクロを使用できるように変更していきます。
JsonCodableMacro.swift を編集していきます。
コードは以下の通りです。

JsonCodableMacro.swift
@attached(member, names: named(init), named(toJson), named(fromJson), named(CodingKeys))
public macro JsonCodable() = #externalMacro(module: "JsonCodableMacroPlugin", type: "JsonCodableMacro")

少し詳しくみていきます。

以下の部分では @attached アノテーションをつけることでSwiftコンパイラに対して、このマクロがメンバーを追加するものであることを示しています。今回作成した JsonCodableMacroMemberMacro に分類されるため、この@attached アノテーションを使用します。
また、マクロが生成するメンバーの名前として、init, toJson fromJson CodingKeys を指定しています。ここで定義していないメンバーはエラーになるのでご注意ください。

@attached(member, names: named(init), named(toJson), named(fromJson), named(CodingKeys))

また、以下の部分ではコード内で JsonCodable マクロを使用できるようにしています。
具体的には、JsonCodable というマクロを定義し、その実装が外部のモジュール JsonCodableMacroPlugin にあることを示しています。

public macro JsonCodable() = #externalMacro(module: "JsonCodableMacroPlugin", type: "JsonCodableMacro")

これで JsonCodableMacro.swift の設定は完了です。

4. Packageの変更

次に Package.swift の変更を行います。
コードは以下の通りです。
通常であれば特に変更する必要はないかと思います。
筆者の場合は JsonCodableMacroMacro だったフォルダやファイルを JsonCodableMacroPlugin に変更したりするなどしています。

import PackageDescription
import CompilerPluginSupport

let package = Package(
    name: "JsonCodableMacro",
    platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)],
    products: [
        .library(
            name: "JsonCodableMacro",
            targets: ["JsonCodableMacro"]
        ),
        .executable(
            name: "JsonCodableMacroClient",
            targets: ["JsonCodableMacroClient"]
        ),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0-swift-5.9-DEVELOPMENT-SNAPSHOT-2023-04-25-b"),
    ],
    targets: [
        .macro(
            name: "JsonCodableMacroPlugin",
            dependencies: [
                .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
                .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
            ]
        ),
        .target(name: "JsonCodableMacro", dependencies: ["JsonCodableMacroPlugin"]),

        .executableTarget(name: "JsonCodableMacroClient", dependencies: ["JsonCodableMacro"]),

        .testTarget(
            name: "JsonCodableMacroTests",
            dependencies: [
                "JsonCodableMacroPlugin",
                "JsonCodableMacroClient",
                .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
            ]
        ),
    ]
)

5. main.swift の実装

次は main.swift を変更していきます。
コードは以下の通りです。

import Foundation
import JsonCodableMacro

@JsonCodable
public class Person: Codable {
  let name: String
  let age: Int
  let birthDate: Date?
}

@JsonCodable
public struct Book: Codable {
    var title: String
    var author: String
    var publishedYear: Int
}

@JsonCodable
public struct Library: Codable {
    var name: String
    var books: [Book]
}

let calendar = Calendar.current
let date = calendar.date(from: DateComponents(year: 2021, month: 3, day: 1))

let person = Person(name: "Bob", age: 21, birthDate: date)

// DateFormatter を使用して適切なタイムゾーンを設定
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
dateFormatter.timeZone = TimeZone.current

if let birthday = person.birthDate {
    let birthdayString = dateFormatter.string(from: birthday)
    print("Name: \(person.name), Age: \(person.age), Birthday: \(birthdayString)")
}

if let json = person.toJson() {
    print(json)
    if let decodedPerson = person.fromJson(json) {
        if let birthday = decodedPerson.birthDate {
            let birthdayString = dateFormatter.string(from: birthday)
            print("Name: \(decodedPerson.name), Age: \(decodedPerson.age), Birthday: \(birthdayString)")
        }
    }
}

let book = Book(title: "Swift Programming", author: "Apple", publishedYear: 2021)
if let bookJson = book.toJson() {
    print(bookJson)
    if let decodedBook = book.fromJson(bookJson) {
        print("Title: \(decodedBook.title), Author: \(decodedBook.author), Published Year: \(decodedBook.publishedYear)")
    }
}

let library = Library(name: "City Library", books: [book])
if let libraryJson = library.toJson() {
    print(libraryJson)
    if let decodedLibrary = library.fromJson(libraryJson) {
        print("Library Name: \(decodedLibrary.name), Books: \(decodedLibrary.books.map { $0.title }.joined(separator: ", "))")
    }
}

それぞれ詳しくみていきます。
以下の部分では Person Book Library クラス、構造体に対して @JsonCodable アノテーションを付与しています。

@JsonCodable
public class Person: Codable {
  let name: String
  let age: Int
  let birthDate: Date?
}

@JsonCodable
public struct Book: Codable {
    var title: String
    var author: String
    var publishedYear: Int
}

@JsonCodable
public struct Library: Codable {
    var name: String
    var books: [Book]
}

以下では Person に付与された @JsonCodable アノテーションが正常に動作しているかを確認しています。birthDate の部分でタイムゾーンを設定するなどして少し処理を加えていますが、PersontoJson fromJson がうまく動作するかを確かめています。

let calendar = Calendar.current
let date = calendar.date(from: DateComponents(year: 2021, month: 3, day: 1))

let person = Person(name: "Bob", age: 21, birthDate: date)

// DateFormatter を使用して適切なタイムゾーンを設定
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
dateFormatter.timeZone = TimeZone.current

if let birthday = person.birthDate {
    let birthdayString = dateFormatter.string(from: birthday)
    print("Name: \(person.name), Age: \(person.age), Birthday: \(birthdayString)")
}

if let json = person.toJson() {
    print(json)
    if let decodedPerson = person.fromJson(json) {
        if let birthday = decodedPerson.birthDate {
            let birthdayString = dateFormatter.string(from: birthday)
            print("Name: \(decodedPerson.name), Age: \(decodedPerson.age), Birthday: \(birthdayString)")
        }
    }
}

コードを実行すると以下のような出力になり、正常に toJson fromJson が動作していることがわかります。

Name: Bob, Age: 21, Birthday: 2021-03-01  // Person に当てはめて各プロパティを出力
{"name":"Bob","birthday":636217200,"age":21}  // toJson した内容を出力
Name: Bob, Age: 21, Birthday: 2021-03-01  // fromJson した内容を出力

以下では Book Library の動作確認を行なっています。

let book = Book(title: "Swift Programming", author: "Apple", publishedYear: 2021)
if let bookJson = book.toJson() {
    print(bookJson)
    if let decodedBook = book.fromJson(bookJson) {
        print("Title: \(decodedBook.title), Author: \(decodedBook.author), Published Year: \(decodedBook.publishedYear)")
    }
}

let library = Library(name: "City Library", books: [book])
if let libraryJson = library.toJson() {
    print(libraryJson)
    if let decodedLibrary = library.fromJson(libraryJson) {
        print("Library Name: \(decodedLibrary.name), Books: \(decodedLibrary.books.map { $0.title }.joined(separator: ", "))")
    }
}

実行してみると以下のように、Person と同様うまく動作していることがわかります。

{"title":"Swift Programming","author":"Apple","published_year":2021}
Title: Swift Programming, Author: Apple, Published Year: 2021
{"name":"City Library","books":[{"title":"Swift Programming","author":"Apple","published_year":2021}]}
Library Name: City Library, Books: Swift Programming

6. テストの記述

最後にテストを記述してみます。
JsonCodableMacroTests.swift を編集していきます。
コードは以下の通りです。

import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import XCTest

#if canImport(JsonCodableMacroPlugin)
import JsonCodableMacroPlugin
import JsonCodableMacroClient

let testMacros: [String: Macro.Type] = [
    "JsonCodable": JsonCodableMacro.self,
]
#endif

final class JsonCodableMacroTests: XCTestCase {
    func testMacro() throws {
        #if canImport(JsonCodableMacroPlugin)
        assertMacroExpansion(
            """
            @JsonCodable
            class Person: Codable {
                let name: String
                let age: Int
                let birthday: Date?
            }
            """,
            expandedSource:
            """
            class Person: Codable {
                let name: String
                let age: Int
                let birthday: Date?
            
                init(
                    name: String,
                    age: Int,
                    birthday: Date?
                ) {
                    self.name = name
                    self.age = age
                    self.birthday = birthday
                }
            
                public func toJson() -> String? {
                    let encoder = JSONEncoder()
                    guard let data = try? encoder.encode(self) else {
                        return nil
                    }
                    return String(data: data, encoding: .utf8)
                }
            
                public func fromJson(_ json: String) -> Self? {
                    let decoder = JSONDecoder()
                    guard let data = json.data(using: .utf8),
                          let object = try? decoder.decode(Self.self, from: data) else {
                        return nil
                    }
                    return object
                }
            
                public enum CodingKeys: String, CodingKey {
                    case name = "name"
                    case age = "age"
                    case birthday = "birthday"
                }
            }
            """,
            macros: testMacros
        )
        #else
        throw XCTSkip("macros are only supported when running tests for the host platform")
        #endif
    }
    
    func testBookMacro() throws {
        #if canImport(JsonCodableMacroPlugin)
        assertMacroExpansion(
            """
            @JsonCodable
            struct Book: Codable {
                let title: String
                let author: String
                let publishedYear: Int
            }
            """,
            expandedSource:
            """
            struct Book: Codable {
                let title: String
                let author: String
                let publishedYear: Int
            
                init(
                    title: String,
                    author: String,
                    publishedYear: Int
                ) {
                    self.title = title
                    self.author = author
                    self.publishedYear = publishedYear
                }
            
                public func toJson() -> String? {
                    let encoder = JSONEncoder()
                    guard let data = try? encoder.encode(self) else {
                        return nil
                    }
                    return String(data: data, encoding: .utf8)
                }
            
                public func fromJson(_ json: String) -> Self? {
                    let decoder = JSONDecoder()
                    guard let data = json.data(using: .utf8),
                          let object = try? decoder.decode(Self.self, from: data) else {
                        return nil
                    }
                    return object
                }
            
                public enum CodingKeys: String, CodingKey {
                    case title = "title"
                    case author = "author"
                    case publishedYear = "published_year"
                }
            }
            """,
            macros: testMacros
        )
        #else
        throw XCTSkip("macros are only supported when running tests for the host platform")
        #endif
    }

    func testLibraryMacro() throws {
        #if canImport(JsonCodableMacroPlugin)
        assertMacroExpansion(
            """
            @JsonCodable
            struct Library: Codable {
                let name: String
                let books: [Book]
            }
            """,
            expandedSource:
            """
            struct Library: Codable {
                let name: String
                let books: [Book]
            
                init(
                    name: String,
                    books: [Book]
                ) {
                    self.name = name
                    self.books = books
                }
            
                public func toJson() -> String? {
                    let encoder = JSONEncoder()
                    guard let data = try? encoder.encode(self) else {
                        return nil
                    }
                    return String(data: data, encoding: .utf8)
                }
            
                public func fromJson(_ json: String) -> Self? {
                    let decoder = JSONDecoder()
                    guard let data = json.data(using: .utf8),
                          let object = try? decoder.decode(Self.self, from: data) else {
                        return nil
                    }
                    return object
                }
            
                public enum CodingKeys: String, CodingKey {
                    case name = "name"
                    case books = "books"
                }
            }
            """,
            macros: testMacros
        )
        #else
        throw XCTSkip("macros are only supported when running tests for the host platform")
        #endif
    }
}

SwiftSyntaxパッケージにはテストのサポートもあり、assertMacroExpansion 関数の中でマクロを展開し、expandedSource に書かれている展開後のマクロと一致するかどうかでテストを行うことができます。
想定していた出力と異なる場合は、どこが違うかを +, - で比較しながら見ることができます。

以上です。今回実装したマクロ以外にも様々カスタマイズして作成してみると理解も深まり、面白いと思います。

まとめ

最後まで読んでいただいてありがとうございました。

今回は Swift Macros の実装を行いました。
元々は Dart Macros との比較を行い、 Swift の実装を参考にするというモチベーションでした。
今回 Swift Macros を触ってみて感じたことは、 Dart に比べて構文解析やマクロに関する情報の蓄積が非常に多いことです。もちろん、自分が Dart に関してそこまで深く調べきれていないことや、 Dart Macros が登場して間もないことも大きいかと思いますが...

また、Dart 側で実装されている JsonCodable マクロに関して、公式ページでその変更方法や実装方法が詳しく書かれていないことを考えると、Dart ではマクロを「作る」というよりは「使う」方に重点を置いているように感じました。

誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

今回は以下の「iOSDC Japan 2023: Mastering SwiftSyntax」をもとに実装しました。よろしければこちらも併せてご覧ください。
https://www.youtube.com/watch?v=bivRQUgavD8

Discussion