Swift Digest
SE-0409 | Swift Evolution

Access-level modifiers on import declarations

Proposal
SE-0409
Authors
Alexis Laferrière
Review Manager
Frederick Kellison-Linn
Status
Implemented (Swift 6.0)

01 何が問題だったのか

Swiftでは型や関数などの宣言に対して public / package / internal / fileprivate / private のアクセス修飾子を付けられますが、import 宣言自体 に対するアクセス修飾子は存在しませんでした。その結果、モジュールをインポートすると、そのモジュール由来の宣言はすべてそのソースファイル内で「公開扱い」され、ライブラリの public API のシグネチャにそのまま現れても何も警告されません。

import DatabaseAdapter

// DatabaseAdapter は本来ライブラリの実装詳細のつもりでも、
// このシグネチャから間接的にクライアントへ露出してしまう
public func publicFunc() -> DatabaseAdapter.Entry { ... }

このため、ライブラリ作者が「この依存モジュールは実装詳細としてだけ使いたい」と意図していても、誤って public な宣言のシグネチャから参照してしまうと、依存モジュールがクライアントに漏れ出してしまいます。これは dependency creep(依存の膨張)の温床になります。

さらに、依存関係が「公開」か「実装詳細」かをコンパイラが区別できないため、ライブラリのクライアントをビルドするとき、本来は必要ないはずの transitive な依存モジュールまで常にロードする必要があり、ビルドパフォーマンス面でも無駄が生じていました。

従来、この問題を部分的に回避する手段として非公式な @_implementationOnly import が使われてきましたが、独自の型検査ロジックを持ち、非 resilient モジュールや @testable import との組み合わせで実行時クラッシュを引き起こすなど、安定性に難がありました。宣言のアクセスレベルと同じ枠組みで、インポートの可視性を一貫して扱える公式の仕組みが求められていました。

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

import 宣言の前に、宣言と同じアクセス修飾子(public / package / internal / fileprivate / private)を書けるようにします。open はインポートでは使えません。インポートに付けたアクセスレベルは、そのソースファイルにおける「インポート先の宣言の可視性の上限」として機能し、従来のアクセスレベル型検査ロジックがそのまま適用されます。

internal import DatabaseAdapter

internal func internalFunc() -> DatabaseAdapter.Entry { ... } // OK
public func publicFunc() -> DatabaseAdapter.Entry { ... }
// error: function cannot be declared public because its result uses an internal type

各アクセスレベルの意味

  • public import: どの宣言のシグネチャからでも参照でき、クライアントからも見えます。
  • package import: 同じパッケージ内のモジュールから見え、package 以下の宣言のシグネチャでのみ参照できます。
  • internal import: モジュール内からのみ見え、internal 以下の宣言のシグネチャでのみ参照できます。
  • fileprivate import / private import: いずれもインポートを書いたソースファイル内のみに閉じ、fileprivate / private 宣言のシグネチャでのみ参照できます(インポートについてはこの2つの挙動は同じです)。
fileprivate import DatabaseAdapter

fileprivate func fileprivateFunc() -> DatabaseAdapter.Entry { ... } // OK

internal func internalFunc() -> DatabaseAdapter.Entry { ... }
// error: ... its return uses a fileprivate type

public func publicFunc(entry: DatabaseAdapter.Entry) { ... }
// error: ... its parameter uses a fileprivate type

public func useInBody() {
  DatabaseAdapter.create() // OK(シグネチャではなく本体での参照)
}

@inlinable
public func useInInlinableBody() {
  DatabaseAdapter.create()
  // error: 'create()' is fileprivate and cannot be referenced from an '@inlinable' function
}

通常の関数本体からの参照には制約がかからない一方、@inlinable@backDeployed の本体、デフォルト引数、@frozen struct のプロパティなど インライン化されうるコード からの参照はアクセスレベルで弾かれます。

@usableFromInline import

インライン化されうるコードから依存モジュールを参照したいが、宣言シグネチャでは公開したくない、という場合は @usableFromInline をインポートに付けられます。package または internal のインポートに対してのみ使用できます。

@usableFromInline package import UsableFromInlinePackageDependency
@usableFromInline internal import UsableFromInlineInternalDependency

@usableFromInline 付きのインポートは、シグネチャに対しては元のアクセスレベル(package / internal)として振る舞いつつ、インライン化されうるコード本体からの参照を許可します。クライアントからは可視として扱われます。

デフォルトのアクセスレベル

アクセス修飾子のない素の import のデフォルトは言語モードによって変わります。

  • Swift 5 / Swift 6: public がデフォルト(ソース互換性のため、従来の import と同じ挙動)
  • 将来の言語モード: internal がデフォルト(宣言のデフォルトと揃え、依存の意図しない露出を防ぐ)

現時点でも upcoming feature flag InternalImportsByDefault を有効にすると、将来の挙動を先取りして internal デフォルトにできます。将来の言語モードに移行する際には、これまで暗黙に public として動いていたインポートへ public を明示的に付ける対応が必要になります。

推移的依存のロード

インポートのアクセスレベルは、モジュールの 推移的(transitive)依存 をクライアントがロードする必要があるかどうかの判断にも使われます。モジュール全体としては、同じ依存に対して複数ファイルから異なるアクセスレベルでインポートされている場合、最も緩いものが採用されます。

次のいずれにも当てはまらない依存は「隠し」可能で、推移的クライアントはロードしなくて済みます。

  1. public または @usableFromInline の依存は常にロードが必要。
  2. 非 resilient モジュールの依存は、型のストレージレイアウトの都合で常にロードが必要。
  3. package の依存は、クライアントが同じパッケージに属する場合はロードが必要。
  4. @testable import されている場合は、private / fileprivate を含め全依存をロードが必要。

依存が隠せる場合、モジュールインターフェイス(.swiftinterface)の配布も不要になります(実行用のバイナリは引き続き必要です)。これによりビルド時間の短縮と、実装詳細モジュールの配布回避が見込めます。

他の属性との関係

  • @_exportedpublic import(または現行モードで public がデフォルトになっている素の import)にのみ付けられます。
  • @testable import でも、インポートに書いたアクセスレベルは上限として働きます。@testable で見えるようになる internal 宣言についても、この上限が適用されます。
  • 既存の @_implementationOnly importinternal import 以下に置き換えることが推奨されます。新方式のほうが型検査が厳密で、非 resilient モジュールや @testable との組み合わせで起きていた不安定さも解消されます。
  • スコープ付きインポート(import struct Foo.Bar のような形)はアクセスレベルと独立に機能します。アクセスレベルはモジュール単位で、スコープは名前解決の範囲を絞るためのもので、両者を組み合わせて使えます。

今後の見通し

非 resilient モジュールでも推移的依存を隠せるようにする方向が議論されています。型のストレージレイアウト情報を別経路でクライアントに届ける仕組みが必要になるため本提案のスコープ外ですが、将来的にこの制約が緩和される可能性があります(speculativeであり、実現を約束するものではありません)。