wandres.dev
CARGO A FONDO · workspaces, features, perfiles

Features: compilación condicional y unificación

Las features son conjuntos con nombre de funcionalidad opcional que gobiernan la compilación condicional con cfg. Dependencias opcionales, la sintaxis dep: y la débil crate?/feature, y la ley que lo sostiene todo: las features deben ser aditivas, porque el build calcula la unión de todo lo que cualquier crate del árbol activa.

⏱ 20 min

Una feature es un interruptor con nombre que activa funcionalidad opcional: código que se compila solo si la enciendes, dependencias que solo se traen si hacen falta. Es el mecanismo con el que un mismo crate sirve a un microcontrolador sin std y a un servidor con todo el ecosistema. Pero bajo su comodidad late una ley inflexible que decide si tu diseño funciona o se rompe en árboles grandes: las features son aditivas y se unifican. El build no elige la feature de un consumidor, calcula la unión de las que pide todo el árbol. Diseñarlas como capacidades que se suman, y nunca como modos que se excluyen, es la diferencia entre componer y colisionar.

🎯 Al terminar esta lección sabrás
  • Declarar features en [features] y activarlas con #[cfg(feature = "x")].
  • Convertir una dependencia en opcional y exponerla con la sintaxis dep:.
  • Componer features entre crates con crate/feature y la débil crate?/feature.
  • Comprender la unificación de features y por qué deben ser aditivas.

Declarar y consumir features

La tabla [features] define cada interruptor como una lista de otras features o dependencias que activa. La feature default es la que se enciende si no dices lo contrario:

[features]
default = ["std"]
std = []
serde = ["dep:serde"]
completo = ["std", "serde"]

[dependencies]
serde = { version = "1", optional = true }

En el código, #[cfg(feature = "x")] incluye o excluye elementos en tiempo de compilación. Lo que queda fuera no pesa ni un byte en el binario final:

#[cfg(feature = "std")]
pub fn cargar(ruta: &str) -> String {
    std::fs::read_to_string(ruta).unwrap()   // requiere std
}

#[cfg(not(feature = "std"))]
pub fn cargar(_ruta: &str) -> &'static str {
    "modo sin std"                            // variante para no_std
}

Quien te consume puede renunciar a tus defaults con default-features = false y reactivar solo lo que necesite. Así se compila un crate en un entorno no_std: apagando std.

[dependencies]
mi_crate = { version = "1", default-features = false, features = ["serde"] }

Dependencias opcionales y la sintaxis dep:

Marcar una dependencia como optional = true crea, por defecto, una feature implícita con su mismo nombre: activarla trae el crate. Eso es cómodo pero acopla el nombre de tu feature al de la dependencia. La sintaxis dep: rompe ese acoplamiento: referencia la dependencia opcional sin crear la feature implícita, de modo que tú decides cómo se llama la capacidad y qué la activa.

[features]
# La capacidad publica se llama "serializar", no "serde":
serializar = ["dep:serde", "chrono?/serde"]

[dependencies]
serde  = { version = "1", optional = true }
chrono = { version = "0.4", optional = true }

Ahí aparecen las dos formas de activar la feature de una dependencia. crate/feature enciende esa feature y, si el crate es opcional, lo trae. La forma débil crate?/feature —con la interrogación— es más quirúrgica: enciende la feature de chrono solo si chrono ya está activado por otra vía, sin traerlo por su cuenta. Es la herramienta para decir «si además usas fechas, que sepan serializarse» sin imponer la dependencia a todos.

🔌

dep:crate

Referencia una dependencia opcional sin crear su feature implícita. Desacopla el nombre de la capacidad del nombre del crate.

➡️

crate/feature

Enciende esa feature de la dependencia y, si es opcional, la trae al árbol. La forma fuerte y habitual de propagar.

crate?/feature

Débil: enciende la feature solo si el crate ya está activo por otra vía. Nunca lo trae por sí sola.

🚫

default-features = false

Renuncia a los defaults del crate para reactivar solo lo justo. La puerta a compilar en no_std.

⚠️
No diseñes features mutuamente excluyentes

La tentación de un par std frente a no_std que se contradigan es un error clásico. Como el build unifica, si un crate profundo de tu árbol enciende std y otro esperaba no_std, ambos acaban activos y tu cfg produce un error que tú no escribiste. Las features son capacidades que se suman, no modos que se eligen. Si necesitas exclusión real, sepáralo en dos crates.

La unificación y la ley de aditividad

Aquí está el concepto que decide si tu crate escala. Cuando dos partes del árbol usan el mismo crate con features distintas, Cargo no lo compila dos veces: lo compila una vez con la unión de todas las features pedidas. Si a activa serde/std y b usa serde sin std, el build entero recibe std. Nadie eligió esa combinación; emergió de sumar.

flowchart TD
APP[Build del binario] --> A[crate a pide serde con std]
APP --> B[crate b pide serde sin std]
A --> U[serde compilada UNA vez]
B --> U
U --> R[Union de features: gana std]
style U fill:#89b4fa,color:#11111b
style R fill:#a6e3a1,color:#11111b

De ahí se sigue la ley de aditividad: activar una feature solo puede añadir API o comportamiento, jamás quitarlo ni cambiarlo. Si la respetas, la unificación es inocua: recibir features de más nunca rompe a nadie. Si la violas —una feature que elimina un método, que cambia una firma, que contradice a otra—, algún consumidor lejano encenderá lo que tú apagabas y el fallo aflorará en código ajeno. El resolvedor 2 y el 3 afinan los bordes —las dependencias de compilación y las macros procedurales se resuelven aparte del grafo normal, y las features específicas de una plataforma no se filtran a otras—, pero dentro del grafo ordinario la unión manda.

cargo build --features "serde std"        # activa dos a la vez
cargo build --no-default-features --features serde
cargo build --all-features                # enciende todo: util en CI
💡
Prueba el conjunto potencia, no solo tus defaults

Como cualquier consumidor puede activar cualquier subconjunto de tus features, compilar solo con los valores por defecto esconde combinaciones rotas. En CI corre al menos --no-default-features y --all-features; para ir más lejos, cargo hack --feature-powerset check compila cada combinación posible y destapa el cfg olvidado antes que tus usuarios.

La aditividad es la promesa que compra la composición barata

Mira la unificación de features como lo que es: un teorema con una hipótesis. Las features de un crate forman un retículo —un conjunto ordenado donde cualquier par tiene una cota superior—, y el build calcula el join, la unión de todos los conjuntos de features solicitados por el árbol. Esa unión es lo que permite que una sola copia compilada de un crate sirva a todos sus consumidores; sin ella, un crate requerido con dos configuraciones distintas tendría que compilarse dos veces, multiplicando los tiempos y, peor aún, produciendo dos tipos incompatibles que el resto del programa no podría mezclar. La unificación es, pues, la que mantiene finito y coherente un árbol de miles de crates. Pero todo teorema cobra su hipótesis, y aquí es la aditividad: encender una feature solo puede sumar. Mientras la cumplas, recibir features de más por culpa de un vecino es inofensivo, porque más nunca significa distinto ni menos. En cuanto la violas —en cuanto una feature quita, sustituye o contradice—, la unión deja de ser inocua: un crate en las profundidades de tu grafo enciende lo que tú creías apagado y rompe una expectativa que jamás escribiste, en un punto que no controlas. Por eso el consejo canónico no es una preferencia estética sino una condición de sanidad: diseña features como capacidades que se acumulan monótonamente, nunca como modos entre los que se escoge. Es exactamente el mismo trato que el versionado semántico —comodidad y composición a cambio de una promesa que te obligas a mantener—, y no es coincidencia: todo el modelo de dependencias de Cargo está construido sobre contratos aditivos que hacen segura la unión.

📝
Lo esencial de las features

[features] define interruptores; #[cfg(feature = "x")] compila condicionalmente. Una dependencia optional = true crea una feature implícita; dep: la referencia sin crearla y desacopla el nombre. crate/feature enciende y trae; crate?/feature enciende solo si el crate ya está activo. El build unifica: compila cada crate una vez con la unión de las features pedidas. De ahí la ley: las features deben ser aditivas —solo añaden—; las mutuamente excluyentes son un antipatrón. --all-features en CI destapa combinaciones rotas.

⚔️ Comprueba la unificación con tus manos
  1. Crea un crate con features std (por defecto) y serde, y una función con dos variantes bajo #[cfg(feature = "std")] y #[cfg(not(...))]. Compílalo con y sin --no-default-features.
  2. Haz serde una dependencia optional y expónla como feature serializar usando dep:serde. Verifica que el nombre público ya no es serde.
  3. Monta un árbol con dos crates intermedios que pidan un tercero con features distintas; usa cargo tree -f "{p} {f}" para observar la unión resultante.
  4. Diseña a propósito dos features contradictorias y provoca el error de compilación que aparece cuando ambas se unifican. Explica por qué es inevitable.
  5. Ejecuta cargo build --all-features sobre un crate con varias features y descubre si alguna combinación no compila.