Swift Digest
SE-0386 | Swift Evolution

New access modifier: package

Proposal
SE-0386
Authors
Ellie Shin, Alexis Laferriere
Review Manager
John McCall
Status
Implemented (Swift 5.9)

01 何が問題だったのか

Swiftのプログラムは、宣言をモジュールという単位にまとめ、モジュール間の境界をアクセス制御で守る作りになっています。一方でSwift Package Manager(SwiftPM)のような仕組みでは、複数のモジュールをまとめたパッケージという上位の単位が存在しますが、Swift本体はこの単位を認識していません。

そのため、パッケージ内の別モジュールから使いたいAPIは、たとえ「本当はパッケージの外には出したくないユーティリティ」であっても、public として公開するしかありませんでした。しかし public にすると、パッケージの外のクライアントからも同じAPIを直接呼べてしまいます。

たとえばパッケージ gamePkgEngineGame という2つのモジュールを含み、Enginerun()Game だけから使うヘルパーだとします。現状では run()public にするしかありません。

Engine:

public struct MainEngine {
    public init() { /* ... */ }
    public var stats: String { /* ... */ }
    // Game から呼びたいだけなのに public にするしかない
    public func run() { /* ... */ }
}

Game:

import Engine

public func play() {
    MainEngine().run() // 同じパッケージ内なので意図通り呼べる
}

AppgamePkg に依存する外部パッケージ):

import Game
import Engine

let engine = MainEngine()
engine.run() // 本来は外から使ってほしくないが、public なので呼べてしまう
Game.play()

この「意図せぬ公開」は単なる設計上の不満にとどまりません。

  • クライアントがヘルパーAPIに依存してしまうと、パッケージ側はあとから気軽に変更できなくなります。特にOSSパッケージでは、クライアントの素性がわからないため、既存APIの削除はメジャーバージョンまで持ち越す、といった慎重な運用が求められます。
  • public であるためにシンボルが最終バイナリへ常にエクスポートされ、コードサイズやパフォーマンスに余計なコストがかかります。
  • ライブラリの進化(library evolution)を有効にすると、パッケージ内で閉じているはずの呼び出しにもABIレジリエンスのオーバーヘッドがついて回ります。

@_spi@_implementationOnly@testable などで似たことをしようとする回避策もありますが、いずれもパッケージ境界を表現するための仕組みではなく、用途や粒度が合わなかったり、モジュール単位でしか効かなかったりと、パッケージ内部APIの表現手段としては不十分でした。必要だったのは、モジュール境界は越えられるがパッケージ境界は越えられないという、ちょうど中間のアクセスレベルです。

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

新しいアクセス修飾子 package を導入します。package を付けた宣言は、同じパッケージに属する他のモジュールからは参照できますが、パッケージの外のモジュールからは参照できません。アクセスレベルの強さの順では、internal より広く、public より狭い位置に入ります。

基本的な使い方

packageclass / struct / enum / func / var / protocol など、既存のアクセス修飾子を書ける場所ならどこでも使えます。他のアクセス修飾子と組み合わせることはできません。

前節の例は次のように書き直せます。run()package にすれば、同じパッケージ内の Game からは引き続き呼べて、外の App からは呼べなくなります。

Engine:

public struct MainEngine {
    public init() { /* ... */ }
    public var stats: String { /* ... */ }
    package func run() { /* ... */ } // 同じパッケージ内だけに公開
}

Game(同一パッケージ):

import Engine

public func play() {
    MainEngine().run() // OK: 同じパッケージなので package にアクセス可能
}

App(別パッケージ):

import Game
import Engine

let engine = MainEngine()
engine.run() // エラー: スコープ内に run が見つからない

package は [contextual keyword] として追加されるため、既存の package という名前の識別子は引き続き使えます。次のように、同じ行に修飾子と変数名を書くことも可能です。

package var package: String { /* ... */ }

また、シグネチャ要素は宣言自身と同じかそれ以上のアクセスレベルでなければならないというSwiftのルールは、package にも適用されます。public な関数の引数や戻り値に package 型を使うことはできず、package な関数の引数や戻り値に internal 型を使うこともできません。setterだけ絞りたいときは package(set) も書けます。

パッケージの境界はビルドシステムが決める

言語としては「パッケージ」の範囲を厳密に定めず、同じ -package-name を指定してビルドされたモジュール同士が同じパッケージとみなす、という素朴な仕組みになっています。-package-name はコンパイラに渡す文字列で、ソースコードからは見えません。

swiftc -module-name Engine -package-name gamePkg ...
swiftc -module-name Game   -package-name gamePkg ...
swiftc -module-name App    -package-name appPkg ...

