Learn Rust Series (#57) - Feature Flags and Conditional Compilation (cfg)

Words
3085
Reading
14 min
Listen
Play
39m

Learn Rust Series (#57) - Feature Flags and Conditional Compilation (cfg)

rust-banner.png

What will I learn

  • You will learn how the #[cfg(...)] attribute compiles code in or out based on a condition;
  • how feature flags let users opt into optional functionality and dependencies;
  • how to write platform-specific code with target_os, unix, and target_pointer_width;
  • the difference between the #[cfg] attribute (drops code) and the cfg! macro (a compile-time bool);
  • how cfg_attr applies an attribute conditionally, and how to combine predicates with all, any, and not.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu, with Cargo;
  • An installed Rust toolchain (via rustup, from rustup.rs);
  • The previous fifty-six episodes, especially crates, modules, and workspaces;
  • The ambition to learn systems programming from the ground up.

Difficulty

  • Intermediate

Curriculum (of the Learn Rust Series):

Learn Rust Series (#57) - Feature Flags and Conditional Compilation (cfg)

Last episode we split a single crate into a whole workspace -- a library holding the logic, a thin binary wrapping it, a shared common crate that both a client and a server agree on. That gave us structure across files and crates. But real programs need a second kind of variation, one that lives inside a single crate: the same source needs to compile differently depending on the situation. A system call that only exists on Linux. An optional JSON integration a user might not want to pay for. An expensive consistency check you want in debug builds but not in the shipped binary. All three are the same question -- "should this code be part of the build or not?" -- and Rust answers all three at compile time with one mechanism: #[cfg(...)].

The word to hold onto is compile time. Unlike a runtime if that lives in your binary and is decided while the program runs, a #[cfg] decision is made by the compiler before a single instruction is emitted. Code behind a false predicate is not merely skipped at runtime -- it is never compiled at all, never type-checked into the final artifact, and its dependencies never even have to be present. That is what lets one codebase serve a bare-bones embedded target and a batteries-included server from the exact same src/, paying only for the parts you actually switch on ;-)

Solutions to Episode 56 Exercises

Episode 56 was Cargo workspaces, and all three exercises were about wiring crates together and controlling what leaks out of them. Here they are in full.

Exercise 1 asked for a two-crate workspace -- a core library with an area function, called from a cli binary -- written with a module stand-in so it compiles in one file, plus a comment showing the real path dependency:

// cli/src/main.rs -- in reality `use core::area;` where core is a path dependency
// and cli/Cargo.toml carries: core = { path = "../core" }
mod core {
    pub fn area(w: f64, h: f64) -> f64 {
        w * h
    }
}

fn main() {
    let (w, h) = (3.0, 4.0);
    println!("area = {}", core::area(w, h)); // area = 12
}

In a genuine workspace the mod core { ... } block would instead be a separate crate at core/src/lib.rs, and cli would reach it through use core::area; after listing core = { path = "../core" } under its [dependencies]. The single-file module here is just a faithful stand-in so the whole thing runs as one program.

Exercise 2 wanted two functions living in separate internal modules, re-exported through a facade with pub use, then called from the crate root as if those modules did not exist -- and a rename to prove the public path is stable:

mod geometry {           // renamed from `shapes`; the public call below does not change
    pub fn circle_area(r: f64) -> f64 { std::f64::consts::PI * r * r }
}
mod bodies {             // renamed from `solids`
    pub fn sphere_volume(r: f64) -> f64 { 4.0 / 3.0 * std::f64::consts::PI * r * r * r }
}

pub use geometry::circle_area;   // facade: re-exported at the crate root
pub use bodies::sphere_volume;

fn main() {
    println!("{:.3}", circle_area(1.0));   // 3.142
    println!("{:.3}", sphere_volume(1.0)); // 4.189
}

The point is that circle_area and sphere_volume are called with no module qualifier at all. I renamed shapes to geometry and solids to bodies, and the call sites at the root did not budge -- because the facade re-export absorbs the internal layout. That is exactly the promise pub use buys you: the module names behind it are yours to change forever.

Exercise 3 asked for a common-style struct deriving Debug, Clone, and PartialEq, an internal helper marked pub(crate), and a pub wrapper calling that helper -- confirming the wrapper compiles while a direct outside call to the helper would be the crate's private business:

#[derive(Debug, Clone, PartialEq)]
pub struct Point {
    pub x: i32,
    pub y: i32,
}

pub(crate) fn manhattan(a: &Point, b: &Point) -> i32 {   // shared inside the crate only
    (a.x - b.x).abs() + (a.y - b.y).abs()
}

pub fn distance_report(a: &Point, b: &Point) -> String {  // public wrapper over the helper
    format!("distance = {}", manhattan(a, b))
}

fn main() {
    let p = Point { x: 0, y: 0 };
    let q = p.clone();
    assert_eq!(p, q);                              // PartialEq + Clone in action
    println!("{}", distance_report(&p, &Point { x: 3, y: 4 })); // distance = 7
    println!("{p:?}");                             // Point { x: 0, y: 0 }
}

Inside this crate, distance_report can freely call manhattan. From a dependent crate, manhattan would be invisible -- pub(crate) draws exactly that line. The derives give us value equality (assert_eq!) and cheap copying (clone) for free, which is why common-style shared types lean on them so heavily. Right, homework cleared. Now, conditional compilation.

The cfg attribute drops code

#[cfg(predicate)] placed on an item means "only compile this item if the predicate is true; otherwise behave as though the code were never written". The most common predicate you have already met -- as far back as episode 52 -- is #[cfg(test)], which is precisely how test modules stay out of your shipped binary:

pub fn double(n: i32) -> i32 {
    n * 2
}

#[cfg(test)] // this whole module exists only during `cargo test`
mod tests {
    use super::*;
    #[test]
    fn doubles() {
        assert_eq!(double(21), 42);
    }
}

Because the compiler literally removes the item, code behind a false cfg costs nothing -- not a byte in the binary, not a nanosecond at runtime -- and it does not even need its dependencies to be present. That last point matters more than it looks: a test module gated on #[cfg(test)] can pull in a heavy testing-only crate, and that crate never touches your release build. The predicate is not a suggestion the optimizer might act on; it is a hard gate the compiler applies before the code exists at all.

You can attach #[cfg] to almost anything an attribute can sit on -- a function, a struct, an impl block, a use statement, a whole mod, even a single field of a struct. Gating an entire module is the workhorse pattern, because it lets you quarantine a big chunk of situational code behind one line and forget about it everywhere else.

Platform-specific code

target_os selects by operating system. The idiomatic pattern is one function name with several cfg-gated definitions, exactly one of which survives on any given target:

#[cfg(target_os = "macos")]
fn platform_name() -> &'static str { "macOS" }

#[cfg(target_os = "windows")]
fn platform_name() -> &'static str { "Windows" }

#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn platform_name() -> &'static str { "some other OS" }

fn main() {
    println!("running on {}", platform_name());
}

There are two things worth staring at here. First, from the compiler's point of view there is only ever one platform_name in the build -- the other two definitions simply do not exist on your target, so there is no duplicate-definition error. Second, that not(any(...)) arm is the fallback, and it is doing real work: without it, the code would fail to compile on Linux, FreeBSD, or anything else not explicitly named, because platform_name would be undefined there. Forgetting a fallback arm is the classic "compiles on my machine, breaks in CI on a different OS" bug. Whenever you gate by platform, ask yourself "what happens on a target I did not name?" -- and answer it with a fallback.

Beyond target_os there is a small vocabulary of built-in predicates the compiler sets for you: unix and windows (broad families), target_arch ("x86_64", "aarch64", ...), target_pointer_width ("32" or "64"), target_endian, and debug_assertions (true in debug builds, false under --release). You compose these to describe precisely the situation you care about, which is where the combining operators come in later.

Feature flags: opt-in functionality

Platform predicates are set by the compiler; features are set by you, the crate author, and toggled by whoever depends on you. A feature is a named switch declared in Cargo.toml, and code gated on #[cfg(feature = "name")] compiles only when someone builds with that feature turned on:

#[cfg(feature = "extra")]
fn describe() -> &'static str { "extra features enabled" }

#[cfg(not(feature = "extra"))]
fn describe() -> &'static str { "baseline build" }

fn main() {
    // exactly one `describe` exists, depending on whether `--features extra` was passed
    println!("{}", describe()); // "baseline build" by default
}

The declarations that make this work live in a [features] table. A feature can be empty (a pure switch) or it can pull in an optional dependency, so turning it on both enables code paths and adds a crate to the build:

# Cargo.toml
[dependencies]
serde_json = { version = "1", optional = true } # only compiled when a feature needs it

[features]
default = ["extra"]        # what you get with a plain `cargo build`
extra = []                 # a bare switch, no extra deps
json = ["dep:serde_json"]  # turning on `json` also pulls in serde_json

A user then builds with cargo build --features json, or opts out of everything with cargo build --no-default-features. This is how a crate offers optional integrations -- a JSON codec, an async backend, extra numeric types -- without forcing that weight onto everyone who does not want it. Somebody targeting a tiny embedded device takes the minimal build; somebody writing a server flips on the extras.

There is one rule you must respect, and Cargo will punish you quietly if you break it: features must be additive. Turning a feature on should only ever add capability, never remove or change existing behaviour. The reason is feature unification -- if crate A and crate B both depend on your crate, and A wants json while B does not, Cargo compiles your crate once with the union of the requested features (so, with json on). If enabling json had removed a function that B relied on, B would now fail to build through no fault of its own. Additive features are what make it safe for Cargo to merge everyone's requests into a single compilation.

The cfg! macro: a compile-time boolean

Everything so far has removed code. Sometimes you do not want to drop a whole item -- you just want a runtime if whose condition is known at compile time. That is the cfg! macro. It evaluates a predicate to a plain bool, but crucially keeps both branches compiled and type-checked:

fn main() {
    let debug_build = cfg!(debug_assertions);
    println!("debug assertions on? {debug_build}");

    if cfg!(target_pointer_width = "64") {
        println!("this is a 64-bit target");
    } else {
        println!("this is a 32-bit (or other) target");
    }
}

The distinction from the attribute is the whole point of this section, so let me make it sharp. #[cfg(...)] decides whether an item exists; cfg!(...) produces a bool from a predicate while both arms of the surrounding if must still compile on every target. That second requirement is a real constraint: because both branches are type-checked, you cannot call a function inside a cfg! branch that only exists under one configuration -- the branch for the other configuration would reference a function that is not there, and the build breaks. Use cfg! for small in-line decisions where both paths are always valid code; reach for the #[cfg] attribute whenever one path mentions something that only exists in that configuration.

In practice the compiler folds a cfg! down to a constant true or false, so the "dead" branch is optimised away and you pay nothing at runtime -- you get the readability of an ordinary if with the cost of a compile-time constant.

cfg_attr: a conditional attribute

cfg_attr(predicate, attribute) applies attribute only when predicate holds. The textbook use is deriving a trait only when a feature is enabled -- so an optional serde derive never appears in a minimal build that does not want the dependency:

// derive Debug always; under a `serde` feature we would also derive its traits
#[cfg_attr(feature = "serde", derive(Clone))]
#[derive(Debug)]
struct Config {
    verbose: bool,
}

fn main() {
    let c = Config { verbose: true };
    println!("{c:?}"); // Config { verbose: true }
}

Read #[cfg_attr(feature = "serde", derive(Clone))] as "if the serde feature is on, act as if #[derive(Clone)] were written here; otherwise, act as if this line were not written". In real crates this is how you offer #[derive(Serialize, Deserialize)] on your public types only when a downstream user opts into serialization, so the base build never compiles serde at all. You can pass several attributes at once -- #[cfg_attr(feature = "serde", derive(Serialize), derive(Deserialize))] -- and the same trick works for any attribute, not just derives.

Combining predicates

all, any, and not compose predicates into precise conditions -- the boolean AND, OR, and NOT of the cfg world. This lets you target something like "unix, but specifically not macOS", which comes up whenever Linux and the BSDs share an implementation that macOS does not:

#[cfg(all(unix, not(target_os = "macos")))]
fn family() -> &'static str { "unix but not macOS (Linux, BSD, etc.)" }

#[cfg(target_os = "macos")]
fn family() -> &'static str { "macOS (which is also unix)" }

#[cfg(not(unix))]
fn family() -> &'static str { "not a unix (Windows, etc.)" }

fn main() {
    println!("{}", family());
}

Note how the three arms are mutually exclusive and exhaustive: macOS matches the second, other unixes match the first, and everything else matches the third, so exactly one family exists on any target and the code compiles everywhere. Designing your cfg arms so they tile the whole space of targets -- no overlaps, no gaps -- is the same discipline as writing an exhaustive match, and it prevents both the "two definitions" error and the "no definition" error in one stroke.

Having said all that, resist the temptation to sprinkle cfg through every function. Conditional compilation scattered widely turns into code that is genuinely hard to read, because you can no longer tell at a glance which lines are live on your machine. The cleanest pattern is to isolate platform or feature differences behind a small internal module with one common interface -- a mod platform that exposes, say, fn line_ending() -> &'static str, with the cfg gymnastics hidden inside it -- so the rest of your codebase calls one stable function and stays blissfully configuration-free.

How C, Go and Python handle it

A look sideways sharpens the picture, as always. C does conditional compilation with the preprocessor: #ifdef, #if, #else, #endif. It is text substitution that runs before the compiler proper even sees the code, and it is famously error-prone -- unbalanced #endifs, macros that expand in surprising ways, and code inside a false #ifdef that is never checked by anything, so it can rot into syntactic garbage without anyone noticing until the flag flips. Rust's #[cfg] is a real language attribute, parsed and understood by the compiler; even code behind a false predicate must still be syntactically valid Rust, which catches a whole class of bugs the C preprocessor happily lets through:

/* C: the preprocessor, pure text substitution before compilation */
#ifdef _WIN32
    const char *platform_name(void) { return "Windows"; }
#else
    const char *platform_name(void) { return "some other OS"; }
#endif

Go takes a different route with build tags -- a special comment at the top of a file, plus a filename convention. A file named foo_linux.go compiles only on Linux, foo_windows.go only on Windows, and a //go:build line can express boolean combinations. Go's model is coarser: it gates whole files, not individual items, which keeps things simple but pushes you toward one-file-per-platform organisation:

//go:build linux && !android

package main

func platformName() string { return "Linux (but not Android)" }

Python, being interpreted, has no compile-time gating at all -- the nearest thing is a runtime check, import sys; if sys.platform == "win32": ..., which decides while the program runs and always ships every branch. There is no way to make a block of Python literally not exist on a given platform, because there is no compile step to remove it. Seen against those three, Rust's approach is the sweet spot: finer-grained than Go (any item, not just a file), safer than C (real parsing, not text), and genuinely compile-time unlike Python (the dropped code truly is not there).

Wrapping up

So here is the shape of it. #[cfg(predicate)] is a compile-time gate on an item: if the predicate is false, the item is not compiled, not type-checked into your artifact, and its dependencies need not be present -- #[cfg(test)] is the example you already knew. Features are named switches you declare in a [features] table and gate on with #[cfg(feature = "...")]; they let users opt into optional code and optional dependencies, and they must be additive so Cargo can safely unify everyone's requests into one build. The cfg! macro is the runtime-if cousin -- it yields a compile-time bool but keeps both branches compiled, so use it only where both paths are always valid. cfg_attr applies an attribute conditionally, most often a feature-gated derive. And all, any, and not compose predicates into exhaustive, non-overlapping conditions the way an honest match covers every case. Above all, keep the cfg noise contained -- push it behind a small module with a stable interface, and let the rest of your code stay readable.

There is one more source of build-time variation we have not touched. Everything today was decided by predicates the compiler or the user already knows. But sometimes a crate needs to run code during the build itself -- to detect what is available on the host, to compile a bit of C alongside your Rust, or to generate Rust source from some other input before compilation proper begins. Cargo has a dedicated hook for exactly that, and next time we look at how a crate can do real work at build time and feed the results back into the compilation ;-)

Exercises

  1. Write two target_os-gated versions of a line_ending() function returning "\r\n" on Windows and "\n" otherwise, with a not(...) fallback arm so it compiles on every platform, and print the result from main.
  2. Declare a pretty feature in a [features] table (sketch the Cargo.toml snippet in a comment), then gate a show(v: i32) helper behind #[cfg(feature = "pretty")] with a #[cfg(not(feature = "pretty"))] fallback that prints plainly.
  3. Use cfg!(debug_assertions) inside an ordinary if to print whether the current build has debug assertions on, and then use cfg_attr to add a second derive (Clone) to a small struct only under a serde feature, confirming the baseline build still compiles.

Tot de volgende keer, en veel plezier met compileren! ;-)

scipio@scipio

Learn Rust Series (#57) - Feature Flags and Conditional Compilation... | Ecency