Conditionally adding trait bounds based on feature flags

I currently have code that looks somewhat like this:

pub trait Bla {
  #[clf(feature = "serde")]
  type Ta: A + B + C + D + E + F + G + Serialize

  #[clf(not(feature = "serde"))]
  type Ta: A + B + C + D + E + F + G

  #[clf(feature = "serde")]
  type Ta_two: H + I + J + K + L + M + Serialize

  #[clf(not(feature = "serde"))]
  type Ta_two: H + I + J + K + L + M

  fn func(x: Ta) -> usize;

...

}

Is there a better way of doing this?

2 Likes

That seems quite SemVer/crate composition unfriendly in general. Can you say more about the intention / motivation? Perhaps a conditional subtrait with a blanket implementation would be suitable.

(Click for an example of why it can be unfriendly.)

That's a non-additive feature:

  • I add your crate
  • I implement Bla but my Ta is not Serialize
  • I add another crate that has you as a dep with the serde feature[1]
  • Compilation now fails

...and a no_serde feature would be too:

  • I add your crate
  • I implement some fn<T: Bla>(arg: Bla::Ta) and get to use Serialize in the body
  • I add another crate that has you as a dep with the no_serde feature[2]
  • Compilation now fails

  1. or someone with such a dep tries to add my crate ↩︎

  2. or someone with such a dep tries to add my crate ↩︎

2 Likes

Not sure I follow and there were some typos, so I updated my snippet. Does this help?

Are you writing a library crate that you might publish for others to use? If you are, you should not change trait bounds like this because it creates a library that is difficult to use reliably. That's what @quinedot is saying.

Either way, us more about what you need this for, and maybe a better way could be found.

There isn’t a stable syntax for conditionally compiled trait bounds. On nightly, this is allowed:

#![feature(where_clause_attrs)]

pub trait Bla {
    type Ta: A + B + C + D + E + F + G
    where
        #[cfg(feature = "serde")]
        Self::Ta: Serialize;
}

But it is often a bad idea.

3 Likes

Are you writing a library crate that you might publish for others to use?

I suspect that that is the aim of my colleagues, yes.

Tell us more about what you need this for, and maybe a better way could be found.

I will ask my colleagues to be sure, but I was guessing one might want to compile without any serialization support, e.g., for size or dependency reasons (e.g., embedded or minimal environments)? Or for benchmarking raw efficiency / performance?

Regardless, isn't features = ["serde"] quite a common sight in a Cargo.toml?

Yes, but such a feature will generally add impl Serialize for ..., which doesn't break code not using serialization, not add + Serialize trait bounds, which will break downstream code that implements your trait without also implementing Serialize.

10 Likes

Do you mean something like this? Is that how it's usually done?

pub trait Bla {
    type Ta: A + B + C + D + E + F + G + MaybeSerialize;
    type TaTwo: H + I + J + K + L + M + MaybeSerialize;
}

#[cfg(feature = "serde")]
pub trait MaybeSerialize: serde::Serialize {}
#[cfg(not(feature = "serde"))]
pub trait MaybeSerialize {}

#[cfg(feature = "serde")]
impl<T: serde::Serialize> MaybeSerialize for T {}
#[cfg(not(feature = "serde"))]
impl<T> MaybeSerialize for T {}

No, that is not what I mean. I mean that libraries normally contain optional impls like these,

pub struct Foo {...}

#[cfg(feature = "serde")]
impl Serialize for Foo { ... }

#[cfg_attr(feature = "serde", derive(Serialize)]
pub struct Bar {...}

and do not contain any conditional bounds.

The situation I'm in is closer to this:

#[cfg_attr(feature = "serde", derive(Serialize))]
struct Bar<T: Bla> {
   a: Vec<T::Ta>
}

Would you suggest that Ta is always required to implement Serialize here?

In this case, the Bla trait should not have anything to say about serialization; rather, the Serialize impl can and should have a T::Ta: Serialize bound.

In the case of serde's derives, you can specify that with the #[serde(bound)] attribute:

#[cfg_attr(feature = "serde", derive(Serialize))]
#[cfg_attr(feature = "serde", serde(bound = "T::Ta: Serialize"))]
struct Bar<T: Bla> {
   a: Vec<T::Ta>
}

This way, Bar can still be used with types which do not implement Serialize, if the usage does not involve serialization, regardless of the state of feature = "serde".

5 Likes

That sounds great, thanks. I'm now having issues with the Deserialize part. If I try this

#[cfg_attr(feature = "serde", serde_as)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(bound = "T::Ta: Serialize + Deserialize<'de>"))]

then the compiler complains that 'de is an undeclared lifetime, but when I change the last line to

#[cfg_attr(feature = "serde", serde(bound = "T::Ta: Serialize + for<'de> Deserialize<'de>"))]

the compiler says that 'de shadows a lifetime name that is already in scope.

You should use T::Ta: serde::de::DeserializeOwned as your trait bound. (Using a different name than 'de would also work.)

Thanks, that seems to work. Now what do I do with impl {...} blocks? They don't seem to recognise that T::Ta has the trait bound Serialize or Deserialize. Add the traits in the where part?

Now what do I do with impl {...} blocks?

What impl blocks of yours have this problem? Why do they care about serialization?

They don't seem to recognise that T::Ta has the trait bound Serialize or Deserialize.

Yes, all such bounds are to be specified per impl block. The point of this is that all functionality unrelated to serialization keeps working when T::Ta doesn't implement Serialize.

Add the traits in the where part?

Yes, that is how you would do it when it is necessary. Such impl blocks must also have #[cfg(feature = "serde")].

1 Like