Ir al contenido

Features de Cargo y compilación condicional (#[cfg]) en Rust

Piensa en pedir una pizza. Todas llevan la base y la salsa de tomate. Después eliges los extras: champiñones, queso extra, aceitunas. La cocina solo prepara lo que pediste, así que no pagas (ni esperas) por ingredientes que no quieres.

El código de Rust funciona igual. Con la compilación condicional (#[cfg]), le dices al compilador “construye esta parte solo en Windows” o “solo en las pruebas”. Con las features de Cargo (funcionalidades opcionales), quienes usan tu crate eligen los extras que necesitan, como el soporte para JSON. El código que nadie eligió no se compila en absoluto: no hace más lenta la compilación y no acaba en el programa final.

Esta página se apoya en Cargo y crates (Cargo.toml, cargo add) y en Herramientas de Rust. Si esas palabras te suenan nuevas, échales un vistazo rápido primero.

#[cfg(...)] es un atributo que pones sobre una función, un struct, una línea use, un módulo o incluso una sola instrucción. Si la condición es verdadera, el código se queda. Si es falsa, el compilador lo elimina por completo, como si nunca lo hubieras escrito.

#[cfg(target_os = "linux")]
fn os_name() -> &'static str {
"Linux"
}
#[cfg(target_os = "windows")]
fn os_name() -> &'static str {
"Windows"
}
#[cfg(target_os = "macos")]
fn os_name() -> &'static str {
"macOS"
}
fn main() {
println!("¡Hola desde {}!", os_name());
}

Salida:

¡Hola desde Linux!
▶ Pruébalo tú mismo

El Rust Playground se ejecuta en Linux, así que imprime ¡Hola desde Linux!. En tu portátil con Windows, el mismo código imprime ¡Hola desde Windows!. Hay tres funciones con el mismo nombre, pero no pasa nada: en cada computadora solo sobrevive una de ellas.

Aquí hay una pequeña trampa. En FreeBSD ninguna de las tres condiciones es verdadera, así que os_name no existe y la compilación se detiene con E0425: “cannot find function os_name in this scope” (no se encuentra la función os_name en este ámbito). En las siguientes secciones verás cómo añadir una alternativa.

A veces no quieres quitar código. Solo quieres hacer una pregunta. La macro cfg!() acepta las mismas condiciones, pero te devuelve un simple bool:

fn main() {
let bits = if cfg!(target_pointer_width = "64") { 64 } else { 32 };
println!("Este es un programa de {bits} bits");
println!("El usize más grande es {}", usize::MAX);
if cfg!(target_os = "windows") {
println!("Las rutas se ven así: C:\\Users\\ana");
} else {
println!("Las rutas se ven así: /home/ana");
}
}

Salida:

Este es un programa de 64 bits
El usize más grande es 18446744073709551615
Las rutas se ven así: /home/ana
▶ Pruébalo tú mismo

target_pointer_width es el tamaño de una dirección de memoria: "64" en casi cualquier computadora moderna y "32" en algunos dispositivos pequeños. También es el tamaño de usize, y por eso usize::MAX es tan grande aquí.

Entonces, ¿cuándo usar cada uno?

#[cfg(...)] cfg!(...)
Qué hace Elimina el código cuando es falso Se convierte en true o false
¿El código “falso” se sigue compilando? No, desaparece Sí, las dos ramas deben compilar en todas partes
Úsalo para Código que no puede compilar en otros sitios (APIs de un solo sistema, crates opcionales) Pequeñas diferencias de comportamiento

La fila del medio es importante. Cosas exclusivas de Unix, como std::os::unix::fs::PermissionsExt, no existen en Windows, así que el código que las usa tiene que ir detrás de #[cfg(unix)]. Un if cfg!(unix) no basta, porque el compilador de Windows igualmente intentaría compilar esa rama.

Puedes construir condiciones más grandes a partir de otras pequeñas:

  • not(a): es verdadera cuando a es falsa
  • any(a, b, ...): es verdadera cuando al menos una lo es (como “o”)
  • all(a, b, ...): es verdadera cuando todas lo son (como “y”)

Aquí tienes otra vez el ejemplo de los sistemas operativos, esta vez con una alternativa para que compile en todas partes:

#[cfg(any(target_os = "linux", target_os = "macos"))]
fn shell() -> &'static str {
"sh"
}
#[cfg(target_os = "windows")]
fn shell() -> &'static str {
"cmd"
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn shell() -> &'static str {
"desconocida"
}
fn main() {
println!("Shell por defecto: {}", shell());
if cfg!(all(unix, target_pointer_width = "64")) {
println!("Un sistema Unix de 64 bits");
}
}

Salida:

Shell por defecto: sh
Un sistema Unix de 64 bits
▶ Pruébalo tú mismo

unix y windows son nombres cortos muy prácticos. unix es verdadero en Linux, macOS, los BSD y sistemas parecidos. Estas son las condiciones que verás más a menudo:

Condición Es verdadera cuando…
target_os = "linux" / "windows" / "macos" Compilas para ese sistema operativo
unix / windows Compilas para cualquier sistema tipo Unix / para Windows
target_arch = "x86_64" / "aarch64" / "wasm32" Compilas para ese tipo de procesador (WebAssembly es wasm32)
target_pointer_width = "64" Las direcciones (y usize) son de 64 bits
debug_assertions Es una compilación debug (cargo build), no --release
test Estás ejecutando cargo test
feature = "json" La feature json de Cargo está activada (más abajo lo verás)

¿Quieres ver la lista completa para tu máquina? Ejecuta rustc --print cfg. En un PC Linux de 64 bits imprime líneas como target_os="linux", target_family="unix" y debug_assertions. Verás #[cfg(windows)] y #[cfg(unix)] muy a menudo en el código de FFI, porque las bibliotecas de C suelen cambiar de un sistema a otro.

Ya usaste una condición sin darte cuenta. Todos los módulos de pruebas de la página de Pruebas empiezan con #[cfg(test)]:

pub fn add_points(total: u32, points: u32) -> u32 {
total + points
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_points() {
assert_eq!(add_points(10, 5), 15);
}
}

cargo test activa test, así que el módulo se compila y sus pruebas se ejecutan. Un cargo build normal deja fuera el módulo entero, así que tu programa real no lleva nada de código de pruebas. #[cfg] funciona así con cualquier módulo (mira Módulos), por ejemplo #[cfg(windows)] mod windows_paths;.

#[cfg_attr(condición, atributo)] significa: “si la condición es verdadera, añade este atributo”. Un uso habitual es un derive que solo quieres a veces:

#[cfg_attr(debug_assertions, derive(Debug))]
struct Player {
name: String,
score: u32,
}
fn main() {
let player = Player { name: "Ana".to_string(), score: 120 };
// solo en compilaciones debug; va a stderr, no a stdout
#[cfg(debug_assertions)]
eprintln!("debug: {:?}", player);
println!("{} tiene {} puntos", player.name, player.score);
}

Salida:

Ana tiene 120 puntos
▶ Pruébalo tú mismo

En una compilación debug (la opción por defecto del Playground), también verás debug: Player { name: "Ana", score: 120 } en la parte “Standard Error” de la salida. Cambia el Playground a “Release” y esa línea desaparece, junto con el código de Debug para Player. Fíjate en que el eprintln! necesita su propio #[cfg(debug_assertions)]. Sin él, la compilación release falla, porque ahí Player no implementa Debug. Tienes más sobre las compilaciones debug y release en Cargo y crates.

Ahora, el menú de pizzas. Una feature es un interruptor con nombre que defines en Cargo.toml, dentro de una tabla [features]. Vamos a construir un pequeño programa, pizza_order, con dos features:

  • extra_cheese: un extra gratis, activado por defecto.
  • json: imprime el pedido en JSON. Necesita los crates serde y serde_json, así que solo se descargan y se compilan cuando alguien pide json.
[package]
name = "pizza_order"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
[features]
default = ["extra_cheese"]
extra_cheese = []
json = ["dep:serde", "dep:serde_json"]

Línea por línea:

  • optional = true significa “no uses este crate a menos que una feature lo pida”.
  • extra_cheese = [] crea una feature que no activa nada más. Es solo un interruptor que tu código puede consultar.
  • json = ["dep:serde", "dep:serde_json"] dice: cuando json esté activada, activa también estas dos dependencias opcionales. dep: significa “esto es una dependencia, no el nombre de una feature”.
  • default = [...] enumera las features que están activadas cuando nadie dice lo contrario.

