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:
[features]
default = ["featureA"]
featureA = []
featureB = []
featureAB = ["featureA", "featureB"]
schema = []
[dependencies]
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:
[dependencies]
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
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.
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:
[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, 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.