SE-0409: Access-level modifiers on import declarations

I would absolutely expect private import struct Foo.Bar to work. It would be pretty strange to have to choose between being strict in what you import, and being strict in what you expose to the rest of the module.

1 Like

I don’t understand why there are separate fileprivate and private modifiers on imports. Conceptually it doesn’t make sense on imports even if it does on types, because we are now talking at the module boundaries and not within a module.

It will be more confusing to have fileprivate/private than to not have some unified modifiers with APIs, which are already a subset by omitting open.

1 Like

-1
Thinking more on it, I think this proposal is unnecessarily complicated, conflating module boundaries and API modifiers.

There are only two actual visibility choices on imports: either the import is visible outside the module or it is not. Everything else used in this proposal is access control related within the module itself and not at the boundary.

Thus, the solution should be public/private (or if we default to internal visibility in Swift 6, just public and nothing — and perhaps something for package too).

1 Like

I’m glad to see @_implementationOnly formalized.

I kind of agree that internal import and private import both don’t exactly fit. If internal import doesn’t affect name resolution in other files (something that’s been requested, for prefix-header-like centralization of global imports), it doesn’t add that much value to distinguish from private import, as 8675309 says above. I don’t think there are that many times where I’ve wanted not only names restricted to a particular file, but keeping those types from escaping in internal API as well. Especially now that package exists and breaking things up is easier. But I see the value of the simple explanation as well, “use at least X access on the import to use types in APIs with X access”.

How does @usableFromInline fit into this? Can I have an import that’s @usableFromInline internal, so I can use its APIs in my inlinable functions? And frozen structs, for resilient modules.

5 Likes

I share this concern. The meaning of "public import" in other languages seems quite different from what is being proposed here. I do wonder if a better alternative is available (bike shedding alert!) - how about shifting the access modifier to the end and adding "export" to make it clearer about what the modifier pertains to :
import Dependency export public
import Foo.Bar export private

1 Like

I can definitely think of times I would use this. File-scoped imports would be helpful when you're writing a wrapper around a component in another module and you want a guarantee that that module won't accidentally leak out of that file—for example, a Swift wrapper around a lower-level C library or system framework.

2 Likes

but the module can leak, if it declares conformances, the conformances will leak, if it declares operator lexemes, the lexemes will leak.

IMO, those sound like additional areas for improvement that are orthogonal to the step in the right direction that this proposal would still provide.

but do we want these things to vary based on the imports at the top of the file? i don’t know if i would want import statements to change the precedence or associativity of ><, or suddenly cause String to conform (or stop conforming) to Error

  1. import declarations are only valid at file scope. So as long as Swift doesn't allow them to be nested in name-resolution-scope-defining entities, private access level on import is (at least) useless. IMO only fileprivate should be accepted for now. (or private in sense of fileprivate, wording doesn't matter a lot).
    Consider a situation if some day Swift will support import decls in narrower scopes.

    class Foo {
      fileprivate import Module1
      private import Module2
    
      fileprivate init(_ value: Module1.SomeType) {...}
    
      fileprivate func f1() -> Module1.SomeType {...}
      private func f2() -> Module2.SomeType {...}
    }
    
    private func useF1(foo: Foo) {
      Foo(foo.f1())
    } 
    

    Here it makes sense to distinguish fileprivate from private, as Module2 types shouldn't escape the scope of Foo, while Module1 types can.

  2. While I agree fileprivate imports should be supported, I see that someone can argue with this reason by saying: "One can always decompose the "wrapper part" into a third separate module, with internal imports of low level C library, and thus provide a sealed isolation.".
    The reason I think fileprivate imports should be supported is access level parity between import decls and other decls.

I don't think that argument wouldn't hold much weight because telling someone to move a type out to a completely different API boundary would be an extreme and often inappropriate solution. If the wrapper type is meant to be a public part of the module, it can't just be moved to a different module without re-exporting it, and we don't want to encourage re-exporting as the solution for this much simpler problem.

Well, I wouldn't call it inappropriate, but agree with "extreme". This is the solution we have right now on hands without additional complexity in the language/compiler. And this is quite rare case when one actually has to achieve this level of isolation. So amortised level of inconvenience due to the lack of fileprivate imports will be low.

i think that if you are trying to insulate parts of a wrapper module from the thing it's wrapping, that's an early indicator that the wrapper module has gotten too large and should be vertically split into two modules.

in this situation, re-exporting the wrapper type is the correct approach. there is a world of difference between re-exporting an entire module's namespace and re-exporting a single type.

I simply don't agree with that. The solution you're proposing provides that library author (and their clients) no significant benefit while causing actual harm by making their build graph and build process more complex. If the original module is going to re-export the thing from the new module, why is that better than just putting it in that module?

You're also assuming that nothing in the hypothetical wrapping file is using anything from the rest of the module. If it is, now the author has to slice up their module even more to make your proposed solution work. That's a lot of overhead when someone just wants to create a small intra-module boundary that's easy to reason about.

i think that ideally, for every module in your project, you should always have an answer to the question: what does this module do?

if you have a module named Bike, and your answer to the previous question is: “this module is a wrapper around CBike”, then it shouldn’t be a problem for any of the code in Bike to be aware of CBike and its definitions - the purpose of Bike is to interface with CBike.

now, if it turns out you’ve got a bunch of “library API” in Bike that only interacts with a handful of definitions in Bike, and doesn’t care at all about CBike, then that’s an indicator that maybe you shouldn’t have Bike and CBike, maybe you should have Bike, BikeShims, and CBike.

now, there are tons of awful reasons we are forced to “slice up” modules in swift today:

  • inability to publish more than one type within a module with the same name

  • inability to nest protocols within a namespace

but to me, organizing a project's build graph into clearly delimited layers is not one of them.

1 Like

We didn't previously consider @usableFromInline on imports but it's reasonable if there's an interest. Nothing should really be blocking it and it will only add one more condition preventing hiding a dependency.

5 Likes

Will there be a compiler diagnostic for when an import is unnecessarily public, according to the APIs exposed in the module?

When combined with module-level imports, this could result in surprising code (tested with the experimental flag in Xcode):

private import AppKit                 // (1)
public import class AppKit.NSColor    // (2)

public func f(_ c: NSColor) { }       // (3)
public func g(_ v: NSView) { }        // (4) (!)

If lines 1 & 2 are both permitted, I'd expect line 4 to be prohibited, just based on the text of the code, but it's not. Once default imports are internal, this will be even easier to trip over, I think. Should this be handled, either by erroring on line 4 or by just disallowing the mismatched imports for now?

This is something I'm currently looking into. My plan would be to warn on any import marked as public or package if they could be internal or lower. It would apply by default only in Swift 6 mode as I don't want to force people to add internal everywhere just for them to remove it a few months later when it becomes the default. I don't plan on reporting internal imports that could be private/fileprivate by default either, for the use case of a single module app one could still use the default imports without having to worry about access levels.

I think it should at least warn on the inconsistent imports. We should report different imports of the same module from the same file if they have different access levels. Currently the compiler only takes into account the most public import access level.

2 Likes

Imo this question is really worth an official answer — which, afaics, did not happen yet.
I don't see any significant benefit in the option of using different levels in different files, which has the downside that the actual choice is hidden in the source instead of being declared in a central place.

In larger codebases, a full text search is not that convenient, and it is hard to track which modules are actually exported.

3 Likes