Exploring options for orphan rule bypass between multiple local crates?

Consider this a sort of pre-pre RFC discussion -- I'm trying to gather thoughts and ideas before posting a pre-RFC on this one since I know it's a controversial topic.

I'm interested in exploring ideas for ways to bypass the orphan rule in situations where you as an author or as an organization own and maintain all of the crates in question. One pain point I find with the orphan rule is that Rust uses the crate boundary for multiple orthogonal things. Among other things, it's a compilation unit boundary, and also the boundary at which you can control optimization level. Combining these controls along the same axis as the coherence boundary for the orphan rule can make it difficult to separate large projects into different crates for debugging or compilation speed reasons. I'm experiencing this especially in gamedev, where the game is not performant enough in debug to actually run and test a build (this is not unique to Rust, C++ also has this issue), and so you want to be able to selectively control what is and isn't optimized -- you might want to optimize your engine code, but not optimize your gameplay code. Often when I separate these two layers, I end up with code on opposite sides of the orphan rule boundary that I need to work around in unfriendly ways.

The orphan rule is useful and good for building an ecosystem where largely anonymous collaborators need to agree to a coding contract for the sake of coherence to avoid code breakages downstream and ensure compatibility between any two arbitrary crates. However, in in-house orgs where you can maintain your own coding conventions, can resolve compilation errors through explicit collaboration, and have a greatly reduced scope of concern for compatibility between crates, I find that the orphan rule is less useful and more of an obstacle that necessitates some code smells (newtyping, etc.) to bypass. Because of this, I'd like a way to turn it off between two crates that I own and am willing to accept the compatibility burden for. In this case, I'm also fine if that renders those crates ineligible to upload to crates.io.

I'd like to propose the idea of "linked" or "coherent" crates. Imagine, if you will, a field in your Cargo.toml where you can specify another crate (by name) as a linked/coherent crate, where doing so is reciprocal. If crates A and B are linked in this way, they are considered as being part of the same crate for the sake of coherence and orphan rule application. Crate B can implement a trait from A on a struct from A, or on any other type that crate B pulls in from its dependencies, as if it were happening in crate A.

Certainly this can cause the kinds of issues the orphan rule is designed to prevent. I think it would be reasonable for crates.io to reject crate linking, and to have this be purely an in-org construct for splitting large projects. However, if you are willing to accept the burden for fixing these issues within your org (they would present as compiler errors), I think this would enable greater flexibility.

Am I completely off base here? Any thoughts? What sorts of questions would you like to see addressed in an RFC for this proposal?

5 Likes

The other direction that could be explored here is pushing the other things to see whether those could be less impacted by crate boundaries.

For example, we have an accepted RFC for Tracking issue for RFC 2412, "The optimize attribute" · Issue #54882 · rust-lang/rust · GitHub, to allow tweaking optimization levels for individual functions despite the overall crate level.

And of course there's lots of work on incremental compilation performance, to try to make one larger crate more feasible. But it's a big step to make a major improvement here -- the difference between "nothing at all because the crate is unmodified" and "something impacting the crate changed" is pretty huge. (But maybe if coherence checks are no longer per-crate then an unmodified crate might still need coherence checks somehow too?)

Or for debugging, if the optimized code isn't debugging well because we lost debug info at some point, then those examples would probably be good bug reports.


All that said, it would probably be good to look at the exact things for which you'd want to use this cross-crate coherence. What traits and types are the kinds of things that most want it?

There's certainly been lots of discussions about generalizing things like the #[global_allocator] pattern, where lots of crates want to use something but it's up to something at the binary level to decide exactly which implementation of that trait is the one that would make sense.

Is there any possibility that the uses you're thinking of here could be rephrased into that sort of pattern, if it was available more generally?

I'd love something like that.

Especially if it could also work with crates-io, where the ecosystem needs to be able to have "adapter" crates, e.g. implement ToSql trait of some database for a chrono DateTime, and where you don't want either crate to pull in the other as a dependency.

4 Likes

I'd love to have both. In fact, I want it all! I want more fine-grained compilation units. I want #[optimize(none)] as a block attribute. I want that level of control everywhere. But I think it's still also worth exploring ways to escape the orphan rule in addition to these things, because that is also something that doesn't always make the most sense to have linked to the crate border.


