[Anti-Pitch] Just say "no" to protocols on structural types?

Elevator Pitch: Although it's highly desired, allowing tuple types to conform to protocols is something we must not support.

Primary Reason: Conflicts
Secondary Reason: May go against what tuples are all about


If you think that slapping a protocol on a tuple is awesome, you're probably not the only one to come up with it. The problem comes from the fact that tuple type declarations are global (modulo that a scope can access all the components' types). What happens if you bring in a library that already had the protocol added to a tuple type you wanted to add? What happens if two different libraries do this? Would there be some kind of priority system? Since you can differentiate function overloads with protocol conformance, what if you didn't want a particular tuple type to have a conformance? There's no way to remove a conformance.

This can't even begin to work unless tuples' conformances can't go past internal, and thus not visible to outside code. In other words, slapping an irrevocable conformance to a global public type you really shouldn't have control over is rude to anyone who disagrees with your decision.

The best workaround would be a newtype facility and restrict your conformance to that type copy. And that's better from a philosophical view too. Tuples are supposed to be immediate; if your abstraction should have a richer interface, including protocol conformance, you are getting into struct territory.

There's another practical problem, people want to do Equatable and such on tuples. You can't just do a variadic generic function (once we support those) == that needs each field's type to be Equatable, because we need to allow a tuple that has a field that is itself a tuple to be supported. (And if we add sum-tuples and fixed-size arrays as structural types, the combinations explode.) I think we need a family of global generic functions:

func areEqual<T: Equatable>(_ l: T, _ r: T) -> Bool? {
    return l == r
}

func areEqual<T>(_ l: T, _ r: T) -> Bool? {
    // Check if T is a function pointer; if so, compare.
    // Check if T is already covered by an overload; if so, return that call.
    return nil
}

func areEqual<variadic U>(_ l: (U...), _ r: (U...)) -> Bool? {
    for lens in lenses(of: (U...).Type) {
        guard let fieldEqual = areEqual(l[key: lens], r[key: lens]) else {
            return nil
        }
        guard fieldEqual else { return false }
    }
    return true
}

// Theoretical sum-tuple
func areEqual<variadic U>(_ l: (; U... ;), _ r: (; U... ;)) -> Bool? {
    for prism in prisms(of: (; U... ;).Type) {
        switch (l[key: prism], r[key: prism]) {
        case let (ll?, rr?):
            return areEqual(ll, rr)
        case (_?, nil), (nil, _?):
            return false  // Or should `nil` be returned here?
        case (nil, nil):
            continue
        }
    }
    fatalError("One of the cases should have been reached!")
}

// Theoretical fixed-size array
func areEqual<T, variadic let N: Int>(_ l: [N... ; T], _ r: [N... ; T]) -> Bool? {
    for i in indices(of: l) {
        guard let elementEqual = areEqual(l[i], r[i]) else { return nil }
        guard elementEqual else { return false }
    }
    return true
}
1 Like

If someone is doing this, the protocol isn't from your module. That doesn't seem different from adding conformance to any type that you don't 'own'. It is questionable, sure, but limiting it to internal scope is The Right Thing™️ in most cases. All of the relevant standard library protocol conformances should come from the standard library so… I don't actually see the issue here.

