Если взять случайный крейт с crates.io, поставить на него Miri и подождать минут пять, шанс увидеть красное сообщение про undefined behavior где-то в зависимостях стремится к единице. Чаще всего виноват не автор хитрого unsafe-блока ради скорости, а вполне обычная библиотека, которой пять лет, у которой звёзд на гитхабе больше, чем у твоего пет-проекта строчек кода, и которая всё это время спокойно лежит в продакшене.

Самое неприятное в этой истории то, что компилятор ничего не скажет. Тесты пройдут. Бенчмарки покажут красивые наносекунды. А потом LLVM 19 обновится до LLVM 20, поменяет один проход оптимизации, и твой сервис начнёт ронять прод по понедельникам. Чтобы понять, почему так происходит, придётся залезть в три темы, которые в обычной жизни Rust-разработчика не встречаются: pointer provenance, Stacked Borrows и пришедшую им на смену Tree Borrows.

Указатель это не адрес

Самое вредное заблуждение, которое тащится в Rust из C, звучит так: указатель это число, представляющее адрес в памяти. С точки зрения процессора так и есть. С точки зрения компилятора уже лет двадцать как нет, просто в C про это стесняются говорить вслух, а в Rust решили оформить явно.

У каждого указателя в модели Rust помимо адреса есть невидимый ярлык, называется provenance. Это по сути идентификатор аллокации, из которой указатель родился, плюс набор разрешений на доступ. Когда ты делаешь Box::new(42), рантайм возвращает тебе не просто адрес, а адрес плюс свежий ярлык, привязанный именно к этому боксу. Когда ты кастуешь &mut T в *mut T, ярлык наследуется. Когда ты складываешь два числа и пытаешься объявить результат указателем через transmute, ярлыка у получившейся штуки нет, и любое разыменование такого указателя является UB, даже если по битам адрес совпадает с тем, что вернул аллокатор пять строк назад.

Проверить это руками просто:

fn main() {
    let a = [1u8, 2, 3, 4];
    let b = [5u8, 6, 7, 8];
    let pa = a.as_ptr();
    let pb = b.as_ptr();
    let offset = pb as isize - pa as isize;
    let forged = unsafe { pa.offset(offset) };
    unsafe { println!("{}", *forged); }
}

На любом нормальном процессоре forged будет указывать ровно туда же, куда pb. Программа что-то напечатает. Скорее всего пятёрку. Запусти под Miri, и увидишь сообщение про trying to retag from a pointer that was not derived from this allocation. С точки зрения железа всё корректно, с точки зрения модели памяти Rust ты использовал ярлык от массива a, чтобы залезть в массив b, а это запрещено независимо от того, что числа сошлись.

Смысл провенанса в том, чтобы компилятор мог делать алиасинг-анализ без ограничений языка C. В C спецификация говорит: если два указателя на разные объекты, они не алиасят, кроме случаев. Случаев много, они громоздкие, и restrict помогает редко. В Rust спецификация говорит: ярлыки разные, значит указатели не алиасят, точка. Это позволяет помечать почти все ссылки атрибутом noalias на уровне LLVM, и оптимизатор имеет право переставлять загрузки и записи как ему удобно.

Stacked Borrows как формальная игра в реборроу

Долгое время не существовало вообще никакой формальной модели, описывающей, какой код Rust считается корректным на уровне unsafe. Команда rustc писала компилятор, опираясь на интуицию, LLVM делал оптимизации по своим правилам, а пользователи писали unsafe-блоки, надеясь, что оба этих мира между собой договорятся. В 2018 году Ральф Юнг предложил Stacked Borrows, и с этого момента у Rust появилась хотя бы рабочая теория, которую можно проверять на машине.

Идея устроена так. К каждому байту памяти мысленно прицеплен стек тегов. Когда ты создаёшь новую ссылку на этот байт, на стек кладётся новый тег. Когда ты используешь ссылку, проверяется, что её тег где-то в стеке есть, и все теги выше неё снимаются. Если тега в стеке не оказалось, это UB.

