`#[fundamental]` impl Trait for Type

Also previously called #[inherent] impl. If temperature is generally positive, I'll hopefully write up a full RFC.

Proposal:

Allow impl Trait for Type blocks to be tagged as #[fundamental] when it would be coherent for the containing crate to write an inherent impl for Type. (If it would be an error to write impl Type) This has the following effects:

  • Communicates to the compiler and reader that this trait functionality is a fundamental part of the functionality offered by the receiver type. (e.g. types that exist to be used as an Iterator.)
  • Documentation features the trait implementation more prominently than other trait implementations. This plays a similar role to doc(notable_trait), but tags the impl instead of the trait. The ⓘ for types with a #[fundamental] impl should probably point out the tagged impls instead of doc(notable) traits.
  • When performing method lookup for Type, always treat Trait as in scope. That is, item.method() should find Trait::method even if Trait has not been imported into scope.
  • If Trait::method is ambiguous with another trait method which is in scope but for which the impl block is not marked #[fundamental], the method from the #[fundamental]ly implemented trait is chosen. This applies even to default methods on the trait which are not present in the #[fundamental] impl block. Inherent methods still have priority over all trait methods.
  • If Type defines an associated item which shadows an item of Trait, this is a warning.

That last point is what primarily differentiates this from prior concepts/proposals, where #[inherent] would actually create an inherent impl with the trait items. Leaving it as a lint is for one specific reason: adding default/provided methods to a trait should remain backwards-compatible, and potentially causing a duplicate method error is decidedly not backwards-compatible. The lint could potentially be split into two groups on two axis: shadowing with the same or different signature is more/less likely to be intentional/nonproblematic, and shadowing for a locally defined trait is more likely to be resolvable than for an external crate.

This doesn't completely eliminate the desire for trait import preludes (e.g. trait impls for external types), but does cut their necessity somewhat. Consider that if this existed from day 0, Iterator wouldn't have as strong of a need to be in the std prelude, since iterator types could tag their impl as #[fundamental] to get the iterator methods available without the need of importing the type.

8 Likes

I like this idea. One issue I can think of is derive macros — what if a trait is commonly implemented via derive macro, but its fundamentalness depends on the application? This would be a motivation for each derive macro to come up with its own helper attribute to attach #[fundamental] to the impl, or if they don't, users don't get the choice. Of course, important impls are often the explicitly implemented ones, but not always; e.g. thiserror::Error (though that case probably should always be #[fundamental]).

1 Like

In the extreme, you could imagine a derive_fundamental alternative entry point to derive which adds the #[fundamental] annotation to a derive. Personally I think this ends up as a similar customization axis pain point as "perfect derive" and/or specifying different where bounds for a derived impl, so the solution(s) will look somewhat similar.

Not to ignore the concern, but to temper it somewhat.

2 Likes

The biggest question I've always had about this: When wouldn't I want to mark an impl this way?

Beware that when I hear "fundamental" I think Tracking issue for `fundamental` feature · Issue #29635 · rust-lang/rust · GitHub; I don't know if there's an alternative word that might avoid that, or if this should just co-opt it.

FWIW, I came up with the idea of calling it #[fundamental] actually in the context of considering pushing to make #[fundamental] trait (types which don't impl can be assumed to not impl for coherence) and #[fundamental] struct (traits which aren't impld can be assumed to not be impld for coherence[1]) available to user code; I do think #[fundamental] impl fits in with a lens of "this represents a fundamental property of the type" even though it doesn't impact impl coherence like the other uses of #[fundamental].

