Better Access Control on Protocol Requirements

The Issue

Consider the following:

/// A simple Event.
public struct Event {}

/// Publishes Events.
public protocol Publisher {
    /// Publish an event.
    func publish(event: Event)
}

/// My Simple Publisher.
public struct MyPublisher: Publisher {
    /// Print the event to be published
    public func publish(event: Event) {
        print("Event: ", event)
    }
}

To use MyPublisher's publish method:

let publisher = MyPublisher()
publisher.publish(event: Event()) 
    // prints: Event:  Event()

That implementation is simple and quite functional, but some projects may require that an event be published only from inside the library. So:

/// Publishes Trusted Events.
internal protocol TrustedPublisher: Publisher {
    /// The internal method that publishes events.
    func _publish(event: Event)
}

extension TrustedPublisher {
    /// Do not use this method, it will crush. Use _publish instead.
    public func publish(event: Event) {
        fatalError("Trusted Publisher can't publish events coming from untrusted sources.")
    }
}

/// My Simple Trusted Publisher.
public struct MyTrustedPublisher: TrustedPublisher {
    /// Print the event to be published.
    internal func _publish(event: Event) {
        print("Event: ", event)
    }
}

To use MyTrustedPublisher's _publish method:

let trustedPublisher = MyTrustedPublisher()
trustedPublisher._publish(event: Event()) 
    // prints: Event:  Event()

But calling the publish method:

trustedPublisher.publish(event: Event())
    // fatal error: Trusted Publisher can't publish events coming from untrusted sources.

So since the publish method crashes and the '_publish' method is only available internally, events can't be published outside the module. That means that the user can create their own Publishers while they can still use the TrustedPublisher safely.

The Problem

The disadvantages of such an implementation are what lead to the creation of this proposal. The main problem is that you have to write a lot of boilerplate code. For every new requirement that you want to be inaccessible to the user for your own internal Types, you have to:

  1. Write the initial Protocol requirement (like the 'publish' method in Publisher).
  2. Rewrite that requirement in the internal Protocol (like the '_publish' method in TrustedPublisher).
  3. Cancel the initial requirement with an extension implementation that throws a fatal error (like the 'publish' method in the extension of TrustedPublisher).

Proposal

What I propose is enabling the marking of Protocol requirements with Access Levels. With the proposed change:

/// A simple Event.
public struct Event {}

/// Publishes Events.
public protocol Publisher {
    public internal func publish(event: Event)
}

/// My Simple Publisher.
public struct MyPublisher: Publisher {
    internal func publish(event: Event) {
        print("Event: ", event)
    }
}

To use the publish method inside the module (internally) you would write:

let publisher = MyPublisher()
publisher.publish(event: Event())
    // prints: Event:  Event()

But if you wrote that outside the module where the protocol was declared:

let publisher = MyPublisher()
publisher.publish(event: Event())
    // compile error: 'publish' is inaccessible due to 'internal' protection level.

All that's great! But what if the user of the module decides they want to create their own Publisher? Well we'd write:

/// Another Simple Publisher.
public struct AnotherPublisher: Publisher {
    // We are required to use the 'public'
    // Access Level of the 'publish' requirement
    // (we can't use 'internal' here, to see why 
    // go to the `Access Levels in Depth` section).
    public func publish(event: Event) { 
        print("Event: ", event)
    }
}

And to access the Type's publish method:

let publisher = AnotherPublisher()
publisher.publish(event: Event())
    // prints: Event:  Event()

Detailed Design

So how exactly would Access Levels in Protocol requirements work? Here are some notes about the proposed change:

  • The default requirement Access Level is 'internal', unless the protocol has an Access Level of 'public'. In case the protocol's Access Level is 'public', the requirement's default Access Level becomes public.

  • Requirements cannot have an Access Level of 'private'.

  • Requirements can be accessed only when the accessor's Access Level is sufficient (That includes extensions).

  • Requirement implementations shall have an Access Level equal to or higher than the corresponding requirement's Access Level.

  • When a requirement is marked with more than one Access Level, the lower Access Level available will be chosen.

How does this impact @testable import?

  • Requirements with 'internal' Access Level, will be considered 'public' by the Testing Module. All other requirements will remain the same.

That means that a Protocol changes according to the current Access Level. Consider the following Protocol:

/// A Protocol with requirements that have different Access Levels.
public protocol MultipleLevel {
    /// Greet internally (only accessible in the current module and the testing module).
    internal func internalGreet()

