Overriding existential any in subprotocol almost works (except it doesn't)

Say I have a following protocol family:

protocol A {}
protocol B {
    static var a: any A { get set }

Respectively, in some submodule I have extended versions of those protocols:

protocol A2: A {}
protocol B2: B {
    static var a: any A2 { get set }

(compiler allows that: just tested on stable 5.7 and on main snapshot toolchain; and honestly I can't see why would it be illegal)

Obviously, I can create implementations of A/B protocols:

struct AImpl: A {}
struct BImpl: B {
    static var a: any A = AImpl()

But if I try to create A2/B2 implementations, things are getting funny:

struct A2Impl: A2 {}
struct B2Impl: B2 { // Type 'B2Impl' does not conform to protocol 'B'
    // proposed fix
    // static var a: A

    static var a: any A2 = A2Impl()

Someone is definitely wrong, but who?

(trivia: the case is not abstract, it's more than concrete: in my project B is base active record, and A is base storage for it; respectively, B2 is an active record for FoundationDB context, same for A2, it works with FDBSwift connector; utilising associatedtype and some is not the answer for many reasons)

You cannot assign a value of any type that conforms to A to a variable of type any A2, so the new requirement in B2 is independent of the requirement in B. B2 cannot actually be conformed to by a type using only publicly available features of Swift, because the protocol requires two identically named static members named a of distinct types.[*] I don't think this is what you mean to express but it is what you've written.

[*] It may be possible to finagle this using extensions in different modules.

What you're trying to express, near as I can tell, is meant to be expressed using associatedtype. What are the many reasons you cannot use it?

1 Like

Ok, so now we have two questions instead of one, however I'd like to emphasize on the original issue: compiler should either prohibit the declaration of B2 (it makes no sense since it cannot be implemented), or it should allow it (as it doesn't seem to have any logical flaws). It looks like a clear bug to me.

(longread incoming)

As for your question, let me rewrite my example in more concrete terms (as I see myself now that it's a bit confusing), it should get more obvious.

So I have an abstract entity protocol:

public protocol Entita2Entity: Codable {
    // ...
    static var storage: any Entita2Storage { get set }
    // ...

Entita2Storage doesn't really have anything of interest, it's just a protocol for abstract storage:

public protocol Entita2Storage {
    func begin() async throws -> any Entita2Transaction

    func load(by key: Bytes, within transaction: (any Entita2Transaction)?) async throws -> Bytes?

    // etc

Somewhere else there's my FDBSwift package with FoundationDB connector, which has a protocol

public protocol FDBConnector {
    /// basically all methods like `connect`, `begin`, `get`, `set` etc 

and an actual implementation:

public extension FDB {
    final class Connector: FDBConnector { ... }

Back to Entita2, there's an additional Entita2FDB package, which inherits and extends both Entita2Entity and Entita2Storage:

public protocol Entita2FDBEntity: Entita2Entity {
    // ...

    static var storage: any Entita2FDBStorage { get set }
    // ...

public protocol Entita2FDBStorage: Entita2Storage, FDBConnector {
    // FDB-related stuff

Originally it was actually done using associatedtype, but back then there was only actual FDB connector class, a respective protocol didn't exist, and therefore it was super easy to provide entities with storage:

public extension E2FDBEntity {
    static var storage = App.current.fdb

And this is the most interesting part: there's an App class with all configs, resources, caches, storages etc for the runtime, and originally it looked like this:

final class App {
    // ...
    public static var current: App!
    // ...

    public let fdb: FDB
    // ...

Now I want the app to have not old FDB, but rather an any Entita2FDBStorage, which I can easily replace with a mock implementation for tests. Since some Entita2FDBStorage would mean that the App becomes App<Storage: Entita2FDBStorage>, I can't have static current instance anymore (I keep forgetting why, but ok), and because the App is defined in a library module, and executable is different module (like Run in Vapor), everything falls apart.

Speaking of Vapor. I don't like the idea of passing the Application into every route action and Fluent model action, and I doubt that it will work anyway without any existential. I'm not saying it's wrong, I just like my approach (with singleton dependency container) more, that's all. I realize it's unsafe, not clean and against all mortal sins of app design, but it's handy.

Ref links (pre-any implementations):

  1. GitHub - kirilltitov/FDBSwift: FoundationDB client for Swift
  2. GitHub - 1711-Games/Entita2
  3. GitHub - 1711-Games/Entita2FDB

The issue here comes down to covariance and contravariance. To expand on @xwu's comment more concretely:

B.a has a setter that implies that for any variable of type B that I have, I can assign any general A value to it.

B2 attempts to refine this same property to restrict assignment to a to only allow more specific values of type A2, but: since any B2 is a B, you could validly upcast the B2 to B and attempt to assign a there, with a value that conforms to A but not A2.

More concretely:

// These structs are completely unrelated types.
struct S_A: A {}
struct S_A2: A2 {}

struct S_B: B {
    static var a: any A = S_A()

struct S_B2: B2 {
    // Assuming this were allowed...
    static var a: any A2 = S_A2()

// These are all allowed, as you would expect.
S_B.a = S_A() // ✅
S_B.a = S_A2() // ✅
S_B2.a = S_A2() // ✅

// This is _disallowed_, as you would expect.
S_B2.a = S_A() // ❌ -- S_A does not conform to A2

// Oops...
(S_B2.self as B.Type).a = S_A() // 💥 -- the upcast is valid, and the type of `B.a` allows this assignment!

Method parameters and variable setters are contravariant in this position, which means that this type of refinement isn't valid.

As @xwu says, the compiler then sees the static var a requirement on B2 as a completely different property (which happens to have the same name). I agree that this likely isn't the most useful behavior, as it leads to unimplementable code; there's room for significant clarity improvement here with better and earlier diagnostics.

1 Like