Swift Digest

#bundle の導入

Introduce #bundle

Proposal
SF-0024
Authors
Matt Seaman, Andreas Neusuess
Review Manager
Tina L
Status
Accepted

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

Foundation のローカライズ用 API は、bundle 引数を省略するとデフォルトで Bundle.main を使う仕様になっています。アプリ本体のターゲットでは Bundle.main がそのままリソースを格納したバンドルなので問題ありませんが、フレームワークターゲットや Swift パッケージターゲットからローカライズされた文字列を読み込みたい場合は、毎回その target 用のバンドルを bundle 引数として明示的に渡す必要があります。

label.text = String(
    localized: "She didn't clean the camera!",
    bundle: Bundle(for: MyViewController.self),
    comment: "Comment of astonished bystander"
)

このボイラープレートを減らすために、フレームワーク側で Bundle のエクステンションを書くことが定石になっています。

private class LookupClass {}
extension Bundle {
    static let framework = Bundle(for: LookupClass.self)
}

label.text = String(
    localized: "She didn't clean the camera!",
    bundle: .framework,
    comment: "Comment of astonished bystander"
)

bundle identifier をキーにして Bundle を引くやり方も使われていますが、こちらは検索コストの面でも非効率です。いずれにせよ、フレームワークターゲットごとに同じようなバンドル取得用のコードを書き続けることになります。

ローカライズされた Swift パッケージの場合、ビルドシステムがビルド時に Bundle.module というエクステンションを自動生成してくれるため、ボイラープレートはある程度減らせます。ただし、その性質上、フレームワークやアプリのターゲットからパッケージへコードを移動するときには、bundle: Bundle(for: ...) のような呼び出しを bundle: .module に書き換えて回らなければならず、リソース取得 API の呼び出しを残らず確認・修正する必要があります。

つまり、リソースバンドルの指定はターゲットの種類(アプリ・フレームワーク・Swift パッケージ)ごとにやり方が違い、ターゲット間でコードを行き来させる際に余分な書き換えが発生していました。

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

Foundation に、呼び出し元コンテキストに応じて適切なリソースバンドルを返す #bundle マクロが追加されます。アプリ・フレームワーク・Swift パッケージのいずれから呼んでも、その場所からリソースを読み込むのに最も適したバンドルが得られます。

label.text = String(
    localized: "She didn't clean the camera!",
    bundle: #bundle,
    comment: "Comment of astonished bystander"
)

利用側は、Bundle(for:) を使ったエクステンションも Bundle.module も意識せず、bundle: #bundle と書くだけで済みます。アプリのターゲットからフレームワークや Swift パッケージのターゲットへコードを移動する場合でも、#bundle のままで動作するため、書き換えが不要になります。

マクロの定義と展開

#bundle は freestanding な expression マクロとして定義されます。

@available(macOS 10.0, iOS 2.0, tvOS 9.0, watchOS 2.0, *)
@freestanding(expression)
public macro bundle() -> Bundle = #externalMacro(module: "FoundationMacros", type: "CurrentBundleMacro")

展開結果は、ビルド時に渡される条件付きコンパイル用フラグによって切り替わります。

{
#if SWIFT_MODULE_RESOURCE_BUNDLE_AVAILABLE
    return Bundle.module
#elseif SWIFT_MODULE_RESOURCE_BUNDLE_UNAVAILABLE
    #error("No resource bundle is available for this module. If resources are included elsewhere, specify the bundle manually.")
#else
    return Bundle(_dsoHandle: #dsohandle) ?? .main
#endif
}()

SWIFT_MODULE_RESOURCE_BUNDLE_AVAILABLE は、SwiftBuild や SwiftPM などが Bundle.module を生成しているのと同じ条件で -D で定義する想定のフラグです。これが定義されている場合は Bundle.module がそのまま使われます。

SWIFT_MODULE_RESOURCE_BUNDLE_UNAVAILABLE は、Bundle.module が生成されない上に #dsohandle から得られるバンドルが正しいリソースバンドルにならない、と分かっている場合にビルドシステムが定義します。例えば、リソースを持たない Swift パッケージから #bundle を使おうとした場合がこれに該当し、ビルド時に #error でエラーになります。

どちらのフラグも立っていない場合は、新しく追加される Bundle(_dsoHandle:) を介して、呼び出し元のバイナリの #dsohandle からバンドルを引きます。これに失敗したときは Bundle.main にフォールバックします。

Bundle(_dsoHandle:)

#bundle の展開先になる新しいイニシャライザが Bundle に追加されます。@_alwaysEmitIntoClient 付きでバックデプロイされるため、#bundle の利用がプロジェクトの deployment target で大きく制限されることはありません。

extension Bundle {
    /// Creates an instance of `Bundle` from the current value for `#dsohandle`.
    ///
    /// - warning: Don't call this method directly, and use `#bundle` instead.
    @available(FoundationPreview 6.2, *)
    @_alwaysEmitIntoClient
    public convenience init?(_dsoHandle: UnsafeRawPointer)
}

このイニシャライザは #bundle から呼び出される実装詳細であり、ユーザーコードから直接呼ぶことは想定されていません。

LocalizedStringResource 用の追加イニシャライザ

LocalizedStringResourceBundle ではなく LocalizedStringResource.BundleDescription を引数として受け取る設計になっており、#bundle の戻り値である Bundle をそのまま渡すと既存の BundleDescription 受け取りオーバーロードと競合します。これを解消するために、Bundle を直接受け取る初期化が 2 つ追加されます。

@available(FoundationPreview 6.2, *)
extension LocalizedStringResource {
    @_alwaysEmitIntoClient
    @_disfavoredOverload
    public init(_ keyAndValue: String.LocalizationValue, table: String? = nil, locale: Locale = .current, bundle: Bundle, comment: StaticString? = nil)

    @_alwaysEmitIntoClient
    @_disfavoredOverload
    public init(_ key: StaticString, defaultValue: String.LocalizationValue, table: String? = nil, locale: Locale = .current, bundle: Bundle, comment: StaticString? = nil)
}

どちらも @_alwaysEmitIntoClient@_disfavoredOverload が付いており、内部で BundleBundleDescription に変換した上で既存の初期化処理に橋渡ししています。これにより、LocalizedStringResource(..., bundle: #bundle, ...) のような呼び出し方が、既存 API と曖昧にならずにそのまま書けるようになります。

03 今後の見通し

#bundle のさらなる展開として、いくつかの方向性が示されています。いずれも将来の構想であり、その実現を約束するものではありません。

bundle 引数のデフォルトを #bundle

ローカライズ系 API の bundle 引数のデフォルト値を #bundle に置き換え、最終的にはバンドル指定そのものを書かずに済むようにする方向性が挙げられています。SE-0422 によってデフォルト引数のマクロ式は呼び出し側で展開されるようになっているため、これと組み合わせれば #bundle をそのままデフォルトとして埋め込むことが可能になります。

Bundle.module を介さない実装への移行

将来的に MacroExpansionContext がターゲット名や種類などのビルドシステム由来の情報を扱えるようになれば、#bundle の実装側でリソースバンドルを直接計算できるようになる構想も示されています。これが実現すれば、ビルドシステムが生成するディスク上のバンドル情報を Foundation 側にビルド時に渡し、#bundle がそれを使ってバンドルを読み込むコードを生成できるようになります。Bundle.module 自体は既存コードとの互換性のために即座には削除できませんが、deprecated 化したりビルド設定で無効化したりするといった選択肢が想定されています。