    /// Greet publicly (accessible everywhere!).
    public func publicGreet()
}

When using this Protocol inside the module it is declared in:

let multipleLevel: MultipleLevel = // ... 
    
multipleLevel.internalGreet()
multipleLevel.publicGreet()

But when used outside the module:

multipleLevel.internalGreet()
    // compile error: 'internalGreet' is inaccessible due to 'internal' protection level.

multipleLevel.publicGreet()

In a testing module, the internalGreet method is exposed. So everything works as if it were used internally:

multipleLevel.internalGreet()
multipleLevel.publicGreet()

Access Levels in Depth

Marking requirements with more than one Access Levels would make this proposal even more powerful.
You might be wondering how this would work. It's quite simple. But it'd be better to explain how marking requirements with Access Levels would work.

  • A requirement can be marked with no Access Levels. In this case the default is used. The default is 'internal', unless the Protocol is 'public', where the default is 'public'.

  • If a requirement is marked with one Access Level, that Access Level is used. It's similar to how structs, enums and classes treat different Access Levels. For example, if a requirement were marked internal, this requirement would only be accessible inside the module the Protocol is declared in.

  • Marking a requirement with more than one Access Level is a bit more complicated. Note that a given Access Level can only be used once. When having multiple Access Levels, the lower Access Level available is used. For Instance, a requirement marked with 'public' and 'internal' would use the 'internal' Access Level when used inside its module (the module the Protocol is declared in). But when used outside that module, it will be required to use 'public'. Also, it's important to point out that a conforming Type can use an Access Level higher than the one chosen. Such an Access Level would be 'public' in the first case. An example of using multiple Access Levels is shown in the Publisher Protocol in the Proposal section.

Thanks for reading my proposal!
If you have any feedback please share it with the community, it'd be really appreciated. Your feedback is critical to Swift evolving and improving!

1 Like

For one, it does break the current layered design, that the closer you are to the declaration, more likely you'll be able to use it (depending on the access control).

It'd also impact the testing with the current design at the very least since you can't test the code you wrote as a stand alone. At the very least, I think the pitch should address how it'd work with @testable import.

I'm not sure the added benefit would outweigh the added complexity. Since the problem it's trying to solve is "to use functions that should be used by the library only", it is already much alleviated by a proper documentation. And it is already encouraged that the conformers read, and understand the protocol they're conforming.

3 Likes

How is this supposed to work ?

/// [Module Foo] Publishes Events.
public protocol Publisher {
   internal func publish(event: Event)
}

/// My Simple Publisher.
public struct MyPublisher: Publisher {
    internal func publish(event: Event) {
        print("Event: ", event)
    }
}

/// [Module Bar] Publishes Events.
public struct BarPublisher: Publisher {
    internal func publish(event: Event) {
        print("Event: ", event)
    }
}

let publishers: [Publisher] = [ MyPublisher(), BarPublisher() ]
publishers.forEach { $0.publish(Event()) }

If it doesn't work, what's the point having a Protocol if you can't use the existential and write "generic" code using it.

1 Like

I definitely think this is a big, fixable problem. We should allow public protocols to have less-than-publc requirements, with some kind of flag which prevents others outside the module from trying to conform to them.

I really dislike the convention of prefixing public declarations with an underscore to mean "don't touch this".

5 Likes

Thanks for the feedback and for mentioning that I didn't address how @testable import would be handled! I have updated the proposal to mention how it'd be addressed.

Also, I wanted to say that although this proposal will mostly benefit swift library developers, it will also help other swift developers. That's because most library developers mark the library Protocols 'internal', to avoid having to do all the steps described in the Problem section. This change, though, would make exposing internal Types used in libraries to clients much easier and safer. Additionally, this proposal would eliminate the use of methods prefixed with an underscore to indicate that they should not be used by clients, while making Swift's Type System even more powerful.

This seems very similar to protocols that can only be implemented by a particular module. What is the expectation when another developer will be able to implement Publisher and provide their own publish method - would use be be limited to within their module?

I have updated the proposal to better explain this behaviour. Here's what would happen with the code you wrote in your reply:

  1. Since Publisher's publish method is internal method it will only be accessible in the module "Foo". Thus, MyPublisher conforms to Publisher by implementing the publish method.

  2. Inside "Bar", Publisher has no requirements, because its only requirement is the internal method publish, which is inaccessible to the module "Bar". Therefore, BarPublisher's publish method is not an implementation of a protocol requirement.

  3. The final line of your code would be erroneous due to the fact that Publish has no method named publish when accessed from the module "Bar". As a result, you'd get the error:
    "publish" is inaccessible due to internal protection level.

