#traits #reduce-boilerplate #macro-derive #boilerplate #macro #derive #sweet

no-std zoet

Adds #[zoet] macro to reduce boilerplate when implementing common traits

15 releases

0.1.14 Jul 30, 2024
0.1.12 Jul 16, 2023
0.1.10 Oct 29, 2022
0.1.9 Mar 12, 2022
0.1.0 Jul 24, 2019

#205 in Rust patterns

Download history 2/week @ 2024-08-09 2/week @ 2024-08-23 4/week @ 2024-08-30 12/week @ 2024-09-13 37/week @ 2024-09-20 16/week @ 2024-09-27 5/week @ 2024-10-04

826 downloads per month
Used in catwalk-s-protobuf

MIT license

23KB
166 lines

Adds #[zoet] macro to reduce boilerplate when implementing common traits.

If you are sick of writing impl Deref for Bar etc., and it didn't compile because you confused it with AsRef, had a hard-to-debug problem because you implemented PartialOrd and mistakenly thought that deriving Ord would do the sane thing, and/or you would rather just implement these core traits as regular functions in your impl Bar like lesser languages, this crate is for you!

zoet is superficially similar to the various derive macros such as derive_more, except that rather than generating traits based on the contents of a struct, it generates them based on individual functions. An example works better than a textual description ever would:

use core::{
    cmp::Ordering,
    hash::{Hash, Hasher},
};
use zoet::zoet;

#[derive(Clone, Copy, Debug, Eq)]
struct Length(usize);
#[zoet]
impl Length {
    #[zoet(Default)] // generates `impl Default for Length`
    pub fn new() -> Self {
        Self(0)
    }

    #[zoet(From)] // generates `From<usize> for Length`
    fn from_usize(value: usize) -> Self {
        Self(value)
    }

    #[zoet(From)] // generates `From<Length> for usize`
    fn to_usize(self) -> usize {
        self.0
    }

    #[zoet(AsRef, Borrow, Deref)] // generates all of those.
    fn as_usize(&self) -> &usize {
        &self.0
    }

    #[zoet(Hash)] // see note below about traits with generic functions
    fn hash(&self, state: &mut impl Hasher) {
        self.0.hash(state)
    }

    #[zoet(Add, AddAssign)] // generates `impl Add for Length` and `impl AddAssign for Length`
    fn add_assign(&mut self, rhs: Self) {
        self.0 += rhs.0;
    }

    #[zoet(Ord, PartialOrd, PartialEq)] // you get the idea by now
    fn ord(&self, other: &Self) -> Ordering {
        self.0.cmp(&other.0)
    }
}

let mut v = Length::default();
v += Length(1);
assert_eq!(v + Length(2), Length(3));
v += Length(4);
assert_eq!(v, Length(5));
assert_eq!(Length::from(v), Length(5));

Supported traits