En el código, compruebas una feature con feature = "nombre":

src/main.rs
#[cfg_attr(feature = "json", derive(serde::Serialize))]
struct Order {
size: String,
toppings: Vec<String>,
}
fn main() {
let mut toppings = vec!["tomate".to_string(), "albahaca".to_string()];
if cfg!(feature = "extra_cheese") {
toppings.push("queso extra".to_string());
}
let order = Order { size: "grande".to_string(), toppings };
println!("Pedido: pizza {} con {}", order.size, order.toppings.join(", "));
#[cfg(feature = "json")]
println!("En JSON: {}", serde_json::to_string(&order).unwrap());
}

La línea del JSON usa #[cfg], no cfg!. Sin la feature json, serde_json ni siquiera es una dependencia, así que esa línea tiene que desaparecer por completo. La comprobación del queso compila bien en cualquier caso, así que cfg! es suficiente.

Ahora pide tu pizza desde la línea de comandos. (-q solo oculta las líneas “Compiling…” de Cargo.)

Terminal window
cargo run -q # solo las features por defecto
cargo run -q --features json # las de por defecto + json
cargo run -q --no-default-features # nada extra, ni siquiera las de por defecto
cargo run -q --all-features # todas las features

Esta es la salida real:

$ cargo run -q
Pedido: pizza grande con tomate, albahaca, queso extra
$ cargo run -q --features json
Pedido: pizza grande con tomate, albahaca, queso extra
En JSON: {"size":"grande","toppings":["tomate","albahaca","queso extra"]}
$ cargo run -q --no-default-features
Pedido: pizza grande con tomate, albahaca
$ cargo run -q --all-features
Pedido: pizza grande con tomate, albahaca, queso extra
En JSON: {"size":"grande","toppings":["tomate","albahaca","queso extra"]}

Puedes combinar las opciones. --no-default-features --features json te da JSON sin el queso. Para varias features, sepáralas con comas: --features json,extra_cheese. Las mismas opciones funcionan con cargo build, cargo check y cargo test.

La mayor parte del tiempo estarás al otro lado del mostrador: usas crates que tienen features. Ya lo viste en Cargo y crates:

[dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
serde_json = { version = "1", features = ["preserve_order"] }
  • La feature derive de serde te da #[derive(Serialize, Deserialize)].
  • tokio no tiene features por defecto. Por sí solo no te da casi nada. full lo activa todo: el runtime, #[tokio::main], temporizadores, red, archivos y más. Es cómodo mientras aprendes. En un proyecto real puedes pedir menos, por ejemplo features = ["rt-multi-thread", "macros"].
  • preserve_order de serde_json mantiene las claves de los objetos en el orden en que las insertaste.

cargo add escribe estas líneas por ti: cargo add tokio --features full.

¿Por qué los crates se dividen así? Porque cada parte que no usas es código que no tienes que descargar, compilar ni distribuir. Un crate grande como tokio haría que cualquier proyecto pequeño tardara mucho en compilar si siempre lo compilara todo. Las features mantienen bajos los tiempos de compilación y el tamaño de los binarios.

También puedes desactivar las features por defecto de un crate y elegir solo lo que necesitas, con default-features = false:

[dependencies]
serde_json = { version = "1", default-features = false, features = ["alloc"] }

Para ver qué features están activadas en tu proyecto, pregúntale a Cargo:

Terminal window
cargo tree -e features --features json --depth 1

Para pizza_order, esto imprime:

pizza_order v0.1.0 (/home/ana/pizza_order)
├── serde feature "default"
├── serde feature "derive"
└── serde_json feature "default"

Sin --depth 1 ves el árbol completo, incluidas las features que tus dependencias activan en sus propias dependencias. Es largo, pero es la mejor herramienta cuando te preguntas “¿por qué se está compilando este crate?”.

Las features solo deberían añadir cosas. Activar una feature nunca debería quitar una función ni cambiar lo que hace una función que ya existe. El motivo es este: si dos crates de tu proyecto usan serde_json, y uno pide preserve_order y el otro no, Cargo compila serde_json una sola vez, con las features de ambos activadas. Así que cada crate tiene que seguir funcionando cuando otro activa features extra.

Evita features que no pueden estar activadas a la vez. Un diseño como “feature postgres O feature sqlite, pero nunca las dos” se rompe en cuanto dos crates eligen una distinta, y --all-features deja de funcionar. Mejor usa features que puedan estar todas activadas al mismo tiempo.

Documenta tus features. Escribe una lista corta en tu README o en la documentación de tu crate: el nombre de la feature, qué añade y si está activada por defecto. En docs.rs, cada crate tiene una página “Feature flags” (por ejemplo, la de tokio), y muchos crates marcan elementos con “Available on crate feature … only” (disponible solo con la feature …).

Comprueba todas las combinaciones en CI. El código detrás de una feature solo se compila cuando esa feature está activada. Añade esto a tu CI para que no se esconda nada ahí:

Terminal window
cargo check --all-features
cargo check --no-default-features
cargo test --all-features

En crates más grandes, la herramienta cargo-hack puede comprobar cada feature por separado (cargo hack check --each-feature).

Un error de escritura en el nombre de una feature. Si escribes #[cfg(feature = "jsno")], el código sigue compilando. La línea del JSON simplemente nunca se imprime, ni siquiera con --features json. Por suerte, las versiones actuales de Rust te avisan:

warning: unexpected `cfg` condition value: `jsno`
--> src/main.rs:17:11
|
17 | #[cfg(feature = "jsno")]
| ^^^^^^^^^^------
| |
| help: there is a expected value with a similar name: `"json"`
|
= note: expected values for `feature` are: `default`, `extra_cheese`, and `json`
= help: consider adding `jsno` as a feature in `Cargo.toml`
= note: `#[warn(unexpected_cfgs)]` on by default

El aviso dice que el valor jsno es inesperado, que hay uno con un nombre parecido ("json") y cuáles son las features que sí existen. No lo ignores. En la línea de comandos, Cargo es más estricto: cargo run --features jsno se detiene con error: the package 'pizza_order' does not contain this feature: jsno (el paquete no tiene esa feature) y te sugiere json.

Comprobar el nombre de la dependencia en lugar del de la feature. Por culpa de dep:serde, en pizza_order no existe ninguna feature llamada serde. Si escribes #[cfg_attr(feature = "serde", ...)], recibes el mismo aviso “unexpected cfg condition value”, esta vez para serde. Y luego cargo run --features json falla con E0277: “the trait bound Order: serde::Serialize is not satisfied” (Order no implementa serde::Serialize), porque el derive nunca se añadió. Usa el nombre que aparece en [features]: feature = "json".

Olvidar dep:. En cuanto una feature dice dep:serde, tienes que usar dep: para serde en todas partes. Añade una feature nueva yaml = ["serde"] y Cargo se niega a leer el archivo:

error: failed to parse manifest at `/home/ana/pizza_order/Cargo.toml`
Caused by:
feature `yaml` includes `serde`, but `serde` is an optional dependency without an implicit feature
Use `dep:serde` to enable the dependency.

Cargo te explica que serde es una dependencia opcional sin feature implícita y te pide usar dep:serde. La solución es yaml = ["dep:serde"]. Y si no pones dep: en ningún sitio, Cargo crea en silencio una feature pública para cada dependencia opcional (aquí funcionaría --features serde). Ese es un estilo antiguo. Usar dep: mantiene tu lista de features limpia e intencionada.

Código que nunca se compila. Imagina que escribes mal serde_json::to_strng dentro de la línea con #[cfg(feature = "json")]. cargo check dice que todo está bien, porque esa línea no se compila por defecto. Solo cargo check --all-features lo encuentra:

error[E0425]: cannot find function `to_strng` in crate `serde_json`
--> src/main.rs:18:41
|
18 | println!("En JSON: {}", serde_json::to_strng(&order).unwrap());
| ^^^^^^^^
|
help: a function with a similar name exists
|
18 | println!("En JSON: {}", serde_json::to_string(&order).unwrap());
| +

El compilador no encuentra to_strng en serde_json y te sugiere to_string. Precisamente por esto --all-features tiene que estar en tu CI.

1. Una carpeta de configuración para cada sistema

Sección titulada «1. Una carpeta de configuración para cada sistema»

Escribe una función config_dir() que devuelva dónde guarda su configuración una app de notas: "~/.config/notes" en Linux, "~/Library/Application Support/notes" en macOS, r"%APPDATA%\notes" en Windows y "./notes" en cualquier otro sistema. Imprime el resultado en main. Pista: una función por caso con #[cfg(target_os = "...")], y not(any(...)) para la alternativa.

Ver solución
#[cfg(target_os = "linux")]
fn config_dir() -> &'static str {
"~/.config/notes"
}
#[cfg(target_os = "macos")]
fn config_dir() -> &'static str {
"~/Library/Application Support/notes"
}
#[cfg(target_os = "windows")]
fn config_dir() -> &'static str {
r"%APPDATA%\notes"
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn config_dir() -> &'static str {
"./notes"
}
fn main() {
println!("La configuración se guarda en {}", config_dir());
}

