Search code examples
rustenumslifetimeconditional-compilation

How to add a lifetime to an enum depending on a compilation feature flag


I'm currently trying to implement a protocol library with many parts of the protocol behind feature flags. For that purpose, I was wondering if there is an ergonomic way in Rust to have one of my enum have a lifetime parameter or not, depending on some feature flags.

On the parsing side I have a parsed item called Action that looks like this:

enum Action<'a> {
    Nop(Nop),
    WriteFileData(WriteFileData<'a>),
    ...
}

struct Nop {
    pub group: bool,
    pub response: bool,
}

struct WriteFileData<'a> {
    ...
    pub data: &'[u8], // Not exactly the final code, but equivalent
}

Up to this point everything is fine. But then in my library I want each of those action types to be behind a feature flag:

enum Action<'a> {
    #[cfg(feature = "decode_nop")]
    Nop(Nop),
    #[cfg(feature = "decode_write_file_data")]
    WriteFileData(WriteFileData<'a>),
    ...
}

At this point, if you choose only features that decode action types without lifetime (only decode_nop for example), you end up compiling:

enum Action<'a> {
    Nop(Nop),
}

Which does not compile because, there's a useless lifetime in the enum declaration.

I first found a promising lead for conditional lifetimes:

enum Action<#[cfg(feature = "decode_action_lifetime")] 'a> {
   ...
}

impl<#[cfg(feature = "decode_action_lifetime")] 'a> Action<#[cfg(feature = "decode_action_lifetime")] 'a> {
   ...
}

Unfortunately, this won't compile because:

error: expected one of `>`, const, identifier, lifetime, or type, found `#`
   --> src/v1_2/action/mod.rs:123:63
    |
123 | impl<#[cfg(feature = "decode_action_lifetime")] 'a> Action<#[cfg(feature = "decode_action_lifetime")]'a> {
    |                                                               ^ expected one of `>`, const, identifier, lifetime, or type

As this is a syntax error, I'm not even sure that what I tried to do here is legal in Rust.

I then came up with 2 ways of fixing my problem:

  1. Duplicate the Action definition with a lifetime and a non lifetime variant behind the appropriate feature flags combinations.

    #[cfg(feature = "decode_with_lifetime")]
    enum Action<'a> {
        #[cfg(feature = "decode_nop")]
        Nop(Nop),
        #[cfg(feature = "decode_write_file_data")]
        WriteFileData(WriteFileData<'a>),
        ...
    }
    
    #[cfg(not(feature = "decode_with_lifetime"))]
    enum Action {
        #[cfg(feature = "decode_nop")]
        Nop(Nop),
        ...
    }
    

    The problem is that it then requires duplicating all the code that refers to that type:

    • impl blocks.
    • other functions using this type.
    • external code that would use this library while trying to support the same feature flags.

    So I'm clearly not a fan of that solution because of its high maintenance cost for my crate and potentially for code using that crate.

  2. Add a lifetime parameter to all sub items without one (Nop<'a>) using a PhantomData<&'a ()> marker in their struct.

    struct Nop<'a> {
        pub group: bool,
        pub response: bool,
        pub phantom: PhantomData<&'a ()>,
    }
    

    But then the public declaration of those non lifetime requiring struct, which I considered part of my API (all fields accessible so that one could build them directly), now suddenly includes an inconvenient and unintuitive phantom field.

    So if I want my API to be clearer for a user, it probably means I have to add builder functions for each of those structures so that the user does not have to care about my library implementation details (phantom field).

    Plus, Rust does not support any kind of named parameter in its functions (as far as I know) which makes a builder for a struct with lots of fields (> 4 fields) hard to read and error prone to use. The solution would probably be to create a parameter type that is exactly like the first struct without the phantom parameter, but that would mean adding even more code.

    struct Nop<'a> {
        pub group: bool,
        pub response: bool,
        pub phantom: PhantomData<&'a ()>,
    }
    
    struct NopBuilderParam {
        pub group: bool,
        pub response: bool,
    }
    
    impl<'a> Nop<'a> {
       pub new(group: bool, response: bool) -> Self {
           Self {
               group,
               response,
               phantom: phantomData,
           }
       }
    }
    

    And, while it should not really be a problem, it bothers me a bit to add this lifetime constraint to a fully owned struct (which implies additional constraints on the user code). But I guess I can live with that one.

In conclusion:

I am currently going with solution number 2. But I would very much like to know if there is another way to do what I'm trying to do.


Solution

  • It's usually a bad idea to change the number of type or lifetime paramaters of a type, based on features. If you have multiple crates in the same project that use different features, all of the features activated by any will be activated for all of them. That would be fragile because one crate can enable a feature, which causes a compilation error in a different crate.

    The only good times to change a type based on a feature is for conditions that are always the same for all crates, like target architecture or word size.

    Your solution with PhantomData doesn't seem like a great compromise. Contaminating all types that are used by your enum is pretty unwieldy. Some alternatives:

    Add an extra enum variant:

    enum Action<'a> {
        Nop(Nop),
        ...
        _NotUsed<PhantomData<&'a ()>>,
    }
    

    This actually wouldn't be so bad with #[non_exhaustive] (assuming that made sense), since it would force users to match on _ and wouldn't have to see the ugly extra variant.

    Another way to avoid spreading the lifetime any further than this enum would be to add it to each variant like so:

    enum Action<'a> {
        Nop(Nop, PhantomData<&'a ()>),
        WriteFileData(WriteFileData<'a>),
        ...
    }
    

    It's worth questioning the idea of using features at all. Feature-gates can be valuable if one of these is true:

    • you can reduce the number of dependencies when the feature isn't used
    • you can noticeably reduce compile time when the feature isn't used
    • you really care about binary size and this makes a real difference
    • there are genuine compatibility differences, e.g. OS and target architecture, or availability of an allocator.

    If none of these are true then you may just be adding maintenance overhead for yourself and your users, for little gain.