Фичи Cargo и условная компиляция (#[cfg]) в Rust
Представь, что заказываешь пиццу. Основу и томатный соус получают все. А дальше ты выбираешь добавки: грибы, двойной сыр, оливки. Кухня готовит только то, что ты заказал, так что ты не платишь (и не ждёшь) за начинки, которые тебе не нужны.
Код на Rust устроен так же. С помощью условной компиляции (#[cfg]) ты говоришь компилятору: «собирай эту часть только под Windows» или «только в тестах». А с помощью фич Cargo (features) люди, которые пользуются твоим крейтом, выбирают нужные им необязательные добавки, например поддержку JSON. Невыбранный код вообще не компилируется. Он не замедляет сборку и не попадает в итоговую программу.
Эта страница продолжает уроки Cargo и крейты (Cargo.toml, cargo add) и Инструменты и установка. Если эти слова тебе пока незнакомы, сначала загляни туда.
#[cfg]: оставить или убрать код
Заголовок раздела «#[cfg]: оставить или убрать код»#[cfg(...)] — это атрибут, который ставят на функцию, структуру, строку use, модуль или даже отдельную инструкцию. Если условие истинно, код остаётся. Если ложно, компилятор полностью убирает его, будто ты его и не писал.
#[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!("Привет из {}!", os_name());}Вывод:
Привет из Linux!Rust Playground работает на Linux, поэтому выводит Привет из Linux!. На твоём ноутбуке с Windows тот же код выведет Привет из Windows!. У трёх функций одно имя, но это не проблема: на любом конкретном компьютере выживает только одна из них.
Здесь есть маленькая ловушка. На FreeBSD ни одно из трёх условий не выполняется, функции os_name нет, и сборка останавливается с ошибкой E0425: «cannot find function os_name in this scope» (функция os_name не найдена в этой области видимости). В следующих разделах ты увидишь, как добавить запасной вариант.
cfg!(): просто true или false
Заголовок раздела «cfg!(): просто true или false»Иногда код убирать не нужно — нужно просто задать вопрос. Макрос cfg!() принимает те же условия, но возвращает обычный bool:
fn main() { let bits = if cfg!(target_pointer_width = "64") { 64 } else { 32 }; println!("Это {bits}-битная программа"); println!("Самый большой usize равен {}", usize::MAX);
if cfg!(target_os = "windows") { println!("Пути выглядят так: C:\\Users\\ana"); } else { println!("Пути выглядят так: /home/ana"); }}Вывод:
Это 64-битная программаСамый большой usize равен 18446744073709551615Пути выглядят так: /home/anatarget_pointer_width — это размер адреса в памяти: "64" почти на любом современном компьютере и "32" на некоторых маленьких устройствах. Это же размер usize, поэтому usize::MAX здесь такой большой.
Так когда что использовать?
#[cfg(...)] |
cfg!(...) |
|
|---|---|---|
| Что делает | Убирает код, если условие ложно | Превращается в true или false |
| Компилируется ли «ложный» код? | Нет, его просто нет | Да, обе ветки должны компилироваться везде |
| Для чего | Код, который не может скомпилироваться в других местах (API только для одной ОС, необязательные крейты) | Небольшие различия в поведении |
Средняя строка важна. Вещей только для Unix, вроде std::os::unix::fs::PermissionsExt, в Windows нет, поэтому код, который ими пользуется, должен стоять за #[cfg(unix)]. if cfg!(unix) не хватит: компилятор под Windows всё равно попытается скомпилировать эту ветку.
Комбинируем условия: not, any, all
Заголовок раздела «Комбинируем условия: not, any, all»Из маленьких условий можно собирать большие:
not(a)— истинно, когдаaложноany(a, b, ...)— истинно, когда истинно хотя бы одно (как «или»)all(a, b, ...)— истинно, когда истинны все (как «и»)
Вот снова пример с ОС, на этот раз с запасным вариантом, чтобы он компилировался везде:
#[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 { "неизвестно"}
fn main() { println!("Оболочка по умолчанию: {}", shell());
if cfg!(all(unix, target_pointer_width = "64")) { println!("64-битная Unix-система"); }}Вывод:
Оболочка по умолчанию: sh64-битная Unix-системаunix и windows — удобные короткие имена. unix истинно на Linux, macOS, BSD-системах и похожих. Вот условия, которые встречаются чаще всего:
| Условие | Истинно, когда… |
|---|---|
target_os = "linux" / "windows" / "macos" |
Ты собираешь под эту операционную систему |
unix / windows |
Ты собираешь под любую Unix-подобную систему / под Windows |
target_arch = "x86_64" / "aarch64" / "wasm32" |
Ты собираешь под этот тип процессора (WebAssembly — это wasm32) |
target_pointer_width = "64" |
Адреса (и usize) занимают 64 бита |
debug_assertions |
Это отладочная сборка (cargo build), а не --release |
test |
Ты запускаешь cargo test |
feature = "json" |
Включена фича Cargo json (подробнее ниже) |
Хочешь увидеть полный список для своего компьютера? Выполни rustc --print cfg. На 64-битном ПК с Linux он выведет строки вроде target_os="linux", target_family="unix" и debug_assertions. #[cfg(windows)] и #[cfg(unix)] часто встречаются в коде FFI, потому что библиотеки на C нередко различаются от системы к системе.
#[cfg(test)] и #[cfg_attr]
Заголовок раздела «#[cfg(test)] и #[cfg_attr]»Одним условием ты уже пользовался, даже не задумываясь. Каждый тестовый модуль на странице Тестирование начинается с #[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 включает test, поэтому модуль собирается и его тесты запускаются. Обычный cargo build выбрасывает модуль целиком, и в настоящей программе нет тестового кода. #[cfg] так работает с любым модулем (см. Модули), например #[cfg(windows)] mod windows_paths;.
#[cfg_attr(условие, атрибут)] значит: «если условие истинно, добавь этот атрибут». Частый случай — derive, который нужен только иногда:
#[cfg_attr(debug_assertions, derive(Debug))]struct Player { name: String, score: u32,}
fn main() { let player = Player { name: "Аня".to_string(), score: 120 };
// только в отладочных сборках; идёт в stderr, а не в stdout #[cfg(debug_assertions)] eprintln!("debug: {:?}", player);
println!("{}: {} очков", player.name, player.score);}Вывод:
Аня: 120 очковВ отладочной сборке (в Playground она по умолчанию) ты увидишь ещё и debug: Player { name: "Аня", score: 120 } в разделе вывода «Standard Error». Переключи Playground на «Release», и эта строка исчезнет вместе с кодом Debug для Player. Обрати внимание: eprintln! нужен свой #[cfg(debug_assertions)]. Без него release-сборка упадёт, потому что там Player не реализует Debug. Подробнее об отладочных и release-сборках — на странице Cargo и крейты.
Фичи Cargo в твоём крейте
Заголовок раздела «Фичи Cargo в твоём крейте»Теперь меню пиццерии. Фича — это именованный переключатель, который ты объявляешь в Cargo.toml, в таблице [features]. Соберём небольшую программу pizza_order с двумя фичами:
extra_cheese— бесплатная добавка, включена по умолчанию.json— выводит заказ в виде JSON. Ей нужны крейтыserdeиserde_json, поэтому они скачиваются и компилируются, только когда кто-то просит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"]Построчно:
optional = trueзначит «не используй этот крейт, пока его не попросит какая-нибудь фича».extra_cheese = []создаёт фичу, которая больше ничего не включает. Это просто переключатель, который может проверить твой код.json = ["dep:serde", "dep:serde_json"]говорит: когдаjsonвключена, включи и эти две необязательные зависимости.dep:значит «это зависимость, а не имя фичи».default = [...]перечисляет фичи, которые включены, если никто не сказал иначе.
В коде фича проверяется через feature = "имя":
#[cfg_attr(feature = "json", derive(serde::Serialize))]struct Order { size: String, toppings: Vec<String>,}
fn main() { let mut toppings = vec!["tomato".to_string(), "basil".to_string()];
if cfg!(feature = "extra_cheese") { toppings.push("extra cheese".to_string()); }
let order = Order { size: "large".to_string(), toppings }; println!("Order: {} pizza with {}", order.size, order.toppings.join(", "));
#[cfg(feature = "json")] println!("As JSON: {}", serde_json::to_string(&order).unwrap());}Строка с JSON использует #[cfg], а не cfg!. Без фичи json крейт serde_json вообще не является зависимостью, поэтому эта строка должна исчезнуть полностью. Проверка сыра компилируется в любом случае, так что ей хватает cfg!. (Строки в этом примере оставлены на английском: ниже показан настоящий вывод именно этой программы.)
Теперь закажи пиццу из командной строки. (-q просто прячет строки Cargo «Compiling…».)
cargo run -q # только фичи по умолчаниюcargo run -q --features json # по умолчанию + jsoncargo run -q --no-default-features # никаких добавок, даже тех, что по умолчаниюcargo run -q --all-features # все фичиЭто настоящий вывод:
$ cargo run -qOrder: large pizza with tomato, basil, extra cheese
$ cargo run -q --features jsonOrder: large pizza with tomato, basil, extra cheeseAs JSON: {"size":"large","toppings":["tomato","basil","extra cheese"]}
$ cargo run -q --no-default-featuresOrder: large pizza with tomato, basil
$ cargo run -q --all-featuresOrder: large pizza with tomato, basil, extra cheeseAs JSON: {"size":"large","toppings":["tomato","basil","extra cheese"]}Флаги можно комбинировать. --no-default-features --features json даёт JSON без сыра. Несколько фич перечисляются через запятую: --features json,extra_cheese. Те же флаги работают с cargo build, cargo check и cargo test.
Включаем фичи у зависимостей
Заголовок раздела «Включаем фичи у зависимостей»Чаще всего ты стоишь по другую сторону прилавка: ты пользуешься крейтами, у которых есть фичи. Ты уже видел это на странице Cargo и крейты:
[dependencies]serde = { version = "1", features = ["derive"] }tokio = { version = "1", features = ["full"] }serde_json = { version = "1", features = ["preserve_order"] }- Фича
deriveуserdeдаёт#[derive(Serialize, Deserialize)]. - У
tokioнет фич по умолчанию. Сам по себе он почти ничего не даёт.fullвключает всё: среду выполнения,#[tokio::main], таймеры, сеть, файлы и многое другое. Пока учишься, это удобно. В настоящем проекте можно попросить меньше, напримерfeatures = ["rt-multi-thread", "macros"]. - Фича
preserve_orderуserde_jsonсохраняет ключи объекта в том порядке, в котором ты их добавил.
cargo add пишет такие строки за тебя: cargo add tokio --features full.
Зачем крейты так дробятся? Каждая неиспользуемая часть — это код, который не нужно скачивать, компилировать и поставлять. Большой крейт вроде tokio делал бы сборку любого маленького проекта медленной, если бы всегда компилировал всё. Фичи сокращают время компиляции и размер программ.
Можно ещё выключить фичи крейта по умолчанию и выбрать только нужные — через default-features = false:
[dependencies]serde_json = { version = "1", default-features = false, features = ["alloc"] }Чтобы узнать, какие фичи включены в твоём проекте, спроси Cargo:
cargo tree -e features --features json --depth 1Для pizza_order эта команда выводит:
pizza_order v0.1.0 (/home/ana/pizza_order)├── serde feature "default"├── serde feature "derive"└── serde_json feature "default"Без --depth 1 ты увидишь всё дерево, включая фичи, которые твои зависимости включают у своих зависимостей. Оно длинное, но это лучший инструмент, когда хочется понять: «а почему вообще компилируется этот крейт?».
Простые правила
Заголовок раздела «Простые правила»Фичи должны только добавлять. Включение фичи никогда не должно удалять функцию или менять поведение существующей функции. Вот почему. Если два крейта в твоём проекте пользуются serde_json, и один просит preserve_order, а другой нет, Cargo компилирует serde_json один раз — с фичами обоих. Значит, каждый крейт должен продолжать работать, когда кто-то другой включил дополнительные фичи.
Избегай фич, которые нельзя включить вместе. Схема «фича postgres ИЛИ фича sqlite, но никогда обе» ломается, как только два крейта выберут разные, и --all-features перестаёт работать. Лучше делай фичи, которые можно включить все сразу.
Документируй свои фичи. Напиши короткий список в README или в документации крейта: имя фичи, что она добавляет и включена ли по умолчанию. На docs.rs у каждого крейта есть страница «Feature flags» (например, у tokio), а многие крейты отмечают элементы пометкой «Available on crate feature … only».
Проверяй все сочетания в CI. Код за фичей компилируется, только когда фича включена. Добавь в CI такие команды, чтобы там ничего не пряталось:
cargo check --all-featurescargo check --no-default-featurescargo test --all-featuresДля крупных крейтов инструмент cargo-hack умеет проверять каждую фичу по отдельности (cargo hack check --each-feature).
Частые ошибки
Заголовок раздела «Частые ошибки»Опечатка в имени фичи. Если написать #[cfg(feature = "jsno")], код всё равно скомпилируется. Просто строка с JSON никогда не выведется, даже с --features json. К счастью, современный Rust предупреждает:
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Компилятор сообщает о неожиданном значении jsno в условии cfg, перечисляет ожидаемые фичи и подсказывает похожее имя "json". Не игнорируй это предупреждение. В командной строке Cargo строже: cargo run --features jsno останавливается с error: the package 'pizza_order' does not contain this feature: jsno и предлагает json.
Проверяешь имя зависимости, а не имя фичи. Из-за dep:serde в pizza_order нет фичи с именем serde. Если написать #[cfg_attr(feature = "serde", ...)], получишь то же предупреждение «unexpected cfg condition value», только для serde. А потом cargo run --features json упадёт с ошибкой E0277: «the trait bound Order: serde::Serialize is not satisfied» (Order не реализует serde::Serialize), потому что derive так и не добавился. Бери имя из [features]: feature = "json".
Забыл dep:. Как только хоть одна фича пишет dep:serde, для serde везде нужно использовать dep:. Добавь новую фичу yaml = ["serde"] — и Cargo откажется читать файл:
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 объясняет, что serde — необязательная зависимость без неявной фичи, и просит написать dep:serde. Исправление — yaml = ["dep:serde"]. А если не писать dep: нигде, Cargo молча создаст публичную фичу для каждой необязательной зависимости (здесь сработало бы --features serde). Это старый стиль. С dep: список фич остаётся чистым и продуманным.
Код, который никогда не компилируется. Допустим, ты опечатался и написал serde_json::to_strng в строке под #[cfg(feature = "json")]. cargo check скажет, что всё в порядке, потому что по умолчанию эта строка не компилируется. Найдёт ошибку только cargo check --all-features:
error[E0425]: cannot find function `to_strng` in crate `serde_json` --> src/main.rs:18:41 |18 | println!("As JSON: {}", serde_json::to_strng(&order).unwrap()); | ^^^^^^^^ |help: a function with a similar name exists |18 | println!("As JSON: {}", serde_json::to_string(&order).unwrap()); | +Компилятор не находит функцию to_strng в крейте serde_json и подсказывает похожую — to_string. Именно поэтому --all-features должен быть в CI.
Практика
Заголовок раздела «Практика»1. Папка настроек для каждой ОС
Заголовок раздела «1. Папка настроек для каждой ОС»Напиши функцию config_dir(), которая возвращает, где приложение для заметок хранит настройки: "~/.config/notes" на Linux, "~/Library/Application Support/notes" на macOS, r"%APPDATA%\notes" на Windows и "./notes" на всех остальных системах. Выведи результат в main. Подсказка: по одной функции на каждый случай с #[cfg(target_os = "...")] и not(any(...)) для запасного варианта.
Показать решение
#[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!("Настройки сохраняются в {}", config_dir());}Вывод:
Настройки сохраняются в ~/.config/notesНа любом компьютере выживает ровно одна из четырёх функций, поэтому main всегда находит config_dir. Playground работает на Linux, так что ты видишь путь для Linux. Настоящее приложение искало бы эти папки как следует, с помощью крейта вроде dirs.
2. Необязательный экспорт в JSON
Заголовок раздела «2. Необязательный экспорт в JSON»У тебя есть маленькая программа leaderboard, которая выводит очки. Добавь фичу export (выключена по умолчанию), которая подключает serde_json и добавляет функцию export_json. С --features export программа должна ещё и выводить очки в виде JSON. Подсказка: сделай serde_json необязательным, используй dep:serde_json в фиче и поставь #[cfg(feature = "export")] и на функцию, и на строку, которая её вызывает. Playground не умеет задавать фичи Cargo, так что попробуй это у себя через cargo new leaderboard.
Показать решение
[package]name = "leaderboard"version = "0.1.0"edition = "2024"
[dependencies]serde_json = { version = "1", optional = true }
[features]export = ["dep:serde_json"]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!("{}: {} points", s.name, s.points); }
#[cfg(feature = "export")] println!("Export: {}", export_json(&scores));}$ cargo run -qAna: 120 pointsBo: 95 points
$ cargo run -q --features exportAna: 120 pointsBo: 95 pointsExport: [{"name":"Ana","points":120},{"name":"Bo","points":95}]Строки default = [...] нет, поэтому export выключена, пока ты её не попросишь. Без неё serde_json даже не скачивается. #[cfg] нужен и функции, и вызову, потому что каждый из них упоминает serde_json.
#[cfg(условие)]оставляет код, только когда условие истинно. Иначе код удаляется ещё до компиляции.cfg!(условие)— это простоbool. Обе ветки всё равно должны компилироваться на любой платформе.- Условия комбинируются через
not(..),any(..)иall(..). Самые частые —target_os,unix/windows,debug_assertionsиtest. #[cfg_attr(условие, атрибут)]добавляет атрибут, только когда условие истинно.- Фичи Cargo живут в
[features]. Для необязательных крейтов используйoptional = trueплюсdep:имя, аdefault = [...]— для того, что включено по умолчанию. - Фичи должны только добавлять. Запускай
cargo check --all-featuresв CI и следи за предупреждениямиunexpected_cfgs.
Проверь себя
Заголовок раздела «Проверь себя»Проверь себя
1. Что происходит с функцией с пометкой #[cfg(target_os = "windows")], когда собираешь под Linux?
2. Почему if cfg!(unix) { ... } не хватает для кода, который использует API только для Unix?
3. Что делает json = ["dep:serde_json"] в Cargo.toml?
4. Ты по ошибке написал #[cfg(feature = "jsno")]. Что произойдёт?
Результат: 0 / 4
Что почитать
Заголовок раздела «Что почитать»- Features — The Cargo Book (на английском): полное руководство по
[features], необязательным зависимостям,dep:и тому, как объединяются фичи. - Conditional compilation — The Rust Reference (на английском): все опции
cfgи точные правила для#[cfg],#[cfg_attr]иcfg!. - Макрос
cfg!— стандартная библиотека Rust (на английском): что возвращаетcfg!и чем он отличается от атрибута#[cfg]. - Specifying Dependencies — The Cargo Book (на английском): как писать
features,default-features = falseиoptionalдля каждой зависимости. - cfg — Rust на примерах: короткие примеры
#[cfg]иcfg!бок о бок (перевод на русский).