Search code examples
rustrust-macros

Is it possible to create a macro that implements Ord by delegating to a struct member?


I have a struct:

struct Student {
    first_name: String,
    last_name: String,
}

I want to create a Vec<Student> that can be sorted by last_name. I need to implement Ord, PartialOrd and PartialEq:

use std::cmp::Ordering;

impl Ord for Student {
    fn cmp(&self, other: &Student) -> Ordering {
        self.last_name.cmp(&other.last_name)
    }
}

impl PartialOrd for Student {
    fn partial_cmp(&self, other: &Student) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl PartialEq for Student {
    fn eq(&self, other: &Student) -> bool {
        self.last_name == other.last_name
    }
}

This can be quite monotonous and repetitive if you have a lot of structs with an obvious field to sort by. Is it possible to create a macro to automatically implement this?

Something like:

impl_ord!(Student, Student.last_name)

I found Automatically implement traits of enclosed type for Rust newtypes (tuple structs with one field), but it's not quite what I'm looking for.


Solution

  • Yes, you can, but first: please read why you shouldn't!


    Why not?

    When a type implements Ord or PartialOrd it means that this type has a natural ordering, which in turn means that the ordering implemented is the only logical one. Take integers: 3 is naturally smaller than 4. There are other useful orderings, for sure. You could sort integers in decreasing order instead by using a reversed ordering, but there is only one natural one.

    Now you have a type consisting of two strings. Is there a natural ordering? I claim: no! There are a lot of useful orderings, but is ordering by the last name more natural than ordering by the first name? I don't think so.

    How to do it then?

    There are two other sort methods:

    Both let you modify the way the sorting algorithm compares value. Sorting by the last name can be done like this (full code):

    students.sort_by(|a, b| a.last_name.cmp(&b.last_name));
    

    This way, you can specify how to sort on each method call. Sometimes you might want to sort by last name and other times you want to sort by first name. Since there is no obvious and natural way to sort, you shouldn't "attach" any specific way of sorting to the type itself.

    But seriously, I want a macro...

    Of course, it is possible in Rust to write such a macro. It's actually quite easy once you understand the macro system. But let's not do it for your Student example, because -- as I hope you understand by now -- it's a bad idea.

    When is it a good idea? When only one field semantically is part of the type. Take this data structure:

    struct Foo {
        actual_data: String,
        _internal_cache: String,
    }
    

    Here, the _internal_cache does not semantically belong to your type. It's just an implementation detail and thus should be ignored for Eq and Ord. The simple macro is:

    macro_rules! impl_ord {
        ($type_name:ident, $field:ident) => {
            impl Ord for $type_name {
                fn cmp(&self, other: &$type_name) -> Ordering {
                    self.$field.cmp(&other.$field)
                }
            }
    
            impl PartialOrd for $type_name {
                fn partial_cmp(&self, other: &$type_name) -> Option<Ordering> {
                    Some(self.cmp(other))
                }
            }
    
            impl PartialEq for $type_name {
                fn eq(&self, other: &$type_name) -> bool {
                    self.$field == other.$field
                }
            }
    
            impl Eq for $type_name {}
        }
    }
    

    Why do I call such a big chunk of code simple you ask? Well, the vast majority of this code is just exactly what you have already written: the impls. I performed two simple steps:

    1. Add the macro definition around your code and think about what parameters we need (type_name and field)
    2. Replace all your mentions of Student with $type_name and all your mentions of last_name with $field

    That's why it's called "macro by example": you basically just write your normal code as an example, but can make parts of it variable per parameter.

    You can test the whole thing here.