Traits for is_empty and/or is_default

I checked and didn't find a topic on this. However, there are quite many, so maybe I overlooked it. Sorry in that case.

Having worked with serialization again today, I recognized two patterns that I believe could be simpler out of the box.

A trait for is_empty and a trait for is_default. I believe this should be two dedicated traits.

Empty

For the lack of a better name I use Empty, and the idea is like this:

pub trait Empty {
  fn is_empty(&self) -> bool;
}

That would map nicely to things that can be turned into iterators, like: Vec, HashMap, but also Option.

And instead of having a bunch of different serde attributes like #[serde(skip_serializing_if="HashMap::is_empty")] it would allow for a simpler approach of #[serde(skip_serializing_if="Empty::is_empty")] for all cases. Or maybe even: #[serde(skip_serializing_empty)], expected the type to implement Empty.

It might also be possible to come up with a derive, which does that automatically for e.g. structs:

#[derive(Empty)]
struct MyData {
  data1: Option<Data1>,
  data2: Option<Data2>,
}

IsDefault

The other pattern is a check if the value is equal to the "default value". Meaning that: (value == Default::default()) == true. Which could be implemented as:

pub trait IsDefault {
  fn is_default(&self) -> bool;
}

With implementations like this:

impl <T> IsDefault for Option<T> {
  fn is_default(&self) -> bool {
    self.is_none()
  }
}

Having available would also make it easier to define serialization: #[serde(default, skip_serializing_if="IsDefault::is_default")]. Or maybe just: #[serde(default, skip_serializing_default)].

Having this as part of the Default trait doesn't seem to work, as that would require all Default implementations to also require at least PartialEq.

Alternatives

An alternative to adding this to std or core would be to have a dedicated crate for this. However, it feels like a very basic feature, and having Rust support for this out of the box, would make adoption of this in crates like serde more likely.

is_default seems like the simpler and more general version here, since I'd expect most containers to have their Default impl be an empty container.

That said, I think this is something that probably should start out as a crate, to see if people pick up and adopt it.

8 Likes

I think a default impl like this will cover most use cases

#![feature(exact_size_is_empty)]

pub trait Empty {
  fn is_empty(&self) -> bool;
}

impl<T> Empty for T where for <'a> &'a T: IntoIterator, for <'a>  <&'a T as IntoIterator>::IntoIter: ExactSizeIterator {
    fn is_empty(&self) -> bool {
        // stable: IntoIterator::into_iter(self).len() == 0
        IntoIterator::into_iter(self).is_empty()
    }
}
1 Like

I wanted to be careful with default implementations like this.

To my understanding, that would block explicit implementations for concrete types, and so blocking more optimized checks. Assuming that HashMap::is_empty is faster than the route via the iterator.

4 Likes

I was actually expecting Empty to be simpler :slight_smile: Worth trying that out.

I agree that making a PoC/Demo crate for this makes perfect sense. However, I am not sure that will lead to any reasonable adoption, or if the metric of adoption is a good metric for having this feature out of the box.

I added a repo with a PoC: GitHub - ctron/isx: Traits for checking certain conditions of values

I think IsDefault is a better interface than IsEmpty, namely because is_empty is already a method on so many containers, and also IsDefault is more general[1].


  1. I could see it being used on enums a fair amount ↩︎

1 Like

See also all the conversation in Add `is_empty` function to `ExactSizeIterator` · Issue #35428 · rust-lang/rust · GitHub

A more direct solution to the underlying desire would be to extend serde's capabilities directly. E.g. serde could allow #[serde(skip_serializing_if = ".is_empty()")] to use a method as the condition instead of a function call. Since the derive has access to the field type, serde could allow using an associated function of that type without restating it, but any reasonable syntax runs the risk of being valid syntax in the future.

2 Likes

I'm in favor of IsEmpty over IsDefault:

  1. Default values are not empty values. IsDefault means that the default value is a magic value, which I think is an idiom that should be avoided. Should 0 be a magic value? Should it be treated differently?

    Having a new concept of "emptiness" is a better approach. It means something more than just "this this value happens to be what we're initializing its type as".

  2. IsDefault suggests that if a type implements both Eq (or just PartialEq?) and Default then it should also implement IsDefault. Which is not true. Consider some complex settings struct that implements Default so that it can be constructed with ..Default::default(). - does it make sense for it to implement IsDefault? Do we want to give the is_default of such a struct a special meaning?

  3. Speaking of settings structs (and similar types) - consider:

    #[derive(PartialEq, Eq, IsDefault)]
    struct MySettings {
        pub foo: i32,
        pub bar: i32,
        pub baz: i32,
        pub qux: i32,
    }
    
    impl Default for MySettings {
        fn default() -> Self {
            Self {
                foo: 1,
                bar: 2,
                baz: 3,
                qux: 4,
            }
        }
    }
    

    How should MySettings::is_default behave? Should it construct a MySettings::default() and compare it to self? This seems wasteful, considering that the point of IsDefault is to be able to do this check more efficiently. But if it's do what most of the builtin derives do - which is iterate on the fields to recursively and then aggregate the results - it'd mean that MySettings::default().is_default() will be false because the derived IsDefault implementation will check with i32::is_default() which checks for equality with zero.

    IsEmpty does not have that problem - it'll go with the latter approach and it'll make sense (or it'll fail to compile because numbers will not implement IsEmpty because this is not C)

  4. If you want Serde to skip a field if it's equal to the default value, the value that should be checked is the one from the #[serde(default = "...")] attribute - not the type's default value. An IsDefault trait will encourage that footgun.

Does the serde case really need to be a separate trait, if the use is things like HashMap::is_empty?

It could always just check <&T as IntoIterator>::into_iter(x).next().is_none() for empty containers.

2 Likes

here's my take on it: isdefault

I think both have their use cases. I don't think it should be an either/or discussion.

True that would be more direct. However, only serde would benefit from that. And I do think that others too might want to use this. Simular to the Default trait.

I think both traits have their use case. It shouldn't be an either/or discussion.

Assuming you have a boolean field and what to skip it when it is false. That could be achieved by IsDefault. While for an Option it could be IsEmpty. While is could also be IsDefault for an Option<bool>.

Correct. That's why I believe there should be two traits. And int already defines a default value, which is magic. i32 should not implement IsEmpty, but IsDefault.

Not necessarily. That's my I would not propose a blanket implementation based on Default. As that would required Eq indeed. However, an implementation could still implement IsDefault without being Eq.

I would say it is up the implementation of the struct how it would deal with that. Whatever the struct defines as "default" it should check. True, a simple approach to that would be self ==&Self::default(), but indeed, that might not be the best implementation.

Serde is one use case. I think there can be more. One could also argue that Default should not exist, as serde already has a way for this. Serde is just my personal motivation for that.

The serde case itself not. But that trait seems useful beyond serde. Serde is just one concrete example.