Фича-флаги времени компиляции в Rust: зачем, как и когда используются

Возможность указывать фичи (функционал) для компиляции в 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;

Ссылка на Github.

Когда различные компоненты имеют одинаковую реализацию: вы можете предложить все что угодно без каких-либо недостатков, поскольку компилироваться будет только выбранный функционал.

Компромисс в том, что теперь у вас есть расширенная матрица тестов, которая комбинаторно растет с добавлением каждой новой альтернативы.

В этом примере библиотека позволяет выбрать любой распределитель ресурсов, поскольку они имеют грамотно прописанный интерфейс и для переключения не требуют от вас никакой работы.

//! Распределитель памяти
#[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—  подробнее описано здесь.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Dotan Nahum: Compile Time Feature Flags in Rust: Why, How, and When?

Предыдущая статья4 модели поведения для поддержания психического здоровья на работе
Следующая статьяПлюсы и минусы Deno