This doesn't solve much that can't be (and isn't) solved today. Protocol conformance would allow tuples to be used as type parameters constrained to Equatable and such. These functions do nothing for that.

We already have this problem with retroactive conformances on nominal types. Our behavior there is not great, but we deal with it.

If tuples of Equatable types are themselves Equatable, then this problem takes care of itself—the inner tuple has all Equatable types, so it is itself an Equatable element of the outer tuple. Note that, for instance, Dictionary<String, Array<Int>> is Equatable even though Dictionary<String, Array<Any>> is not. Conditional conformance can handle it.

4 Likes

I'll note that we really don't deal with it. We just pick one at run time. And this is one of the remaining forms of library evolution that will break clients.

Retroactive conformances (i.e. conformances where your module owns neither the type nor the protocol) are dangerous no matter what if you depend on a library you can't pin to a specific version. That said, conformances to protocols you do own are perfectly reasonable.

4 Likes

At a high level, I don't see any reason to hold one kind of type to a different standard than the rest of the system. Allowing structural types to conform to protocols wouldn't create any new problems we don't already know about and need to solve.

12 Likes

To be concrete, there aren’t problems with:

  • conforming types that you do own to protocols you do own
  • conforming types that you don’t own to protocols you do own
  • conforming types that you do own to protocols you don’t own

There are problems with:

  • conforming types that you don’t own to protocols you don’t own

It’s true that a tuple type made up of only types you don’t own is in turn a type you don’t own, but even then you can appropriately conform it to protocols that you own.

If the tuple type is made up of at least one type you do own, it’s a tuple type that you own, and you can conform it without issue to any protocol of your choosing.

5 Likes

Well, that depends whether tuple types turn out to be different types, or a single Tuple struct with different generic arguments.

3 Likes

... which I suppose begs the question of whether the compiler should allow such conformances, and if it should, whether we should at least emit a warning for them.

There are analogies to the public/open debate for classes - i.e. that even though it might be dangerous in some circumstances, introducing a retroactive conformance can be useful in the same way that subclassing a non-open class can be useful. We solved this by introducing the 'open' modifier for classes, but the situation for protocols is even worse because of the possibility of collisions.

That is one of the reasons I continue to think we urgently need some kind of 'sealed' modifier for protocols. I predict that lots of problems will emerge in this area, as Apple starts to publish public protocols in OS libraries. The weakness of the package manager and library ecosystem is masking this problem today.

This isn't related to public/open / sealed. That's about protocols that can't be satisfied outside their original module; the retroactive conformance problem is about protocols that are meant to be satisfied outside their original module, but then two people do it for the same type.

2 Likes

(I do, in fact, thing retroactive conformances were a mistake, but got pushback last time I tried to bring it up: Retroactive Conformances vs. Swift-in-the-OS. Even that doesn't account for subclasses, though.)

1 Like

There are several interpretations about how sealed protocols should be. It could make sense for protocols to separate the idea of "conformance scope" from type visibility, with degrees similar to our regular access control. The current situation where type visibility == conformance scope is a mistake, IMO.

So yeah, basically I agree with what you wrote in the linked thread.

In theory I like the idea of having more narrowly scoped conformances. I have even had to create otherwise undesirable shadow types to work around this in the library I work on so I know the pain the current limitation causes very well. But this would dramatically expand the potential for multiple conformances and incoherence. We need to deal with that problem in a principled way first before we can consider access control for conformances.

1 Like

Access control for conformances is yet another thing distinct from both restricting who can conform to a protocol and the problems of retroactive conformances.

Sorry, I was ambiguous. I meant for the phrase "we deal with it" to expand to "[users of the language] deal with [the language's not-great behavior when nominal types have conflicting conformances]". That is, the language does do something (picks one at runtime) that sort of works but is less than ideal, and there's no reason tuple conformances wouldn't work the same way.

1 Like

Before ABI stability, we did at least add an "is retroactive" bit to conformance metadata, so future runtimes have the ability to have a more predictable rule around dynamic casting in the future (such as perhaps always favoring the non-retroactive conformance if one is available).

Aside from dynamic casting, though, Swift's runtime model is designed to accommodate multiple conformances. With language design work to allow specific conformances to be named and chosen when there's more than one, we could conceivably allow for retroactive conformances to be disambiguated, and maybe even intentially allow for multiple alternative conformances to be expressed.

10 Likes

Since I don't think tuples should be able to have protocol conformances added to them, the consequence of not having tuples match constraints is intentional. If you want to add conformances, then you want more from a tuple than use as a grouping mechanism, which means it has a semantic, which means you really need a struct or a strong type-alias (if we add those) since your type with full semantics should have a name.

1 Like