Generics would work similarly to how they already work in structs, enums and classes. So that generic code will be able to access an internal property when inside the declaration module, but unable to do so outside this module.

Consider the following code:

/// A simple Event.
public struct Event {}

/// Publishes Events.
public protocol Publisher {
    public internal func publish(event: Event)
}

If we want to use the Publisher protocol inside the declaration module:

/// My Simple Publisher.
public struct MyPublisher: Publisher {
    internal func publish(event: Event) {
        print("Event: ", event)
    }
}

But when using Publisher outside its module, the behaviour is a bit more complex (to see how it works check out the explanation in the third dot under the Access Levels in Depth section). Since Publisher's publish internal access level is not accessible from an outside module, the public access level is used:

/// Another Simple Publisher.
public struct AnotherPublisher: Publisher {
    // We are required to use the 'public'
    // Access Level of the 'publish' requirement
    // we can't use 'internal'
    public func publish(event: Event) { 
        print("Event: ", event)
    }
}

This is how it would work. So to answer your question: No, use would not be limited to their module, since Publish's publish has the Access Level of 'public' when used outside its declaration module.

If you have any other ideas of how this proposal could be differently implemented please share them!

Protocol requirements that are less than public would not be visible outside the protocol's declaring module. As such, it would not be possible to conform to them outside the declaring module.

This pattern is used quite a lot by the Swift project itself (e.g. StringProtocol._guts, _SyntaxNode) and is essential to a lot of library designs in Swift. However, early on the standard library couldn't support access control and so we developed this hack with the underscores that IDEs would just agree not to display (because they get their understanding of Swift via the compiler tools). Now nobody cares about modelling this in the language properly.

I pitched a similar feature once before, and again, despite it being a widely-used pattern by the project itself, the core team were uninterested. So while it's fun to speculate, I wouldn't get your hopes up on this becoming part of the language.

2 Likes

I still don't see how multiple access level works.

/// [Module Foo] Publishes Events.
public protocol Publisher {
  public internal func publish(event: Event)
}

/// My Simple Publisher.
public struct MyPublisher: Publisher {
    internal func publish(event: Event) {
        print("Event: ", event)
    }
}

/// [Module Bar] Publishes Events.
public struct BarPublisher: Publisher {
    public func publish(event: Event) {
        print("Event: ", event)
    }
}

var p: Publisher = MyPublisher()
p.publish(Event()) // Is it valid code or no ?

var p: Publisher = BarPublisher()
p.publish(Event()) // Is it valid code or no ?

I’m going to update the proposal to address this issue. Maybe multiple modifiers shouldn’t be added to Swift as currently proposed. Any ideas of how swift could handle multiple modifiers are welcome.

Nonetheless, I consider the proposal without the “multiple modifiers” part a really useful tool that should be added to Swift to make Protocol Oriented Programming even more powerful and in my opinion expressive.

Here is what I do. Is there a reason this wouldn’t solve the problem?

public protocol Chunk: CustomStringConvertible {
    /// the type of the chunk
    var type: ChunkType { get }
    /// the textual content of the chunk
    var content: String { get }
}
protocol ChunkPrivate {
    var endToken: Token? { get set }

    func isEqualTo(_ other: Chunk) -> Bool
}

/// chunk type used internally in the package in place of Chunk
typealias InternalChunk = Chunk & ChunkPrivate

Every public api, which are always in a protocol, uses Chunk or the type-erased AnyChunk. Internal-only funcs use InternalChunk or AnyChunk, where the wrapped value is scoped internal so it can be unwrapped.

I don't see how this would work. Could you please explain how this could work on this Publisher Protocol:

/// The Event Type used by Publishers.
public struct Event {}

// The Publisher used publicly.
public protocol PublicPublisher {
    func publish(event: Event)
}

// The private Publisher.
protocol PrivatePublisher {
    func publish(event: Event)
}

/// The Publisher used internally.
typealias Publisher = PublicPublisher & PrivatePublisher

/// A publicly used Publisher Type.
public struct MyPublisher: InternalPublisher {
     func publish(event: Event) { /* ... */ }
}

/// How could we use MyPublisher in 'test',
/// since MyPublisher doesn't and
/// shouldn't conform to PublicPublisher?
func test(publisher: Publisher) { 
    publisher.publish()
}