I'm not sure what the shape of this would look like. Would it be some sort of #[no_orphan] attribute on a trait? I think that could work, but seems like it would be riskier as far as polluting an ecosystem. Linked crates would be a commitment surfaced at the top level (Cargo.toml) and could be audited more easily I think than attribute usage in arbitrary positions in code. I think it's the most explicit way to, as an author, accept the responsibility for having to resolve orphan rule conflicts expressed as compiler errors in your code.

As far as use cases, I've run into this in all sorts of places, though it can be pretty difficult to reduce to a sharable min repro. Serialization is a big one (serde has its own sets of workarounds), but also in game networking (where you're using traits to drive behavior for state replication), and in extensions to my gecs library, which relies on trait information to simplify operations via turbofish in a way that can't easily be fixed by the newtype pattern. Really, I find the orphan rule is a pretty common thorn in my side and I often have to combine projects into singular mega-crates that can take considerable time to compile.

One other potential approach that comes to mind is an adopt keyword, or something like it:

use external_crate::traits::ExternalTrait;
use other_external_crate::data::OtherDataStructure;

// The `adopt` keyword pulls the trait into scope as if it belonged to this
// crate, allowing you to implement it on both local and foreign types.
adopt ExternalTrait;

impl ExternalTrait for OtherDataStructure {
    // ...
}

Or if a keyword is too much of a headache, something analogous to the old #[macro_use] attribute:

#[adopt_traits]
use external_crate::traits::ExternalTrait;

A way of potentially structuring this which I thought would be interesting is allowing for multiple [[lib]] crates in a single [package] in Cargo.toml.

Right now a single [package] can contain multiple crates, but only if some of them are [[bin]] crates.

This approach also versions all of the crates as a single unit, which I think would help prevent potential problems with this approach if all of the crates were versioned separately.

4 Likes

I've seen that feature but never used it. My one concern with that approach is that it doesn't look like you can individually control exposed features for each binary in that case. You can control whether or not the binary builds based on which top-level features are enabled, but that's a little different.

One of the reasons I'm very specifically avoiding the approach of allowing orphan rule bypass in workspaces is feature independence. In my particular use cases I need to be able to individually control features between crates independently of where I'd like to bypass the orphan rule. I can't use workspaces in this case because workspaces currently unify features.

Yeah, a package-with-multiple-lib-crates approach would likely have all crates in a package share the same set of features (and dependencies), or at least that's how it works today with packages with both [lib] and [[bin]] crates.

That said, I do wonder if orphan rule bypass will have problems if the crates involved aren't versioned as a single unit. A package-of-many-crates functions a lot more like a single logical crate. With multiple independent crates I'd be worried that misspecified version requirements might lead to some hard-to-debug errors, at the very least.

1 Like

I think you'd also need to declare that other_external_crate abstains from implementing ExternalTrait. Otherwise there potential that eventually it could implement the trait in a newer version. AFAICT this is slightly different from negative a trait impl in that it appears negative and positive trait impls cannot overlap.

Beyond that, for coherence you need to check that there is only one adoptee for each type and trait, makes me wonder if any form of the adopt keyword also needs to list the type? i.e. s/impl/adopt impl/

