Defining a trait without methods?

Is the following idiomatic rust code?

pub trait Timestamp: Eq + Ord {}

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct TimestampSeconds {
    pub seconds: u64,
}

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct TimestampMillis {
    pub seconds: u64,
    pub milli_seconds: u64,
}

impl Timestamp for TimestampSeconds {}
impl Timestamp for TimestampMillis {}

#[derive(Debug)]
pub struct Timeframe<T: Timestamp> {
    pub start: T,
    pub end: T,
}

You don't have to pay attention too much to the derived traits, some I would have to implement myself to cater to specific behavior.
Basically, I want to implement some sort of inheritance/interface-like hierarchy for the timestamps, but I'm not sure if this is the best way to do it in rust.

If your trait Timestamp doesn't have any methods, you can't do anything special with a Timeframe<T>, where T: Timestamp, other than using Eq and Ord.

Moreover, you usually don't put bounds on structs but only on the methods that use the struct.

Also, Ord implies Eq.

Following your example, I would maybe consider this:

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct TimestampSeconds {
    pub seconds: u64,
}

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct TimestampMillis {
    pub seconds: u64,
    pub milli_seconds: u64,
}

#[derive(Debug)]
pub struct Timeframe<T> {
    pub start: T,
    pub end: T,
}

impl<T: PartialOrd> Timeframe<T> {
    /// Check if a timestamp (of type `T`) is within `Timeframe<T>`
    pub fn foo(&self, arg: &T) -> bool {
        arg > &self.start && arg < &self.end
    }
}

(Playground)

3 Likes

I like that idea. I guess the only difference/issue with that is that I could now pass any type that implements PartialOrd to Timeframe, even though I technically only want it to "work" with the two timestamp variants I have.

This feels unusual, but it's idiomatic Rust.

To give an example, let's look at BTreeMap from the standard library, which (more or less) looks like this:

pub struct BTreeMap<K, V> {
    root: Option<Root<K, V>>,
    length: usize,
    /* … */
}

Here, you don't see any bound on K, even though you can't really use a BTree when your keys aren't comparable.

Instead, the bound is put on the methods, e.g. BTreeMap::insert.

3 Likes

I'd say yes. num::Unsigned is a famous example for such a trait that is pretty much only used to mark any unsigned number type, independent of the precision. All the functionality you can use is provided by the supertrait Num in that case which is Eq + Ord in your case. Though PartialOrd like @jbe suggested would be sufficient.

2 Likes

Do you know where/how this trait is actually used?

Note that, because Timestamp is a public trait, any downstream user will be able to implement it for their own types— You can't rely on the fact that it will only be one of the two types you provide.

1 Like

I don't know any crates using Unsigned in their apis, but it offers the same interface like Num, so you can do basic arithmetic, equality comparisons and string conversion.

With a sealed supertrait users would be unable to implement Timestamp, even though it is public.

I think generally, the purpose of a trait without methods is to describe certain properties (usually of other methods which are not defined by that trait). Maybe the most prominent example would be Eq:

pub trait Eq: PartialEq<Self> { }

If I have a function or method that takes an argument T: Eq, then I guarantee that the type's PartialEq::eq implementation (which isn't part of Eq) will behave in a certain way. Eq is neither an unsafe trait nor sealed. Violating that guarantee must not cause undefined behavior but may result in other errors.

So getting back to the OP, I think the question is: Does Timestamp give any guarantees that go beyond its supertrait Ord? These guarantees don't need to be enforced (compare Eq, which also doesn't enforce a compliant PartialEq::eq implementation). But if there are no guarantees at all (not even unenforced ones), then I would consider the Timestamp trait to be non-idiomatic.

3 Likes

I think the guarantee given here is that the number will never be negative.

Hmm, but wouldn't the guarantee that only TimestampSeconds and TimestampMillis implement this trait be a sufficient to make this code idiomatic in your opinion? The guarantee being upheld with a sealed supertrait. Or more broadly speaking, the guarantee that the crate's author has the full control which types will be accepted by their api?

You don't even need to seal the trait for that purpose, I guess (then it's not enforced, but it can still be the "meaning" of the trait).

The question is: What's the purpose of that guarantee?

Sometimes unsafe code relies on that (in which case the trait can be defined as unsafe, so implementing it requires unsafe).

Maybe it can make sense if the documentation doesn't want to describe which behavior in particular is expected and if the behavior can change (without breaking the API).

I agree that in these cases, sealing the trait may be best.

I don't really know. But I've seen the wish for restricting what types are accepted further than the implementors of the needed trait (often times a widely implemented one from the standard library, like in OP's case) crop up here or on Stack Overflow occasionally. I always thought restricting an interface like that is for the purpose of api ergonomics, maybe to make your crate feel more self-contained or be closer to your domain model? I like your idea that such a trait can help in case one wants to change the api in the future. Or maybe you anticipate the need to actually add methods in a future release.