Swift Digest
SE-0444 | Swift Evolution

メンバーimport可視性

Member import visibility

Proposal
SE-0444
Authors
Allan Shortlidge
Review Manager
Becca Royal-Gordon
Status
Implemented (Swift 6.1)

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

01 何が問題だったのか

Swift では、別モジュールで宣言された名前がソースファイル内で「スコープにある」と見なされるかどうかはインポートのルールで決まります。トップレベル宣言については直感どおりで、たとえば swift-algorithms パッケージの chain() を使うには、そのファイルに import Algorithms と書かないとコンパイラに見つけてもらえません。

// 'import Algorithms' が無いファイル
let chained = chain([1], [2]) // error: Cannot find 'chain' in scope

ところがメンバー宣言(型の中のメソッドや、extension で追加されたメソッドなど)は扱いが異なり、そのモジュールが推移的(transitive)にインポートされているだけでもスコープに入ってきてしまいます。推移的インポートには、同じターゲット内の別のソースファイルで直接インポートされている場合や、自分が直接依存しているモジュールがさらに依存しているモジュールなども含まれます。

これは仕様というよりはむしろ細かなバグに近い挙動で、普段のコードでは気づかずに済むことも多いのですが、extension のメンバーが絡むと驚きの原因になります。extension は「その型を持つモジュール」とは別のモジュールで書けるため、どこからともなく extension のメンバーがスコープに入ってきて衝突を起こすことがあるのです。

たとえばアプリ main.swiftRecipeKit だけを使っているとします。

// main.swift
import RecipeKit

let recipe = "2 slices of bread, 1.5 tbs peanut butter".parse()
// RecipeKit
public struct Recipe { /*...*/ }

extension String {
  public func parse() -> Recipe?
}

後から別ファイルで GroceryKit を使い始めます。

// Groceries.swift
import GroceryKit

var groceries = GroceryList()

GroceryKit も同じく Stringparse() を生やしていたとします。

// GroceryKit
public struct GroceryList { /*...*/ }

extension String {
  public func parse() -> GroceryList?
}

すると、GroceryKitインポートしていないはずの main.swift で突如コンパイルエラーが発生します。

// main.swift
import RecipeKit

let recipe = "2 slices of bread, 1.5 tbs peanut butter".parse()
// error: Ambiguous use of 'parse()'

このように、無関係に見える別ファイルのインポートが遠くのファイルの名前解決に影響してしまうため、ローカルに読み解くのが難しく、曖昧性エラーの出方も直感に反したものになります。トップレベル宣言では起きないことがメンバーでは起きてしまう、というこの非対称性が問題の本質です。

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

メンバー宣言の可視性ルールをトップレベル宣言と揃え、そのソースファイルから「見えている」モジュールが宣言したメンバーだけがスコープに入るようにします。言い換えると、あるメンバーを使うには、それを宣言しているモジュールをそのファイルで直接インポート(またはそれに準ずる形で可視化)していなければなりません。

この新しい挙動は、将来の言語モードで既定となるほか、それより前のモードでも upcoming feature flag MemberImportVisibility を有効にすることで先取りして使えます。

「可視なモジュール」の定義

あるソースファイルから可視なモジュールは、トップレベルの名前解決で従来から使われているルールと同じ集合になります。具体的には次のいずれかに当てはまるモジュールです。

  • そのソースファイル内の import 文で直接インポートされているモジュール
  • そのファイルに対応する bridging header からインポートされているモジュール
  • 上記のいずれかで直接インポートされたモジュールから 再エクスポート(re-export) されているモジュール

再エクスポートは「このモジュールを import したクライアントに別のモジュールも見えるようにする」仕組みです。Clang モジュールでは modulemap の export * などで広く使われています。Swift モジュール側には公式な再エクスポート構文はありませんが、コンパイラ内部で @_exported 属性が用いられることがあります。再エクスポートは推移的にも働くため、AB を、BC を再エクスポートしていれば、import A だけで A / B / C の宣言が見えます。