That's true, but this is first and foremost an opt-in for manual control. You're accepting the risk of extra compiler errors (I don't think this can result in runtime errors, can it?) in exchange for greater control over how your codebase is structured.

What's the worst case scenario here? As far as I understand it, if you get a conflict due to coherence in this situation, it would just be a compiler error, and you'd have to resolve it the way you'd resolve any other similar kind of conflict, right? In this case, were this to occur, you would have to decide for yourself where the implementation happens.

Could you elaborate a bit more on why this is necessary? If the end result is a compiler error (similar to having a trait be implemented twice for the same type), then it could be resolved if/when it arises, no?

1 Like

I didn't really see anything in SemVer Compatibility - The Cargo Book Regarding the addition of an impl for an existing trait. It largely depends upon whether this is considered a major or minor change.

// before
use bar::Bar;
struct Foo();

// after
use bar::Bar;
struct Foo();
impl Bar for Foo {}

While an adoptee depending upon it has:

#[adopt]
impl Bar for Foo{}

getting a compiler error is fine if it is considered a major breaking change, but less so if it is considered a minor one. The thought behind both at most one adoptee for a trait/type, and type declarer must specifically abstain from implementing a trait is so that:

  1. removing an abstain and implementing the trait becomes a major change.
  2. adding an adoption becomes a major change.

So that automatic upgrades don't cause compilation errors in crates that depend upon your adoption crate. Perhaps I missed something in the semver docs though which make these unnecessary, but that was my motivation behind mentioning the additional things...

Also, might as well mention GitHub - Ixrec/rust-orphan-rules: An unofficial, experimental place for documenting and gathering feedback on the design problems around Rust's orphan rules as it is great

1 Like

It's a bugclass that doesn't exist for the packages-of-multiple-crates approach, however.

It also gives you a specific structural reason why the orphan rules can be relaxed.

Another, somewhat similar, approach would be moving the orphan rules to the workspace level.

In a sense, a workspace already identifies a number of crates that are strongly linked together -- though they can be versioned separately.

Right now workspaces are an entirely local concept that has no impact on published packages/crates, so having them impact orphan rules would require some sort of "workspace awareness" in published crates

2 Likes

Both the workspace and the [[lib]] solutions have the issue (for me personally) that you don't have feature independence. The [[lib]] approach would make everything use the base crate's features, and the workspace approach unifies features (which means that mutually exclusive features aren't possible).

Here's an alternative proposal: a new flag for dependencies, adopt-traits = true with the restriction that you can only use it with path = or git = dependencies (no version =). So for example:

[dependencies]
# Ok! Only uses a local or raw git crate with no semver promise
cat = { path = "path/to/cat", adopt-traits = true }
dog = { path = "https://.../dog.git", adopt-traits = true }

# Illegal -- Uses a versioned crates.io crate
bird = { version = "0.3", adopt-traits = true }

# Illegal -- Still uses a versioned crates.io crate when published
fish = { path = "path/to/fish", version = "0.3", adopt-traits = true }

These two dependency reference types make no semver promises and are idiomatically reserved for non-published usage. If you import a dependency this way, you pull in all of that dependency's traits as if they were part of this crate. This is also transitive, so you would also adopt all of the traits that that crate adopted.

I want to underscore my intent here which is that this is an off-switch for situations where you (or your team) own and are locally working with all of the crates in question. The orphan rule is built for situations where the author of the upstream crate and the author of the downstream crate are far removed from one another and may never talk to each other. That's a good thing! It's part of what keeps Rust's ecosystem functioning and healthy for published, open-source crate sharing. I'm not trying to solve the whole orphan rule coherence problem for the whole Rust ecosystem -- that's a much larger discussion!

The use case for this switch is not the global Rust ecosystem situation. It's much more local. Here, the author of the upstream and the author of the downstream crate are the same person, or are close collaborators, and just want to be able to organize their project with more freedom in crate structure for a single application (or collection of applications). In this situation, I'm aiming for a direction to allow disabling the orphan rule and manually handling the consequences because everything is much more local and reasonable to resolve in the honestly rather rare situation that a conflict would arise.

I don't think a solution that doesn't work for versioned crates is acceptable. I think the idea @bascule brought up seems to be the most promising. I actually wanted to proposed [[lib]] as a thing myself (just haven't found the time), though primarily as a way of making crates with proc-macro sibling crates easier to publish, and to allow library developers to split up large crates to improve compile times without complicating the publishing process.

I guess one thing I tend to wonder is how often relaxing orphan rules will actually end up useful without a similar relaxation to visibility like pub(package).

1 Like

Would RFC 3452 change the calculus on this? This would allow published crates to locally package other crates (proc macros are cited as a primary use case) in a way that [[lib]] would otherwise allow, while still enabling feature independence. I had this RFC in mind when posting the adopt-traits dependency flag since combining the two would let you publish crates that internally adopt one another's traits without creating hazards by allowing external adoption.

4 Likes

I didn't know about that RFC! Seems really nice and indeed I think nested packages could also be treated specially w.r.t. coherence.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.