I've been working on a project recently where I've made extensive use of the recent package
access control level, and I have some feedback to share.
(Proposal link, if you need to refresh: SE-0386 New access modifier: package
)
The Project
The project is a set of libraries and a suite of applications. These are specialised applications deployed on a fleet of managed devices, and there is a desire for some of the technology to be made available as an SDK.
One of the priorities when doing this was to keep the SDK libraries feeling like "our" libraries - they should be able to share implementation details with each other and the first-party apps, as we do now, without making API commitments. Additionally, the working environment should undergo as few changes as possible - we don't want to make it cumbersome to make changes for the first-party apps.
One issue with making this transition is that it would result in public
having a different weight in certain files within the same workspace. Previously, the only way for libraries to communicate with each other was via their public
interfaces, so we use public
a lot and are in the habit of writing it, and for non-published libraries that's fine. But for SDK libraries we want developers to be more conservative - those libraries will be published, so public
in those modules makes commitments to external clients.
.
โโโ Project/
โโโ lib-sdk/
โ โโโ SharedTypes
โ โโโ SDKModuleA
โโโ lib/
โ โโโ InternalModuleA
โ โโโ InternalModuleB
โโโ app/
โโโ AppA
โโโ AppB
So my idea was to try making package
our new default for internal interfaces between modules and to reserve public
for external API commitments across the board. It's a bit of an adjustment, but that way, access levels have a consistent meaning.
And as an added benefit, the proposal notes that the compiler will not use resilient access patterns for package
symbols in library evolution mode, since modules in the same package are part of the same resilience domain. Our SDK libraries will be distributed as binary XCFrameworks, so using package
everywhere looks like the right approach to get the best performance.
Feedback 1: package
is great
For the most part, replacing public
with package
for the use-case I described just works. You basically don't need any additional code changes, and it's great - it does everything I wanted it to do.
For instance, in the diagram above, SharedTypes
is able to contain types and expose implementation details to our internal modules, without making them available to SDK clients:
// In SharedTypes.
public struct Country {
package init(rawISOCode: String)
}
// SDK Clients can receive a 'Country' from the API,
// but can't construct them.
// Internal modules can construct them as freely as before.
I just made everything that was public
now package
, everything still built, then I made only the Country
type definition public. It's that easy to manage these two levels of exported API.
If I think of trying to implement this without package
, it would require making everything public
and using #if
build-time conditions everywhere. I can imagine we'd sometimes forget to include everything in the right #if
blocks and get build failures when building in the other mode. This is much better.
It's been so successful that I think I might adjust my coding style for personal projects to also make package
the default for package interfaces, rather than making them public
.
Even if you don't have the same kind of needs that I do in this project, and your libraries are never published and used by external clients, I think it's probably a good habit to develop to write your package-level interfaces with package
, and to get out of the habit of making them public
by default. The language is allowing us to express module interfaces more precisely, so I'd encourage everybody else to also consider adjusting their habits to make use of it.
Then, in the future, if you do ever want to publish parts of the library, you can expose a very minimal API surface with basically no additional barriers impeding your regular workflow - you don't need to break things up in to smaller modules, you're not prevented from sharing implementation details within the package, etc. It's really nice.
However, one thing to be aware of is that if you start wholesale replacing public
with package
, you need to do it everywhere at once. The compiler doesn't know that your non-published modules won't be published, so uses of package types in nominally-public interfaces will not compile:
// In InternalModuleA
// Error:
// Function cannot be declared public because its type uses a package type
public func doSomething(_: SomePackageType)
This may seem obvious, but this "viral" property of access control becomes an issue later.
Feedback 2: classes are painful
The one area where package
falls down is classes.
Swift, of course, favours mutable value semantics, structs and protocols, but classes are still important. Lots of Apple's APIs are object-oriented, and even users of modern frameworks such as SwiftUI make use of classes for things like view-models and @Observable
state. As such, it is not unusual for developers to maintain classes, and to refactor those classes in to hierarchies. It is not always feasible to adapt such interfaces to structs and protocols.
Unfortunately, package
does not really work with subclassing at all. While an internal class
can be subclassed freely throughout the declaring a module, a package class
cannot be subclassed throughout the declaring package.
// Cannot be subclassed in other modules of the package.
package class Base {
package func foo(_: SomePackageType)
}
Of note, protocols do not have this limitation, and can be refined and conformed-to throughout the package:
// Can be refined and conformed to in other modules of the package.
package protocol BaseProtocol {
func foo(_: SomePackageType)
}
In order to allow subclassing, we need to violate our package
-everywhere rule and declare Base
as open
, which also makes it public
. If Base
happens to be in a published SDK module, we would be forced in to making an API commitment we don't necessarily want to make.
But let's say we're lucky and Base
is in an internal module. It won't be published, so we can violate our rule without really making API commitments. The next problem occurs when we need to override a function which uses package-level types:
// Technically "open", but this whole module is package-use-only.
open class Base {
// Error:
// Function cannot be declared open because its type uses a package type
open func foo(_: SomePackageType) {
}
}
This is the "viral" nature of access control. If you look at open
solely from a subclassing perspective, this seems overly strict.
I understand why this happens, but it's quite annoying and you can't really work around it. It's the one area where adopting package
puts barriers in your way that weren't there with public
.
I note the proposal mentions subclassing as a "future direction", but I would like to call it out specifically because this aspect didn't get that much attention in the review discussions, although I would like to point out this post from @John_McCall
The current package class
is Box C.
In my opinion, it would be more in keeping with the spirit of the proposal if it were Box B. If we're going with the idea that a package is managed by one "team" - it's a unit of distribution, version-locked, resilience domain, etc - there seems little value in making a class accessible to your team-members but artificially restricting their ability to subclass it.
But in any case, the lack of anything in Box B is what I'm struggling with here. We should really add a spelling for this.
Conclusion
In summary, I'm really happy with the new capabilities package
gives us. It's early, but I'm confident that it doesn't put obstacles in the everyday work of the development team (beyond having to unlearn a lot of uses of public
), and it still allows to make the minimum API commitments possible to clients.
As I mentioned earlier, I encourage all library developers to use it for their package-internal interfaces rather than public
, even if you've never had these issues. I think that's probably a new Swift "best practice".
But we do need to sort out classes to make the most of these new capabilities.