🖨️

任意の繰り返しのボイラープレートを生成できるSwift Macro gsbを作った

に公開

ボイラープレートとSwift Macro

プログラミングにおいて、「似た構造が複数箇所または複数回繰り返される冗長なコード」が出現することがあります。これをボイラープレートと呼びます。

Swiftでのボイラープレートの解決策の一つにSwift 5.9から追加されたSwift Macroがあります。Swift Macroはmacro呼び出しから別のSwiftコードをコンパイルタイムで変換・出力する言語機能で、対応するmacroを実装すれば、ボイラープレートをmacro呼び出しに置き換えることができます。

gsb (generate swift boilerplate) macro

ボイラープレート削減手法の一つとして、任意の繰り返しのボイラープレートを生成できるマクロを作りました。

https://github.com/kntkymt/swift-gsb-macro

  • freestanding declaration macroの#gsbDeclを用いることで、任意のString Literalから任意の宣言(struct/enum/actorclass/protocol)を出力することができます
  • freestanding expression macroの#gsbForEachを用いることでクロージャー内に書かれたテンプレートを元に、展開されたコード(のString Literal)を生成できます。

この例では

  1. #gsbForEachに「system imageをstatic varで定義するテンプレート」と「展開に用いる値(変数名とsystem name)」を渡すことで、#gsbForEachがテンプレート展開後のStringLiteralを生成し
  2. そのStringLiteralを更に#gsbDeclに渡すことでテンプレート展開後のコードを定義として生成

しています。

import SwiftUI

extension Image {
    #gsbDecl {
        #gsbForEach(
            [
                ("clear", "xmark.circle.fill"),
                ("search", "magnifyingglass"),
                ("down", "chevron.down"),
                ("filter", "line.3.horizontal.decrease.circle")
            ]
        ) { varName, sysName in
            """
            static var \(varName): Image {
                Image(systemName: "\(sysName)")
            }
            """
        }
    }
}
展開結果
extension Image {
    static var clear: Image {
        Image(systemName: "xmark.circle.fill")
    }
    
    static var search: Image {
        Image(systemName: "magnifyingglass")
    }
    
    static var down: Image {
        Image(systemName: "chevron.down")
    }
    
    static var filter: Image {
        Image(systemName: "line.3.horizontal.decrease.circle")
    }
}

また、#gsbExprによって定義だけではなく、任意の手続きも生成できます。(即時実行クロージャー{}()として展開されます)

import GSB

struct StorageTest {
    func testStorageReadWrite() {
        #gsbExpr {
            """
            var storage = Storage()
            """

            #gsbForEach(
                [
                    ("bool", "false", "true"),
                    (
                        "string",
                        #""""#,
                        #""value""#
                    ),
                    ("int", "0", "10"),
                ]
            ) { key, defaultValue, newValue in
                """
                assert(storage.\(key), \(defaultValue))
                storage.\(key) = \(newValue)
                assert(storage.\(key), \(newValue))
                """
            }
        }
    }
}
展開結果
struct StorageTest {
    func testStorageReadWrite() {
        {
            var storage = Storage()
            
            assert(storage.bool, false)
            storage.bool = true
            assert(storage.bool, true)
            
            assert(storage.string, "")
            storage.string = "value"
            assert(storage.string, "value")
            
            assert(storage.int, 0)
            storage.int = 10
            assert(storage.int, 10)
        }()
    }
}

このmacroはAppleがSwiftコンパイラ開発周辺で作成・利用しているツールgyb (generate your boilerplate) にインスパイアを受けて作りました。また、SE-0397 Freestanding Declaration Macros でも利用例としてほぼ同じアイデアのmacro#gybが紹介されています。

良さそうな利用例

コンセプトがシンプルだったのと、可能性は秘めてそうだったのでこのmacroを作りましたが、実際どの程度実用性があるのかは自分も検証中です。いくつか使ってみて良さそうだった例を紹介します。

ぜひ皆さんもgsbで遊んでみて、良さそうな例を共有してほしい

リソース定義

自分は普段マルチモジュールで開発しており、リソース(Color, Image)を置くモジュールと利用するモジュールを分けていた。そのためpublic static varでリソース定義を外部に公開する必要があった。この定義の記述にgsbが使えた。Imageも同様。

public extension Color {
    #gsbDecl {
        #gsbForEach([
            "brand",
            "brandHighlight",
            "brandSecondary",
            "highlight",
            "highlightedText",
        ]) { colorName in
            """
            static var \(colorName): Color {
                Color(.\(colorName))
            }
            """
        }
    }
}
展開結果
public extension Color {
    static var brand: Color {
        Color(.brand)
    }
    
    static var brandHighlight: Color {
        Color(.brandHighlight)
    }
    
    static var brandSecondary: Color {
        Color(.brandSecondary)
    }
    
    static var highlight: Color {
        Color(.highlight)
    }
    
    static var highlightedText: Color {
        Color(.highlightedText)
    }
}

また、以下のケースでも利用できた。

  • system colorはColor(.systemBackground)のようにinitになっていて鬱陶しいのでstatic varに再定義していた。
  • SF symbolに関してもtype-safeに取得できるようにstatic varに再定義していた。

(Parameterizedが上手くできない) テスト

Swift Testingには@Testmacroを使ったParameterlized testによってボイラープレートを削減できる仕組みが既に存在する。

しかし、@Testはジェネリクスには対応していない関係で、パラメーターがExistentialやAnyとして扱われて具合が悪い/Parameterlizedを解いてベタ書きせざるを得ないことがある。そのような場合に#gsbExprを利用できた。

こういうのが具合悪い
@Test(
    arguments: [
        (
            "bool",
            Bool.self,
            false
        ),
        (
            "a",
            Data.self,
            "test data".data(using: .utf8)!
        )
    ]
    // これもめんどい、attach先のfuncの方情報をTest macroの引数部分は参照しないので期待通りの推論がされず、このasがないと[Any]になる
    as [(String, ValueStoreable.Type, any ValueStoreable)]
)
func testReadWriteValues(
    key: String,
    type: ValueStoreable.Type,
    value: any ValueStoreable
) {
    let storage = ...

    #expect(storage.get(for: key, type: type) == nil)

    let data = value
    storage.set(data, for: key)
    #expect(storage.get(for: key, type: type) == data) // 🔴 Type `any ValueStoreble` cannot conform to `Equatable`.

    storage.reset()
    #expect(storage.get(for: key, type: type) == nil)
}
func testReadWriteValues() {
    let storage = ...

    #gsbExpr {
        #gsbForEach(
            [
                (
                    "bool",
                    "Bool",
                    "false"
                ),
                (
                    "string",
                    "String",
                    #""test""#
                ),
                (
                    "data",
                    "Data",
                    #""test data".data(using: .utf8)!"#
                )
            ]
        ) { key, type, value in
            """
            do {
                let key = "\(key)"
                #expect(storage.get(for: key, type: \(type).self) == nil)

                let data = \(value)
                storage.set(data, for: key)
                #expect(storage.get(for: key, type: \(type).self) == data)
            
                storage.reset()
                #expect(storage.get(for: key, type: \(type).self) == nil)
            }
            """
        }
    }
}
展開結果
func testReadWriteValues() {
    let storage = InMemoryStorage()

    {
        do {
            let key = "bool"
            #expect(storage.get(for: key, type: Bool.self) == nil)
            
            let data = false
            storage.set(data, for: key)
            #expect(storage.get(for: key, type: Bool.self) == data)
            
            storage.reset()
            #expect(storage.get(for: key, type: Bool.self) == nil)
        }
        
        do {
            let key = "string"
            #expect(storage.get(for: key, type: String.self) == nil)
            
            let data = "test"
            storage.set(data, for: key)
            #expect(storage.get(for: key, type: String.self) == data)
            
            storage.reset()
            #expect(storage.get(for: key, type: String.self) == nil)
        }
        
        do {
            let key = "data"
            #expect(storage.get(for: key, type: Data.self) == nil)
            
            let data = "test data".data(using: .utf8)!
            storage.set(data, for: key)
            #expect(storage.get(for: key, type: Data.self) == data)
            
            storage.reset()
            #expect(storage.get(for: key, type: Data.self) == nil)
        }
    }()
}

使えなかった利用例

型定義・extension

これが最も痛い。元ネタgybの最頻出パターンの一つが型ファミリー(Int/UInt/Int64/UInt64/...やSIMD2/3/4/8...など)の定義やファミリーへのextensionの一括記述なのだが、型定義はSwift Macroの制約によってトップレベルに任意識別子のdeclを出力できないようになっており、extensionは識別子以前にfreestanding decalartion macroでの出力が禁止されている (abuseを防ぐため)。よってこれらのどちらもgsbでは利用できない。

(実際にはエラーになるが)使用例: (8/16/31/64)*Signed/Unsignedの8個の整数型に「全てビットが1」のcomputed propertyを生やす

引用元記事

