const fn может делать намного больше

от автора


Привет, Хабр!

const fn в Rust давно перестал быть просто инструментом для сложения чисел на этапе компиляции. Сегодня это мощный инструмент, который умеет циклы, условия, матчинг, парсинг и даже кусочки бизнес-логики — и всё это ещё до запуска программы.

Факториал

Начнём с простого, но показательного примера — вычисления факториала. Того самого n!, который мы все писали на первом курсе рекурсией. Но здесь важно не само число, а то когда оно считается. Благодаря const fn можно посчитать факториал ещё до запуска программы — и не тратить на это ни байта времени в runtime.

Взглянем на код:

// Классический пример вычисления факториала — но на этапе компиляции! const fn factorial(n: u64) -> u64 {     let mut result = 1;     let mut i = 1;     while i <= n {         result *= i;         i += 1;     }     result }  // Вычисляем значение при компиляции — никакой нагрузки в runtime const FACT_5: u64 = factorial(5);  fn main() {     println!("5! = {}", FACT_5); // Вывод: 120 }

Функция factorial объявлена как const fn, значит, её можно вызывать в контексте, где значения вычисляются на этапе компиляции — например, при инициализации const-переменной. В данном случае factorial(5) превращается в обычное число 120 уже во время сборки проекта, и в итоговом бинарнике будет просто зашит литерал 120.

Если бы мы делали то же самое без const fn, у нас был бы выбор между:

  • использовать литерал (не всегда возможно),

  • вычислять в main() (и тратить ресурсы),

  • или, что ещё хуже, тащить это в lazy_static! или once_cell.

А так — всё просто, чисто и заранее готово.

Вычисление полинома

Теперь перейдём к чему-то более практичному. Допустим, есть математическая модель или таблица предрасчитанных значений, где полином с фиксированными коэффициентами используется для расчётов. Вместо того чтобы каждый раз на runtime перебирать коэффициенты и выполнять арифметику, можно вынести всё это вычисление в стадию компиляции.

// Вычисление полинома: a0 + a1*x + a2*x^2 + ... + an*x^n const fn eval_poly(coeffs: &[i32], x: i32) -> i32 {     let mut result = 0;     let mut pow = 1;     let mut i = 0;     while i < coeffs.len() {         result += coeffs[i] * pow;         pow *= x;         i += 1;     }     result }  const COEFFS: &[i32] = &[3, 2, 5]; // Представим полином: 3 + 2*x + 5*x^2 const VALUE: i32 = eval_poly(COEFFS, 2); // Вычисление: 3 + 2*2 + 5*2^2 = 3 + 4 + 20 = 27  fn main() {     println!("Значение полинома: {}", VALUE); }

Функция eval_poly объявлена как const fn, что позволяет вычислить результат во время компиляции. Она принимает срез коэффициентов и значение переменной x, что особенно удобно, когда набор коэффициентов известен заранее. Внутри функции мы начинаем с result = 0 и pow = 1 (где pow представляет текущую степень x), затем в цикле while для каждого коэффициента прибавляем произведение коэффициента на pow, обновляем pow, умножая его на x, и увеличиваем счётчик.

После завершения цикла функция возвращает сумму всех слагаемых полинома. В примере определяем константу COEFFS как [3, 2, 5] (полином 3+2x+5x²) и вычисляем значение с помощью const VALUE: i32 = eval_poly(COEFFS, 2), что даёт 27. В функции main выводится готовый результат, поскольку все вычисления уже выполнены на этапе компиляции, избавляя runtime от лишних операций.

Парсинг строк

Что делать с неизменными конфигурационными данными, представленными в виде CSV, JSON или INI? Вместо того чтобы парсить их каждый раз во время работы программы, можно сделать это один раз — прямо на этапе компиляции.

CSV-парсер

Представим ситуацию: есть строка с данными, разделёнными запятыми, например Rust,Const,Cats. Вместо того чтобы парсить её в runtime, можно заранее разбить её на элементы, используя const fn.

