
Привет, Хабр!
Когда вы пишете вот такую строчку:
const N: u64 = fib(50);
внутри компилятора происходит странное. rustc не генерирует машинный код для fib, не зовёт процессор и не подставляет ответ откуда-то сбоку. Он берёт вашу функцию, разворачивает её в промежуточное представление и исполняет шаг за шагом прямо у себя внутри, на маленькой виртуальной машине.
И вот вопрос, на который мало кто может ответить с ходу: а кто конкретно это исполняет? Где живёт тот интерпретатор? Почему 255 + 1 в const падает с ошибкой компиляции, а в рантайме просто паникует? Почему можно посчитать таблицу из тысячи элементов циклом, но нельзя написать if a < b для дженерика? И почему 0.0 / 0.0 в const официально разрешили вести себя недетерминированно?
Погнали разбираться.
Сначала про контекст, иначе всё развалится
Половина путаницы вокруг const fn растёт из непонимания, что такое const-контекст.
В Rust есть места, где значение обязано быть известно компилятору. Их немного, и вот они все:
const SIZE: usize = 4 * 8; let buf = [0u8; SIZE]; // длина массива enum Flags { A = 1 << 0, B = 1 << 1 } // дискриминант варианта enum struct Matrix<const N: usize>; // аргумент const-дженерика static TABLE: [u32; 256] = build(); // инициализатор const и static let x = const { SIZE * 2 }; // инлайн const-блок, с 1.79
Это и есть const-контекст. Внутри него разрешены не любые выражения, а только те, что компилятор гарантированно посчитает сам.
Пометка const на функции ничего не меняет для обычных вызовов:
const fn square(x: u64) -> u64 { x * x } const NINE: u64 = square(3); // посчитано на этапе компиляции fn main() { let n = read_number(); // рантайм-значение println!("{}", square(n)); // та же функция, обычный рантайм-вызов }
Одна и та же square. В первом случае её исполняет компилятор, во втором она компилируется в машинный код и работает как все остальные функции. const это не «функция работает на этапе компиляции», а «функцию можно позвать из const-контекста, и за это она соглашается на ограничения».
Кто это исполняет: Miri, вшитый в компилятор
Внутри rustc живёт интерпретатор.
Не оптимизатор, не генератор кода, а именно интерпретатор: виртуальная машина, которая выполняет промежуточное представление среднего уровня (MIR, Mid-level Intermediate Representation) напрямую, ничего не компилируя. Когда компилятору нужна константа, он не идёт в процессор, а скармливает MIR этой машине и ждёт результат.
Это тот же самый движок, что и Miri, инструмент для отлова неопределённого поведения (UB, undefined behavior). Народ думает, что Miri и вычисление констант это разные штуки. Нет. У них общая виртуальная машина в rustc_const_eval, а отличия в поведении вынесены в трейт Machine, который для каждого режима реализован по-своему:
// сильно упрощённо, изнутри rustc pub trait Machine<'tcx> { type MemoryKind; // ... десятки методов: как звать функции, как трогать память, // что считать ошибкой, разрешён ли доступ к ОС, и так далее }
Const-evaluator это, грубо говоря, Miri с урезанными правами. Ему запрещено дёргать настоящие системные вызовы, открывать файлы, звать функции из C.
Биография у движка занятная. Miri начинался в 2015 году как студенческий исследовательский проект @solson. В 2016-м к нему подключился @oli-obk и довёл до состояния, в котором интерпретатор можно встроить в компилятор на роль const-evaluator, заменив старый движок, ходивший прямо по абстрактному синтаксическому дереву (AST). С тех пор разработчики Miri и авторы движка const-eval это во многом одни и те же люди.
Снаружи всё это дёргается через семейство запросов tcx.const_eval_* и работает лениво. Пока константа никому не нужна, её не считают. Когда понадобилась, движок входит в цикл и крутит метод step (он лежит в rustc_const_eval/src/interpret/step.rs), выполняя MIR-инструкции одну за другой, пока не дойдёт до результата или не упрётся в операцию, которую не умеет. Тогда всё останавливается с ошибкой компиляции. Результат кэшируется, так что дважды одно и то же не пересчитывается.
У константы внутри компилятора два представления. Для системы типов (например, чтобы сравнить два аргумента const-дженерика на равенство) результат гонят в valtree, структурированное дерево значений, которое можно осмысленно разобрать. А для генерации кода тот же результат живёт как ConstValue, низкоуровневый блоб в памяти. Запросы так и разведены: const_eval_global_id_for_typeck отдаёт valtree, const_eval_global_id отдаёт ConstValue.
Заглянем в MIR
Чтобы было видно, что именно исполняет интерпретатор, посмотрим на MIR. Возьмём простейшее:
const fn add(a: u32, b: u32) -> u32 { a + b }
В MIR это разворачивается примерно так (упрощённо, точный вид зависит от версии rustc):
fn add(_1: u32, _2: u32) -> u32 { let mut _0: u32; // возвращаемое значение let mut _3: (u32, bool); // (результат, флаг переполнения) bb0: { _3 = AddWithOverflow(copy _1, copy _2); assert(!move (_3.1: bool), "attempt to add with overflow") -> bb1; } bb1: { _0 = move (_3.0: u32); return; } }
Видно главное. MIR это граф из базовых блоков (bb0, bb1), переходы между которыми явные. Сложение это не просто +, а вычисление пары (результат, было_ли_переполнение) и явная проверка assert. Интерпретатор просто идёт по этим блокам: посчитал rvalue, проверил assert, записал в локальную переменную, перешёл дальше.
И вот тут вылезает первое отличие от рантайма. Если переполнение случится при вычислении константы, assert не паникнет в рантайме, а станет ошибкой компиляции:
const Y: u8 = 200 + 100; // error: this arithmetic operation will overflow
В рантайме то же сложение в debug-сборке паникнет, а в release тихо завернётся по модулю. В const-контексте у вас нет рантайма, в котором можно паниковать, поэтому компилятор обязан поймать это здесь и сейчас.
Память, которой нет
Чтобы ловить UB, интерпретатор не имеет права представлять память так, как она лежит в железе. Адрес в его модели это не просто число.
Результатлом вычисления будетConstValue, и у него несколько форм:
// упрощённая суть, не дословно enum ConstValue { Scalar(Scalar), // одно скалярное значение Slice { .. }, // байтовый срез или строка Indirect { .. }, // ссылка на виртуальную аллокацию } enum Scalar { Int(..), // сырое целое Ptr(AllocId, Provenance), // указатель: в какую аллокацию смотрит }
Обратите внимание на Ptr. Указатель тащит с собой происхождение (provenance): информацию о том, в какую аллокацию он показывает. Память здесь не плоский массив байтов, адресуемый числами, а набор отдельных аллокаций со своей структурой.
Из-за этого интерпретатор видит то, что в рантайме прошло бы незаметно. Вышли за границу куска памяти? Это не «чтение мусора», а ошибка компиляции:
const A: [i32; 3] = [1, 2, 3]; const X: i32 = A[5]; // error[E0080]: evaluation of constant value failed // index out of bounds: the length is 3 but the index is 5
Попытались превратить указатель в число и обратно? В const это всегда UB, ещё с тех пор как движок научился transmute и объединениям. Причина в модели: у указателя есть происхождение, у целого числа его нет, обратное преобразование теряет эту информацию и ломает абстрактную машину. Поэтому операции с сырыми указателями в const жёстко ограничены.
У всего этого есть имя, const-безопасность (const safety). Безопасный код в const-контексте обязан гарантированно не упереться в ошибку вычисления. Паниковать ему можно, как и обычному коду, а вот наткнуться на операцию, которую движок физически не умеет посчитать, нет. Система типов отсекает такое заранее.
&mut в const и промоушен констант
Долго в const почти нельзя было ничего менять по ссылке. Никаких &mut внутри вычисления. И ограничение это не техническое, а смысловое.
Константа обязана оставаться константой: её значение и её смысл как образца при сопоставлении должны быть одинаковыми на всём протяжении работы программы. Узел развязывали постепенно, и в Rust 1.83 наконец застабилизировали &mut, mut, &Cell и const Cell в const-контексте. Теперь так можно:
const fn doubled() -> [i32; 3] { let mut a = [1, 2, 3]; let r = &mut a[0]; // ок с 1.83: меняем по &mut прямо в вычислении *r *= 2; a } const RESULT: [i32; 3] = doubled(); // [2, 2, 3]
Но с важной оговоркой: &mut это рабочий инструмент внутри вычисления. Протащить мутабельную ссылку в финальное значение константы нельзя:
const BAD: &mut i32 = &mut 4; // error[E0764]: mutable references are not allowed // in the final value of constants
Логика та же, что и со статиками: ссылаться на static в const разрешили, а читать значение мутабельного статика по-прежнему нельзя, иначе константа зависела бы от изменчивого состояния.
Заодно стоит знать про близкий механизм, промоушен констант. Некоторые выражения за & компилятор втихаря превращает в константы и выдаёт им 'static:
let x: &'static i32 = &42; // &42 промоутится в анонимную константу, поэтому ссылка живёт 'static
Это работает ровно потому, что под капотом есть const-evaluator, который умеет посчитать 42 на этапе компиляции и положить в статическую память.
Что застабилизировали
История const fn это десятилетие медленного расширения. По вехам, чтобы был виден масштаб.
Стартовало в 1.31 (2018), и умела она тогда совсем мало: базовая арифметика, ноль ветвлений и циклов. К 1.46 (2020) завезли управляющие конструкции, и вот после этого в const стало можно писать настоящие алгоритмы:
const fn build_squares() -> [u32; 16] { let mut t = [0u32; 16]; let mut i = 0; while i < 16 { // циклы в const, с 1.46 t[i] = (i * i) as u32; i += 1; } t } static SQUARES: [u32; 16] = build_squares(); // посчитано на этапе компиляции
Параллельно подъехали const-дженерики (min_const_generics в 1.51), а в 1.57 разрешили panic! в const, что открыло дорогу проверкам инвариантов прямо при компиляции:
const fn checked_cap(n: usize) -> usize { assert!(n.is_power_of_two(), "ёмкость должна быть степенью двойки"); n } const CAP: usize = checked_cap(64); // ок // const BAD: usize = checked_cap(63); // ошибка компиляции, не рантайма
Дальше темп только рос. 1.79 (2024) дал инлайн-блоки const { ... }: можно явно войти в const-контекст посреди выражения, без отдельного объявления, причём с выводом типа и доступом к дженерикам из области видимости:
fn check<T>() { const { assert!(size_of::<T>() > 0, "ZST сюда нельзя") }; // ... }
1.82 (октябрь 2024) разрешил арифметику с плавающей точкой, к ней вернёмся через абзац. 1.83 (ноябрь 2024) принёс мутабельные ссылки. И всё это время стандартная библиотека планомерно метила свои функции как const: методы срезов, строк, целочисленных типов, Option, Duration и так далее. К 1.85 (февраль 2025) подоспела редакция 2024.
Итог: к лету 2026 года на стабильном Rust в const можно делать много чего. Считать таблицы подстановок, валидировать конфиги, парсить байты, собирать хитрые константы циклами, временно меняя данные по &mut. Если код неполиморфный и не лезет в кучу, потолок высокий.
Главная проблема
Звать методы трейтов в const по дженерикам на стабильном Rust нельзя до сих пор.
const fn min<T: Ord>(a: T, b: T) -> T { if a < b { a } else { b } } // error[E0015]: cannot call non-const operator in constant functions
a < b это вызов метода трейта PartialOrd, а вызвать метод трейта по обобщённому параметру в const компилятор не даёт. По той же причине нет const-версии обобщённого ==, нет Option::map в const, нет много чего. Поэтому compile-time логику люди до сих пор уносят в build.rs или просто не пишут.
На nightly это уже работает, под фичей const_trait_impl, но синтаксис до сих пор переписывают, и именно эта возня держит фичу в экспериментальном статусе. Сейчас рабочий пример выглядит примерно так:
#![feature(const_trait_impl)] #[const_trait] trait Min { fn min(self, other: Self) -> Self; } impl const Min for u32 { fn min(self, other: Self) -> Self { if self < other { self } else { other } // примитивный <, в const ок } } const fn pick<T: [const] Min>(a: T, b: T) -> T { a.min(b) } const M: u32 = pick(3, 7); // 3
Трейт помечается как готовый к const (#[const_trait]). Реализация помечается impl const. А граница [const] Min означает «реализация обязана быть const, когда мы в const-контексте». Условную границу раньше писали ~const Trait, недавно заменили на [const] Trait, обсуждается и форма с ключевым словом const trait.
Компилятору пришлось завести понятие условий константности (const_conditions) и отдельный вид предикатов (HostEffectPredicate), которые ведут себя по-разному в зависимости от того, зовут функцию в рантайме или на этапе компиляции. Граница T: const Tr означает «всегда const» и проверяется как обычный предикат. Граница T: [const] Tr означает «const, только в const-контексте» и проверяется отдельно, через эти самые const_conditions.
Если попытаться скормить функции с const-границей тип без const-реализации, получите ошибку:
fn needs_const(_: impl const Min) {} // needs_const(value_of_some_non_const_type) // error[E0277]: the trait bound `T: const Min` is not satisfied
Сама фича на nightly уже зрелая, часть стандартной библиотеки под неё переписана. Чего нет, так это принятого RFC на синтаксис и семантику. В планах Rust на 2026 год const-трейты стоят целью: дописать RFC, закрыть оставшиеся вопросы в компиляторе, вынести на публичное тестирование. Так что шанс увидеть их в стейбле оч приличный.
Чего нельзя и почему: NaN, куча, ввод-вывод
Начнём с истории, ради которой команде пришлось пойти на принципиальную уступку: числа с плавающей точкой.
Долго в const fn их можно было только копировать, но не считать. Держал это детерминизм. У const fn негласный контракт: посчитанная при компиляции, она даёт тот же результат, что и в рантайме. А с плавающей точкой это неправда. Стандарт IEEE 754 почти ничего не гарантирует про биты значения «не-число» (NaN), и одна и та же операция на одних входах может выдать разный NaN. Например, a b и b a, если оба NaN, на разном железе вполне дадут разные битовые представления. Выходит, 0.0 / 0.0 недетерминирован, при том что const C: f32 = 0.0 / 0.0; работает на стабильном Rust ещё с версии 1.0.
Решили это с помощью того, что Rust официально признал, что const fn может вести себя недетерминированно в рантайме и выдавать на этапе компиляции результат, зависящий от платформы, версии и флагов. Касается это битов NaN. После этого в 1.82 арифметику с плавающей точкой в const fn разблокировали:
const fn mix(a: f64, b: f64) -> f64 { a * b + 1.0 // с 1.82 это ок }
Код не должен полагаться на то, что const fn всегда выдаёт строго одинаковый результат. Так что считать f64 в const теперь можно, но если в вычислении замешан NaN, его точные биты вам никто не обещает. И длины массивов от хитрых float-вычислений лучше не делать.
Дальше трансцендентные функции. Они в const не работают, но не из-за глубокого запрета, а просто потому, что в стандартной библиотеке пока не помечены как const fn:
const LOG2PI: f64 = (2.0 * std::f64::consts::PI).ln(); // error[E0015]: cannot call non-const fn `f64::ln` in constants
Это вопрос времени.
И главное, чего нет совсем: куча. Выделить память на этапе компиляции нельзя, потому что за кучей стоит аллокатор рантайма, а его в компиляторе нет:
const fn make() -> Box<i32> { Box::new(5) } // error[E0015]: cannot call non-const fn `Box::<i32>::new` in constant functions
По той же причине нет Vec, String, динамической диспетчеризации через dyn Trait и нет async. На nightly, правда, уже есть низкоуровневые штучки выделения памяти в const (const_allocate, const_deallocate), но это не готовый Vec. Кучу в const, кстати, во многом блокируют именно const-трейты: без них нормальный Vec не сделать.
Что в итоге
Если подытожить, картина складывается такая.
За десять лет const fn заметно повзрослела. На стабильном Rust к 2026 году внутри константных вычислений доступны управляющие конструкции, мутабельные ссылки, inline-блоки const {}, операции с плавающей точкой и целый набор const-методов в стандартной библиотеке — внешне почти обычный код, только исполняемый на этапе компиляции.
Главные пробелы по‑прежнему сводятся к двум словам: полиморфизм и куча. Методы трейтов в обобщённом контексте остаются за пределами стабильного канала; формулировку «const-трейты уже работают» стоит читать с уточнением «на nightly, со всеми оговорками, RFC ещё не закрыт». Выделение памяти в куче (Box, Vec и прочее) в const-контексте отсутствует фундаментально — по техническим причинам, и в ближайшей перспективе этого не изменится.
Для заранее известных данных и чистой арифметики инструмент вышел мощный. Как только возникает желание поднять dyn Trait или аллокации во время компиляции — мы пока вне игры. Остаётся следить за RFC, при необходимости сидеть на nightly и трезво оценивать границы применимого.
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

nikon_y
Раньше думал, что const fn просто считает значение заранее, а тут оказывается внутри целый процесс. Особенно интересно, что компилятор сам выполняет код через свой интерпретатор и сразу ловит ошибки, которые в обычной программе появились бы только во время работы. Rust, конечно, сложный язык, но такие вещи хорошо показывают, почему он считается безопасным.
Dhwtj
Они и есть runtime в каком-то смысле
okhsunrog
А вы точно не нейросеть? Посмотрел комментарии остальные у вас в профиле, что-то подозрительно выглядит.