Как отличить debug от release в Rust? Разбираем cfg на реальных примерах
Вы когда-нибудь писали код на Rust и думали: «Вот бы в debug-режиме добавить подробное логирование, а в release — убрать всё лишнее»? Или, может быть, хотели включить ассерты только во время разработки, но выключить их в финальной сборке? Если да — вы попали по адресу. Сегодня мы разберём, как с помощью магии cfg управлять поведением кода в зависимости от типа сборки. Это не просто сухая теория — мы пройдём путь от простого флага до production-решения.
Тема взята не с потолка: на Stack Overflow вопрос «How to check release / debug builds using cfg in Rust?» собрал тысячи просмотров. Значит, это нужно многим. И мы это разжуём.
Почему это важно? Два мира — два подхода
В Rust есть два основных профиля сборки: debug (с флагом --debug или просто cargo build) и release (cargo build --release). Они отличаются не только оптимизациями, но и поведением некоторых встроенных проверок.
Например:
- debug: медленный, но с проверками границ массивов, отладочными символами и ассертами.
- release: быстрый, но без лишних проверок — всё ради производительности.
Что если вам нужно, чтобы в debug-версии программа писала в лог каждое действие, а в release — только критические ошибки? Или чтобы тестовый эндпоинт был доступен только локально? Для этого и существует cfg.
Основные инструменты: cfg!() и #[cfg()]
В Rust есть два способа проверить тип сборки:
- Макрос
cfg!()— выполняется во время компиляции, возвращаетbool. Используется внутри функций. - Атрибут
#[cfg()]— полностью включает или исключает блок кода из компиляции.
Оба используют одни и те же ключи. Самый популярный для нашей задачи — debug_assertions. Он активен только в debug-сборке. Есть и обратный — not(debug_assertions) для release.
Пример 1: Простейшая проверка внутри функции
Начнём с макроса cfg!(). Допустим, у нас есть функция расчёта чего-то сложного. В debug мы хотим проверять входные данные:
fn calculate_something(x: i32) -> i32 {
// Проверка только в debug-сборке
if cfg!(debug_assertions) {
println!("[DEBUG] calculate_something вызвана с x = {}", x);
assert!(x > 0, "x должен быть положительным!");
}
// Основная логика
x * 2
}
fn main() {
let result = calculate_something(10);
println!("Результат: {}", result);
}
Что произойдёт?
- При
cargo build(debug) — вы увидите лог и, если x ≤ 0, программа упадёт с ассертом. - При
cargo build --release— строка сcfg!(debug_assertions)превратится вfalse, и компилятор выкинет этот блок как мёртвый код. Никакого оверхеда!
Пример 2: Полное исключение кода с #[cfg()]
Иногда нужно не просто пропустить выполнение, а полностью убрать функцию или модуль из бинарника. Для этого используем атрибут:
// Этот модуль будет скомпилирован ТОЛЬКО в debug-сборке
#[cfg(debug_assertions)]
mod debug_helpers {
pub fn print_state(state: &str) {
println!("[DEBUG] Текущее состояние: {}", state);
}
pub fn validate_data(data: &[u8]) -> bool {
// Дорогая проверка, не нужна в релизе
data.len() < 1000
}
}
// Этот модуль — только для release
#[cfg(not(debug_assertions))]
mod release_helpers {
pub fn print_state(_state: &str) {
// В релизе ничего не делаем — экономим CPU
}
pub fn validate_data(_data: &[u8]) -> bool {
true // Всегда true — доверяем данным
}
}
fn main() {
// В зависимости от сборки будет вызвана нужная версия
debug_helpers::print_state("старт");
release_helpers::print_state("старт"); // Эта строка не скомпилируется в debug!
}
Важный нюанс: если вы используете #[cfg()] на уровне модуля, то в другом модуле не должно быть прямого вызова его функций без условной компиляции. Иначе получите ошибку «function not found». Лучше вынести общий интерфейс, как мы сделали выше.
Пример 3: Реальный сценарий — разное поведение HTTP-сервера
Представьте, что вы пишете веб-сервер на Actix или Axum. В debug вам нужно показывать панель отладки и принимать запросы только с localhost. В release — открыть доступ для всех, но убрать лишние эндпоинты.
Вот как это может выглядеть с cfg:
use axum::{Router, routing::get};
#[cfg(debug_assertions)]
fn add_debug_routes(app: Router) -> Router {
app.route("/debug/state", get(|| async { "Секретная отладочная информация" }))
.route("/debug/heap", get(|| async { "Статистика памяти" }))
}
#[cfg(not(debug_assertions))]
fn add_debug_routes(app: Router) -> Router {
// В релизе ничего не добавляем
app
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(|| async { "Hello, World!" }))
// Добавляем отладочные маршруты только если это debug
.and_then(add_debug_routes);
let addr = if cfg!(debug_assertions) {
"127.0.0.1:3000" // Только локально
} else {
"0.0.0.0:3000" // Для всех
};
println!("Сервер запущен на {}", addr);
axum::Server::bind(&addr.parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}
Обратите внимание: мы используем #[cfg()] для целых функций и cfg!() для выбора адреса. Это пример грамотного разделения ответственности.
Что ещё можно проверять через cfg?
debug_assertions — не единственный ключ. Вот список самых полезных для повседневной работы:
debug_assertions— true в debug, false в release.target_os = "windows"— проверка ОС.target_arch = "x86_64"— архитектура.feature = "my_feature"— кастомные фичи из Cargo.toml.- <