I have a question about protocol design and access control in Swift, especially regarding usage from external modules.
I want to design a protocol where:
a method is public
some properties required for implementation are NOT public (internal only)
Here is a simplified example:
public protocol VCInjectable: ViewController {
associatedtype VM: ViewModel
associatedtype UI: UserInterface
var viewModel: VM! { get set }
var ui: UI! { get set }
func inject(viewModel: VM, ui: UI)
}
As expected, since the protocol is public, all requirements must also be public, which exposes implementation details that I would prefer to keep internal.
Workaround
I split the protocol into public API and internal storage:
internal protocol _VCInjectableStorage: ViewController {
associatedtype VM: ViewModel
associatedtype UI: UserInterface
var viewModel: VM! { get set }
var ui: UI! { get set }
}
public protocol VCInjectable: ViewController {
associatedtype VM: ViewModel
associatedtype UI: UserInterface
func inject(viewModel: VM, ui: UI)
}
extension VCInjectable where Self: _VCInjectableStorage {
public func inject(viewModel: VM, ui: UI) {
self.viewModel = viewModel
self.ui = ui
}
}
My concern
With this design, _VCInjectableStorage is internal, so:
Types in the same module can conform to both protocols and use the default implementation.
But types in external modules cannot see or conform to _VCInjectableStorage.
This means:
They can conform to VCInjectable
But they cannot use the default inject implementation
Questions
Is there a better pattern if I want:
to keep storage requirements internal
while still allowing external modules to use the default implementation?
A protocol requirement canβt be treated as an implementation detail. A type that conforms a protocol (possibly external, if the protocol is public) must be able to see all requirements in order to satisfy them.
Your workaround illustrates the issue well. Look: to implement inject(viewModel:ui:), a type must have settable viewModel and ui properties. But _VCInjectableStorage is internal (as those hypothetical 'internal requirements' would be), so an external type has no way to know it must implement such properties.
// External module
struct ViewModel: Library.ViewModel { }
struct UserInterface: Library.UserInterface { }
struct Module: VCInjectable { // Under your idea, this should compile, since `inject(viewModel:ui:)` has a default implementation in the library
typealias VM = ViewModel
typealias UI = UserInterface
}
// Library
extension VCInjectable where Self: _VCInjectableStorage {
mutating func inject(viewModel: VM, ui: UI) {
self.viewModel = viewModel // Oops! Implementation has no `viewModel` property!
self.ui = ui
}
}
That said, you can add internal functionality that relies only on the public requirements of a public protocol, for example, via protocol extensions:
// Library
public protocol LoggingConvertible {
var loggingDescription: String { get }
}
internal extension LoggingConvertible {
func log() { // Not accessible outside the module
print(loggingDescription)
}
}
public func libraryLog(_ value: some LoggingConvertible) {
value.log() // Internal usage
}
// External Module
struct Entity: LoggingConvertible {
private let id: UUID = .init()
var loggingDescription: String { "Entity \(id)" }
}
let entity = Entity()
libraryLog(entity)
If you specifically want a design with hidden storage and a public interface, classes are the right tool:
// Library
open class InjectableBase<VM: ViewModel, UI: UserInterface> {
internal var viewModel: VM!
internal var ui: UI!
public func inject(viewModel: VM, ui: UI) {
self.viewModel = viewModel
self.ui = ui
}
}
You could split this your protocol into two: one public, one internal which inherits the public one. In your case, you can have VCInjectable: ViewController, _VCInjectableStorage.
For instance, in the following examaple, the Pub protocol is exposed to public, and the Internal protocol defines internal used properties but also inherits all the funcs and properties.
public protocol Pub {
func publicFunc()
}
internal protocol Internal: Pub {
func internalFunc()
}
public struct Test: Internal {
func internalFunc() {
// stay internal
}
public func publicFunc() { // removing pub will be an error
}
}
func operation(_ value: some Internal) {
value.publicFunc()
value.internalFunc()
}
This solution is not perfect. In some cases, you could run into compiler error "Conditional conformance of type to protocol does not imply conformance to inherited protocol", but I think it should be fine in your use case.