Из этой простой механики вытекает почти вся семантика заимствований, включая ту, которую ты привык получать от borrow checker, плюс куча правил для unsafe, которых borrow checker не видит. Например, классика:

fn main() {
    let mut x = 42;
    let r1 = &mut x;
    let raw = r1 as *mut i32;
    let r2 = &mut *r1;
    *r2 = 7;
    unsafe { *raw = 13; }
    println!("{}", x);
}

Borrow checker этот код пропускает: raw это сырой указатель, его время жизни никого не волнует, r2 валидный реборроу от r1. Программа компилируется, запускается, печатает 13. Под Miri ловится UB. Объяснение в терминах стека: создание r2 положило новый тег поверх тега raw, поэтому, когда ты пишешь через raw, его тег уже не на вершине, а правила Stacked Borrows требуют для записи, чтобы тег был в стеке и выше него ничего не лежало.

Любимый сценарий, в котором это превращается в баг прода, это передача сырого указателя в C-библиотеку. Ты честно создаёшь &mut, кастуешь, передаёшь. Где-то рядом другой &mut от того же объекта живёт в Rust-коде, и компилятор имеет полное право предположить, что C через сырой указатель туда не полезет, потому что ярлык не тот. Ассемблер, сгенерированный из таких предположений, начинает кешировать значения в регистрах, и привет.

Почему пришлось выключать noalias и причём тут LLVM

В районе 2014 года rustc впервые включил noalias для &mut ссылок. Через несколько недель выяснилось, что в LLVM есть баги, которые срабатывают только на коде с большим количеством noalias-параметров, потому что C-компиляторы такой код почти не генерируют. Атрибут пришлось выключить. Включили обратно через пару лет, поймали следующий слой багов, выключили снова. Эта история с переменным успехом продолжалась примерно до 2021 года, и каждый раз rustc находил в LLVM что-то новое, что Clang за двадцать лет не нашёл, потому что человек на C просто не пишет в стиле «двадцать ссылок без алиасинга подряд».

Побочный эффект для всего сишного мира получился приятный: те же оптимизации заработали корректнее в Clang, и restrict стало можно использовать без страха, что компилятор споткнётся. Rust здесь сыграл роль адского стенда нагрузочного тестирования для алиасинг-анализа в LLVM, и до сих пор играет.

Tree Borrows и почему Stacked Borrows оказалось слишком строгим

Stacked Borrows красивая модель, но у неё есть проблема. Она запрещает паттерны, которые широко встречаются в реальном unsafe-коде и при этом интуитивно выглядят корректными. Самый болезненный пример это interior mutability через сырые указатели и работа со стандартными коллекциями.

use std::cell::Cell;

fn main() {
    let c = Cell::new(0i32);
    let p = &c as *const Cell<i32> as *mut i32;
    unsafe { *p = 1; }
    println!("{}", c.get());
}

По интуиции код корректен: Cell для того и существует, чтобы внутрь можно было писать через общую ссылку. По Stacked Borrows здесь нюансы с тем, как именно был получен p, и разные версии Miri разные версии этого кода ругают по-разному. Накопилась библиотека таких пограничных случаев, и стало понятно, что модель надо чинить.

Tree Borrows это работа Невена Вильмена, защищённая в 2023 году. Вместо стека тегов на каждый байт строится дерево, корнем которого является исходная аллокация, а детьми идут все производные ссылки и указатели. У каждого узла есть состояние: Reserved, Active, Frozen, Disabled. Переходы между состояниями описывают, что разрешено делать с памятью через данный тег, и что происходит с потомками, когда родителя используют для чтения или записи.

Эта модель строго слабее Stacked Borrows на куче существующих unsafe-паттернов и одновременно строго сильнее на тех местах, где Stacked Borrows был дырявым. Самое практичное следствие: код вокруг UnsafeCell, который раньше ругался в Miri без явной вины автора, под Tree Borrows проходит. Включить в Miri можно через MIRIFLAGS=-Zmiri-tree-borrows.

Что с этим делать сегодня

