Swift Digest
SE-0450 | Swift Evolution

Package traits

Proposal
SE-0450
Authors
Franz Busch, Max Desiatov
Review Manager
Mishal Shah
Status
Implemented (Swift 6.1)

01 何が問題だったのか

SwiftPMのパッケージは機能も規模も拡大し、Apple プラットフォームだけでなくサーバ、embedded、Wasm など多様な環境で使われるようになりました。その過程で、「同じパッケージを状況に応じて少しずつ違う構成で使いたい」というニーズが強まってきましたが、SwiftPM にはそれを素直に表現する仕組みがありませんでした。

オプショナルな依存を差し替えたい

たとえば Swift OpenAPI Generator は、HTTP 通信部分として URLSession / AsyncHTTPClient / Hummingbird / Vapor のいずれかを選べるように作られています。ところが、これらの依存を単一のパッケージにすべて含めてしまうと、使わないライブラリまで利用者のバイナリに混ざり込んでしまいます。結果として、transport ごとに別リポジトリを切り出す運用が行われていますが、利用者は「どの追加リポジトリを足せばよいか」を自力で調べて依存関係を書き足す必要があり、発見性と使い勝手の両面で負担が残ります。

プラットフォーム別に振る舞いを切り替えたい

ロギングのように、プラットフォームごとに流儀が違う機能もあります。Apple プラットフォームでは OSLog、サーバ側では swift-log が一般的ですが、「Apple プラットフォームでも swift-log を使いたい」という利用者もいます。こうしたケースは #if os(...) のようなプラットフォーム条件ではうまく表現できず、ライブラリ側で選択肢を提供する手段がありませんでした。

Package.swift の環境変数への依存

多くのパッケージは、オプショナルな依存の有効化やローカル開発向けの define 設定などを、Package.swift の中で環境変数を読んで切り替えていました。これは公式にサポートされた使い方ではなく、今後 SwiftPM のサンドボックス化が進めば動かなくなる可能性もあります。それでも他に手段がないため、事実上の回避策として広く使われてきました。

実験的 API の扱い

まだ公開 API として安定させる準備はできていないが配布はしたい、という API を扱うために、アンダースコア始まりの名前や専用の属性で「隠す」運用が行われてきました。この方法は動きはするものの、コード補完から API が見えにくくなるなどの副作用があり、機能そのものをオプトインで有効化する仕組みとしては不十分です。

02 どのように解決されるのか

SwiftPM に package traits という新しい設定を導入します。パッケージ作者は Package.swift で「どんな trait を提供するか」を宣言し、利用者側は依存を書くときにどの trait を有効化するかを選べます。trait に応じてオプショナルな依存を引き込んだり、#if でコードを出し分けたりできるため、環境変数や別リポジトリ分割といった従来の回避策に頼らずに、条件付きコンパイルとオプショナル依存を素直に表現できます。

trait の宣言

trait は Packagetraits: に列挙します。各 trait は名前を持ち、「この trait を有効化したときに一緒に有効になる他の trait」を enabledTraits で指定できます。.default(enabledTraits:) を使うと、デフォルトで有効になる trait の集合を宣言できます。

let package = Package(
    name: "Example",
    traits: [
        "Foo",
        .trait(
            name: "Bar",
            enabledTraits: [
                "Foo",
            ]
        ),
        .trait(
            name: "FooBar",
            enabledTraits: [
                "Foo",
                "Bar",
            ]
        ),
        .default(enabledTraits: ["Foo"]),
    ],
    // ...
)

trait 名は Swift の識別子として有効な文字(加えて -+)で構成する必要があり、default / defaults は大文字小文字を問わず予約されています。また、パッケージあたりの trait 数は当面 300 個までに制限されます。

依存パッケージの trait を有効化する

依存パッケージを書くときに traits: を指定すると、その依存でどの trait を有効化するかを選べます。デフォルト trait を有効にしたい場合は .defaults を明示的に含めます。下の例は、デフォルト trait に加えて SomeTrait も有効化します。

dependencies: [
    .package(
        url: "https://github.com/Org/SomePackage.git",
        from: "1.0.0",
        traits: [
            .defaults,
            "SomeTrait",
        ]
    ),
]

traits: [] と空集合を渡すと、デフォルト trait も含めてすべて無効化できます。

dependencies: [
    .package(
        url: "https://github.com/Org/SomePackage.git",
        from: "1.0.0",
        traits: []
    ),
]

「自分のパッケージで Foo が有効なときだけ、依存側の SomeOtherTrait も有効にしたい」という条件付き有効化は .when(traits:) で表現します。