Transformations for most traits in the standard library (core, alloc, and/or std crates) are provided. The current list is as follows:

  • core::borrow: Borrow and BorrowMut.
  • core::clone: Clone.
  • core::cmp: Ord, PartialEq, and PartialOrd. (Eq is recognised, but you'll be told to #[derive(Eq)] instead).
  • core::convert: AsMut, AsRef, From, Into, TryFrom, and TryInto.
  • core::default: Default.
  • core::fmt: Binary, Debug, Display, LowerExp, LowerHex, Octal, Pointer, UpperExp UpperHex, and Write (implements write_str).
  • core::future: Future and IntoFuture.
  • core::hash: Hash (implements hash).
  • core::iter: IntoIterator and Iterator (implements next).
  • core::ops: Deref, DerefMut, Drop, Index, IndexMut, plus all arithmetic and bitwise operations, and assignment variants such as Add and AddAssign.
  • core::str: FromStr.

The alloc feature (which is enabled by default) also adds these:

  • alloc::borrow: ToOwned.
  • alloc::string: ToString.

Most of the generated traits normally just include the trait boilerplate and forward the arguments to your method. There are a few useful extra special cases:

  • PartialOrd can also be applied to an Ord-shaped function, in which case it wraps the result with Some() to make it fit. PartialEq does the same with a PartialOrd- or Ord-shaped function and returns true if it returns Ordering::Equal. This allows you to do #zoet[(Ord, PartialEq, PartialOrd)] to implement all of them in one go. (You'll also need to #[derive(Eq)] if you generate PartialEq.)
  • Add can also be applied to an AddAssign-shaped function, in which case it generates a trivial implementation which mutates its mut self and returns it. This applies to all of the other operator traits with OpAssign variants.

Unsupported traits

Since this macro turns single functions into traits, there needs to be a 1:1 mapping between a function and a trait. This means that traits which require more than one function (e.g. Hasher) cannot be sanely supported. Likewise, marker traits like FusedIterator are not supported even though doing so would be trivial to implement, because that's really a job for a derive macro. Finally, traits which themselves have generic functions like FromIterator or Extend are not supported because they are beyond the abilities of zoet's current signature parser.

Additionally, traits which are nightly-only like Generator are being avoided since there's no guarantee that zoet will be able to keep track of any future updates.

Feel free to raise an issue or PR on the mooli/zoet-rs GitHub repository if you would like these to be added and have productive suggestions.

What it generates

A suitable impl is emitted which proxies to your function, such as this:

# struct Length(usize);
# impl Length {
#   fn add_assign(&mut self, rhs: Self) { self.0 += rhs.0; }
# }
impl ::core::ops::Add<Self> for Length {
    type Output = Length;
    fn add(mut self, rhs: Length) -> Length {
        <Length>::add_assign(&mut self, rhs);
        self
    }
}
impl ::core::ops::AddAssign<Length> for Length {
    fn add_assign(&mut self, rhs: Length) {
        <Length>::add_assign(self, rhs);
    }
}

You can use cargo-expand to check the actual expansion.

As a side-note, this particular generated code may look like infinite recursion at a first glance, but <Length>::add_assign explicitly refers to the method in the inherent impl and there is no ambiguity. However, human readers may still find it confusing—the clippy::same_name_method lint agrees—and you might like to consider using a different method name such as _add_assign or add_assign_impl even if those names are less aesthetically appealing.

Gotchas

… due to Rust macro limitations

You must add #[zoet] to your struct's impl block so that the self type of its associated functions can be determined. This is obviously not necessary (or possible) for free functions as they don't have a self type.

Macros run before type checking, so the actual type is unknown and macros have to work with just tokens. Most of the time, it is sufficient to just paste the tokens into the output and let the compiler work it out, and that's what zoet does where possible. If the type is wrong, you should get a helpful compiler error pointing this out. However, some traits require a bit more than simple pasting, and for sanity (and/or disambiguation) zoet requires that the type has a specific name:

  • PartialOrd: the function should return Option. If not it will Some-wrap it as if it was Ord.
  • Future: the function parameters must be Pin and Poll respectively.
  • Iterator: the function must return Option.
  • TryFrom, TryInto, FromStr: the function must return Result. If only one type parameter is given, the name Error is used for the error type.

Only the last part of the path is compared, so eyre::Result, crate::Result, ::core::result::Result etc. work just as well as a bare Result.

This name-checking is only noted here in case you're doing something very strange with type aliases, and should not affect sensible code.

… due to zoet design limitations

Generic parameters on the function and/or its inherent impl are all just accumulated and added to the trait impl's generic parameters, which does the right thing for the vast majority of traits. However, where a trait's function is itself generic, zoet isn't (yet) smart enough to figure out which of the generic parameter is for the function. As a perfectly good workaround, use an impl Trait parameter instead. So while Hash defines its single method as fn hash<H: Hasher>(&self, state: &mut H), your function needs to have a signature like fn hash(&self, state: &mut impl Hasher).

Elided lifetimes cannot be used on types which are copied into the generated traits, and the function will need to be tweaked to have named lifetimes. This mainly affects traits such as IntoIterator, and in such cases you would change the signature from e.g. fn iter(&self) -> slice::Iter<T> to fn iter<'a>(&'a self) -> slice::Iter<'a, T>.

… due to you going overboard

While this macro makes it easy to stamp out loads of core traits, don't go crazy but consider each trait you add and whether there is a more suitable macro to do the job, or indeed whether that trait should be added. Here are a few tips for avoiding using this crate:

  • The example above generates Default based on new(), but since that function returns 0 which is the default value anyway, it is better to #derive(Default) and implement new() in terms of that.
  • Similarly, its Add- and AddAssign-adorned functions are trivial delegations to its field's Add and AddAssign traits. The derive_more crate handles this and will reduce the amount of boilerplate further, and in this case a simple #[derive(Add, AddAssign)] on the struct will replace those functions.
  • educe lets you derive and customise Debug, Default, Hash, Clone, and Copy without writing actual boilerplate functions.
  • Borrow is not just a synonym for AsRef, but gives specific guarantees, notably that "Eq, Ord and Hash must be equivalent for borrowed and owned values". If your AsRef doesn't offer those guarantees, don't write #[zoet(AsRef, Borrow, Deref)].

Dependencies

~1.5MB
~38K SLoC