Если ты пишешь только safe Rust и не используешь unsafe, можно условно расслабиться. Условно потому, что любая твоя зависимость, у которой внутри есть unsafe impl Send или ручная работа с указателями, может протащить UB к тебе. Поэтому простой совет звучит так: раз в спринт прогоняй тесты под Miri и Tree Borrows.

cargo +nightly miri test
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test

Если тесты под Miri идут на порядок дольше обычных, это нормально, Miri это интерпретатор, а не нативный код. Но они находят такое, что не находит ни санитайзер, ни fuzz, ни ручной ревью.

Для авторов библиотек есть пара более жёстких правил, которые стоит держать в голове. Никогда не получай ссылку и сырой указатель из одного и того же &mut так, чтобы они оба пережили друг друга. Если нужен сырой указатель, который будет жить долго, бери его до создания всех ссылок и не делай реборроу. Используй UnsafeCell для всего, во что планируешь писать через общий доступ, даже если кажется, что рядом нет ни одной ссылки. И не пытайся восстановить указатель из числа через as, если у тебя нет провенанса под рукой, для этого с недавних пор есть with_exposed_provenance, и это не косметика, а единственный задокументированный способ сделать такое без UB.

Финальная мысль

Главное, что меняется в голове после знакомства с провенансом и Tree Borrows, это отношение к unsafe. Unsafe в Rust часто подаётся как «здесь компилятор тебе верит». На самом деле всё наоборот: в unsafe компилятор тебе верит ровно по тем правилам, которые описаны в модели памяти, и эти правила строже, чем в C. Цена этой строгости агрессивные оптимизации, которых нет нигде больше. Каждый раз, когда твой бенчмарк показывает, что Rust на десять процентов быстрее аналогичного C, где-то под капотом сработал noalias, который смог сработать только потому, что Stacked или Tree Borrows гарантировал отсутствие алиасинга.

Так что когда видишь в выводе Miri незнакомое слово retag, не закрывай терминал. Это компилятор показывает, что между твоим представлением о памяти и его представлением начали расходиться чертежи, и через пару релизов LLVM это разойдение станет видно невооружённым глазом, прямо в проде.


Люблю Rust, пишу на нём и разбираю код так, чтобы сложные вещи становились понятнее — подписывайтесь: t.me/rust_code

