Conditional conformance collisions

I ran into a potentially nasty issue with conditional conformances recently. I was importing the SourceKit-LSP code into our (Google's) monorepo so I could set it up building with Bazel; it depends on SwiftPM, which we already have imported and also build from source. The version of SwiftPM we have is tracking fairly close to HEAD, so it's moved beyond the last 0.3.0 release. Let's assume that rolling our copy of SwiftPM back to 0.3.0 is not possible.

SourceKit-LSP extends stdlib's Range: Codable where Bound == Position (Position is another type defined by SourceKit-LSP). SourceKit-LSP also depends on SwiftPM's Utility target, which depends on Basic, which provides a different but conflicting conditional conformance: Range: Codable where Bound: Codable.

SourceKit-LSP currently depends on SwiftPM 0.3.0 which doesn't have the conformance, but the next release will. Trying to build SourceKit-LSP against SwiftPM HEAD today produces the following error:

Sources/LanguageServerProtocol/Position.swift:44:18: error: conflicting conformance of 'Range<Bound>' to protocol 'Decodable'; there cannot be more than one conformance, even with different conditional bounds
extension Range: Codable where Bound == Position {
                 ^
Basic.Range<Bound>:1:11: note: 'Range<Bound>' declares conformance to protocol 'Decodable' here
extension Range : Codable where Bound : Comparable, Bound : Decodable, Bound : Encodable {
          ^

What's worse is that the conformances use different stringValues for their CodingKeys, so they're not runtime compatible—cooperatively pushing the conformance down to the lowest dependency wouldn't solve this. We could talk about various approaches to fixing the problem for SK-LSP specifically (e.g., introduce a separate type for text ranges), but that just dodges what seems like a tricky situation with conditional conformances in general.

Some random thoughts:

  • These conformances seem like they were added to be used in the project itself but are not necessarily useful as exported public API. However, since requirements must have the same visibility as the protocol itself, there's no way to avoid exporting a conditional conformance to a public protocol to all the targets that depend on you.

  • You can be broken by anyone in your build graph, not just your direct dependencies. In the case above, a dependency of a dependency of SK-LSP added a conformance that breaks it, and does so in a way that it could not know about and seemingly cannot recover without one of the two modules redesigning its types in a major way.

  • The two conformances in question seem like they could be prioritized; one is a protocol constraint Bound: Codable, and one is a concrete type constraint Bound == Position. Could the most-specific one win? (This is probably a disaster for conformance checking and dispatching to the correct implementation.) This wouldn't fix the problem everywhere but would probably reduce occurrences, especially where libraries add conditional conformances involving types that they define.

Unfortunately I have a lot of questions and not many answers, but has this issue come up before? It seems similar to other scenarios I've seen discussed (like operator declarations across modules), but I couldn't find anything specifically about conditional conformances.

3 Likes

This particular conformance is a bit evil and I feel bad for adding it. We should probably use our own concrete type for this so that we can control serialization.

2 Likes

For SourceKit-LSP specifically, that might be the safest approach (although I think it's also reasonable to say that there are certain ergonomics that you get for free if you use Range that it would be unfortunate to lose or have to replicate).

What concerns me more (and why I raised it in the general forum) is the underlying problem separate from SK-LSP—we encourage this kind of retroactive conformance (conditional and otherwise), so extending Range as you did feels like a perfectly reasonable "Swifty" thing to do, but things can fall apart due to reasons entirely outside your control. I'm not sure what the answer is here, though.

2 Likes

This is definitely a known behavior. It doesn't really have anything to do with conditional conformances - you can end up with conflicts any time a library adds a conformance where it does not declare either the conforming type or the protocol. IMO a library that adds such a conformance is a bad citizen. A wrapper type or other alternative design should be used. It makes code clunkier, but ensures that users of the library do not run into this issue.

@Joe_Groff has talked about enhancements to the type system that would be able to distinguish "conflicting" conformances like this in the type system. That may alleviate this issue in the future, but for now we just need to be aware of it and take responsibility as library authors to ensure we do not contribute to problems for our users.

4 Likes

Ah, thanks for reminding me of that. I thought the conditional part of it was complicating the matter, but you're right—this happens with regular retroactive conformances as well. (A sample I came up with to test it didn't catch it at first because my imports were wrong originally.)

There's also a proposal and implementation, which currently uses unkeyed containers.

https://github.com/apple/swift-evolution/pull/915

https://github.com/apple/swift/pull/19532

See also Retroactive Conformances vs. Swift-in-the-OS, which I keep meaning to circle back to and then don't find the time for.

2 Likes

Agreed. SwiftNIO explicitly takes the position that either conforming one of our types to a stdlib protocol, or conforming a stdlib type to our protocol, is a SemVer minor change: that is, it is not considered source breaking. This is despite the fact that users may in fact have their source code broken if they (or, more likely, one of their dependencies) have written such a conformance themselves.

This is simply the only way to handle the fact that extensions are extremely powerful in Swift. Extending types you do not own with protocol conformances to protocols you do not own puts you at risk of breakage.

4 Likes

I have not tried to work through the consequences of using wrapper types to avoid this, but it seems like it might be so painful as to be somewhat untenable in practice.

Is the right answer, as a start, that the stdlib should try hard to define conformances for compositions of types inexports? That also feels risky and bloats the standard library.

While inconvenient, could some of these conformance be relegated to external packages that could try and be the “authoritative” source of them (vending little else), to try and avoid the issue?

I would argue that compiler should provide a way to disallow extending types that you don’t own in library modules. Not everyone is aware of this collision problem until they run into it. This problem will only grow with Swift’s package ecosystem. The restriction can lifted once we have a proper solution for this problem. Otherwise, we’ll just keep seeing this kind of collisions in the package ecosystem.

Note that having private conformance is also not a solution because libraries can be made up to multiple modules. Maybe the private import feature can help there?

2 Likes

With respect to conditional conformances specifically, I'd argue that we can be more conservative. The thing to be discouraged, specifically, is conforming a type you don't own to a protocol you also don't own.

If we'd like, we can be even more conservative in forbidding this only in the specific case where the type and protocol are exported by the same module (e.g., forbidding extension T: P where T and P are both exported by the standard library or both exported by Foundation). This would permit standalone modules to vend extensions that 'glue' together disparate frameworks.

I did the work on this and any changes that were raised, but have no idea what the state of it going any further is. I don't know if it's come down to time constrains of the core team or something else :man_shrugging:t2:

I think I like this approach for conditional conformance. Your second suggestion about gluing together frameworks by random module also has the same risks, I think. Imagine if a random package extends some type in a famous package.

I think this could be solved by having names extensions which would allow a single type to conform to the same protocol multiple times. (Idris does this with names implementation of interfaces Interfaces — Idris 1.3.3 documentation)

This topic already came up in a different context than conditional conformances. It is possible that conditional conformance and conforming using types outside the control of the library owner bring additional motivation for such a feature.

1 Like

I think this could be solved by having names extensions which would allow a single type to conform to the same protocol multiple times.

Doesn't that effectively bring us full circle to Objective-C categories and all the slings and arrows that flesh was heir to? We'd end up wanting to be able to spell "use the extension named X" or "use the extension from the module named Y" somehow, at which point the problem has just been pushed up one level in the syntax. Explicit disambiguation of which module's extension to call has already been brought up in the past as a possible solution to this problem, and I for one wouldn't care to see Swift sprout C++-like scope resolution (constructions like fooObject->TheDiamondPatternIsBad::commonVirtualBaseMethod() make me shiver). How would you reconcile multiple conformances to protocols that the compiler calls into on its own, such as Equatable, Comparable, or even more nightmarishly, Codable?

That scope resolution already exists, the compiler just hides it from you. Every time you need a protocol conformance, it's really an independent parameter of the generic context being instantiated, and the compiler fills in the corresponding argument implicitly by picking the protocol conformance it has available. The conformances have names in the sense they have unique identities, it's just currently not possible to explicitly spell them in the surface syntax. The underlying implementation supports having multiple conformances of the same type to the same protocol, since they are modeled as independent entities.

6 Likes