Что поменялось в компиляторе Rust за 2026, пока вы писали код

Пока я готовил эту статью, rustc 27 минут жевал один крейт с процедурными макросами, упёрся в лимит памяти и умер от OOM-killer. Крейт обычный, diesel_derives, компилятор тоже обычный, свежий nightly. Я всего лишь попросил его проверять заимствования старой Datalog-версией Polonius, которая до сих пор лежит в компиляторе за флагом.
Полез я туда неслучайно. В августе на nightly по умолчанию включили новый борроу-чекер, через пару недель новый трейт-солвер, а ещё через 3 дня стабилизировали тип never, который провисел в нестабильных 10 лет. Три большие штуки, которые годами жили в долгих планах, сменились почти одновременно, и заметили это в основном те, у кого nightly крутится в CI.
В stable за год тоже кое-что поменялось. С весны в match можно писать if let прямо в гарде, а с июля наши функции в бинаре называются по-другому. Если вы обновляетесь через rustup update и changelog не читаете, ниче страшного, так живут почти все, но пару сюрпризов вы могли пропустить.
Борроу-чекер научился смотреть на ветки
Вот проблемная функция, об которую многие уже спотыкались:
use std::collections::HashMap;
fn get_default<'r, K: std::hash::Hash + Eq + Clone, V: Default>(
map: &'r mut HashMap<K, V>,
key: K,
) -> &'r mut V {
match map.get_mut(&key) {
Some(value) => value,
None => {
map.insert(key.clone(), V::default());
map.get_mut(&key).unwrap()
}
}
}Глазами видно, что всё ок: ключ нашёлся, отдаём ссылку, не нашёлся, вставляем и отдаём новую. Stable 1.98.1 с этим не согласен и выдаёт две E0499: cannot borrow *map as mutable more than once at a time, на insert и на второй get_mut. Nightly компилирует молча, и тестовый main печатает {"a": 1}. Добавляем -Zpolonius=off, и обе ошибки возвращаются. Значит, всё дело в новом чекере, который в начале августа включили на nightly по умолчанию. Зовут его Polonius Alpha.
Старый чекер, NLL, рассуждает так: в ветке Some ссылка уходит наружу, значит, заимствование из map.get_mut(&key) обязано прожить весь 'r, то есть пережить саму функцию. Это требование NLL вешает на функцию целиком и не разбирается, по какой ветке мы идём. В None отдавать нечего, но для NLL заимствование там всё равно живо, и insert в него врезается. Alpha смотрит, в каких точках программы требование «прожить до 'r» реально действует. В None ссылка из get_mut не попадает, значит, и требования там нет. В блоге Rust это называют flow-sensitive проверкой отношений между лайфтаймами, а по-простому чекер научился смотреть на ветки.
До этого жили на костылях. Для HashMap есть entry(), но сам паттерн шире мап: кэши, деревья, парсеры с откатом. В остальных случаях искали дважды через contains_key и платили за лишнее хеширование, либо тащили крейт polonius-the-crab (название топ). В его описании на crates.io прямо написано, что это инструменты для Polonius-паттернов в стабильном Rust, то есть костыль под фичу, которой в stable ещё нет. Только и Alpha пропускает не всё, и отсюда растёт история с OOM.
27 минут и OOM-killer
У Polonius есть ранняя версия, написанная на Datalog. Она пропускает часть кода, который Alpha отвергает (бывает и наоборот, в блоге это отдельно оговаривают). Классический пример оттуда же, курсор по связному списку:
struct X { next: Option<Box<X>> }
fn conditional() {
let mut b = Some(Box::new(X { next: None }));
let mut p = &mut b;
while let Some(now) = p {
if true {
p = &mut now.next;
}
}
}На Alpha это всё ещё E0499 плюс E0503. Datalog-версия живёт за флагом -Zpolonius=legacy, и с ним код проходит. Логичный вопрос, почему по умолчанию включили не её (спойлер: там всё плохо).
Взял связку из diesel 2.3.13 (без фич по умолчанию, с sqlite и 32-column-tables) и axum 0.8.9 и прогнал полный cargo check с --timings. На обычном nightly весь граф зависимостей проверяется за 32 секунды. С legacy syn 2.0 собирался 17,3 секунды вместо 4,8, а darling_core 24 секунды вместо 2,5. Потом очередь дошла до diesel_derives, и на нём всё закончилось: через 27 минут rustc упёрся в лимит памяти для процессов сборки в моём контейнере, это около 6 ГБ, и OOM-killer его прибил. С Alpha тот же крейт собирается за 4,5 секунды. Так что Datalog-версию держат за флагом не из вредности, лол.
Сама Alpha, по моим замерам, почти ничего не стоит. cargo check одного diesel на обычном nightly занял 13,0 секунды по медиане из трёх прогонов, с -Zpolonius=off 12,6. На axum вышло 3,6 против 3,9, то есть с Polonius даже чуть быстрее. По блогу Alpha делает не меньше работы, чем NLL, да и десятые доли секунды на трёх прогонах ничего не значат,.
Команда пишет, что целится стабилизировать Alpha до конца года, а активно развивать Polonius дальше пока не собирается. Посмотрим, как выйдет.
Почему diesel теперь проверяется в полтора раза дольше
В той же серии замеров я гонял ещё один флаг. Каждая конфигурация по три прогона, пересобирается только сам крейт:
Конфигурация | diesel | axum |
|---|---|---|
nightly по умолчанию | 13,00 с | 3,61 с |
| 8,62 с | 2,52 с |
| 12,62 с | 3,88 с |
оба флага | 8,67 с | 2,67 с |
Polonius тут ни при чём, это видно по третьей строке. Всю разницу даёт -Znext-solver=coherence: с ним diesel проверяется за 8,6 секунды, без него за 13,0, то есть с новым солвером на 51% дольше. У axum плюс 43%. Разброс между прогонами меньше секунды, на шум это не спишешь.
Флаг выключает то, что 21 августа включили на nightly по умолчанию, новый трейт-солвер. Солвер — это кусок компилятора, который доказывает where-условия и раскрывает ассоциированные типы. Пишете T: Iterator<Item = u8> или зовёте метод трейта, и работает он. Новый писали почти 4 года, а в блоге его называют самым крупным изменением компилятора с первого релиза (да, серьёзно, так и написано). В stable он немного есть уже с 1.84 и отвечает там за coherence, то есть за проверку, что две реализации трейта не пересекаются. На nightly он теперь делает вообще всё.
Зачем терпеть лишние 4,4 секунды на diesel? Затем, что старый солвер не пропускал вполне нормальный код. Вот рекурсия с impl Trait:
fn fib(n: u64) -> impl Sized + std::fmt::Debug {
if n < 2 { n } else { fib(n - 1) + fib(n - 2) }
}Stable и beta отвечают error[E0369]: cannot add impl Debug to impl Debug. Функция сама решает, что у неё внутри u64, но на рекурсивном вызове смотрит на собственный тип как на чёрный ящик, у которого сложения нет. Новый солвер внутри определяющей функции видит скрытый тип, находит сложение для u64, и fib(20) на nightly печатает 6765. На порядок веток ему тоже пофиг, рекурсию можно поставить первой.
Второй пример посерьёзнее, он про ассоциированные типы под for<'a>:
trait Codec { type Borrowed<'a>; }
impl Codec for String { type Borrowed<'a> = &'a str; }
trait Handler {}
impl<C: Codec> Handler for (C, for<'a> fn(C::Borrowed<'a>) -> usize) {}
fn register<H: Handler>(_: H) { println!("зарегистрировал"); }
fn main() {
let f: for<'a> fn(&'a str) -> usize = str::len;
register((String::new(), f));
}Stable пишет E0277: the trait bound (String, for<'a> fn(&'a str) -> usize): Handler is not satisfied. Чтобы выбрать реализацию, солверу надо понять, что C::Borrowed<'a> для String это &'a str. Но 'a связан for<'a>, а такие типы старая реализация толком раскрывать не умела. Новая умеет, и nightly печатает «зарегистрировал».
С -Znext-solver=coherence компилятор ведёт себя как stable: новый солвер работает только для coherence, старый для всего остального, и оба примера снова падают с теми же ошибками. Если nightly у вас что-то сломал, это первый флаг, который стоит попробовать.
Про скорость пишут, что у почти всех крейтов из топ-20k она одинаковая, а над выбросами команда работает. Diesel и axum я выбрал нарочно, это самые трейт-тяжёлые крейты, какие пришли в голову, так что мой случай ближе к худшему. Только худший случай кто-то собирает каждый день. Переход на новый солвер и удаление старого откроют дорогу type alias impl Trait и return type notation и позволят закрыть оставшиеся дыры в soundness системы типов. Стабилизировать сам солвер планируют в ближайшие месяцы, но сроки у таких штук любят уезжать.
Пока на nightly шла вся эта движуха, в stable поменялись две вещи.
if let в гарде и дедлок из воздуха
С 1.95, которая вышла 16 апреля, в гарде match можно писать if let. Трекинг-задачу на это открыли в мае 2018-го, так что ждали почти 8 лет, хотя let-цепочки в if и while стабилизировали ещё в 1.88. Гарды где-то потерялись по дороге.
Условно надо сматчить вариант, внутри что-то распарсить, а если не распарсилось, упасть в общий _. Раньше либо вкладывали второй match и дублировали там ветку «не понял», либо ставили в гард matches! и парсили то же самое второй раз, уже в теле. Теперь так:
enum Cmd { Set(String), Del(String), Nop }
fn handle(c: &Cmd) -> String {
match c {
Cmd::Set(s) if let Some((k, v)) = s.split_once('=') => format!("set {k} -> {v}"),
Cmd::Del(k) if let Ok(n) = k.parse::<u32>() => format!("del #{n}"),
_ => "не понял".to_string(),
}
}Если паттерн в гарде не совпал, match идёт дальше по армам, как с обычным if. Set("мусор") и Del("x") получают «не понял», а k, v и n видны прямо в теле арма. На 1.94 тот же файл падает с error[E0658]: if let guards are experimental, и компилятор ещё советует переписать через matches!.
Еще я нашел, что в гарде работают и let-цепочки вида Some(t) if let Ok(n) = t.parse::<i32>() && n > 0 =>. В обычном if на edition 2021 такая цепочка даёт error: let chains are only allowed in Rust 2024 or later, а в гарде match на той же 2021 компилируется и работает. Проверял на 1.98.1.
А дедлок растёт вот откуда: всё, что создалось справа от = в гарде, живёт до конца тела арма. Проще всего это увидеть на типе, который орёт в Drop:
struct Loud(&'static str);
impl Drop for Loud { fn drop(&mut self) { println!(" drop {}", self.0); } }
impl Loud { fn get(&self) -> Option<&str> { Some(self.0) } }
fn main() {
let x = Some(1);
match x {
Some(n) if let Some(s) = Loud("guard-temp").get() => println!(" тело арма: n={n}, s={s}"),
_ => println!(" fallback"),
}
println!("после match");
match x {
Some(_) if let None = Loud("guard-temp-2").get() => println!(" тело"),
_ => println!(" fallback"),
}
}Вывод расставляет всё по местам:
тело арма: n=1, s=guard-temp
drop guard-temp
после match
drop guard-temp-2
fallbackСработал гард, и временное значение умирает после тела. Не сработал, и оно умирает до перехода к следующему арму. Логика та же, что у обычного if let в edition 2024, и пока вместо Loud там что-то безобидное, всё норм. Теперь подставим MutexGuard:
fn touch(cache: &Mutex<HashMap<u32, u32>>, key: Option<u32>) {
match key {
Some(k) if let Some(n) = cache.lock().unwrap().get(&k).copied() => {
cache.lock().unwrap().insert(k, n + 1);
}
_ => {}
}
}copied() вроде как отвязывает n от мапы, и кажется, что лок уже можно отпускать. Гард от cache.lock() живёт до закрывающей скобки арма, а второй lock() в теле его ждёт. Я гонял это под timeout 3, и процесс каждый раз снимался по таймауту с кодом 124. По документации повторный lock() в том же потоке может запаниковать или зависнуть, у меня на Linux он завис. С RefCell та же песня, только вместо зависания прилетает паника RefCell already borrowed.
Лечится фигурными скобками вокруг выражения:
Some(k) if let Some(n) = { cache.lock().unwrap().get(&k).copied() } => {В edition 2024 временные значения из хвостового выражения блока умирают на его закрывающей скобке, поэтому лок отпускается ещё до тела. А вот на edition 2021 этот трюк не работает и программа снова виснет, я проверил на обеих. Самый надёжный вариант: вынести поиск в let перед match.
Ещё гарды не участвуют в проверке полноты match, и вот такой код не соберётся, хотя Ok и Err вместе покрывают весь Result:
fn f(x: Option<Result<u8, ()>>) -> u8 {
match x {
Some(r) if let Ok(v) = r => v,
Some(r) if let Err(()) = r => 0,
None => 1,
}
}Компилятор пишет error[E0004]: non-exhaustive patterns: Some(_) not covered и отдельно поясняет: match arms with guards don't count towards exhaustivity. С обычными гардами было так же, а в анонсе 1.95 это прописали. Нужна полнота, раскладывайте паттерн прямо в арме: Some(Ok(v)) и Some(Err(())).
И последнее: забирать значения из арма в гарде нельзя.
match v {
Some(list) if let Some(first) = list.into_iter().next() => println!("{first}"),
_ => {}
}Получаем error[E0507]: cannot move out of list in pattern guard. Гард может не сработать, тогда match пойдёт к следующему арму, а тому v нужен целым. Поэтому в гарде переменные доступны только по ссылке. Компилятор предлагает .clone(), но обычно хватает матчить по ссылке, match &v: тогда list становится ссылкой, и into_iter() ходит по ссылкам, ничего не забирая.
С гардами всё видно прямо в коде. Второе изменение в коде не видно вообще, оно живёт в бинаре.
Бэктрейс наконец знает, какой это был T
В 1.97, которая вышла 9 июля, rustc сменил схему манглинга символов по умолчанию на v0. Манглинг превращает путь вроде mangle::parse::<u32> в имя, которое переварят линкер и отладчик. До июля Rust брал схему из C++ (Itanium ABI), а в ней дженерик-параметры в имя не попадали вообще, вместо них в конце висел хеш. Сама v0 не новая, флагом её можно было включить ещё с 1.59, но по умолчанию включили только сейчас.
Проверяем на дженерик-функции с двумя инстанцированиями:
#[inline(never)]
fn parse<T: std::str::FromStr>(s: &str) -> Option<T> { s.parse().ok() }
fn main() {
let a: Option<u32> = parse(std::hint::black_box("42"));
let b: Option<f64> = parse(std::hint::black_box("2.5"));
println!("{a:?} {b:?}");
}Собираю на 1.96 и на 1.98.1 с -O -Ccodegen-units=1 и смотрю nm. На 1.96 (адреса убрал):
_ZN6mangle5parse17h76a3314ff097a9ffE
_ZN6mangle5parse17hb66e1907962575bdEnm -C превращает оба символа в одинаковое mangle::parse. Две разные функции с одним именем, отличаются только хешем, и какая из них для u32 непонятно. На 1.98.1 уже нормально:
_RINvCs6rp7NsxS3AF_6mangle5parsedEB2_ mangle::parse::<f64>
_RINvCs6rp7NsxS3AF_6mangle5parsemEB2_ mangle::parse::<u32>Символ v0 читается слева направо. R значит v0, I…E обозначает инстанцирование дженерика, Nv это имя из пространства значений, то есть функция. Cs6rp7NsxS3AF6mangle это крейт mangle с дизамбигуатором, а 5parse это имя с длиной впереди. Дальше идёт по букве на примитивный тип: d это f64, m это u32. Хвост B2_ обозначает крейт, где инстанцировали дженерик, здесь снова mangle. Записан он обратной ссылкой на уже встречавшийся Cs…_6mangle, чтобы не повторять длинный кусок.
В жизни это вылезает в бэктрейсе паники. Вот функция, которая падает на длинных значениях:
#[inline(never)]
fn check<T: std::fmt::Debug>(x: T) {
if format!("{x:?}").len() > 3 { panic!("слишком длинно: {x:?}") }
}
fn main() { check(1u8); check(vec![1, 2, 3]); }С RUST_BACKTRACE=1 на 1.96 в кадре с нашей функцией стоит просто bt::check, а на 1.98.1 уже bt::check::<alloc::vec::Vec<i32>>. Когда дженерик зовут с десятком типов, а падает он только с одним, это прям кайф.
Сама std, кстати, перешла на v0 раньше вас. В бинаре от 1.96 я насчитал 601 символ в v0 и 10 в старой схеме, и все десять собирались в моём крейте. Среди них были два экземпляра <Option<T> as Debug>::fmt, которые после демангла выглядели одинаково, с буквальным T внутри.
Release notes 1.97 предупреждают, что старые отладчики и профайлеры могут не расшифровать новые символы, а формат бэктрейсов может поменяться. nm -C из binutils 2.42 справляется, c++filt тоже, только печатает ещё и хеш крейта: mangle[4b08c0f14cb3e08f]::parse::<f64>. Если какой-то инструмент показывает сырые R..., есть rustfilt. Больнее всего скриптам, которые ищут символы по префиксу ZN: фильтрам для perf и flamegraph, спискам экспортируемых символов, тестам на имена. Они молча перестанут что-либо находить. Символов с #[unsafe(no_mangle)] (на edition 2021 это просто #[no_mangle]) смена не касается.
Откатиться на stable не выйдет: -Csymbol-mangling-version=legacy на 1.98.1 отвечает requires -Z unstable-options, старая схема осталась только в nightly. По размеру на моём крошечном примере разница вышла 62 байта в .strtab, на большом проекте может быть иначе.
Осталась главная история августа, та самая, которую ждали 10 лет.
!, шестая попытка
! — тип «никогда». Значений у него нет, поэтому выражение такого типа до конца не вычисляется никогда: panic!(), return, continue, loop {} без break. Писать fn fail() -> ! можно было всегда, указатель fn() -> ! тоже, а вот как нормальный тип, например в Result<u32, !>, ! был нестабилен.
Трекинг-задачу открыли в 2016 году. Автор нынешней стабилизации пишет в своём блоге, что до неё было пять неудачных попыток. Дважды, в 2018-м и в 2019-м, стабилизацию даже вливали и оба раза откатывали из-за вывода типов. В 2019-м, например, строчка u32::try_from(1u32)?; внезапно начала требовать от вашего типа ошибки From<()>, хотя у него была реализация From<Infallible>, и код, который собирался на stable, на nightly падал. Шестая попытка влилась 24 августа 2026-го, и, как пишет LWN, готовились к ней больше двух лет. В том же PR поменяли fallback и Infallible, это самое вкусное, но сначала проверим базу:
fn fail() -> ! { panic!() }
fn f(x: Result<u32, !>) -> u32 { let Ok(v) = x; v }
fn main() {
let r: Result<u32, !> = Ok(5);
println!("{}", f(r));
let _ = fail as fn() -> !;
}На stable 1.98.1 и beta 1.99 это две error[E0658]: the ! type is experimental. Nightly собирает без всяких #![feature] и печатает 5. Если оставить в крейте старый #![feature(never_type)], nightly напишет, что фича стабильна «since 1.101.0-nightly». Номер туда подставляется из текущего nightly, так что гадать по нему, в какую stable-версию это попадёт, бесполезно.
Вместе с ! поменялся и Infallible. В core на stable 1.98 это pub enum Infallible {}, пустой enum без значений, то есть самодельный !. На nightly на этом месте стоит pub type Infallible = !;, и это один и тот же тип, поэтому там собирается даже fn same(x: Infallible) -> ! { x }. А этот алиас давно лежит в документации к Infallible на stable, в разделе Future compatibility. Там же расписано, что из-за него сломается, с примером, который на stable 1.98.1 спокойно компилируется:
trait MyTrait {}
impl MyTrait for fn() -> ! {}
impl MyTrait for fn() -> std::convert::Infallible {}На nightly это уже error[E0119]: conflicting implementations of trait MyTrait for type fn() -> !. Два разных типа указателей на функции стали одним, и реализации наложились. Код редкий, но стабильный, так что кого-то это заденет. Зато все реализации трейтов для Infallible, которые уже написаны в крейтах, автоматом работают и для !. А в той же документации сказано, что сам Infallible со временем планируют объявить устаревшим.
Fallback, от которого реально больно
Ломать код будет в первую очередь fallback. Когда у компилятора есть переменная типа, которая пришла только из расходящейся ветки и больше ничем не ограничена, он подставляет запасной тип. Раньше это был (), в edition 2024 его поменяли на !, а новый PR делает ! во всех редакциях. Стреляет это на вполне невинном коде:
fn make<T: Default>() -> Result<T, ()> { Ok(T::default()) }
fn run() -> Result<(), ()> {
make()?;
Ok(())
}make()? раскрывается в match, где одна ветка делает return с ошибкой. Кроме этой ветки, T ничем не ограничен, результат make()? никуда не идёт. На старых редакциях T становился (), у () есть Default, и всё собиралось. Теперь T становится !, а Default для ! нет. По тулчейнам расклад такой:
Тулчейн и редакция | Результат |
|---|---|
stable 1.98.1, edition 2021 | ошибка от линта |
stable 1.98.1, edition 2021 + | собирается, печатает |
stable 1.98.1, edition 2024 |
|
nightly, любая редакция |
|
Последняя строчка проказалось странноватой для всех, кто когда-то заглушил этот линт. С allow nightly пишет warning: lint dependency_on_unit_never_type_fallback has been removed: the code warned by this lint no longer compiles. Линт убрали, потому что код, на который он ругался, больше не компилируется в принципе. Если у вас на edition 2021 стоит такой allow, это такая вот бомбочка с таймером. Чинится одной аннотацией: make::<()>()?;.
Ради чего всё это?
! наконец можно писать там, где он нужен по смыслу. Функция, которая никогда не ошибается, возвращает Result<Vec<u8>, !>, а вызывающий код распаковывает её через let Ok(v) = parse_all(s); без unwrap и мёртвых веток, nightly на моём примере печатает [104, 105]. С Infallible так можно было и раньше, теперь у этого типа просто появилось короткое имя.
Итоги
Короче, год у компилятора вышел плотный, и самое крупное пока сидит в nightly. По-моему, самое время туда заглянуть: когда всё это дойдёт до stable, заметную часть костылей вокруг борроу-чекера можно будет выкинуть, а Infallible окончательно станет вторым именем для !.
А у вас nightly уже что-нибудь сломал или, наоборот, починил? И отдельно интересно про новый солвер: у меня diesel стал проверяться в полтора раза дольше, а у вас на больших проектах как?
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.