// Простой CSV-парсер, разбивающий строку на 3 элемента const fn parse_csv(input: &str) -> [&str; 3] {     let bytes = input.as_bytes();     let mut result: [&str; 3] = [""; 3];     let mut start = 0;     let mut idx = 0;     let mut i = 0;     while i <= bytes.len() {         if i == bytes.len() || bytes[i] == b',' {             // Unsafe тут используется потому, что мы знаем, что наш CSV – валидный UTF-8.             result[idx] = unsafe { std::str::from_utf8_unchecked(&bytes[start..i]) };             idx += 1;             start = i + 1;         }         i += 1;     }     result }  const CSV_DATA: &str = "Rust,Const,Cats"; const PARSED_CSV: [&str; 3] = parse_csv(CSV_DATA);  fn main() {     for field in PARSED_CSV.iter() {         println!("Поле: {}", field);     } }

Сначала вызываем input.as_bytes(), чтобы получить массив байтов для прямой проверки символов (запятая представлена как b’,’), затем создаём массив result из трёх пустых строк, куда будем складывать полученные подстроки; далее, используя цикл while, проходим по байтам, и если текущий индекс равен длине массива или встречается запятая, считаем отрезок с start до i отдельным полем, преобразуя его в строку через unsafe { std::str::from_utf8_unchecked(...) } (т.к уверены в корректности UTF-8, что важно для const-контекста), и после каждого разделителя обновляем индекс начала нового поля (start = i + 1) и увеличиваем счётчик idx.

В итоге, всё вычисление происходит во время компиляции, и во время выполнения программы остаётся лишь вывод уже готовых строк.

Конфигурация

Перейдём к другому кейсу — инициализации неизменяемых конфигурационных данных. Допустим, есть фиксированные настройки, например, для подключения к серверу. Вместо того чтобы задавать их где-то в runtime, можно определить структуру конфигурации, инициализировать её с помощью const fn и быть уверенным, что эти данные не изменятся на протяжении всего жизненного цикла приложения.

#[derive(Debug)] struct Config {     host: &'static str,     port: u16,     use_ssl: bool, }  const fn create_config() -> Config {     Config {         host: "localhost",         port: 8080,         use_ssl: false,     } }  const CONFIG: Config = create_config();  fn main() {     println!("Конфигурация: {:?}", CONFIG); }

Сначала создаём структуру Config, которая включает основные настройки (адрес хоста, порт и флаг использования SSL), и благодаря #[derive(Debug)] легко можем выводить её содержимое; затем функция create_config, объявленная как const fn, позволяет компилятору выполнить инициализацию до запуска программы, гарантируя, что объект Config полностью определён на этапе компиляции, а готовая конфигурация инициализируется через вызов create_config(), после чего в main просто выводится полученная настройка.

Мини-интерпретатор

А теперь заставим компилятор Rust вычислять арифметические выражения из строки ещё до запуска программы:

// Мини-интерпретатор для выражений с + и - // Ограничения: только цифры и знаки +/-, без пробелов, без скобок const fn eval_expr(expr: &str) -> i32 {     let bytes = expr.as_bytes();     let mut i = 0;     let mut current_number = 0;     let mut result = 0;     let mut current_sign = 1;     while i < bytes.len() {         let c = bytes[i];         if c >= b'0' && c <= b'9' {             current_number = current_number * 10 + (c - b'0') as i32;         } else {             result += current_sign * current_number;             current_number = 0;             current_sign = if c == b'+' { 1 } else { -1 };         }         i += 1;     }     result + current_sign * current_number }  const EXPR: &str = "12+34-5+6"; const EVAL_RESULT: i32 = eval_expr(EXPR);  fn main() {     println!("Результат выражения {}: {}", EXPR, EVAL_RESULT); }

Сначала строка преобразуется в массив байтов с помощью as_bytes(), что позволяет обрабатывать каждый символ вручную (а в const fn нет готовых методов типа .split() или регулярных выражений). При итерации по байтам, если встречается цифра (от b’0′ до b’9′), накапливаем число в переменной current_number, умножая предыдущее значение на 10 и добавляя новую цифру.

