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

Когда вы пишете вот такую строчку:

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% при первом пополнении.

Воспользоваться

Комментарии (3)


  1. nikon_y
    20.07.2026 08:18

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


    1. Dhwtj
      20.07.2026 08:18

      которые в обычной программе появились бы только во время работы

      Они и есть runtime в каком-то смысле


    1. okhsunrog
      20.07.2026 08:18

      А вы точно не нейросеть? Посмотрел комментарии остальные у вас в профиле, что-то подозрительно выглядит.