なお、コンパイラが暗黙にインポートする標準ライブラリや「現在ビルド中のモジュール自身」も可視モジュールに含まれます。その副作用として、自分のモジュールが(どこかのソースファイルで)再エクスポートしているモジュールは、自分のモジュールのどのソースファイルからも見えることになります。

挙動の例

先ほどの main.swiftparse() の例では、MemberImportVisibility を有効にすると、main.swiftGroceryKit をインポートしていない以上、GroceryKitparse() は候補に上がらなくなり、曖昧性は発生しなくなります。逆に、従来は推移的インポートだけに頼って使えていたメンバーは使えなくなります。

// RecipeKit は別ファイルで import されているが、このファイルでは import していない
let recipe = "1 scoop ice cream, 1 tbs chocolate syrup".parse()
// error: instance method 'parse()' is inaccessible due to missing import of defining module 'RecipeKit'
// note: add import of module 'RecipeKit'

このようにエラーメッセージは「どのモジュールを import すれば解決するか」を指摘してくれ、fix-it として直接インポートの追加が提案されます。移行の際はこの fix-it を当てていくのが基本的な作業になります。

SE-0409 との関係と併用

import 宣言自体のアクセス修飾子(public import / internal import など)を扱う SE-0409 と本提案は補完関係にあります。SE-0409 はインポートの アクセスレベル を、本提案はインポートの スコープ(どのファイルから見えるか) を統制するもので、両方を組み合わせることで「他モジュールから持ち込まれる宣言」の露出をよりきめ細かくコントロールできます。

プロトコル適合に対する例外(採択された修正)

本提案を素直に適用すると、プロトコルへの適合(conformance)を書いたソースファイルでは、各要件のwitnessとなるメンバーがそのファイルから可視でなければなりません。しかし「ライブラリ側がプロトコルに新しい要件と既定の実装を同時に追加した」ようなケースでは、適合を書いているクライアント側に既定実装の存在は伝わらないため、ファイル単位の可視性ルールに従わせると 既存の適合が突然壊れる ことになります。

この問題に対応するため、本提案には後から修正(amendment)が加えられ、次の条件を満たす 既定の実装 はファイルからimportされていなくても要件のwitnessとして採用されてよい、と整理されました。

  1. その既定実装が、要件を宣言しているプロトコルの 無制約な(unconstrained)エクステンション に書かれている
  2. そのエクステンションが、プロトコル本体と 同じモジュール に属している
// 外部モジュール: GroceryKit
public protocol GroceryItem {
  var name: String { get }       // 既存の要件
  var aisle: String? { get }     // 新しく追加された要件
}

extension GroceryItem {
  public var aisle: String? { nil } // 既定実装
}

// ShoppingList.swift(GroceryKit を import している)
import GroceryKit
internal protocol ShoppingListItem: GroceryItem { }

// IceCream.swift(GroceryKit を import していない単純なモデルファイル)
struct IceCream: ShoppingListItem {
  var name: String { "Ice cream" }
  // aisle 要件は GroceryKit 側の既定実装で自動的に満たされる
}

この例外は、library evolution を有効にしたモジュールがプロトコルに要件を追加したときに、ランタイムで既定witnessとなる実装の選び方とも整合しています。結果として、ライブラリ作者はプロトコルを進化させてもクライアントのソース互換性とABI互換性をまとめて保てるようになります。

03 今後の見通し

本提案を導入してもなお残る extension メンバーの曖昧性を解消するため、呼び出し側でどのモジュールの extension メンバーかを明示する構文が今後の検討材料として挙げられています。たとえば次のような記法で、RecipeKitparse() を呼び出していることを明示する案です。

let recipe = "...".RecipeKit::parse()

本提案はインポートの整備で曖昧性を 予防する ものなのに対し、こうした構文は残った曖昧性を 事後的に解消する 手段という位置づけになります。

ほかにも、protocol の retroactive conformance に対して同様の可視性ルールを適用する案や、演算子・precedence group の名前解決ルールを見直して名前解決全体の一貫性を高める案が示されています。

いずれも将来の方向性として議論されている段階で、実現を約束するものではありません。