Item 26: Be wary of feature creep

Rust allows the same codebase to support a variety of different configurations via Cargo's feature mechanism, which is built on top of a lower-level mechanism for conditional compilation. However, the feature mechanism has a few subtleties to be aware of, which this Item explores.

Conditional Compilation

Rust includes support for conditional compilation, which is controlled by cfg (and cfg_attr) attributes. These attributes govern whether the thing—function, line, block, etc.—that they are attached to is included in the compiled source code or not (which is in contrast to C/C++'s line-based preprocessor). The conditional inclusion is controlled by configuration options that are either plain names (e.g., test) or pairs of names and values (e.g., panic = "abort").

Note that the name/value variants of config options are multivalued—it's possible to set more than one value for the same name:

#![allow(unused)]
fn main() {
// Build with `RUSTFLAGS` set to:
//   '--cfg myname="a" --cfg myname="b"'
#[cfg(myname = "a")]
println!("cfg(myname = 'a') is set");
#[cfg(myname = "b")]
println!("cfg(myname = 'b') is set");
}
cfg(myname = 'a') is set
cfg(myname = 'b') is set

Other than the feature values described in this section, the most commonly used config values are those that the toolchain populates automatically, with values that describe the target environment for the build. These include 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 compiled in only when building for that target.

The standard target_has_atomic option also provides an example of the multi-valued nature of config values: both [cfg(target_has_atomic = "32")] and [cfg(target_has_atomic = "64")] will be set for targets that support both 32-bit and 64-bit atomic operations. (For more information on atomics, see Chapter 2 of Mara Bos's Rust Atomics and Locks [O'Reilly].)

Features

The Cargo package manager builds on this base cfg name/value mechanism to provide the concept of features: named selective aspects of the functionality of a crate that can be enabled when building the crate. Cargo ensures that the feature option is populated with each of 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 six features:

[features]
default = ["featureA"]
featureA = []
featureB = []
# Enabling `featureAB` also enables `featureA` and `featureB`.
featureAB = ["featureA", "featureB"]
schema = []

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

Given that there are only five entries in the [features] stanza; there are clearly a couple of subtleties to watch out for.

The first is that the default line in the [features] stanza is a special feature name, used to indicate to cargo 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:

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

However, default still counts as a feature name, which can be tested in code:

#![allow(unused)]
fn main() {
#[cfg(feature = "default")]
println!("This crate was built with the \"default\" feature enabled.");
#[cfg(not(feature = "default"))]
println!("This crate was built with the \"default\" feature disabled.");
}

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.1 If the crate is compiled with --features rand, then that dependency is activated:

#![allow(unused)]
fn main() {
#[cfg(feature = "rand")]
pub fn pick_a_number() -> u8 {
    rand::random::<u8>()
}

#[cfg(not(feature = "rand"))]
pub fn pick_a_number() -> u8 {
    4 // chosen by fair dice roll.
}
}

This also means that crate names and feature names share a namespace, even though one is typically global (and usually governed by crates.io), 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. It is possible to work around a clash, because Cargo includes a mechanism that allows imported crates to be renamed (the package key), but it's easier not to have to.

So you can determine a crate's features by examining [features] as well as 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:

[dependencies]
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; other features may also be 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, to satisfy everyone.2 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.

For example, if a crate exposes a struct and its fields publicly, it's a bad idea to make the fields feature-dependent:

#![allow(unused)]
fn main() {
/// A structure whose contents are public, so external users can construct
/// instances of it.
#[derive(Debug)]
pub struct ExposedStruct {
    pub data: Vec<u8>,

    /// Additional data that is required only when the `schema` feature
    /// is enabled.
    #[cfg(feature = "schema")]
    pub schema: String,
}
}

A user of the crate that tries to build an instance of the struct has a quandary: should they fill in the schema field or not? One way to try to solve this is to add a corresponding feature in the user's Cargo.toml:

[features]
# The `use-schema` feature here turns on the `schema` feature of `somecrate`.
# (This example uses different feature names for clarity; real code is more
# likely to reuse the feature names across both places.)
use-schema = ["somecrate/schema"]

and to make the struct construction depend on this feature:

let s = somecrate::ExposedStruct {
    data: vec![0x82, 0x01, 0x01],

    // Only populate the field if we've requested
    // activation of `somecrate/schema`.
    #[cfg(feature = "use_schema")]
    schema: "[int int]",
};

However, this doesn't cover all eventualities: the code will fail to compile if this code doesn't activate somecrate/schema but some other transitive dependency does. The core of the problem is that only the crate that has the feature can check the feature; there's no way for the user of the crate to determine whether Cargo has turned on somecrate/schema or not. As a result, you should avoid feature-gating public fields in structures.

A similar consideration 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;
}

External trait implementors again have a quandary: should they implement the cddl(&self) method or not? 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 independent features,3 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 CI system (Item 32) covers all of these 2N combinations, in all of the available test variants (Item 30).

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.

Things to Remember

  • Feature names overlap with dependency names.
  • Feature names should be carefully chosen so they don't clash with potential dependency names.
  • Features should be additive.
  • Avoid feature gates on public struct fields or trait methods.
  • Having lots of independent features potentially leads to a combinatorial explosion of different build configurations.

1

This default behavior can be disabled by using a "dep:<crate>" reference elsewhere in the features stanza; see the docs for details.

2

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

3

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