Engine のビルド時に指定した gamePkg がモジュールインターフェースに記録され、Game をビルドするときに自分のパッケージ名と比較されます。一致するので GameEnginepackage 宣言にアクセスできます。AppappPkg を指定しているので、Enginepackage 宣言にはアクセスできません。

-package-name を指定しなかったモジュールは、どのモジュールとも同じパッケージにはなりません。package 修飾子を使っていないコードは、これまで通り -package-name なしでビルドできます。

SwiftPMは内部で持っているパッケージ識別子をそのまま -package-name に渡すため、利用者は特に意識する必要はありません。パッケージ内のターゲットを境界から外したい場合は、マニフェストで packageAccess: false を指定できます。たとえばパッケージ内のサンプルアプリやブラックボックステストなど、「外部クライアントとして振る舞わせたい」ターゲットに使います。

.target(name: "Game", dependencies: ["Engine"], packageAccess: false)

サブクラス化・オーバーライドの扱い

publicopen がそうであるように、package についても「同じパッケージ内のどこからでも参照できるが、サブクラス化・オーバーライドは定義モジュール内に限る」という扱いになります。つまり、package classpackage なメソッドは、別モジュールから呼ぶことはできても、別モジュールでサブクラス化・オーバーライドすることはできません

これは public の挙動と揃っており、通常の public クラス/メソッドと同じように、サブクラスやオーバーライドが存在しなければ暗黙的に final として最適化できる、というコンパイラの挙動をそのまま使えます。必要なら package final class のように明示的に final を組み合わせることもできます。

「パッケージ全体にわたってサブクラス化・オーバーライドも許したい」という用途(open のパッケージ版)は本Proposalのスコープ外で、将来の拡張として検討されます。

インライン化と @usableFromInline

package 宣言にも @inlinable を付けられます。@inlinable public と同様に、@inlinable package 関数の本体から使える他宣言は open / public / @usableFromInline が付いたものに限られます。@usableFromInline は従来の internal に加えて package にも付けられ、この場合、同じパッケージ内の @inlinable public@inlinable package の本体から使えるようになります。

func internalFuncA() {}
@usableFromInline func internalFuncB() {}

package func packageFuncA() {}
@usableFromInline package func packageFuncB() {}

public func publicFunc() {}

@inlinable package func pkgUse() {
    internalFuncA() // エラー
    internalFuncB() // OK
    packageFuncA() // エラー
    packageFuncB() // OK
    publicFunc()   // OK
}

@inlinable public func publicUse() {
    internalFuncA() // エラー
    internalFuncB() // OK
    packageFuncA() // エラー
    packageFuncB() // OK
    publicFunc()   // OK
}

モジュールインターフェースの配布

package 宣言の扱いはビルド物のファイル構成にも反映されます。

  • フロントエンドがソースから .swiftmodule を直接作る場合、そのファイルにパッケージ名とすべての package 宣言が含まれます。
  • ソースから .swiftinterface を生成する場合、パッケージ名は従来の(public な).swiftinterface に入りますが、package 宣言はそれとは別の .package.swiftinterface ファイルに切り出されます。
  • パッケージ名入りの .swiftinterface から .swiftmodule を作るとき、対応する .package.swiftinterface が手元に無ければ、その .swiftmodule は同じパッケージの他モジュールのビルドには使えないものとして印が付きます。

この仕組みにより、package 宣言はパッケージ外に漏れないまま、パッケージ内ビルドでは必要な情報が揃うようになっています。

Future Directions(今後の見通し)

今回のスコープには入っていませんが、関連する拡張としていくつかの方向が議論されています。いずれも本Proposalの範囲外で、実現を約束するものではありません。

  • パッケージを越えたサブクラス化・オーバーライド: open のパッケージ版に相当する表現(packageopenopen(package) のような構文)。
  • パッケージプライベートなモジュール: モジュールごと「このパッケージの外からは一切見えない」と宣言できるようにする仕組み。ユーティリティモジュールを完全に隠せるようになり、同名モジュールの衝突を避けるための module alias も減らせる可能性があります。
  • パッケージ内のさらに細かなグルーピング: 大きなパッケージを複数の「層」に分け、package の適用範囲を層単位に限定できるようにする方向。SPMマニフェストでのグルーピングや、リポジトリ内サブパッケージなどが候補として挙げられています。
  • 最適化: 同一パッケージのモジュールは常に一緒にリビルドされる前提に立ち、library evolution が有効でもパッケージ内部ではABIレジリエンスのオーバーヘッドを省く、非frozenenumへの @unknown default を不要にする、静的リンク時に package シンボルを最終バイナリから隠す、といった最適化が想定されています。