Item 26: Be wary of feature creep

Rust includes support for conditional compilation, which is controlled by cfg (and cfg_attr) attributes. These attributes govern the thing – function, line, block etc. – that they are attached to (in contrast to C/C++'s line-based preprocessor), based on configuration options that are either plain names (e.g. test) or key-value pairs (e.g. panic = "abort").

The toolchain populates a variety of config values that describe the target environment, including the OS (target_os), CPU architecture (target_arch), pointer width (target_pointer_width), and endianness (target_endian); this allows for code portability, where features that are specific to some particular target are only compiled in when building for that target.

The Cargo package manager builds on this base cfg mechanism to provide the concept of features: specific named aspects of the functionality of a crate that can be enabled when building the crate. Cargo ensures that the the feature option is populated with the configured values for each crate that it compiles, and the values are crate-specific. This is Cargo-specific functionality; to the Rust compiler, feature is just another configuration option.

At the time of writing, the most reliable way to determine what features are available for a crate is to examine the crate's Cargo.toml manifest file. For example, the following chunk of a manifest file includes four features:

default = ["featureA"]
featureA = []
featureB = []
featureAB = ["featureA", "featureB"]
schema = []

rand = { version = "^0.8", optional = true }
hex = "^0.4"

However, the four features are not just the four lines in the [features] stanza; there are a couple of subtleties to watch out for.

Firstly, the default line in the [features] stanza is a special feature name, used to indicate which of the features should be enabled by default. These features can still be disabled by passing the --no-default-features flag to the build command, and a consumer of the crate can encode this in their Cargo.toml file like so:

somecrate = { version = "^0.3", default-features = false }

The second subtlety of feature definitions is hidden in the [dependencies] section of the original Cargo.toml example: the rand crate is a dependency that is marked as optional = true, and that effectively makes "rand" into the name of a feature. If the crate is compiled with --features rand, then that dependency is activated (and the crate will presumably include code that uses rand and which is protected by #[cfg(feature = "rand")]).

This also means that crate names and feature names share a namespace, even though one is global (governed by and one is local to the crate in question. Consequently, choose feature names carefully to avoid clashes with the names of any crates that might be relevant as potential dependencies.

So you can determine a crate's features by examining [features] and optional [dependencies] in the crate's Cargo.toml file. To turn on a feature of a dependency, add the features option to the relevant line in the [dependencies] stanza of your own manifest file:

somecrate = { version = "^0.3", features = ["featureA", "rand" ] }

This line ensures that somecrate will be built with both the featureA and the rand feature enabled. However, that might not be the only features that are enabled, due to a phenomenon known as feature unification. This means that a crate will get built with the union of all of the features that are requested by anything in the build graph. In other words, if some other dependency in the build graph also relies on somecrate, but with just featureB enabled, then the crate will be built with all of featureA, featureB and rand enabled, in order to satisfy everyone1. The same consideration applies to default features: if your crate sets default-features = false for a dependency, but some other place in the build graph leaves the default features enabled, then enabled they will be.

Feature unification means that features should be additive; it's a bad idea to have mutually incompatible features because there's nothing to prevent the incompatible features being simultaneously enabled by different users.

A specific consequence of this applies to public traits, intended to be used outside the crate they're defined in. Consider a trait that includes a feature gate on one of its methods:

/// Trait for items that support CBOR serialization.
pub trait AsCbor: Sized {
    /// Convert the item into CBOR-serialized data.
    fn serialize(&self) -> Result<Vec<u8>, Error>;

    /// Create an instance of the item from CBOR-serialized data.
    fn deserialize(data: &[u8]) -> Result<Self, Error>;

    /// Return the schema corresponding to this item.
    #[cfg(feature = "schema")]
    fn cddl(&self) -> String;

This leaves external trait implementors in a quandary: should they implement the cddl() method or not?

For code that doesn't use the schema feature, the answer seems to obviously be "No" – the code won't compile if you do. But if something else in the dependency graph does use the schema feature, an implementation of this method suddenly becomes required. The external code that tries to implement the trait doesn't know – and can't tell – whether to implement the feature-gated method or not.

So the net is that you should avoid feature-gating methods on public traits. A trait method with a default implementation (Item 13) might be a partial exception to this – but only if it never makes sense for external code to override the default.

Feature unification also means that if your crate has N independent2 features, then all of the 2N possible build combinations can occur in practice. To avoid unpleasant surprises, it's a good idea to ensure that your continuous integration system (Item 32) covers all of these 2N combinations, in all of the available test variants (Item 30).

Summing up the aspects of features to be wary of:

  • Features should be additive.
  • Feature names should be carefully chosen not to clash with potential dependency names.
  • Having lots of independent features potentially leads to a combinatorial explosion of different build configurations.

However, the use of optional features is very helpful in controlling exposure to an expanded dependency graph (Item 25). This is particularly useful in low-level crates that are capable of being used in a no_std environment (Item 33) – it's common to have a std or alloc feature that turns on functionality that relies on those libraries.

1: The cargo tree --edges features command can help with determining which features are enabled for which crates, and why.

2: Features can force other features to be enabled; in the original example the featureAB feature forces both featureA and featureB to be enabled.