#gsbDecl {
    #gsbForEach(["8", "16", "32", "64"]) { intSize in
        #gsbForEach(["", "U"]) { sign in
            #gsbLet("\(sign)Int\(intSize)") { intType in
            """
            extension \(intType) {
                public static var allOnes: \(intType) {
            """

                #gsbIf("\(sign)", equalsTo: "") {
                """
                \(intType)(bitPattern: U\(intType).max)
                """
                }

                #gsbIf("\(sign)", equalsTo: "U") {
                """
                .max
                """
                }

            """
                }
            }
            """
            }
        }
    }
}
展開結果
extension Int8 {
        public static var allOnes: Int8 {
            
            Int8(bitPattern: UInt8.max)
            
        }
    }
    
    extension UInt8 {
        public static var allOnes: UInt8 {
            
            .max
            
        }
    }
    
    extension Int16 {
        public static var allOnes: Int16 {
            
            Int16(bitPattern: UInt16.max)
            
        }
    }
    
    extension UInt16 {
        public static var allOnes: UInt16 {
            
            .max
            
        }
    }
    
    extension Int32 {
        public static var allOnes: Int32 {
            
            Int32(bitPattern: UInt32.max)
            
        }
    }
    
    extension UInt32 {
        public static var allOnes: UInt32 {
            
            .max
            
        }
    }
    
    extension Int64 {
        public static var allOnes: Int64 {
            
            Int64(bitPattern: UInt64.max)
            
        }
    }
    
    extension UInt64 {
        public static var allOnes: UInt64 {
            
            .max
            
        }
    }

型宣言については迂回策としてenumなどのネームスペースを区切ってネストした場所になら定義することができるが、インターフェース的に不自然になってしまう場合も多いので微妙である。

演算子定義

これも痛い。これも元ネタgybの最頻出パターンの一つ。
たとえば複数の数値型を保持する箱ような型(Point/Vectorとか)を作ったとき、箱同士の演算子が欲しくなる。この演算子は単純に保持する要素に演算を展開するだけなので、ボイラープレートチックなコードになる。

こちらはgsb macroの記述がコンパイルエラーになるわけではないが、演算子呼び出しの解決時にmacro展開して検索候補に入れてくれないっぽく、演算子の呼び出しがコンパイルエラーになる。(コレはバグなのか仕様なのかわからない)

struct Point<Element: Numeric> {
    var x: Element
    var y: Element

    #gsbDecl {
        #gsbForEach(["+", "-"]) { symbol in
            """
            static func \(symbol)(a: Element, b: Element) -> Element {
                a \(symbol) b
            }
            """
        }
    }
}

let p1 = Point<Int>(x: 1, y: 1)
let p2 = Point<Int>(x: 2, y: 2)
let p3 = p1 + p2 // 🔴 Binary operator '+' cannot be applied to two 'Point<Int>' operands

ランタイムに取れる結果を利用する制御

そもそもコレは動作原理に関わるので最初からできないことが判明している内容なのだが紹介。
元ネタのgybはPythonプログラムを実行しテンプレートを展開するので、Pythonでランタイムに実行できる・取れる情報を活用してテンプレート生成を制御できる。
一方gsbはSwift Macroであり、Swift Macroはコンパイルタイムで構文を置き換える・追加する仕組みなので、コンパイルタイムの構文上の情報しか制御に利用できない。

たとえば自作固定長配列MyArrayに「要素数が偶数の時のみ左右半分一方を取得するcomputed property leftHalf, rightHalf」を追加したいとする。
gybはpythonランタイムに取れるnumber % 2number / 2のような演算の実行結果を利用してテンプレートを制御できるが、gsbはコンパイルタイムの情報しか使えないのでこれが基本的にはできない。(swift macroの中にSwiftコンパイラとランタイムを組み込みでもすれば話は別だが)

%  for number in [1,2,3,4]:
struct MyArray${number} {
    ...
// このifがgsbは無理、`number % 2 == 0`の結果はランタイムで取れる
%    if number % 2 == 0:
%    halfNumber = number / 2
    var leftHalf: Vector${halfNumber} {
        .init(self[..<halfNumber])
    }

    var rightHalf: Vector${halfNumber} {
        .init(self[halfNumber...])
    }
%   end
}

精々できるのは文字列上での単純比較までであり、gsbは#gsbIfとして単純比較による制御を提供している。

使用例

func assert<T: Equatable>(_ a: T, _ b: T) {
    print(a == b)
}

struct FloatTest {
    #gsbDecl {
        #gsbForEach(
            [
                "Float",
                "Double",
                "Float16"
            ]
        ) { floatType in
            #gsbIf("\(floatType)", equalsTo: "Float16") {
                "@available(macOS 11.0, *)"
            }
            """
            func testFloatMultiply\(floatType)() {
                let float: \(floatType) = 10
                assert(float * float, 100)
            }
            """
        }
    }
}

参考資料

https://github.com/swiftlang/swift-evolution/blob/main/visions/macros.md

https://github.com/swiftlang/swift-evolution/blob/main/proposals/0397-freestanding-declaration-macros.md

https://qiita.com/omochimetaru/items/422ddd04e95c55dd3833

Discussion