Mutually exclusive default implementations

The topic of mutually referenced default implementations came up in the thread Combining hashes - #89 by lorentey, specifically in the context of hashValue and the proposed hash(into:). @xwu pointed out that in some cases the standard library provides such mutually referenced default implementations:

We already have other circumstances in the standard library where (for convenience or backwards compatibility) two protocol requirements have default implementations that reference each other (heterogeneous generic operators and homogeneous ones; Comparable requirements and Strideable requirements). Users who stumble into this situation find that their “conforming” types compile but then crash at runtime.

The result is that the mutually referenced conforming members crash due to infinite mutual recursion unless a conforming type provides an explicit implementation of at least one of the requirements. This situation leaves a lot to be desired. @xwu continued:

The solution I’m thinking of is teaching the compiler to recognize these circular references as scenarios in which either requirement A or B must be implemented at minimum, rather than blindly permitting both default implementations to call each other endlessly. (Of course, this would have to be limited to default implementations that are inlinable–i.e., where the compiler can actually determine what’s in the body of the implementations.) As a bonus, if one of these default implementations is marked as deprecated, the compiler might always recommend that the user implement the other one.

With ABI stability, such a feature would be hugely important in allowing us to evolve protocols in backward-compatible ways. For this particular circumstance, it would permit us to rip out custom compiler magic except where it pertains to synthesized conformance to Hashable.

@Karl suggested a new constraint that would allow us to make the default implementations mutually exclusive which @lorentey found interesting in the context of his use case for Hashable:

Supporting either/or protocol requirements by providing default implementations based on the presence/absence of “direct” implementations for other requirements sounds like an intriguing idea. It needs to be explored further: For example, in the case of Hashable, what would extension Hashable where Self.implements(hashValue) mean, exactly? When is an implementation of hashValue not good enough for implements? Default implementations aren’t currently marked as such.

One potential issue this constraint wouldn't address directly is a manually written implementation that calls through to the mutually recursive default. It might be interesting to explore how we might be able to prevent that mistake as well.

The purpose of this thread is to explore the design space and see if we can reach a consensus on what a solution should look like.

2 Likes

I think it would be informative to work out an example for a protocol other than Hashable -- compiler support for conformance synthesis makes that protocol too much of a special case.

The Comparable problem may be a better starting point. However, can we find a self-contained use case for this outside of the stdlib?

Comparable might be a good fit in the case you want to support both (==, <) or <=> as required implementations.
Even though Swift doesn't deal with <=> other people might want to add that in their project, and have some types (like StdLib ones) have <=> defined in terms of (==, <), while their custom types would have the opposite.

I'm glad you're subsetting out this discussion on its own; I think it's an important one to have for the future health of Swift protocol-based programming.

However, I don't actually think that the issue you raise above needs to be solved along with the basic issue I outlined, nor potentially ever:

Default implementations very often have to rely on non-defaulted requirements to carry out their task. In general, today, it is never safe for a user to implement a non-defaulted requirement by calling a requirement of the same protocol with a default implementation. With resilient libraries in the future, even if such an implementation works today, it may not work tomorrow when the library changes its default implementation and causes infinite mutual recursion.

Instead, I would argue that the compiler should warn (or even error) any time a user attempts to write such an implementation regardless of the presence or absence of present-day mutual recursion unless the default implementation called is either always inlined or in the same module as the calling implementation, which in turn must never be inlinable.

1 Like