(My eventual conclusion is that #[fundamental] trait makes sense to provide downstream[2], but #[fundamental] struct is niche enough and only really should apply to primative-ish receiver types in std, since std has the special knowledge of all other code being downstream.)

There's a few cases:

  • Implementing a trait/extension for an upstream type. Maybe you would personally prefer it to be #[fundamental] so you don't have to ask downstream to glob use a prelude with the extension traits available by pub use as _, but most of the rest of the ecosystem would prefer not to have your methods suddenly show up without an import. (This one is enforced by impl coherence.)
  • When implementing multiple traits with conflicting and/or generic method names, e.g. io::Write and fmt::Write. (This one should get a name collision warning if both are marked #[fundamental].)
  • When implementing a trait primarily used by bound rather than methods, e.g. fmt::Debug, Display, etc., or serde::{Serialize, Deserialize}.
  • When implementing a self-extension trait that you don't want available in the normal method namespace, e.g. syn::parse::discouraged::Speculative.

I see this as comparable to CSS !important in the unfortunate way — it's a very useful hammer when it's necessary, but is simultaneously prone to being overused and abused because it can be easier than doing things the more proper way. And like !important, using #[fundamental] impl everywhere is strictly worse than not using it anywhere.

So I'd actually argue that #[derive(thiserror::Error)] probably shouldn't mark the impl with #[fundamental]; even though types which implement Error typically have being an error as their primary purpose, the actual methods of the Error trait typically aren't fundamental to how a concrete error is used, but rather that it can be used in bounds expecting Error (notably Box::<dyn Error>::from and other similar error report types).

And that should probably hold for most derivable traits, since derived functionality is by definition a somewhat trivial derivation over structure, rather than a fundamental point of interest in the type's API.

Documentation should be clear that #[fundamental] impl is intended to be used only when providing that impl is the primary purpose/functionality of the type. It's an alternative to using RPIT like fn iter(&self) -> impl '_ + Iterator for when you want to give the type a name and have the ability to conditionally impl other traits.

It's worth reiterating the above: I see #[fundamental] impl as being roughly analogous to -> impl Trait: a type where all you need to know to use it properly is that it impls some trait(s). Things like future::poll_fn, iter::from_fn, or Path::display which don't actually return just impl Trait, but do return a type with no real functionality outside of implementing some trait. Where writing -> impl Trait might make the signature more meaningful at a glance but would be restrictive in other ways (e.g. prevent providing other useful traits like Debug or clog up the RPIT beyond the point of being clearer if promised inline).

It's for when the trait impl is fundamental to a type's identity, and the type is essentially useless except for that trait implementation. #[fundamental] impl should be an exception rather than the norm for it to be of use. Clippy should probably carry a pedantic lint for #[fundamental] impl of a trait not allowlisted (either by hardcode, #[doc(notable_trait)], or clippy-specific opt-in) and a default style lint to warn for #[fundamental] impl where the receiver type goes over some (configurable? default zero?) threshold of non-trait-impl functionality.


  1. Important note: while I said the same thing twice, the two uses of #[fundamental] have fundamentally (pun slightly intended) different implications to coherence. #[fundamental] trait gives downstream negative impl !Trait for T reasoning permission. #[fundamental] struct doesn't permit that kind of negative reasoning; instead it permits downstream to add impls of unimplemented upstream traits if the first generic type parameter is local to that downstream. ↩︎

  2. Primarily because of the strong coherence implication available to #[fundamental] #[sealed] trait: that the entire set of trait impls is known, and that blanket impls bound on this trait can for the purpose of coherence "inline" reasoning with the impls for the fundamentally-sealed trait. This is a huge win for reducing the need to macro-splat large trait impls across types, since you can now macro-splat a smaller fundamentally-sealed trait to blanket impl from that and get equivalent coherence impacts. ↩︎

1 Like

The "implicitly imported when any implementers are in scope" part seems really tricky to implement.

I'm broadly in favour; I just have two thoughts on linting, both around reasons to push people away from overuse of #[fundamental]:

  1. If you have a #[fundamental] impl, would it be reasonable to lint against any pub items in an inherent impl, other than a pub item that has no receiver and returns a value whose type includes Self (Result<Self, Error>, Option<Self>, Arc<Self>, MyAlternatives<Self, AlternativeType> or even just plain Self)?
    • My thinking is that if the primary reason for the type to exist is to provide an implementation of the trait, then also having public items other than a constructor function is a sign that this isn't actually a #[fundamental] type.
  2. Should there be a lint against having two or more #[fundamental] implementations on the same type?
    • This one is arguable - think about one type implementing io::Read and io::Write, for example, where the type is an implementation detail of doing I/O. If we do want it, there probably needs to be some way to group "traits that the same type can implement fundamentally", and this would automatically apply to a trait and its super-traits.
1 Like

Counterexample: BufReader's primary purpose is to implement BufRead, and having the trait's methods available would be convenient (e.g. io::BufReader::new(file).lines()), but BufReader has several methods of its own.

In general, adapter types are likely to #[fundamental] impl some trait, while also having methods such as into_inner().

5 Likes

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