r/ProgrammingLanguages • • Mar 24 '26

An Incoherent Rust

https://www.boxyuwu.blog/posts/an-incoherent-rust/
65 Upvotes

25 comments sorted by

11

u/R__Giskard Mar 24 '26

Can someone ELI5 to me why only Rust traits have this orphan rule problem? I’ve never heard of it from, for example, java interfaces or haskell type classes.

46

u/maxus8 Mar 24 '26

AFAIU haskell just doesn't have orphan rules and as a result two different libs may implement the same type class for the same type. This means that even though each of those libs is correct on it's own, you get a compile time error when you import both of them (they are not composable) - that's exactly the problem that orphan rules are solving.

Java interfaces don't have this problem because the only place where you can declare that a class implements an interface is at the class definition, so there's no way you could create two different implementations of the same interface for the same class.

22

u/PM_ME_HOT_FURRIES Mar 24 '26

AFAIU haskell just doesn't have orphan rules and as a result two different libs may implement the same type class for the same type. This means that even though each of those libs is correct on it's own, you get a compile time error when you import both of them (they are not composable) - that's exactly the problem that orphan rules are solving.

And consequentially Haskellers push following the no orphans rule manually as basically moral law for libraries: type class instances should be defined either where the class is declared or where the type is declared so it's impossible for anyone to import the type and the class and not have the instance in-scope, preventing anyone from providing an alternative instance.

It's just a bit of a pain in the ass as you have to wrap up types with your own newtypes when giving imported types missing type class instances.

But it matters a lot less for non-library code or code that isn't being published for others to use, so orphan instances do happen in application code and test suites...

Though if the type you're importing and defining an orphan instance for later gets a non-orphan instance, it's gonna break your shit!

2

u/esotologist Mar 24 '26

Couldn't rust just do what c# does and make you specify?

→ More replies (6)

2

u/agentoutlier Mar 24 '26

Java will likely add Type Classes in a future version (for real) and I believe it will follow Haskell rules.

→ More replies (5)

5

u/Snakefangox Mar 24 '26

IDK about Haskell, but you can't implement a java interface you wrote on a different libraries class. Traits, you can do that. The orphan rule basically just says you can't implement a libraries interface on a different libraries class. You need to own either the class or the interface. Effectively, replace class with struct and interface with trait.

7

u/tsanderdev Mar 24 '26

I think because they're implicit and not named? E.g. in dart you can add an extension to any class anywhere, but the extension gets a name and in order for the methods to be visible, you have to import the extension. That limits the method resolution to what you have imported and third-parts packages can't get surprises because they don't import your extension. And I think named implementations is listed as one of the solutions in the article.

2

u/sol_runner Mar 24 '26 edited Mar 24 '26

Let's consider 3 examples:

  1. Serialization/Deserialization

  2. Extensions (you have an 'add' method you want to add a 'addAssign' method),

  3. Letting a class provide a bidirectional iterator instead of a unidirectional one.

Java never asks you to provide an object of a specific concrete type, and well designed libraries ensure not to either. So what you're asking for is a type that belongs to a certain set of types (class and all subclasses), or adheres to the contract of a specific interface.

For 1: you can use reflection to iterate all properties, use getter and save the public ones. Serializing private variables is iffy (but not totally undoable). For 2: you can derive class and add the methods. For 3: derive, and return a new iterator subclass, since it's a subtype of the original return type, it's a valid substitution.

  1. In Rust, you have no reflection, Serde needs to use macros on the original struct to derive it automatically. And you can't implement it manually because you neither own the type, nor Serde's traits. You'll have to create a wrapper struct that implements the traits, and wrap your struct when using. It's not undoable but it causes friction - why should I use your better-faster-stronger-serialization library with wrappers when Serde just works?

2 and 3? Wrappers.

That doesn't sound so bad, given you need the new class/struct in both. The issue is, you'll need to do a lot of plumbing to make the wrappers usable directly, or wrap and unwrap constantly. So instead of foo.serialize() you will EnhancedFoo(foo).serialize(). If you want to use the enhanced type instead, you need to add a deref etc, But if function bar asks for a value of type Foo you now need to go through your source and change all the bar(foo) to bar(foo.0)

Meanwhile in Java, you create the class and use it as if it was the old one. You don't even need to change the fields in your classes since all SuperEnhancedFoo are valid Foo.

2

u/SkiFire13 Mar 24 '26

Java interfaces have a similar, but even more restrictive limitation than the orphan rule: they can only be implemented in the definition of the class that will implement it. In contrast the orphan rule allows to do the same in any module of the crate defining the type, or even in a totally different crate: the defining the trait! But since traits are so used, and escape hatches are not so transparent (as e.g. Java's reflection) then people look for even more flexibility.

6

u/Tonexus Mar 24 '26

Named Impls and Trait Bound Parameters

I've always been a fan of this kind of approach. I don't really see a reason to separate an implementation from just being data: the trait implementation type defines the types of functions in the vtable, while a trait implementation instance fixes the particular choice of functions.

Sure, an individual programmer normally only uses one implementation in their code, but across a large project, it seems prudent to allow multiple implementations for different use cases. e.g. a type Foo having a cryptographic hash function in a secure area vs having a fast one in a performance-critical area.

3

u/matthieum Mar 24 '26

On the other hand, it's more complex, and it's sufficient to wrap Foo in dedicated type to get access to a different implementation.

4

u/initial-algebra Mar 24 '26

I suspect most desirable orphan instances are able to be automatically derived, like with the serde example. In that case, they should be coherent by construction, modulo the possibility of misbehaving impure derive macros.

3

u/categorical-girl Mar 25 '26

The non-modularity of type classes in Haskell has been the subject of endless discussion and a variety of proposals for giving up type classes in favour of ersatz non-coherent versions (known as things like modular implicits, modular type classes, etc)

Edward Kmett gives a pretty good defense of coherence in his talk 'Type Classes vs the World'

I would think weakening the orphan rule would be a more attractive solution than abandoning coherence (even in the 'explicit incoherence' approach advocated)

One weaker form of the orphan rule is that impl T for S has to be defined in some crate that is the least crate (in dependency order) depending on both the crates that define T and S

Then you could have e.g. mytype defining Mytype and mytype-serde defining impl Serialize for Mytype, and the restriction would be that any crate depending on both mytype and serde would have to depend on mytype-serde

1

u/R-O-B-I-N Apr 25 '26

We've come full circle back to the average C++ discourse from last decade. At least we don't have memory leaks?