Over the last few years, the import
statement has been collecting unofficial, unsupported features to help manage the dependencies between libraries. We (@xymus and @beccadax) are thinking about how to stabilize some of these into officially-supported language features.
Chief among them is the @_implementationOnly
attribute. An @_implementationOnly import
is completely hidden from clients who import your module. This allows clients to import your module even if they do not have access to that module, so it's great for hiding libraries that you use only as an implementation detail. To make this work, though, the compiler stops you from using a declaration imported via an @_implementationOnly import
in a public
, open
, or @usableFromInline
declaration (including the function body if it's @inlinable
) if that use would be visible to your clients:
@_implementationOnly import SwiftUI
public struct MyView: View { // error: cannot use protocol 'View' here; 'SwiftUI' has been imported as implementation-only
public var view: EmptyView { // error: cannot use struct 'EmptyView' here; 'SwiftUI' has been imported as implementation-only
EmptyView() // OK to use inside an implementation, just not in its interface
}
}
(Note that this restriction is only really important for libraries; app targets usually don't need to make anything public.)
The most straightforward way to stabilize this feature would be to simply remove the underscore so that it would now be spelled @implementationOnly import
. But we're considering a different alternative: redesigning this feature in terms of access control and switching the default behavior of the import
statement. We believe this would be a better design if we were designing the language from scratch, but it has significant source compatibility impacts, so it would need to be added in a new language version mode and requires careful consideration even in that case.
We are looking for feedback from Evolution as a whole on whether this is an idea worth pursuing and whether there are additional concerns we have not yet identified, and from the Core Team specifically about whether the source compatibility impacts are likely to be too serious to consider this idea even in a new language version.
Proposed solution
We should permit the public
and internal
modifiers to be applied to import
statements.
public import M
should behave like an import M
declaration in Swift 5.5. That is:
-
Your public APIs can visibly use* the public APIs of
M
-
Clients of your module will also load**
M
-
However, name lookups in your clients will not look in
M
unless your client separately importsM
, with two exceptions:- Members of extensions in
M
on types from other modules (e.g. ifM
has anextension String
, public members of that extension will be visible) - Certain unusual declarations, like
operator
andprecedencegroup
- Members of extensions in
(Note that these exceptions were never really intended behaviors; the extension one is even considered a bug. We may want to deprecate them anyway.)
* "Visibly use" means using a declaration in a place where your module's clients will need to be able to access the declaration. For instance, using a type as a parameter or result type, a generic constraint, an extension, a superclass, an associated value, etc. of a public declaration would be visible. The bodies of
@inlinable
declarations and the stored properties of@frozen
structs (regardless of the property's access level) are also visible. Basically, if you could not use aninternal
declaration in a particular place, it is a "visible use".** "Loading" means that the compiler will read a .swiftmodule or similar file from disk and use the information in it. "Importing" means that the compiler will not only load the module, but will also make some of the declarations it describes available for your code to call.
internal import M
should behave like an @_implementationOnly import M
declaration in Swift 5.5. That is:
-
Your public APIs cannot visibly use the public APIs of
M
; attempting to do so will be diagnosed as an error, with a fix-it offering to annotate the import in that file withpublic
-
Clients of your module do not need to be able to load
M
-
Even extension members and operator-related declarations that would be visible with a
public import
will not be visible in your clients
(The compiler should reject open import
, fileprivate import
, and private import
; we don't think these modifiers make much sense for import
statements.)
In Swift 5 (and earlier) mode, Swift should interpret import M
as public import M
, preserving current behavior. In Swift 6 mode (or whatever the next version mode is), Swift should interpret import M
as internal import M
, making implementation-only imports the default.
Minor behavior changes vs. existing @_implementationOnly import
@testable import
will treat all internal imports in the module being imported as though they are public imports. (Currently, the compiler basically ignores @_implementationOnly import
s and hopes that this doesn't break anything. It often does.)
In Swift 6 mode, there will not be a warning if some files write import M
and others write public import M
. (Currently, there is a warning if some files write import M
and others write @_implementationOnly import M
.) We think this warning was necessary with @_implementationOnly
because if you weren't thinking about which behavior you wanted, you would write a plain import
and visibly import the module. But with this change you would now get a non-visible import when you did that, so you'd be less likely to make a mistake.
Swift 5 mode will allow you to write public import
as a synonym for import
and internal import
as a synonym for @_implementationOnly import
without warnings on any of those forms. Swift 6 mode will deprecate @_implementationOnly import
and recommend plain import
instead.
Why make this change?
-
It aligns
import
with other declarations, which generally take access control keywords and are not publicly visible unlesspublic
(oropen
) is used. -
It reuses the existing concept of the
internal
keyword instead of introducing a separate concept for this feature, improving teachability. Reusinginternal
makes sense for this because public declarations forbid usinginternal
-imported declarations in the same places they forbid usinginternal
declarations. In other words, an internalimport
is very much like you imported the module'spublic
declarations but marked theminternal
.*** -
Swift 5's public-by-default behavior has caused difficulties for organizations with multi-project builds which have adopted
@_implementationOnly
to hide project-private modules. These organizations have reported that they frequently experience broken builds because someone accidentally imported a module that isn't available in other projects without using@_implementationOnly
. If internal imports were the default, these mistakes would be much rarer. -
When compiling a client of your module, using a public import requires the compiler to perform extra work and I/O to load and process not only the dependency you're importing, but everything it publicly imports, transitively. A single unnecessary public import can thus cause the compiler to load many extra modules that it doesn't really need. If changing the default causes many imports to become internal instead of public, we should expect some build time improvements as a result.
*** One important difference is that you can conform a
public
type to aninternal
protocol (the conformance is just hidden from clients), but conforming apublic
type to apublic
protocol imported byinternal import
is an error. This reduces the chance that a client will declare a retroactive conformance that clashes with yours, which would not be detectable at compile time.
What source breaks will we see?
This change will cause two kinds of source breaks:
-
Any
public
,open
, or@usableFromInline
declaration in a module that has a visible use of an imported declaration will break in Swift 6 mode. However, these breaks will be easy to detect and offer fix-its for, and thus they'll be easy to accurately mass-migrate. They also should only affect library modules, not executable modules, since executable modules rarely need to use access control levels aboveinternal
. -
If a library makes a once-public import internal, its clients may break if they were depending on extensions or operator-related declarations that were transitively loaded through that library. It will not be possible to detect these breaks from within the library or automatically migrate them. However, clients can solve this problem by explicitly importing the module containing the extensions or operator declarations, and once they become aware of broken clients, libraries can fix this problem by manually marking imports as
public
. If we deprecate these usesâespecially ahead of the release of the next major language versionâpeople can fix these issues before the behavior of imports starts to change en masse.
What other issues will we see?
Developers have been irked in the past by similar language changes (the open
keyword, the @frozen
attribute) where they had to make source code changes when the compiler got stricter about imports. However, those changes did not offer an automatic migration path; here, the migrator will be able to detect when an import
statement's declarations are visibly used and upgrade it to a public import
.
Problems like this where a binary module and its client both link different versions of the same library, but the compiler doesn't detect this because the binary module's import is not visible to its client, will be more likely to happen because more imports will be internal.
Currently, using @_implementationOnly import
in a non-library-evolution module does not allow you to use imported types as nonpublic stored properties of a public struct. This is because your clients need access to the stored property's type to compute some details of its layout. This limitation is probably not acceptable for a supported language feature, so we'll need to fix it somehow. (Perhaps we can serialize a minimal description of the type into the swiftmodule file.)
What about @_exported
?
We're interested in stabilizing @_exported
too, but probably as an @exported
attribute on public import
rather than as an access control modifier in its own right. Re-exporting is too rarely needed to be a good use of public
, and the analogy to the meaning of open
on classes and members is extremely weak.
(But see @jrose's counter-argument that @_exported
should be public
and the thing this pitch calls public
should be removed or sidelined.)