This document explains the rationale for choosing to make implementation coherence a goal for Carbon, and the alternatives considered.
The main thing to understand is that coherence is a desirable property, but to get that property we need an orphan rule, and that rule has a cost. It in particular limits how much control users of a type have over how that type implements interfaces. There are a few main problematic use cases to consider:
Comparable interface for a
Song type to support "by title", "by artist", and "by album" orderings.These last two cases are highlighted as concerns in Rust in Rust RFC #1856: orphan rules are stricter than we would like.
Since Carbon is bundling interface implementations into types, for the convenience and expressiveness that provides, we satisfy those use cases by giving the user control over the type of a value. This means having facilities for defining new compatible types with different interface implementations, and casting between those types as needed.
The "Hashtable problem" is that the specific hash function used to compute the
hash of keys in a hashtable must be the same when adding an entry, when looking
it up, and other operations like resizing. So a hashtable type is dependent on
both the key type, and the key type's implementation of the Hashable
interface. If the key type can have more than one implementation of Hashable,
there needs to be some mechanism for choosing a single one to be used
consistently by the hashtable type, or the invariants of the type will be
violated.
Without the orphan rule to enforce coherence, we might have a situation like this:
Package Container defines a HashSet type.
package Container;
class HashSet(Key:! Hashable) { ... }
A Song type is defined in package SongLib.
Package SongHashArtistAndTitle defines an implementation of Hashable for
SongLib.Song.
package SongHashArtistAndTitle;
import SongLib;
impl SongLib.Song as Hashable {
fn Hash[self: Self]() -> u64 { ... }
}
Package SongUtil uses the Hashable implementation from
SongHashArtistAndTitle to define a function IsInHashSet.
package SongUtil;
import SongLib;
import SongHashArtistAndTitle;
import Containers;
fn IsInHashSet(
s: SongLib.Song,
h: Containers.HashSet(SongLib.Song)*) -> bool {
return h->Contains(s);
}
Package SongHashAppleMusicURL defines a different implementation of
Hashable for SongLib.Song than package SongHashArtistAndTitle.
package SongHashAppleMusicURL;
import SongLib;
impl SongLib.Song as Hashable {
fn Hash[self: Self]() -> u64 { ... }
}
Finally, package Trouble imports SongHashAppleMusicURL, creates a hash
set, and then calls the IsInHashSet function from package SongUtil.
package Trouble;
import SongLib;
import SongHashAppleMusicURL;
import Containers;
import SongUtil;
fn SomethingWeirdHappens() {
var unchained_melody: SongLib.Song = ...;
var song_set: auto = Containers.HashSet(SongLib.Song).Make();
song_set.Add(unchained_melody);
// Either this is a compile error or does something unexpected.
if (SongUtil.IsInHashSet(unchained_melody, &song_set)) {
Print("This is expected, but doesn't happen.");
} else {
Print("This is what happens even though it is unexpected.");
}
}
The issue is that in package Trouble, the song_set is created in a context
where SongLib.Song has a Hashable implementation from
SongHashAppleMusicURL, and stores unchained_melody under that hash value.
When we go to look up the same song in SongUtil.IsInHashSet, it uses the hash
function from SongHashArtistAndTitle which returns a different hash value for
unchained_melody, and so reports the song is missing.
Background: This post discusses the hashtable problem in the context of Haskell, and this 2011 Rust followup discusses how to detect problems at compile time.
In Swift an implementation of an interface, or a "protocol" as it is called in Swift, can be provided in any module. As long as any module provides an implementation, that implementation is used globally throughout the program.
In Swift, since some protocol implementations can come from the runtime environment provided by the operating system, multiple implementations for a protocol can arise as a runtime warning. When this happens, Swift picks one implementation arbitrarily.
In Carbon, we could make this a build time error. However, there would be nothing preventing two independent libraries from providing conflicting implementations. Furthermore, the error would only be diagnosed at link time.
The undesirable result of incoherence is that the interpretation of source code changes based on imports. In particular, imagine there is a function call that depends on a type implementing an interface, and two different implementations are defined in two different libraries. A call to that function will be treated differently depending on which of those two libraries are imported:
Furthermore, this means that the behavior of a file can depend on an import even if nothing from that package is referenced explicitly. In general, Carbon is avoiding this sort of context sensitivity. This context sensitivity would make moving code between files when refactoring more difficult and less safe.
One possible approach would be to bind interface implementations to a value at
the point it was created. In the example above, the
implementation of the Hashable interface for Song would be fixed for the
song_set HashSet object based on which implementation was in scope in the
body of the SomethingWeirdHappens function.
This idea is discussed briefly in section 5.4 on separate compilation of WG21 proposal n1848 for implementing "Indiana" C++0x concepts (1, and 2).
This has some downsides:
SongUtil.IsInHashSet depends
on the dynamic behavior of the program. At the time of the call, we may have
no idea where the HashSet argument was created.As a result, this doesn't make sense as the default behavior for Carbon based on its goals. That being said, this could be a feature added later as opt-in behavior to either allow users to reduce code size or support use cases that require dynamic dispatch.
Carbon could alternatively provide some kind of manual disambiguation syntax to resolve problems where they arise. The problems with this approach have been considered in the context of Rust.
A specific example of this approach is called scoped conformance, where the conflict resolution is based on limiting the visibility of implementations to particular scopes. This hasn't been implemented, but it has the drawbacks described above. Depending on the details of the implementation, either:
In addition, this can create unsoundness when combined with dynamic downcasts and a more complex, less predictable implementation model as discussed in Swift. This approach would be particularly complex in Carbon due to supporting impl specialization.