Как только встречается оператор (+ или -), текущее число закрывается: мы прибавляем его к результату result с учётом текущего знака, сбрасываем current_number и переключаем знак для следующего блока. После завершения цикла, если последнее число осталось необработанным, оно добавляется к result с учётом текущего знака, что и гарантирует корректный итоговый результат.

Компилятор по факту превращается в арифметический движок. Можно зашивать готовые результаты в бинарь, а в runtime просто выводить или использовать их. Без парсинга, без eval, без костылей.

Условные конструкции и циклы в const контексте

Можно использовать условные конструкции прямо в const контексте.

#[derive(Debug)] struct SimpleConfig {     host: &'static str,     port: u16, }  // Предполагаем, что JSON имеет фиксированный формат. const fn parse_simple_json(json: &str) -> SimpleConfig {     // Жестко заданные индексы – для примера     let host_start = 10;     let host_end = 19;     let port_start = 30;     let port_end = 34;      let host = unsafe { std::str::from_utf8_unchecked(json.as_bytes().get(host_start..host_end).unwrap()) };      let mut port = 0u16;     let mut i = port_start;     while i < port_end {         let digit = (json.as_bytes()[i] - b'0') as u16;         port = port * 10 + digit;         i += 1;     }      SimpleConfig { host, port } }  const JSON_STR: &str = r#"{"host": "localhost", "port": 8080}"#; const CONFIG_JSON: SimpleConfig = parse_simple_json(JSON_STR);  fn main() {     println!("Парсинг JSON: {:?}", CONFIG_JSON); }

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

Парсинг сложных форматов

Возьмем, к примеру, JSON-конфигурацию:

{"host": "localhost", "port": 8080}

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

#[derive(Debug)] struct SimpleConfig {     host: &'static str,     port: u16, }  // Предполагаем, что JSON имеет фиксированный формат. const fn parse_simple_json(json: &str) -> SimpleConfig {     // Жестко заданные индексы – для примера     let host_start = 10;     let host_end = 19;     let port_start = 30;     let port_end = 34;      let host = unsafe { std::str::from_utf8_unchecked(json.as_bytes().get(host_start..host_end).unwrap()) };      let mut port = 0u16;     let mut i = port_start;     while i < port_end {         let digit = (json.as_bytes()[i] - b'0') as u16;         port = port * 10 + digit;         i += 1;     }      SimpleConfig { host, port } }  const JSON_STR: &str = r#"{"host": "localhost", "port": 8080}"#; const CONFIG_JSON: SimpleConfig = parse_simple_json(JSON_STR);  fn main() {     println!("Парсинг JSON: {:?}", CONFIG_JSON); }

Аналогично, рассмотрим пример парсинга INI-конфигурации:

#[derive(Debug)] struct IniConfig {     host: &'static str,     port: u16, }  const fn parse_ini(input: &str) -> IniConfig {     // Формат фиксирован:     // [server]     // host=localhost     // port=8080     let host_start = 20;     let host_end = 29;     let port_start = 36;     let port_end = 40;      let host = unsafe { std::str::from_utf8_unchecked(input.as_bytes().get(host_start..host_end).unwrap()) };      let mut port = 0u16;     let mut i = port_start;     while i < port_end {         let digit = (input.as_bytes()[i] - b'0') as u16;         port = port * 10 + digit;         i += 1;     }      IniConfig { host, port } }  const INI_STR: &str = "[server]\nhost=localhost\nport=8080"; const CONFIG_INI: IniConfig = parse_ini(INI_STR);  fn main() {     println!("Парсинг INI: {:?}", CONFIG_INI); }

Спасибо, что дочитали до конца! Если есть чем еще поделиться, пишите комментарии и делитесь своими кейсами с const fn.


ссылка на оригинал статьи https://habr.com/ru/articles/892920/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *