Discouraging protocol methods on concrete values

Hello,

I'd like to pitch an evolution to method availabilty. If a protocol defines a method, then it is not always true that this method should be available on values of any concrete type that adopts the protocol.

protocol P {
    // protocol method
    func method()
}

struct T: P {
    func method() {
        // do it
    }
}

let p: P = T()
p.method() // yes, of course: method() is defined on P.

let t: T = T()
t.method() // undesired: method() is defined on T, but should be discouraged.

I'll provide below a use case that explains why such restriction can be desired by an API designer.

But before, I must show how the goal is currently impossible to achieve.

Making method unavailable on T is forbidden by the compiler, with the "Unavailable instance method 'method()' was used to satisfy a requirement of protocol 'P'" error:

struct T: P {
    // Compiler error
    @available(*, unavailable)
    func method() { ... }
}

Making method deprecated on T "works". It emits a warning, but deprecation is not the exact reason why method should not be used on raw T values:

protocol P {
    func method()
}

struct T: P {
    @available(*, deprecated)
    func method() { }
}

let p: P = T()
p.method() // no warning: OK

let t: T = T()
t.method() // warning: 'method()' is deprecated.

I thus want to pitch two things:

  1. Allow a type that conforms to a protocol to forbid the use of a protocol method, with a compiler error. This would be expressed with @available(*, unavailable), or with a new availability name: @available(*, protocolonly).

  2. Allow a type that conforms to a protocol to discourage the use of a protocol method, with a compiler warnibg. This would be expressed with a new availability name: @available(*, warning).

Both could use the renamed and message options to provide a more detailed compiler output:

struct T: P {
    @available(*, protocolonly, message: "...")
    func method() { }
}

struct T: P {
    @available(*, warning, renamed: "altMethod")
    func method() { }
    func altMethod() { }
}

Now is the time for a supporting use case.

It all boils down to concrete types that are narrower than their adopted protocol.

Take a protocol that defines several fine-grained methods in order to accomodate for the full range of needs for a specific domain. Some concrete types may embrace the full subtleties of the protocol. Some other concrete types may be much simpler, and provide, in practice, the same implementation for several protocol requirements.

This happens to the GRDB.swift database library. It has a protocol that defines various methods for accessing an SQLite connection, with distinct scheduling guarantees:

protocol DatabaseWriter {
    // Provide ability to read in the database
    func read(...)
    func unsafeRead(...)
    func unsafeReentrantRead(...)
    // Provide ability to write in the database
    func write(...)
    func unsafeWrite(...)
    func unsafeReentrantWrite(...)
}

I won't explain here why some methods are deemed "unsafe", and why some methods are marked "reentrant" when others are not. It just happens that GRDB concurrency model needs to expose safe methods for most use cases, and a few other abundantly documented methods for advanced use cases. Thats how the protocol covers the "full range" of SQLite scheduling needs.

Now GRDB ships with two concrete types that adopt the protocol: DatabaseQueue, and DatabasePool. Pools leverage the full protocol subtleties. Queues are much more simple. And the raw use of read, write, etc on database queues should be discouraged:

class DatabaseQueue {
    // The core method of queues, for both reads and writes:
    func inDatabase(...)
}

extension DatabaseQueue: DatabaseWriter {
    // Reading methods should be only allowed on iOS 8.2+, OSX 10.10+,
    // because it is then possible to prevent writes:
    func read(...)
    func unsafeRead(...)
    func unsafeReentrantRead(...)
    
    // Should be forbidden, renamed "inDatabase"
    func write(...)
    func unsafeWrite(...)
    
    // OK
    func unsafeReentrantWrite(...)
}
1 Like

I'm having trouble understanding this idea. If these methods are forbidden or discouraged on some types that conform to the protocol, then why do those types conform to the protocol? How can you write generic algorithms using the protocol when you're not supposed to call some of the methods on some types? Can you instead just either have them not conform to a protocol, or have a hierarchy of protocols that include/don't include these forbidden methods?

Any chance you want a feature like the one I pitched couple of years ago:

protocol P {
  func foo()
}