dependencies: [
    .package(
        url: "https://github.com/Org/SomePackage.git",
        from: "1.0.0",
        traits: [
            .trait(name: "SomeOtherTrait", condition: .when(traits: ["Foo"])),
        ]
    ),
]

ターゲット単位の条件付き依存

ターゲットが引く依存にも、trait による条件を付けられます。既存のプラットフォーム条件と同じ condition: を拡張する形で、次のように書けます。

targets: [
    .target(
        name: "SomeTarget",
        dependencies: [
            .product(
                name: "SomeProduct",
                package: "SomePackage",
                condition: .when(traits: ["Foo"])
            ),
        ]
    )
]

同じ .when(traits:)SwiftSettings / CSettings / CXXSettings / LinkerSettings にも指定できるので、ビルド設定も trait で条件分岐できます。

ソース上での trait チェック

有効化されている trait は、コンパイラに define として渡されます。これにより、#if で trait 名を直接チェックできます。オプショナル依存の import を trait で囲ったり、同じ関数の実装を trait によって切り替えたりといった使い方ができます。

#if Foo
import SomeDependency
#endif

func hello() {
    #if Foo
    Foo.hello()
    #else
    print("Hello")
    #endif
}

trait の統合(unification)と additive 原則

依存解決が終わったあと、SwiftPM はグラフ全体を見て「各パッケージについて最終的にどの trait を有効化するか」を計算します。あるパッケージの trait の有効状態は、その依存元ごとにばらばらに指定されるため、SwiftPM はそれらの 和集合 を取ります。結果として、自分のパッケージに対して依存グラフのどこから有効化が飛んでくるかは予測できません。

このため、trait は additive、つまり「有効化したときに機能が増えるだけで、減らない」ように設計する必要があります。trait を有効化することで API を取り除くような変更は SemVer 非互換の破壊的変更になり得ます。どうしても排他的な trait が必要な場合は、ビルド時に #error で検知できるようにしておくのが推奨されます。

#if Trait1 && Trait2
#error("Trait1 and Trait2 are mutually exclusive")
#endif

同様に、デフォルト trait から既存の trait を外す変更も、これまで「デフォルト trait 経由で API を使っていた」側を壊す可能性があるため、SemVer 非互換として扱う必要があります。

trait 名は package ごとの名前空間

trait 名はパッケージごとに独立した名前空間を持ちます。異なるパッケージが同じ trait 名を使っても問題なく、むしろ依存元で同名 trait を条件付きで有効化する使い方はごく自然です。

コマンドライン

ルートパッケージに対して swift test / swift build / swift run を実行するときは、コマンドラインから trait を制御できます。

  • --traits Trait1,Trait2: 指定した trait を有効化します。
  • --enable-all-traits: すべての trait を有効化します。
  • --disable-default-traits: デフォルト trait をすべて無効化します。

また、swift package dump-package の JSON 出力にも、パッケージと依存の trait 設定が含まれるようになります。

ドキュメント生成との関係

trait による条件付きコンパイルは、ドキュメント生成時にも影響します。symbol graph extractor は実際にコンパイルされたコードしか見られないため、すべての公開 API のドキュメントを出したいツールは、既定で「すべての trait を有効化してビルドする」構成を取るのが安全です。

既存パッケージへの影響

この機能は純粋な追加であり、既存パッケージの挙動は変わりません。ただし trait を導入する場合、既存の API を新しい trait の裏に移動してはいけません。利用者が「デフォルト trait をすべて無効化」して使っている可能性があるため、デフォルトで有効な trait に移したとしても破壊的変更になり得ます。trait は新規 API を opt-in で提供する用途で使うのが基本となります。

Future Directions(見通し)

今回のスコープでは、依存解決の後に trait の統合が行われるため、「無効な trait に守られたオプショナル依存もダウンロード自体はされる」構造になっています。将来的には、依存解決の段階で trait を考慮し、不要な依存を取りに行かないようにすることが検討されています。

また、現状は trait をコンパイラに define として渡して #if Foo で確認する形になっていますが、将来的には #if trait(Foo) のような専用構文や、ある API が特定の trait のもとでのみ利用可能であることをコンパイラが認識する仕組みが議論されています。プラットフォームごとに異なるデフォルト trait、実行可能ターゲット限定のグローバル設定 trait、全 trait 組み合わせを一括テストするツール、trait 前提の API をドキュメントに明示する仕組みなども future direction として挙げられていますが、いずれも speculative な見通しであり、この提案で実装されるわけではありません。