Salida:

La configuración se guarda en ~/.config/notes
▶ Pruébalo tú mismo

En cada computadora sobrevive exactamente una de las cuatro funciones, así que main siempre encuentra un config_dir. El Playground usa Linux, así que ves la ruta de Linux. Una app real usaría un crate como dirs para encontrar estas carpetas como es debido.

Tienes un pequeño programa, leaderboard, que imprime puntuaciones. Añade una feature export (desactivada por defecto) que traiga serde_json y añada una función export_json. Con --features export, el programa también debe imprimir las puntuaciones en JSON. Pista: haz que serde_json sea opcional, usa dep:serde_json en la feature y pon #[cfg(feature = "export")] tanto en la función como en la línea que la llama. El Playground no permite elegir features de Cargo, así que prueba este en tu máquina con cargo new leaderboard.

Ver solución
[package]
name = "leaderboard"
version = "0.1.0"
edition = "2024"
[dependencies]
serde_json = { version = "1", optional = true }
[features]
export = ["dep:serde_json"]
src/main.rs
struct Score {
name: &'static str,
points: u32,
}
#[cfg(feature = "export")]
fn export_json(scores: &[Score]) -> String {
let list: Vec<serde_json::Value> = scores
.iter()
.map(|s| serde_json::json!({ "name": s.name, "points": s.points }))
.collect();
serde_json::Value::Array(list).to_string()
}
fn main() {
let scores = [
Score { name: "Ana", points: 120 },
Score { name: "Bo", points: 95 },
];
for s in &scores {
println!("{}: {} puntos", s.name, s.points);
}
#[cfg(feature = "export")]
println!("Exportado: {}", export_json(&scores));
}
$ cargo run -q
Ana: 120 puntos
Bo: 95 puntos
$ cargo run -q --features export
Ana: 120 puntos
Bo: 95 puntos
Exportado: [{"name":"Ana","points":120},{"name":"Bo","points":95}]

No hay ninguna línea default = [...], así que export está desactivada a menos que la pidas. Sin ella, serde_json ni siquiera se descarga. Tanto la función como la llamada necesitan #[cfg], porque las dos mencionan serde_json.

  • #[cfg(condición)] conserva el código solo cuando la condición es verdadera. Si no, lo elimina antes de compilar.
  • cfg!(condición) es solo un bool. Las dos ramas tienen que compilar en todas las plataformas.
  • Combina condiciones con not(..), any(..) y all(..). Las más comunes son target_os, unix/windows, debug_assertions y test.
  • #[cfg_attr(condición, atributo)] añade un atributo solo cuando la condición es verdadera.
  • Las features de Cargo viven en [features]. Usa optional = true junto con dep:nombre para los crates opcionales, y default = [...] para lo que está activado por defecto.
  • Las features solo deberían añadir cosas. Ejecuta cargo check --all-features en CI y presta atención a los avisos unexpected_cfgs.

Comprueba lo aprendido

1. ¿Qué le pasa a una función marcada con #[cfg(target_os = "windows")] cuando compilas en Linux?

2. ¿Por qué if cfg!(unix) { ... } no basta para código que usa una API exclusiva de Unix?

3. En Cargo.toml, ¿qué hace json = ["dep:serde_json"]?

4. Escribiste #[cfg(feature = "jsno")] por error. ¿Qué pasa?

Puntuación: 0 / 4