extension P {
  final func foo() {
    // ...
  }
}

struct T : P {
  func foo() {} // Compile time error: you cannot implement `foo` because it has a final default implementation
}

This is nice-to-have but I think we should first get the default keyword for default implementations:

protocol P { func foo() }

extension P { default func foo() { /* ... */} }

This provides some compile time guarantees that your implementation is really a default implementation of the protocol requirements (this isn't always trivial), this tells the developer about static/dynamic dispatch and eventually we can apply simple sugar that would allow us to write this instead of the version above:

protocol P {
 func foo() { /* ... */}  
}

How about using composition by making DatabaseQueue instead implement a DatabaseWriterProvider protocol:

protocol DatabaseWriterProvider {
    var writer: DatabaseWriter { get }
}

class DatabaseQueue: DatabaseWriterProvider {
    private class DatabaseQueueWriter: DatabaseWriter {
        func read() { ... }
        func unsafeRead() { ... }
        func unsafeReentrantRead() { ... }
        func write() { ... }
        func unsafeWrite() { ... }
        func unsafeReentrantWrite() { ... }
    }

    private(set) public writer: DatabaseWriter = DatabaseQueueWriter()

    @available(iOS 8.2, *)
    func read() { writer.read() }
    @available(iOS 8.2, *)
    func unsafeRead() { writer.unsafeRead() }
    @available(iOS 8.2, *)
    func unsafeReentrantRead() { writer.unsafeReentrantRead() }

    func inDatabase() { writer.write() }
    func unsafeReentrantWrite() { writer.unsafeReentrantWrite() }
}

And add APIs to types that expect DatabaseWriters to also accept DatabaseWriterProviders:

extension FetchedRecordsController {
    init(
        _ databaseWriterProvider: DatabaseWriterProvider,
        sql: String,
        arguments: StatementArguments? = nil,
        adapter: RowAdapter? = nil,
        queue: DispatchQueue = .main,
        isSameRecord: ((Record, Record) -> Bool)? = nil) throws
    {
        try self.init(
            databaseWriterProvider.writer,
            sql: sql,
            arguments: arguments,
            adapter: adapter,
            queue: queue,
            isSameRecord: isSameRecord)
    }
}
1 Like

If someone has a reference to the concrete object via a variable that is typed as the protocol then there is no way for the compiler to know that it should disallow calling those functions. The best you could do is to disallow calls to those methods via variables that use the concrete type directly.

C# actually has a way to do this. In C# there’s a concept called explicit interface implementation. When implementing a part of an interface you can qualify the name of the method with the interface name to specify that it is explicit like this:

public class Foo : IFoo
{
    IFoo.FooMethod()
    {
    }
}

The effect of this is that FooMethod can only be called on this object by code that is using the IFoo interface. If you have a variable typed as Foo then you can’t call it.

This basically works by making FooMethod implicitly private. Typically in C# methods fulfilling interface requirements must be declared as public, but for explicit interface implementations the compiler forbids you from specifying the access level, and instead it is implicitly private but callable via the interface.

One use case for this in C# is for when you have to make something conform to an interface for implementation reasons (maybe it’s a delegate for some other object and has to conform to the delegate interface), but you don’t want that interface to be part of your contract to other clients. You can essentially conform privately.

Another use case is if you want to provide a better method (maybe with a more useful return type) that is equivalent and encourage its use instead. In that case you can still conform to the interface, but make this method private while still supplying the better one publicly (even using the same name if desired). I think that might be the use case you described.

I do think this could be a useful feature for some of the same reasons. I’m not sure if there are better ways to do it in Swift already.

@jawbroken: the type does adopt the protocol. Methods are there. Generic algorithms can be written. The goal is to discourage the use of those methods on some concrete types only.

Thanks @DevAndArtist, but the goal is not to restrict the protocol, but only carefully chosen concrete types.

@hartbit You're right of course. This is the correct way to do it with the current Swift. Thanks ;-)

Yes, that was the goal.

The C# feature you describe is indeed very similar. Maybe the pitch is not dead, despite the excellent solution provided by @hartbit.