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 は Package の traits: に列挙します。各 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 で提供する用途で使うのが基本となります。
03 今後の見通し
本 Proposal の範囲ではありませんが、将来の拡張として以下のような方向性が示されています。いずれも構想段階であり、実現を約束するものではありません。
依存解決時に trait を考慮する
現在の実装では trait は依存解決の 後 にモジュールグラフを組み立てる段階でのみ考慮されます。これはプラットフォーム固有の依存と同様の扱いですが、将来的には依存解決時にも trait を考慮することで、有効化されていないオプショナル依存を取得しないようにできる可能性があります。これは依存解決の実装詳細に過ぎないため、変更に Swift Evolution の Proposal は不要とされています。
コンパイラへの trait チェック組み込み
現在は有効な trait をコンパイラに define として渡し、#if DEFINE の形でチェックする方式です。将来は #if trait(FOO) のような専用構文や、Rust の cfg マクロのように拡張可能な設定マクロをコンパイラ側で提供することも考えられます。
有効化された trait のコンパイル時チェック
trait の統合(unification)はビルド時にグラフ全体に対して行われるため、「どのモジュールが依存先のどの trait を有効化したか」という情報は失われます。その結果、ある trait に守られた API を、別のパッケージがその trait を有効化していたおかげで偶然使えてしまう、という状況が起こり得ます。trait の有効化はセマンティックバージョンの一部とは見なされないため、trait を無効化したことでビルドが壊れる可能性もあります。将来は、ある API が特定の trait の下でのみ利用可能であることをコンパイラが理解し、これをチェックできるようにする方向が考えられます。
プラットフォームごとに異なるデフォルト trait
将来的には、ビルド対象プラットフォームに応じてデフォルトとなる trait を切り替えられるようにする案もあります。例えば swift-openapi-generator がプラットフォームごとに既定のトランスポートを選べるようになり、利用者の体験が改善されます。ただし、この機能は「依存解決時に trait を考慮する」方向性と密接に関わるため、実現にはそちらとの整合が必要になります。
グローバルに設定する trait
排他的な trait の使い道のひとつに、パッケージ全体の挙動を最終実行ファイル側からグローバルに切り替える、というケースがあります。将来の Proposal では、trait を「グローバル設定用」として宣言し、実行ファイルターゲットだけが有効化できるよう制限する仕組みを導入することが考えられています。
trait の組み合わせをテストするツール
ひとつのパッケージは、原則としてあらゆる trait の組み合わせでビルドできる必要があります。これを支援するため、swift test / swift build / swift run に --all-trait-combinations のようなオプションを追加し、すべての組み合わせを一括でビルド・テストできるようにする案があります。
ドキュメントへの trait 表示
コンパイラが package traits を理解するようになれば、公開 API がどの trait に守られているかという情報も抽出できるようになります。これをドキュメントに表示することで、利用者は API ごとに必要な trait を把握しやすくなります。