Спасибо за внимание, пишите в комментах, плиз ваши замечания!

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


  1. amcured
    09.05.2026 17:28

    Ну я человек простой: рекомендуют проверить — я проверяю. Чтобы не отставать от хайпа, я реализовал акторную модель правильно (как она реализована в эрланге) — и понял, что на этом моё знакомство с растом и закончится: мне визуально не нравится синтаксис, а этого достаточно, чтобы себя от него изолировать.

    Но библиотеку я опубликовал, ей даже кто-то пользуется, надо проверить, решил я.

    test epmd::server::tests::test_handle_list_nodes ... error: unsupported operation: `clock_gettime` with `REALTIME` clocks not available when isolation is enabled
       --> /home/am/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/sys/pal/unix/time.rs:107:22
        |
    107 |         cvt(unsafe { libc::clock_gettime(clock, t.as_mut_ptr()) }).unwrap();
        |                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unsupported operation occurred here
        |
        = help: set `MIRIFLAGS=-Zmiri-disable-isolation` to disable isolation;

    Ну как так-то? Нет, MIRIFLAGS=“-Zmiri-disable-isolation” cargo +nightly miri test прошёл, UB у меня, вроде, нет. Но что-то не так в стандартной библиотеке, в коде, который отвечает за операции со временем (!). Серьёзно?


    1. v_0ver
      09.05.2026 17:28

      Всё с ним норм, явно же написано что unsupported operation - miri не может проверить системный вызов времени. Miri с выключенным isolation требует детерминизма в коде.

      И я не совсем понимаю почему вы используете SystemTime::now(), а не Instant::now() для отсчёта временных интервалов. Но скорее всего последний поддерживается в Miri.


  1. ahabreader
    09.05.2026 17:28

    И не пытайся восстановить указатель из числа через as, если у тебя нет провенанса под рукой, для этого с недавних пор есть with_exposed_provenance, и это не косметика, а единственный задокументированный способ сделать такое без UB.

    Тем временем документация на with_exposed_provenance:

    This is fully equivalent to addr as *const T
    https://github.com/rust-lang/rust/blob/main/library/core/src/ptr/mod.rs#L970

    и эти правила строже, чем в C

    Вспоминая разумный отказ от TBAA - где-то строже, где-то слабее.

    Про UB ещё можно рассуждать, что мы в конечном счёте имеем дело с LLVM, а тот эксплуатирует UB как обычно. Когда LLVM выкидывал пустые бесконечные циклы - это отразилось на всех фронтендах, на C, C++, Rust - хотя с точки зрения Си и раста он на это права не имел.


  1. ptr128
    09.05.2026 17:28

    Я нарывался в Rust на UB при операциях с числами с плавающей запятой из-за оптимизации LLVM. Одно и то же выражение на ПК оказывалось не нулевым, а на МК - нулевым. Просто из-за перестановки местами множителей при оптимизации. При сохранении порядка умножений, как в исходном выражении, всё было хорошо. А при оптимизации при промежуточном умножении возникало исчезновение порядка и результат оказывался нулевым.


    1. MountainGoat
      09.05.2026 17:28

      О том, что результат операции над float нельзя сравнивать с целым, включая 0, мне рассказывали на лекции в году эдак 2003-ем. Последний бит в операции над float является округлённым и может встать так или сяк в зависимости от того, какой именно командой он делался. Ещё не забываем, что множество двоичных float определённого размера не совпадает с множеством десятичных. То есть в переменной хранится число, наиболее близкое к тому, которое подразумевается по логике - из множества тех чисел, которые могут в ней храниться.

      Всё это не Undefined behaviour. Это очень даже подробно defined, в документе от IEEE под номером знать не хочу каким, потому что в быту всё оно прекрасно сводится к фразе "последний бит дробного числа зашумлён"


      1. ptr128
        09.05.2026 17:28

        О том, что результат операции над float нельзя сравнивать с целым, включая 0, мне рассказывали на лекции в году эдак 2003-ем.

        Вы явно перепутали сообщение, на которое отвечаете. Сравнение с нулём не имеет вообще никакого отношения к исчезновению порядка.


        1. MountainGoat
          09.05.2026 17:28

          Одно и то же выражение на ПК оказывалось не нулевым, а на МК - нулевым.

          ???


          1. ptr128
            09.05.2026 17:28

            Именно так. На ПК было что в районе 100, а на МК - чистый ноль.


      1. ptr128
        09.05.2026 17:28

        Исходя из того, что Вы явно впервые слышите об исчезновении порядка, поясняю:

        https://play.rust-lang.org/?version=stable&mode=debug&edition=2024&gist=c67e00613b210f506d07be08e60a9596

        fn main() {
            let a:f32 = 5E-30;
            let b:f32 = 4E-30;
            let c:f32 = 3E30;
            let d:f32 = 2E30;
            println!("a*b*c*d = {}, a*c*b*d = {}", a*b*c*d, a*c*b*d);
        }

        Если оптимизатор LLVM решает умножать сначала a на b, например, потому что они уже в регистрах, а свободных регистров в МК нет, то получит в итоге 0, но если будет умножать в указанном в коде порядке (a*c*b*d), то получится 119.99999. Как это ещё назвать, кроме как UB?

        Понятно, что если отключить fast-math у LLVM, то проблема исчезнет. Но ценой снижения производительности кода. Выбирайте.


        1. MountainGoat
          09.05.2026 17:28

          Спасибо, я над этим поэкспериментирую.


          1. ptr128
            09.05.2026 17:28

            Если честно, я бы предпочёл, чтобы Rust, хотя бы при указании соответствующих флагов, паниковал при исчезновение порядка при операциях с числами с плавающей запятой (floating-point underflow). Но пока воз и ныне там, приходится лазить в дебагере, разбираясь с такими чудесами.


        1. cpud47
          09.05.2026 17:28

          Насколько мне известно, в расте не используется fast-math. Есть настабильный аналог, но он opt-in.

          Касательно разного поведения и порядка вычислений. От того, что у Вас разные результаты ещё не значит, что у Вас уб. И в расте не планируют делать уб для флотатов...


          1. ptr128
            09.05.2026 17:28

            Насколько мне известно, в расте не используется fast-math

            Во-первых, связь между флагами оптимизации LLVM и Rust весьма косвенная. Она ограничивается лишь передачей флагов LLVM через llvm-args. Я уже молчу о неявном изменении порядка вычислений при векторизации и использовании SIMD инструкций. Если на x86 в последнем случае еще всё более-менее предсказуемо, то на ARM, RISC-V и Xtensa можно получить просто массу различных неожиданностей.

            Во-вторых, IEEE 754-2019 явно описывает обработку исчезновения порядка. И к Rust моя претензия именно в том, что он не предоставляет адекватного способа паниковать при исчезновении порядка. В CLang я могу явно указать -ffp-exception-behavior=strict. А что мне предлагает Rust?

            От того, что у Вас разные результаты ещё не значит, что у Вас уб

            Вы можете тут доказать, что различный результат исполнения одного и того же исходного кода с одними и теми же данными - это определенное поведение?


            1. cpud47
              09.05.2026 17:28

              Во-первых, связь между флагами оптимизации LLVM и Rust весьма косвенная.

              Можете воспроизвести на godbolt перестановку инструкций? Потому что насколько мне известно, расту запрещяется переставлять операции с фп, в том числе для векторизации. Собственно именно поэтому в расте сложнее добиться автоматической векторизации в циклах с числами с плавающей запятой.

              Если же Вы через -Cllvm-args поменяли это поведение, то Вы знаете что делаете (ССЗБ).

              И к Rust моя претензия именно в том, что он не предоставляет адекватного способа паниковать при исчезновении порядка.

              Это было совсем неочевидно из Вашего исходного комментария. В таком случае имеет смысл завести пропозал. Впрочем, кажется, это требует изменения глобального состояния системы - может это проще сделать единожды при запуске? Ну или сделать враппер с проверкой...

              Вы можете тут доказать, что различный результат исполнения одного и того же исходного кода с одними и теми же данными - это определенное поведение?

              Термин Undefined Behaviour имеет вполне себе конкретное значение - что является или не является уб могут сказать только авторы компилятора, по определению.

              Отсутствие уб не означает детерминированность. Например, чтение из атомиков есть вполне себе определённая операция. При этом, в зависимости от запуска результат чтения из атомика может разительно отличаться.

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


              1. ptr128
                09.05.2026 17:28

                Можете воспроизвести на godbolt перестановку инструкций?

                Моего конкретного случая - нет. Там просто отсутствует Xtensa ESP32-S3 для LLVM. GCC в данном случае явно не при чём.

                А развлекаться с Cortex-M или RISC-V можете сами, если Вам это интересно.

                В таком случае имеет смысл завести пропозал

                Ну так очередной итерации уже скоро два года. А воз и ныне там.

                Термин Undefined Behaviour имеет вполне себе конкретное значение - что является или не является уб могут сказать только авторы компилятора, по определению.

                Так конкретное значение или авторы компилятора могут интерпретировать этот термин по своему усмотрению?

                Неопределенное значение выражения IEEE называет UB. Например, для C

                int f(int i) {
                    // undefined behavior: two unsequenced modifications to i
                    return i++ + i++;
                }

                С какого перепугу неопределенное значение выражения для Rust должно называться иначе? Только потому, что авторы компилятора так захотели? )))


      1. feelamee
        09.05.2026 17:28

        О том, что результат операции над float нельзя сравнивать с целым, включая 0, мне рассказывали на лекции в году эдак 2003-ем

        и, к сожалению, лгали. Сравнивать float с целыми числами вполне можно. Вот, даже статейку недавно постили неплохую - https://news.ycombinator.com/item?id=47767398

        P.S. каюсь, увидел что написано “результат операции”. Но статейка все равно полезная)


        1. MountainGoat
          09.05.2026 17:28

          Та статья начинается с примера, что

          // Outputs 0.89999998
          std::cout << std::setprecision(8) << ((0.2f + 0.3f) + 0.4f) << '\n';
          
          // Outputs 0.90000004
          std::cout << std::setprecision(8) << (0.2f + (0.3f + 0.4f)) << '\n';

          А продолжается о том, что отнюдь не всегда нас это волнует, и вот тут согласен.


  1. ExCyB
    09.05.2026 17:28

    Увы, вообще не знаю Rust, но почему-то статья зацепила. Пишу на С и C++. UB при работе с указателями представляется мне дьявольскими кознями, которые придумали, чтобы осложнять мне жизнь. Конечно, перестановка команд и прочие оптимизации до некоторой степени ускоряют работу программ (иногда). Но обычно хочется, чтобы код, который я написал, работал именно так, как я его написал. К примеру, написал "++" - увидел в ассемблере inc в этом месте. Написал пустой цикл - и в этом месте будет задержка, причем ее можно точно посчитать в тактах.

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

    С некоторых пор я перестал понимать, как делать это корректно без UB. Кажется, те, кто работает над стандартами C++, не думают о потребностях людей, занимающихся низкоуровневым программированием. А ведь при создании и С и С++ позиционировались как замена ассемблера, и позволяли точно задать, что будет делать программа.

    Возможно, в стандарт языка введены при этом фичи, позволяющие делать вышеперечисленные фокусы, но я их не умею применять и даже не знаю, куда смотреть. Структурированной информации на эту тему мне не попадалось. Кажется, целый мир низкоуровневых программистов ничего не знает про UB и про то, как с его избегать.

    В будущем это наверняка приведет к тому, что компоненты ОС перестанут нормально компилироваться свежими версиями компиляторов, в них будут вылезать неожиданные ошибки, которые при написании кода ошибками не были.

    Интересно, а в Rust есть что-то, позволяющее корректно реализовывать низкоуровневые операции?


    1. MountainGoat
      09.05.2026 17:28

      Но обычно хочется, чтобы код, который я написал, работал именно так, как я его написал. К примеру, написал "++" - увидел в ассемблере inc в этом месте.

      Это будет настолько безбожно медленный код, что никому, кроме преподавателей в университете, такой язык не нужен. Даже у С компилятор делает оптимизации, а уже в С++ и подавно.

      целый мир низкоуровневых программистов

      Очень маленький. А остальные программисты не хотят заморачиваться такими вещами, и очень рады, что умный шайтан машин всё оптимизирует за них.

      Rust есть что-то, позволяющее корректно реализовывать низкоуровневые операции?

      Там есть аллокаторы, работа с памятью, если скажете, что именно вам нужно, я дам ссылку.


    1. ahabreader
      09.05.2026 17:28

      С некоторых пор я перестал понимать, как делать это корректно без UB.

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

      Хотите анекдот про качалку в Люберцах? На эту тему уже жаловались в 80-х, ещё до принятия первого стандарта Си. 38 лет назад в Usenet.

      Apr 5, 1988
      FLAME ON
      I’m also sick of people implying that any program that is not strictly conforming is totally non-portable. I write a lot of programs that are portable to a large number of machines but are not portable to ALL machines. I also write a lot of programs that have small, self-contained, sections that handle non-portable things like shared memory allocation, interupt handling, semaphores, etc…

      В общем, если стандарт не хочет давать гарантии, тем - иногда - хуже для стандарта. Рецепт в том, чтобы полагаться на поведение своего компилятора и принять, что стандарт может охотно избегать ответов (статус UB позволяет ничего не прояснять)

      Живёт идея компилятора (ну, набора опций компилятора), где все недостаточно предсказуемые UB-оптимизации отключены - https://gcc.gnu.org/wiki/boringcc.

      Живут барьеры оптимизации в виде asm-вставок, которые "как volatile, только без лишнего доступа к памяти" (фрагмент из Linux, stackoverflow).

      Нарушать правило strict aliasing - значит полагаться на UB (стандарт не допускает существования -fno-strict-aliasing), но в операционных системах и браузерах так и делают - отключают, без точечных попыток в may_alias (тоже нестандартный). В документах к комитету при этом признавали, что параллельно диалект -fno-strict-aliasing существует - с ним надо считаться - и говорили, что можно создать ещё диалект -fno-provenance.

      Когда язык наслаивают 50 лет, он формирует богатый культурный слой. Скажем, сравнение указателей (через больше/меньше) на разные объекты в Си - это UB, хотя в C++ это лишь Unspecified. Почему... нет, вопрос так даже не стоит, это археологическая находка. В рассылке плюсового комитета однажды проводили раскопки на 100 постов на соседнем культурном слое ([ub] Justification for < not being a total order on pointers?).

      Ещё из подзаброшенного черновика

      Как рождаются числа

      В C++20 malloc и memcpy начали неявно создавать простые (sufficiently trivial) объекты. До C++20 malloc и memcpy создавали UB.

      — Но до этого всё работало!

      Машины компиляторы постоянно умнеют. Никто не знает, как они смогут проэксплуатировать UB. Люди должны быть на шаг впереди.

      В C++23 добавили std::start_lifetime_as, чтобы воспроизвести их магию без UB.

      — Но до этого всё работало!

      — А теперь может и перестать!

      Между двумя взглядами на аллокации всегда было фундаментальное противоречие. Если мы на них посмотрим снизу, со стороны железа (железа без MMU - ещё лучше), то нет ничего, кроме байтов. Если мы на них посмотрим сверху, со стороны стандарта и продвинутых компиляторов, то объекты из байтов возникают только после соблюдения специальных ритуалов. Потом появилось железо со встроенными Capabilities (CHERI), которое проверяет у потолстевших указателей валидность, тип, границы объекта и разрешения, и испортило этот рассказ.

      Или наоборот, придало хоть какой-то смысл происходящему - теперь за программистом гоняется не безумный компилятор, который хочет - или захочет завтра - незаметно наказать его за чтение байтов выравнивания (или за uint16_t, рождённый из двух байтов без соблюдения ритуала), а совершенно конкретное железо с конкретными ошибками за нарушение конкретных правил безопасности (в сферах с повышенными требованиями к оной), которые настраиваются в конкретных пределах.

      Понадеемся же, что std::start_lifetime_as из C++23 будет служить на благо подобных архитектур, а не работать оправданием для странных оптимизаций.


    1. cpud47
      09.05.2026 17:28

      В расте не используется TBAA, поэтому можно взять сырые указатели и использовать ptr::read/ptr::write. Единственное, их не стоит смешивать с ссылками — иначе может быть уб.

      Написал пустой цикл - и в этом месте будет задержка, причем ее можно точно посчитать в тактах.

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

      Но обычно хочется, чтобы код, который я написал, работал именно так, как я его написал

      Внятно написать компилятор под такие требования нельзя. Уб есть необходимое зло. Дальше лишь вопрос в том, как сделать достаточно много уб, чтобы компиляторы хоть как-то могли работать, но при этом чтоьы этим было понятно и удобно пользоваться. В этом аспекте новые языки кмк более удачно подходят.


  1. rsashka
    09.05.2026 17:28

    Иногда странно наблюдать, как С/С++ пытаются заменить на Rust, тогда как С и С++ позиционировались именно как замена ассемблера со всеми его возможностями, а у Rust не было изначально, и нет до сих пор, единой теории, что же такое “безопасное управление памятью” и как это должно быть реализовано в компиляторе.


  1. Jijiki
    09.05.2026 17:28

    самое ближайшее где оно реально нужно, это когда мы соприкасаемся с С/С++ апи, например, если нужны обёртки

    CreateVertexArrays: unsafe extern "C" fn(i32, *mut u32),

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

    я пока лутаю удобство от концепции Раста на поверхности и кайфую без уб на своей тачке, )

    ну, если так нужен С/С++ в конце концов есть соседний Зиг и там что-то удобно и что-то тоже прикольно)


  1. rsashka
    09.05.2026 17:28

    это когда мы соприкасаемся с С/С++ апи

    это не “С/С++”, а чистый С. Rust не умеет взаимодействовать с С++напрямую, только через прослойку C апи.