こんにちは、お久しぶりです。 気づけば入社して一年が経過していました。サカパ・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の構文解析を行うことがきでます。 これで、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はまだ発表されたばかりで、情報も少ないですが使ってみるとかなり興味深いものだと思いました。 上手に使えば、開発の効率を上げることができますが、使いすぎるとかえってコードの可読性が悪くなってしまうので、取り扱いには気をつけたいところです。
いつもの
弊社ではいっしょに働くアプリエンジニアを募集しています! ご興味のある方はぜひご応募いただけますと嬉しいです。