Hi, I was trying to use the Point struct (X/Y point from geo lib) with a sorted vec library, but I noticed something interesting:
use noisy_float::prelude::{r64, R64};
use sorted_vec::SortedVec;
struct Point<T> {
x: T,
y: T,
}
fn main() {
let v: SortedVec<Point<R64>> = SortedVec::new();
}
This will trigger an error because Point do not restrict T to have the Ord Trait, but, while we use any T with Ord Trait we could implement Ord for Point safely.
Is like implement a specific Trait for Point if T has specific Traits.
Note that you're not allowed to impl traits for types you don't own; in your example code you do own it, but it sounds like you don't in your actual use case.
use noisy_float::prelude::{r64, R64};
use sorted_vec::SortedVec;
#[derive(Eq, PartialEq)]
struct Point<T> {
x: T,
y: T,
}
impl<T: Ord> Ord for Point<T> {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
// TODO:
// define what it even *means* that some Point
// is "less than" some other Point. (As far as I know,
// there is no sort of "standard" best way to define
// the "<" operator for 2-dimensional Points)
todo!()
}
}
impl<T: Ord> PartialOrd for Point<T> {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
// here could also do `Eq` and `PartialEq` manually, but I can't imagine
// how you would want to deviate from the automatically derived
// implementation, so they use the `#[derive(…)]` on the struct instead.
// this works fine now:
fn main() {
let v: SortedVec<Point<R64>> = SortedVec::new();
}
when we write struct Foo<T: Ord> we are says, "T must have implemented Ord".
So if we follow this, when we implement impl<T: Ord> means, "Implement this only when T has Ord"?
Something like that? I thought it also worked as constrain.
At the same time, this for example does not works, which I would expect to works if works as conditionals:
impl<T: Ord> Ord for Point<T> {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
todo!()
}
}
impl<T: Ord + std::fmt::Display> Ord for Point<T> {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
todo!()
}
}
So, if T only has implemented Ord, Point should works with the first implementation, but if T has Ord + Display should use the second one, but we get an error due to conflicts, I would expect to Rust to choose from more specialized to less one, if T match to more than one at the same specialization level I would expect an error.
In Rust, using bounds in type definitions, like struct Foo<T: Ord> is conventionally often avoided.
This T: Ord means “everytime a type Foo<…something…> is ever mentioned or used, the user is required to also ensure …something…: Ord”.
The sorted_vec crate that you use here also follows this anti-pattern.
The standard library doesn’t do this. You won’t find any K: Hash + Eq on the struct HashMap, you won’t find T: Ord on BinaryHeap, etc… Only the actual methods (and trait impls) that need that functionality (hashing, comparisons, …) introduce these trait bounds, which can be done either on a whole impl { … } at once a time, or on each individual method. But methods or trait impls that don’t need it, those don’t need it. For example, you can call HashMap::new for non-hashable key types, not problem.
The only 2 cases where you are actually forced to put a trait bound on a struct definition itself are the following:
case 1: the struct’s field need to mention some associated type from a trait
And that's where your mental model falls apart. Rust doesn't do that.
That's the main difference between C++ templates and Rust generics: Rust generics are unfallible. If you have Ord for Point<T: Ord> then you can pass any T: Ord into it, 100% guaranteed, no questions asked.
This assumption is built into language very deeply on many different levels, for better or for worse: when Rust compiles “generics” it works with an abstract T, only when it's time to pass that code into LLVM they become separated, depending on type. Well, at least that was the original idea, for what I understand compiler is slowly moving into doing less and less work with abstract T types and more work after monomorphisation (when T is known), but for now the principle still stay.
Whether that's a good thing or a bad thing is debatable, but that's why your “conditional trait” idea just couldn't work: it's simply completely outside of the scope for generics.
P.S. Specialization is, of course, not supposed to break that property and that's why it's currently unstable and probably would remain unstable in the foreseable future.
I suppose, the idea of this patterns is when we have implemented something, it would allow to use only the methods where T has its Traits, making more flexible code, so to keep everything organized is better to have several impl blocks and in each one set which are the needed traits for that group of functions.
We could actually use macros to make something like conditional traits.
@khimru Why do you say conditional traits is out of the generic's scope while there is the specialization feature?
This is exactly why I prefer and always use the where syntax in my projects. Imho it is more clear to read, especially in impl blocks that a certain implementation is only available where the constraint applies.
Because specialization feature is not supposed to be break the property “if T is Ord then everything would compile and work”.
Currently it doesn't guarantee that and that's why it's perma-unstable in its current form.
And your conditional traits vision, if understand correctly, is closer to how C++ templates work: if this condition is true then do X, if that condition is true then do Y, and if you couldn't decide then stop the compilation and let me decide.
That's the usual answer. You couldn't implement trait for abstract types with macros, though, only for a finite list of them that returns us back into a “proper world”: there are no any ambiguty anywhere, there are nothing to decide, trait is either implemented for a certain type or it's not implemented, hard decisions are resolved upfront by the one who uses these macros.
That's normal, Rusty, ways of doing things.
P.S. I, personally, prefer C++ TMP, not rigid Rust approach (because with C++ TMPconst operations are easy and very useful while Rust approach means they are severely crippled), but oh well… if I'm left with macros instead of TMP then macros would I use…
IMO, templates are a tradeoff Rust shouldn't make: they can be quite flexible, but they also trade a firm type system with good errors for what amounts to compile-time duck typing.
As to const generics being "crippled", yeah, they're not fully complete yet. GCE (generic const expressions) are indev; this is a matter of maturity, not principle.
I think the mentioned pitfall (things failing to compile due to ambiguity) is a good example of why Rust likely shouldn't go this route.
Sure, but the same can be said about macros. Error messages from macros may be much more puzzling than messages from C++ compiler.
And they are more expensive: you have to define everything you may ever need, instead of things that you need “here and now”.
I'm not so sure, unfortunately.
Dream of unrefutable traits combine very poorly with usable const generics. If you allow arbitrary expression to specify the size of array (let's ignore everything else for now) then you immediately hit the obvious dilemma: either you need to permit refutable traits (because array with negative size is impossible) or, alternatively, you are lifting detail of the implementation into interface (but moving all restrictions about intermediate calculations into the high-level type definition).
Whether it's even possible to resolve in a usable fashion is question that I have no idea about…