Возможность указывать фичи (функционал) для компиляции в Rust может повысить быстродействие кода, его обслуживаемость, безопасность и портируемость.
Ниже я аргументирую, почему вам следует активно использовать эти флаги функционала при потреблении зависимостей и предлагать их другим пользователям библиотеки.
Производительность
Использование фича-флагов в Rust позволяет повысить быстродействие итогового кода. Включая только тот код, который необходим для конкретного приложения, вы сможете избежать лишней нагрузки.
И хотя для удаления мертвого кода существуют оптимизации компилятора, такой прием все равно может привести к повышению скорости и эффективности программ (да и упростить жизнь компилятору).
Размер
На общий размер итогового бинарника также влияет то, какие зависимости вы включаете и как их используете.
Выбор функционала помогает уменьшить этот бинарник, что идет на пользу приложениям, которые необходимо распределять или развертывать в средах с ограниченными ресурсами.
Обслуживаемость
Недавно я столкнулся с разрушительной вышестоящей зависимостью, и мне повезло, что ее код находился под фича-флагом — то есть служил для функционала, который я не использовал.
Ожидая обновления той вышестоящей библиотеки, я удалил этот функционал из своего локального проекта, который снова начал отлично собираться. Это означает, что вы можете повысить обслуживаемость кода Rust, разрешив разработчикам выборочно включать или исключать определенную функциональность.
Безопасность
В статистическом смысле чем больше кода, от которого вы зависите, тем выше шанс возникновения проблем с безопасностью. Чтобы их избежать, желательно иметь возможность выбирать зависимость только от необходимого функционала, и крейты, которые предлагают себя “частями”, помогают этого достичь.
Также всегда существуют способы выбора разных реализаций одной и той же функциональности, исходя из того, насколько вы довольны ее безопасностью. К примеру, вы можете предпочесть реализацию TLS на Rust вместо ее варианта на C, поскольку Rust — более безопасный язык, и некоторые крейты вроде Reqwest предлагают серию бэкендов TLS на выбор.
Портируемость
Поскольку Rust является компилируемым языком, важным аспектом фича-флагов является портируемость кода.
Вы можете выборочно включать либо исключать конкретную функциональность, чтобы сделать код более портируемым среди разных платформ и сред.
А если сравнивать с C и C++?
Языки С и С++ исторически являлись архетипами компилируемого портативного кода, развертываемого на многих платформах и архитектурах ЦПУ.
С++ не имеет встроенной возможности, равнозначной предлагаемой Rust возможности выбирать функционал во время компиляции. Однако в нем есть ряд директив предварительной обработки, которые можно использовать для выборочного включения или исключения определенного кода во время компиляции.
Это может предоставить некоторые из тех же преимуществ, что и фича-флаги в Rust. Хотя все это запутано и сложно разобраться — как для программиста, стремящегося вписаться в существующую базу кода, так и для потребителя, ищущего возможность включать/отключать функционал.
Фича-флаги: составляющие
Чтобы активировать конкретный флаг для конкретного крейта, следует использовать атрибуты default-features = false
и features
в его файле Cargo.toml
.
Например:
[dependencies]
my-crate = { default-features = false, features = ["my-feature"] }
Чтобы включить флаг для определенного фрагмента кода, следует использовать атрибут #[cfg(feature = "my-feature")]
. Например:
#[cfg(feature = "my-feature")]
fn my_function() {
// Код, который добавляется, только когда активирован флаг "my-feature"
}
Чтобы включить флаг для определенного модуля, следует использовать атрибут #[cfg(feature = "my-feature")]
в объявлении mod
. Например:
#[cfg(feature = "my-feature")]
mod my_module {
// Код, который добавляется, только когда активирован флаг "my-feature"
}
Чтобы активировать флаг для определенной структуры или перечисления с помощью derive
, следует использовать атрибут #[cfg_attr(feature = "my-feature", derive(...))]
. Например:
#[cfg_attr(feature = "my-feature", derive(Debug, PartialEq))]
struct MyStruct {
// Поля и методы, которые добавляются, только когда активирован флаг "my-feature"
}
Так включается/отключается поддержка конкретной платформы:
#[cfg(target_os = "linux")]
mod linux_specific_code {
// Здесь идет код Linux...
}
Так включается/отключается конкретная реализация признака:
#[cfg(feature = "special_case")]
impl MyTrait for MyType {
// Здесь идет реализация признака особого случая...
}
Так включается/отключается конкретный тестовый кейс:
#[cfg(feature = "expensive_tests")]
#[test]
fn test_expensive_computation() {
// Здесь идет тест, который выполняет дорогие вычисления...
}
А вот код для включения/отключения конкретного бенчмарка:
#[cfg(feature = "long_benchmarks")]
#[bench]
fn bench_long_running_operation(b: &mut Bencher) {
// Здесь идет банчмарк для длительной операции...
}
Чтобы функционал включался только при установке нескольких флагов, используйте атрибут #[cfg(all(feature1, feature2, ...))]
. Например, вот код для включения my_function()
, только когда установлены флаги my_feature1
и my_feature2
:
#[cfg(all(feature = "my_feature1", feature = "my_feature2"))]
fn my_function() {
// Код для my_function
}
Чтобы функционал включался, только когда установлен один из нескольких флагов, используйте атрибут #[cfg(any(feature1, feature2, ...))]
. Вот пример включения my_function()
, когда установлен флаг my_feature1
или my_feature2
:
#[cfg(any(feature = "my_feature1", feature = "my_feature2"))]
fn my_function() {
// Код для my_function
}
Иллюстрация фича-флагов
Один и тот же модуль, но здесь мы указываем на разные пути к его реализации, а затем с помощью pub use
извлекаем из него функцию для создания.
//! Signal monitor
#[cfg(unix)]
#[path = "unix.rs"]
mod imp;
#[cfg(windows)]
#[path = "windows.rs"]
mod imp;
#[cfg(not(any(windows, unix)))]
#[path = "other.rs"]
mod imp;
pub use self::imp::create_signal_monitor;
Когда различные компоненты имеют одинаковую реализацию: вы можете предложить все что угодно без каких-либо недостатков, поскольку компилироваться будет только выбранный функционал.
Компромисс в том, что теперь у вас есть расширенная матрица тестов, которая комбинаторно растет с добавлением каждой новой альтернативы.
В этом примере библиотека позволяет выбрать любой распределитель ресурсов, поскольку они имеют грамотно прописанный интерфейс и для переключения не требуют от вас никакой работы.
//! Распределитель памяти
#[cfg(feature = "jemalloc")]
#[global_allocator]
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;
#[cfg(feature = "tcmalloc")]
#[global_allocator]
static ALLOC: tcmalloc::TCMalloc = tcmalloc::TCMalloc;
#[cfg(feature = "mimalloc")]
#[global_allocator]
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[cfg(feature = "snmalloc")]
#[global_allocator]
static ALLOC: snmalloc_rs::SnMalloc = snmalloc_rs::SnMalloc;
#[cfg(feature = "rpmalloc")]
#[global_allocator]
static ALLOC: rpmalloc::RpMalloc = rpmalloc::RpMalloc
В этом примере показано, как можно дать пользователям возможность “наслаивать” нужную им функциональность, выбирая, насколько глубоко они хотят уйти:
//! Модули запуска сервисов
pub mod genkey;
#[cfg(feature = "local")]
pub mod local;
#[cfg(feature = "manager")]
pub mod manager;
#[cfg(feature = "server")]
pub mod server;
В примере ниже можно использовать блоки, чтобы “искусственно” охватить целые фрагменты кода, относящиеся к функционалу:
#[cfg(feature = "local-tunnel")]
{
app = app.arg(
Arg::new("FORWARD_ADDR")
.short('f')
.long("forward-addr")
.num_args(1)
.action(ArgAction::Set)
.requires("LOCAL_ADDR")
.value_parser(vparser::parse_address)
.required_if_eq("PROTOCOL", "tunnel")
.help("Forwarding data directly to this address (for tunnel)"),
);
}
В этом примере мы встраиваем пустые реализации, поскольку нет смысла тратиться на вызов функции, если ее тело всегда возвращает простейшее и пустое значение (Ok()
).
#[cfg(all(not(windows), not(unix)))]
#[inline]
fn set_common_sockopt_after_connect_sys(_: &tokio::net::TcpStream, _: &ConnectOpts) -> io::Result<()> {
Ok(())
}
И последнее: что за компромисс?
Если этот функционал настолько мощный и превзошел множество примитивных способов C/C++ по выполнению условной компиляции кода, почему бы не использовать его всегда и везде? Вот несколько моментов, которые нужно учесть.
- Вполне возможно использование чересчур большого числа фич. В воображаемом крайнем случае представьте, что у вас есть фича-флаг для каждого модуля и функции программы. Это бы потребовало от потребителей решать сложную загадку в стремлении понять, как лучше скомпоновать библиотеку из ее разрозненных возможностей. В этом опасность флагов. Вам нужно быть скромным относительно количества предлагаемых флагов функционала, чтобы снизить когнитивную нагрузку и оставить только тот функционал, который людям действительно будет важно добавлять и удалять.
- Тестирование в случае использования фича-флагов тоже добавляет сложностей. Вы никогда не будете знать, какую комбинацию функций выберет пользователь, а каждая из таких комбинаций выбирает свой набор фрагментов кода, которые должны плавно взаимодействовать как при компиляции (то есть успешно компилироваться), так и в логике (не вносить ошибок). Так что вам придется тестировать все возможные комбинации функций и создавать супермножество (powerset) этих комбинаций.
- Это можно автоматизировать с помощью
xtaskops::powerset
— подробнее описано здесь.
Читайте также:
- Тип Result в Rust
- Как вызвать из C# генерируемую на Rust библиотеку
- Современное хранилище работает быстро - это API мешают делу
Читайте нас в Telegram, VK и Дзен
Перевод статьи Dotan Nahum: Compile Time Feature Flags in Rust: Why, How, and When?