Комментарии 137
Они верят в какую-то изначальную враждебность компилятора, который только и думает — «Ага! Неопределённое поведение! Я могу делать всё, что захочу!» — и рассчитывают, что отключение оптимизаций его остановит.
Это одна из самых масштабных проблем UB. Встретив неопределенное поведение компилятор совершает совершенную дурь, молча выбрасывая куски кода. Причем отключение оптимизации не всегда спасает от этой дури. И главное, что бесит, что код выбрасывается молча. А потом думай, почему программа работает не так. Да и если UB нет, эта молчаливая оптимизация с выбрасыванием неиспользуемого кода часто выбешивает. Ну выбрасываешь, скажи, какого лешего ты это делаешь. Часто это может указывать на ошибки в логике самой программы. А там, где есть уверенность, что выбрасывается только по делу, можно это уведомление и отключить.
Причем в значительной части случаев это самое UB может никогда не всплывать, а код, вызывающий UB, сознательно спроектирован с целью оптимизации еще в те времена, когда компиляторы еще не были шибко умными.
Тем не менее в 2026 году написание кода на С или С++ без анализа на UB с помощью LLM уже следует расценивать как нарушение SOX, да и просто как банальную безответственность.
Скрытая реклама ЛММLLM?
Кстати, увы, но означенные LLM не таки всегда находят UB, а если находят, то не всегда там, где они на самом деле есть.
Скрытая реклама ЛММLLM?
Какая же она "скрытая", вся статья, можно сказать, ради этой фразы и писалась :) Покупайте наших слонов. А про то, как дерьмо этих слонов выглядит в реальности - молчок-с.
Увы, если бы мы умели диагностировать и печатать, что мы нашли уб — в уб не было бы надобности.
Да и если UB нет, эта молчаливая оптимизация с выбрасыванием неиспользуемого кода часто выбешивает.
Существенная часть выбрасываний "мёртвого кода" связана с тем, что этот код оставила после себя предыдущая оптимизация. Вы вряд ли захотите видеть все такие предупреждения. Но если всё же захотите, то есть optimization report, например беглый гуглинг говорит, что `clang -Rpass=dead-code` делает то, что Вы просили...
А отслеживать почему код стал мёртвым: от оптимизации, либо он изначально был написан пользователем — сложно. Мы всё ещё не до конца справились с точным дебагинфо, а вышеупомянутый трекинг и того сложнее.
Хуже того, большинство странных оптимизаций (в присутствии уб) происходит после инлайнинга — как раз то место, где происходит большое количество "хороших" оптимизаций. Условно:
int foo(T* ptr) {
int id = ptr->id;
update(ptr);
update(ptr);
return id;
}
void update(T* ptr) {
if (ptr == 0) panic("...");
update_or_make_bad_things_if_null(ptr);
}Поэтому увы, как решать сложившуюся проблему с уб — не очень понятно (ну кроме тестов/санитайзеров/стат анализа).
Когда выбрасывается код, ещё понятно, почему. Но мне вот недавно из отсутствия проверки результата функции, в некоторых случаях возвращающей null, GCC молча вставил в ветвь с null инструкцию UND #FF - т.е. он прекрасно понимал, что в этой ветви код упадёт, и втихую сократил путь к падению вместо того, чтобы обругать меня. Выглядит как издевательство, особенно учитывая, что куда более безобидные прегрешения вроде неиспользованного параметра по-умолчанию вылазят в предупреждения. Видимо, потому, что их успели включить в стандарт ещё в адекватные времена.
Ну, он не понимал. Может Вы уверены, что там никогда не будет нулл. И тогда он наоборот решил "помочь".
Если хотите, чтобы компилятор ругался на необработанные нуллы, то может рассмотреть zig/rust?
Я в курсе, что компиляторы Zig/Rust делают куда большую работу по проверке логики на этапе компиляции, это здорово, но мои потребности в выискивании nullов не настолько велики, чтобы менять язык. А вот от компилятора C очень хотелось бы в случаях, где он в рамках текущих его возможностей уже находит потенциальные проблемы, получать не молча заложенную в машинный код мину, а warning. Люди, конечно, порой всякое программируют, но всё-таки молча тактовать каждый найденный null dereference как намеренное желание упасть, пожалуй, перебор. К тому же, оцените иронию, схожее по суицидальности намеренное желание зависнуть (циклы вида while(true) {}) свежие стандарты С трактуют как неопределённое поведение и превращают в "тыкву". Эмбедчики теперь в конце main(), из которой на голом железе возвращаться некуда, вместо while(1) {} пишут while(1) { __asm("NOP"); }. Так почему упасть помогают, а зависнуть - нет? :)
А отслеживать почему код стал мёртвым: от оптимизации, либо он изначально был написан пользователем — сложно.
Поэтому увы, как решать сложившуюся проблему с уб — не очень понятно (ну кроме тестов/санитайзеров/стат анализа).
Я правильно вас понял, что для приведенного примера кода, компилятор не способен выдать предупреждение, а стат. анализатор - может? Можете объяснить, почему так происходит? У компилятора меньше информации о коде, чем у стат. анализатора? Или наоборот, больше, и компилятор в ней "запутывается"? Стат. анализатор лучше понимает намерения компилятора, чем сам компилятор?
Здесь всё сильно зависит от конкретных компилятора и стат.анализатора, но попробую описать общие соображения.
Наверное ключевой момент в том, что компилятор делает совсем другой статический анализ, в отличии от (диагностического) стат.анализатора. Компилятор мутирует представление кода в процессе анализа. При диагностическом стат.анализе же код анализируют по месту, без модификаций.
Второй момент заключается в разных IR. Компилятор, прежде чем он даже приступит хоть к какому-то анализу, преобразует код в промежуточное представление (например llvm ir), в котором уже стёрто много информации об исходнике. И в тот момент, когда он сможет сказать, что условие if всегда ложно — там уже и if-а никакого нет, просто условный джамп.
Диагностический стат.анализ с другой стороны, часто делают напрямую над AST. Например одна весьма эффективная диагностика: сравнить на синтаксическое равенство условия в блоке if else if, — делается чисто на AST. Если же они используют какие-то ir, то либо эти ir делаются таким образом, чтобы можно было точно восстановить из какой конструкции AST пришёл каждый узел IR, либо же эти ir делаются в стиле оракулов.
Пример ir-оракула может быть следующий. Мы компилируем наш код как обычный компилятор, но в каком-то месте вставляем специальную инструкцию. А дальше залаёмся вопросом: а есть ли хоть одно исполнение, которое достигает нашей специальной инструкции. Если нет — то мы нашли недостижимой код. (Сложность правда в том, как выбрать где ставить эту специальную инструкцию; ну и в том что это всё медленно).
Дальше, у компиляторов и у стат.анализаторов немного разные ограничения:
Зачастую время компиляции сильно более ограничено, чем время стат.анализа
Стаи.анализаторы могут ошибаться, у компиляторов такой роскоши нет. На эвристиках можно многое сделать.
Ну и не стоит забывать о банальной сложности. Делать стат.анализаторы на порядок сложнее, чем соответствующего уровня оптимизаторы. В дополнение большинство используемых оптимизаторов не дизайнились под хорошие диагностики. Их только последние несколько лет подпиливают чтобы диагностики были хоть-сколько-нибудь вменяемыми.
Надеюсь не слишком скомкано получилось.
Tl;Dr; сильно отличаются подходы и потому это во-многом просто разные инструменты (с разными гарантиями качества).
P.S. но вообще говоря если бы мне, как человеческому ревьюверю показали чисто код выше — я бы сам не смог бы определиться: ругаться на него, или нет. Максимум, поискал бы места использования, или попросил бы документацию к функции и написать.
P.P.S. кстати, ещё одно отличие связано с раздельной компиляцией: стат.анализаторы видят сильно больше кода, чём компиляторы Си.
я бы добавил - можно попробовать забенчить код. Если какая-то ветка ну совсем никак и никем не выполняется - можно посмотреть более детально на предмет её эксклюзивности (и мёртвости заодно). Как вариант, если она исключительно редка, и при этом тестируется в условии - переместить её в “исключения”. Одним условием меньше ( = код быстрее). А если уж до неё реально добрались через исключение - значит, там уже что-то совсем всё плохо; о скорости можно особо не думать.
Не очень понял, в каком смысле "одним условием меньше". Даже редкие события но всё равно нужно проверять через условия. Можете привести пример?
Впрочем, для подобного есть pgo (ну или хотя бы likely). Но это всё про оптимизацию, а мы здесь про диагностику говорим — поэтому не очень понятно.
Если событие редкое и неожиданное - можно не проверять (на каждой итерации, или даже вообще). Без проверки код получится более предсказуемым (для оптимизатора) и линейным. А на случай, если таки бяка случится - ловить её вне оптимизированного кода, как исключение. Несработавшее исключение очень легковесно (по сути зарезервировали место = заинкрементели регистр-указатель кадра на стеке). А при срабатывании - ну да, пойдёт “во все тяжкие”, начиная с подкачки из бинаря страничек, где находится код обработчиков и т.д.
Например, вот в таком коде я не представляю как его можно применить:
int lookup[];
size_t nlookup;
// ..
for (size_t i = 0; i < nbytes; ++i {
if (bytes[i] >= nlookup) {
panic(); return;
}
int v = lookup[bytes[i]];
// ..
}Здесь условие внутри цикла почти никогда не происходит. Но выкинуть его — вообще непонятно как...
Если за lookup находится память, в которую нет доступа, то можно не проверять условие. Просто при попытке прочитать оттуда случится исключение. Но там другие моменты вылезут (запретить доступ с точного адреса обычно нельзя; можно по границе страницы)
Даже если lookup это malloc, даже выровненный по страницам, никаких гарантий, что следующая страница будет защищена. А часто lookup лежит вообще не стеке — тогда тем более.
Можно конечно через secure_malloc выделить память, но он медленный довольно. Там же нужно два лишних mprotect на старте и в конце сделать, плюс нельзя толком аллокации переиспользовать. Кажется эти сисколы съедят почти весь профит от выкидывания проверки.
В общем, кажется такой подход применим крайне редко. Гораздо чаше и гораздо проще просто оптимизировать проверку через условный SIMD.
А так, это скорее прямой путь к cve...
Можно скомпилировать код с использованием с++26 и уже в коде будет меньше UB.
Осталось где-то найти компилятор с поддержкой c++26. Потому что их не существует.
Каким образом я могу обратиться к адресу 0, используя язык программирования Rust? Существуют ли на 100% безопасные и предсказуемые подходы?
Как по мне, основная беда от UB связана не с тем, что, дескать, аппаратура как-то не так отработает и т.д. и т.п., а то, что компилятору позволено творить любую дичь, даже когда в реальности поведение будет определённым на любой сколько-нибудь "нормальной" платформе.
Возьмём, например, целочисленный знаковый тип размером 2 байта. Как известно, все современные "нормальные" платформы (а существуют ли современные "ненормальные", я, честно говоря, не знаю) для представления целых чисел используют дополнительный код. Соответственно, имея 16 бит, мы имеем диапазон от -32768 до +32767. Прибавление единицы к максимальному положительному значению технически даёт максимальное отрицательное: +32767 + 1 = -32768. И здесь возможны ровно два технически обоснованных результата:
операция сложения молча выполняется и даёт указанный абсолютно предсказуемый и технически корректный результат;
фиксируется возникновение переполнения, что, в конечном итоге, приводит к исключению (неважно, выполняется ли это чисто аппаратными средствами -- скажем, мэйнфреймы IBM умеют ловить такое переполнение, если это разрешено соответствующим битом маски, а на IA-32 aka x86 надо явным образом анализировать флаг OF с помощью команды INTO или же условным переходом).
На мой взгляд, подобные ситуации не должны объявляться как UB на уровне языка и компилятора, т.е. стандарт языка должен указывать, а компиляторы должны реализовывать генерацию кода, выполняющего требуемую операцию, не взирая на возможное (или даже гарантированно возникающее) переполнение -- они, разве что, должны выдать предупреждение, если видят, что переполнение возникнет. А ещё лучше иметь на уровне стандарта (и, естественно, конкретных компиляторов) некую прагму, которая позволяет стандартным образом включать и отключать контроль переполнения во время выполнения программы -- даже если такой контроль, как в случае с IA-32, влечёт за собой потенциальное снижение производительности из-за необходимости выполнения лишних команд. Тогда ситуация остаётся управляемой для программиста: он знает, где переполнение допустимо, а где -- нет, и может соответствующим образом настраивать поведение программы стандартным образом.
То же самое касается и многих других случаев UB. Скажем, для невыровненных указателей тоже можно предусмотреть прагму, заставляющую включать код их проверки, причём даже на платформах, где технически невыровненные доступы разрешены и работают корректно (такая проверка упрощает отлов невыровненных указателей, которые станут проблемой на других платформах), и прагму, обязывающую компилятор сгенерировать код, корректно работающий с невыровненными указателями (для IA-32 эта прагма де-факто ничего делать не будет, а вот на ряде других платформ при её использовании будет формироваться другой, более громоздкий код -- скажем, побайтовая загрузка четырёхбайтовой величины с последующей "сборкой" в регистре процессора). Ну и т.д. и т.п. Но вместо этого, как мы знаем, UB трактуется просто как право компилятора творить любую дичь, в том числе выкидывать сомнительный для него код.
С UB везде и постоянно происходит подмена понятий, потому что никто не задумывается где это поведение не определено. А не определено оно в стандарте, а не в конкретном компиляторе для конкретной платформы! Конкретный компилятор на конкретной платформе пользуется возможностями машинного языка этой конкретной платформы. Стандарт пишется для того чтобы эти возможности ЛЮБОЙ аппаратной платформы использовались самым эффективным образом, именно поэтому стандарт избегает регламентировать поведение там где это может повлиять на эффективность кода для самых распространенных конструкций программирования.
На конкретной платформе и с определенным компилятором UB фактически невозможно - вы всегда можете проверить какое поведение будет у этого компилятора для любой кодовой конструкции. Это будет просто платформо-зависимый код там где это UB по стандарту. Одна из фундаментальных задач программирования разделять и изолировать платформо-зависимый и платформо-НЕзависимый код, а не жаловаться на UB.
Казалось бы, если у языка нет стандарта то у него, вроде бы, нет и UB, но на самом деле это значит, что язык для которого нет стандарта, это одно сплошное UB, так как его поведение нигде и никем не определено.
На конкретной платформе и с определенным компилятором UB фактически невозможно - вы всегда можете проверить какое поведение будет у этого компилятора для любой кодовой конструкции.
Разыменование неинициализированного указателя в интенсивно оперативную память использующей программе- это таки всегда UB, поскольку никогда не знаешь, к чему это приведет, ибо лотерея. Может оказаться вполне доступный адрес, а может исключение случиться по причине попытки доступа "не туда".
Если он вообще не инициализирован, т.е. в него никогда не выполнялась запись -- то да (в той ячейке, где он лежит, может находиться мусор). Однако если он равен нулю или любому другому определённому числовому значению, то и поведение, с точки зрения стандарта и языка, должно быть определённым: обращение к памяти по заданному им адресу. К чему это приведёт -- не забота компилятора, за это должен отвечать программист. (И да, нужна бы стандартная прагма, включающая и отключающая контроль за нулевыми указателями).
мне кажется с таким же успехом можно жаловаться на то, что дифференциальное исчисление непонятно как применять на практике. Причем именно на практике, потому что сказать что: "я понимаю дифференциальное исчисление" могут примерно все из тех кто согласится отвечать на такой вопрос, а вот сделать чтобы работал какой-то самый простенький ПИД-регулятор, например, сможет, скажем, один из десяти кто согласится ответить на первый вопрос.
Согласно статье, тут UB в строке 2 - указатель p оказывается невыровненным и всё, приехали.
&p + 1 - это не “указатель на p плюс один байт”, а “указатель на p плюс смещение на один размер того значения, на которое указывает p”. Так что всё там выровнено.
Тогда непонятно в чем UB? В том что Y станет равен 10 или не станет? Ну если бы программист захотел приравнять Y к 10 то он бы так и сделал. Может это проверка на устройство стека в какой-то конкретной архитектуре.
Формально, по стандарту, - в том, что мы не можем использовать указатель на переменную, чтобы прочитать/записать по нему что-либо, кроме этой переменной. Фактически, в реальном поведении компиляторов, - в том, что указатель из просто адреса превращается в пару “адрес + источник”, и указатели с гарантированно разными источниками будут разными (и компилятор свернёт соответствующее сравнение в false), даже если их адрес совпадает.
у меня был какой- то мелкий ПИК-контроллер и на нем память была постраничная, то есть при переключении страницы все указатели превращались в тыкву! И на нем был Си компилятор, который я успешно использовал. Но этот компилятор физически не мог соответствовать стандарту, но тем не менее это был язык Си и он работал как язык Си. Я к тому что стандарты это хорошо, но реальное программирование это маленько другое, чем просто соответствие стандарту.
По идее, нет гарантии, что при компиляции данного кода переменные x и y будут расположены рядом, причем &y может отличаться от &x на большее число, чем 1; а также нет гарантии, что указатель p таки выровнен - sizeof(int) может быть и 4 при аппаратном требовании выравнивания в 8 байт, тогда *p+1 будет показывать в странное место.
Нет тут никакого дна. Все дело в том, что на восьмибитном железе это не будет UB. Там нет такого понятия, как "неровный адрес". int16, 32, 64, 128... там можно расположить в памяти как угодно, один черт будет несколько операций сложения байт с переносом.
Но если эта платформа 16, 32, 64 и т.д. -разрядная, то это будет UB. И то далеко не на всех. По хорошему, реализацией отлова таких ошибок должен заниматься разработчик компилятора под эту платформу.
Именно из-за того, что на разных платформах какая-то конструкция может быть или фичей или багом, стандарт и допускает UB.
действительно, попытка интерпретировать случайные данные приведет к случайному результату. Но эта случайность результата вполне предсказуемая в этом случае, это и без всякого стандарта понятно. Мне кажется это тот же путь в сторону подмены понятий.
На конкретной платформе и с определенным компилятором UB фактически невозможно - вы всегда можете проверить какое поведение будет у этого компилятора для любой кодовой конструкции.
Это не так. UB означает, что одна и та же конструкция, в том числе, может компилироваться в код с разным поведением в зависимости от оптимизации, инлайнинга и любых других внутренних соображений компилятора. Не говоря уже о том, что проверенное поведение может измениться при малейшем изменении компилятора без нарушения стандарта.
То, о чём вы говорите, - это в стандарте имеет отдельные названия неуточнённое поведение (unspecified behavior) и поведение, определяемое реализацией (implementation-defined behavior). К этим вещам обычно претензий не возникает.
Такой вот портабельный язык Шрёдингера. Или, скорее, язык с принципом неопределённости Гейзенберга: можно либо портабельно
мир в основе своей состоит из противоречий. Когда мы доходим до уровня этих противоречий мы должны КОРРЕКТНО моделировать эти противоречия, в этом нет ничего удивительного и любой язык на этом уровне будет языком Шрёдингера. Очень точный термин вы предложили по моему.
Например если мы программируем что-то по микросекундам мы должны корректно совмещать дискретное и непрерывное.
А указатели изначально противоречивы, они и данные и адрес этих данных, указатель передается на исполнение, но исполняются данные по этому указателю или по указателю под указателем, ...
Вот ровно поэтому я ворчу, что разработчики стандарта злоупотребляют UB. И это - влияние C++, где сделали последовательно несколько глупостей:
Начали пользоваться шаблонами не так, как это было в книге Страуструпа (быстрая и автоматическая генерация нескольких классов с одной и той же логикой, но с разными типами данных) - а для всего подряд: чтобы избавляться от лишних косвенных адресаций, чтобы инлайнить код, чтобы иметь тьюриг-полный язык времени компиляции, и т.д.
Построили все это на SFINAE (т.е. компилятор генерирует тучи версий класса/функции - и потом отбраковывает их потому что они не компилируются)
С ужасом посмотрели на размеры исполняемых файлов, и пошли давать права компилятору вырезать куски кода просто потому что он нашел способ как признать этот код ненужным. Последнее особенно опасно - потому что многие UB проявляются только при определенных данных. Но компилятор выкидывает куски кода - как только сможет доказать что при КАКОМ-ТО сочетании входных данных будет UB.
Имхо, решение очень простое. Помимо UB - стандарт языка определяет такое понятие как "platform dependent". Это означает что в тонком случае - разработчик компилятора на определенной платформе ОБЯЗАН выбрать какое-то поведение, описать его - и далее ему следовать. При этом, на другой платформе - это поведение может быть другим. Но по-большому счету, "C" - используется в двух ипостасях:
Как язык программирования общего назначения - и тогда можно забыть про UB потому что вы даже не лезите в эту область (а еще лучше - возьмите Java/Kotlin и не сношайте себе и людям мозг!)
Как высокоуровневый ассемблер для конкретной платформы. И в этом случае, нас видимо не интересует 100% совместимость. Тот код, который я написал несколько лет назад для AVR - никогда не заработает на STM32. Не потому что у меня там UB, а потому что там куча всего еще прибита гвоздями к платформе. И невозможно простой перекомпиляцией это решить. А значит, дело компилятора - трудолюбиво производить код, а не заниматься широкой интерпретацией намерений программиста - ибо если человек что-то написал, наверное это зачем-то нужно. И должен быть наверное закрытый список оптимизаций которые компилятор может сам выполнять. И они должны быть достаточно тривиальными, чтобы не вызывать проблем. А если нужно что-то нетривиальное - ну что ж, значит придется оптимизировать как раньше - ручками в коде, вроде никто от этого не умирал...
И еще - комитет по стандартизации C - ИМХО поставил себе цель, о котрой его никто не просил: создать такое подмножество языка, которое исполнялось бы с одинаковым результатом и примерно одной производительностью - на любой из существующих (или даже не существующих) платформ. С учетом зоопарка этих платформ - такое подмножество языка стремительно вырожается в ноль. Но по-пути убиваются подмножества языка (через объявление UB) - которые имели смысл для конкретных (и очень распространенных платформ). Те же signed/unsigned для платформы x86 - прекрасно определены, никакой неопределенности - просто генерируй команды процессора и будет счастье... Ну и кто мешал определить это как platform dependent, а не как UB ? Никто, кроме гордыни авторов стандарта...
>Но компилятор выкидывает куски кода - как только сможет доказать что при КАКОМ-ТО сочетании входных данных будет UB
Хммм, слишком пессимистично, тогда что-ли нельзя использовать никакие целочисленные знаковые арифметческие операции (+, -, *, /, %)?
int a, b;
a+b; // (a,b)==(INT_MAX, INT_MAX) --> UB
a-b; // (a,b)==(INT_MIN, INT_MAX) --> UB
a*b; // (a,b)==(INT_MAX, INT_MAX) --> UB
a/b; // (a,b)==(1,0) --> UB
a%b; // (a,b)==(1,0) --> UBНо компилятор выкидывает куски кода - как только сможет доказать что при КАКОМ-ТО сочетании входных данных будет UB.
Это же в точности наоборот. Если в какой-то экзекьюции не проявляется уб, то тогда компилятор обязан сохранить наблюдаемое поведение.
Компилятор может менять наблюдаемое поведение, только для тех экзекьюций, в которых есть уб (согласно абстрактной машине).
Имхо, решение очень простое. Помимо UB - стандарт языка определяет такое понятие как "platform dependent". Это означает что в тонком случае - разработчик компилятора на определенной платформе ОБЯЗАН выбрать какое-то поведение, описать его - и далее ему следовать. При этом, на другой платформе - это поведение может быть другим. Но по-большому счету, "C" - используется в двух ипостасях:
Для этого же есть implementation-defined и unspecified behaviour. И там даже прописываются возможные варианты поведения, из которых компилятор может выбрать.
Предложение хорошее, но поезд компиляторостроения, похоже, слишком далеко уже ушёл.
Вот именно для случая с целыми, что вы описали, недавно была статья: https://habr.com/ru/companies/pvs-studio/articles/276657/
С учётом UB при переполнении знаковых целых компилятор имеет полное право для операций с короткими целыми использовать полноширинные регистры, поэтому +32767 + 1 может быть по факту +32768, потому что под капотом операция выполнилась в 32 или 64 битах. И, если судить по вышеуказанной статье, такое поведение компиляторов можно на деле встретить.
Тему с UB я разложил ниже, но вы упомянули переполнения чисел то это иная проблема в C/C++ которая ортогональна UB. На самом деле ошибкой в дизайне языков C и C++ является ограниченность и неконсистетность числовых типов (для знаковых чисел переполнение это UB а для беззнаковых это wrap-around). Дело в том что числовые типы помимо размера и знаковости на самом деле бывают как минимум 4 типов
1) когда переполнение вызывает эффект wrap-around
2) когда переполнение вызывает эффект saturation
3) когда переполнение вызывает эффект паники (с помощью дополнительных рантайм-проверок) либо ошибки компиляции (если на этапе компиляции компилятору удалось статически проанализировать и доказать переполнение действительно произойдет используя data-flow/range-analysis/symbolic-execution/etc)
4) когда переполнение вызывает эффект UB. Точнее разработчик хочет чтобы был эффект паники в рантайме но из-за дороговизны рантайм-чеков разработчику приходится заниматься микрооптимизациями и либо специальным типом либо другими средствами сообщать компилятору не вставлять рантайм-проверку на переполнение так у него есть дополнительная информация о природе данных (которая недоступна статическому анализатору компилятора) или об особенностях компиляции и оптимизаций конкретно этого компилятора
И если глубоко обдумать эти четыре типа чисел и возможные дефолты для какого-то нового языка то можно прийти к выводу что числа с типом 4 (которые в C/C++ по дефолту для знаковых типов) на самом деле не должны быть дефолтом в языке поскольку этот тип является вынужденным - разработчик на самом деле хочет рантайм-чеков и паники но из-за тормозов вынужден заниматься микрооптимизациями. Соотвественно если мы хотим сделать язык безопасным то правильнее было бы выбрать другие дефолты потому что далеко не всегда, а точнее в >95% случаев при написании приложений и серверов, судя по популярности других интерпретируемых языков, нам не нужны эти микрооптимизации и логично сделать язык с безопасностью по умолчанию и возможность оптимизации по запросу но только после профайлинга и индетификации проблемы (когда рантайм-чек переполнения становится боттлнеком)
Также надо отметить что брать по дефолту тип с эффектом wrap-around (который по дефолту для беззнаковых типов в C/C++) тоже преступление потому что этот тип нужен исключительно тогда когда разработчику нужна модульная арифметика. А нужна она в исключительно редких случаях - в криптографии, в качестве индекса для кольцевого буфера и еще может быть в парочке случаев. А попытка использовать этот тип для случаев когда модульная арифметика не нужна будет только скрывать логические ошибки переполнения (которые могли бы быть пойманы компилятором)
По итогу в контексте вышесказанного у C/C++ дважды неправильные дефолты для числовых типов и единственное что радует так это то что C/C++ здесь не одинок и в других языках тоже неправильные дефолты (когда по дефолту wrap-around)
Тем, кто хочет ещё глубже занырнуть в UB - Путеводитель C++ программиста по неопределённому поведению :)
Удивлен, что в статье не призвали переписать на Rust.
Почему для C и C++ постоянно упоминают компиляторы и их работу?
На С и С++ не писал, но в других языках более высокоуровневых к компилятору (к его поведению) никогда не обращался. Как бы прихожу уже на все готовое
А много ли сейчас компилирующихся в машинный код популярных языков, не принадлежащих так или иначе какой-то одной корпорации или одному сообществу?
Все остальные популярные языки интерпретируемые или с промежуточным байткодом. Среда выполнения там контролируется авторами языка, поэтому все эти проблемы с выравниваниями и тонкостями аппаратной начинки тут исключены. Можно смело прописывать в стандарте языка все что надо, а остальное - дело VM/интерпретатора на конкретной платформе. Да, может потеряться производительность, но для подобных языков это не так важно.
Плюс это в C/C++ мире произошло разделение, есть несколько крупных вендоров компиляторов под разные платформы, не связанных друг с другом ничем кроме стандарта. Есть MSVC, есть GCC, есть кто там, CLang или еще кто-то, я уже не помню. И все они примерно равны. Вроде больше нигде такого нет, в Java, дотнете, питоне, джаваскрипте есть по одной популярной референсной реализации, а все остальные узкоспециализированные и малоиспользуемые. В таком случае гораздо проще все стандартизировать и не оставить пустых мест.
В итоге другие языки более централизованы, менее требовательны к производительности и отделяются от железа большим числом абстракций. И в них нет нужды во всех этих плясках с UB.
Обращение к объекту с неправильным выравниванием
Возьмём для примера такой код:
int foo(const int* p) { return *p;}Так, а в чём в данном случае вина именно С/С++? Какой язык предотвратит невыровненный доступ к памяти?
На Linux Alpha это иногда приводило к вызову аппаратного исключения, которое ядро обрабатывало программно, эмулируя нужное вам поведение. В других случаях ваша программа могла просто упасть по сигналу SIGBUS.
Есть примеры куда более доступные чем эти древние оси и архитектуры. Вполне современные чипы Cortex-M0/M0+, встречающиеся например в некоторых контролерах STM32 в наши дни, делают HardFault на невыровненной памяти.
Приведение float к int
И это не имеет отношения к языку. Переполнения возможны на любом языке и задача программиста учитывать это. Я даже более страшную вещь скажу, банальное умножение int на int в 99% случаев двух взятых произвольных int'ов вызовет переполнение.
Деление на нуль — это UB
И это тоже универсально.
Переменные аргументы и типы (например, printf с %ld вместо %lld)
В плюсах есть libfmt/std::format, которые с синтаксисом похожим на printf проверяют аргументы на этапе компиляции.
Какой язык предотвратит невыровненный доступ к памяти?
Именно предотвратить не может никакой (если в нём в принципе есть произвольный доступ к памяти), но усложнить - любой, в котором есть отдельно указатели (которые используются только по особым случаям и требуют проверок) и отдельно, например, ссылки (которые используются в большинстве случаев и всегда выровнены по построению).
Переполнения возможны на любом языке
Но не в любом языке это undefined behavior. Где-то оно вполне может быть unspecified, где-то - определено однозначно.
Но можно пойти дальше, в более другие языки!
И где же, где же безопасные ОС, браузеры, офисные пакеты и прочий полезный софт на этих более других языках?
Цена ошибки в браузере, ОС или офисном пакете меньше цены разработки на верифицированном языке.
Погодите, погодите. Вы хотите сказать, что эти самые другие языки, на которые вы так запали, тупо не эффективны?
Настолько же, насколько плюсы «тупо неэффективны» потому, что для бекенда веб-сайтов чаще всего выбирают не C++.
Именно, поэтому вы вряд ли встретите C++ников, которые бегают по ресурсам для Web-разработки и доказывают, что специализированные решения на C++ рвут по производительности условные RoR и Django.
Или вы не это имели в виду?
Не это. Но у меня уже давно нет надежды объяснить вам что-то простое.
Попробуйте объяснять не подменами
А вот это:
для бекенда веб-сайтов чаще всего выбирают не C++.
Хм, C+±ники никогда не утверждали, что плюсы быстрее рубей или питона?
Перевод темы с “бекенда веб-сайтов” на “плюсы быстрее рубей или питона” вообще еще “не подмена” или же уже “подмена”? Как вам зеркало подсказывает?
По сути же: я лично наблюдаю снижение интереса к C++ где-то года с 1998-го, может быть раньше. И если в 1990-х это было еще плавное снижение, то в 2000-х уже лавинообразное. Основной же причиной всего этого была как раз экономическая неэффективность разработки на C++ в сравнении с более безопасными мейнстримовыми языками. Тем не менее, прошло уже больше 20 лет с момента, когда начался массовый отток из C++ в Java/C# и пр. языки, а C++ все еще используется. И продолжает не смотря на то, что стабильному Rust- уже 11 лет исполнилось.
Т.е. для C++ все еще есть ниши, где при всех своих недостатках C++ все еще оправдан, в том числе (а может быть и в первую очередь) экономически.
И на этом фоне вы сравниваете C++ с более лучшими языками, которые не смотря на десятки лет развития пока еще и близко не могут заменить C++ в реальной жизни. Выглядит это, мягко говоря, странно.
Т.е. для C++ все еще есть ниши, где при всех своих недостатках C++ все еще оправдан, в том числе (а может быть и в первую очередь) экономически.
Тут, наверное, основной вопрос - а есть ли ниши, в которых создание новых проектов на C++ будет оправдано? Поддержка существующего кода - это всё-таки несколько иная тема, там язык вполне может выживать даже при наличии явно предпочтительных конкурентов, просто потому что стоимость замены кода выше стоимости поддержания.
Тут, наверное, основной вопрос - а есть ли ниши, в которых создание новых проектов на C++ будет оправдано?
Это хороший и объемный вопрос. Не готов его обсуждать в данной ветке.
когда мы в браузере смотрим видео, это скорее всего загружаются и работают библиотеки (кодеки-плагины) написанные на С/С++ . Когда нужны определенные FPS кажется нет альтернативы. Ну и с DirectX я не знаю можно ли на чем то другом работать так же эффективно. И наверно можно долго перечислять всякие сервера и клиенты специальные типа SSH например. Интересно на чем написано VScode.
А еще я всегда вспоминаю свой восторг от Quake II в 2001 году еще! У нас весь офис зависал часами :) ! Какой фронт-енд! Какой бек-енд! Какая бизнес логика! Мне кажется это направление все также за С/С++ .
Одна из сложностей разговора о новых проектах на C++ в том, что практически не бывает новых проектов, которые начинаются с абсолютного нуля и пишутся полностью самостоятельно, без переиспользования уже готовых библиотек.
Для C++ таких библиотек множество. Это только про чисто C++ны. Плюс огромное количество С-шных, которые из C++ используются как родные.
И если проект стартует на базе Boost+Folly+Abceil+ffmpeg+DearImGui и т.д., и т.п., то где грань между “новым” и “легаси”?
Поэтому и не хочется разводить рассуждения на эту тему.
если так рассуждать то все что пишется под Операционной Системой (на любом языке) все будет использовать эту ОС как репозиторий библиотек, какой смысл делать акцент на С++ в этом вопросе тогда? Тогда это тоже не про С++, действительно.
Мне думается, что водораздел достаточно простой: у вас есть код проекта, в этом коде вы используете какие-то библиотеки напрямую. Вот это и имеет смысл рассматривать. А то, что библиотека где-то внутри как-то обращается к функциональности ОС – это уже за рамками разговора. Т.е. глубину рекурсии можно жестко ограничить.
Подмена, конечно.
Тогда может не надо обвинять собеседника в собственных грехах? Вы делаете подмены, вы же от меня просите объяснить вам не прибегая к подменам.
Плюсы отбивают возможность видеть аналогии, или что?
Если вы хотите обсудить мою ненормальность, то вовсе необязательно приплетать плюсы. Я, собственно, на нормальность особенно и не претендую.
Где я в этой ветке сравнивал C++ хоть с чем-то?
Где-то здесь. Где вы заговорили про более другие языки.
Я вам показываю аналогией, как ваш «аргумент» выглядит в другом контексте.
Вы изливаете на меня какую-то дурно пахнущую субстанцию из своей головы. Например, я вообще не могу понять как у вас возникла вот эта “аналогия”:
Это вообще ничем не отличается по сути от гипотетического:
— Другой чувак: питон так же быстр, как C++.
— Вы: нет, вот пример.
— Я: а чего тогда на C++ не пишут бекенды?
Здесь ничего и близко нет к тому, о чем я вам говорю. Как по мне, так это какое-то говно, к которому я не имею никакого отношения от слова совсем. Но вы что-то за меня додумываете и потом с этим же додуманным старательно сражаетесь, да еще и приписывая подмены мне.
Осталась вторая половина — чтобы вы увидели то же самое в ваших исходных словах.
Вы упорно хотите заставить меня искать темную кошку в темной комнате. Только вот а) мне это не нужно и b) вряд ли эта кошка есть вообще.
Я сравнил этот более другой язык с хаскелем (на котором был предыдущий пример), потому что в хаскеле система типов недостаточно выразительна.
Сперва вы говорили про то, что C/C++ один большой unsafe, после чего перешли к более безопасным языкам. Т.е. все эти примеры на безопасных языках и есть сравнение с C++.
А как называется переход от «язык позволяет выражать такие-то вещи [невозможность делить на ноль]» к «а чего тогда на нём не пишут $X, где эти вещи не являются определяющим фактором»?
Это не переход. Это ключевой момент касающийся вашей критики C++. Когда вы сравниваете C++ с Haskell-ем или Adga2, вы сравниваете огурцы с ананасами. И те, и другие можно есть, но они не являются друг другу заменой.
И может быть многие бы хотели бы есть больше ананасов, но даже экономические возможности этого не позволяют. Не говоря уже про другие факторы.
Это я вам на языке “аналогий” пытаюсь донести то, что вы никак не поймете.
сравнение C++ с другими языками там есть
О том и речь.
Правда, на том же расте пишут и ОС, и браузеры.
Только вот ваши примеры относились не к Rust-у.
Сколько там нового кода в андроиде на плюсах, а сколько — на расте?
ХЗ, я не пишу код Андроида. В том, чем занимаюсь я в последнее время, Rust лично мне не кажется 100% оправданной заменой. Т.е. где-то за счет Rust-а было бы проще. Где-то – сильно нет.
То, что замены C++у появляются меня лично радует, т.к. не смотря на всю мою любовь к C++, тратить время на разбирательства с падениями из-за порчи памяти жалко. Пока, к сожалению, из адекватных замен только Rust.
Моя основная критика C++ — что это неюзабельный язык с фрактальной сложностью, единственная причина выбирать который для программирования в 2026-м — легаси.
Ну так не выбирайте. Вас кто-то силой заставляет?
А это — другой поинт, и не надо его смешивать с предыдущим.
Или это вам так кажется. Может вам хотелось бы, чтобы ваш комментарий, в котором вы приводите примеры на Haskell-е и Agda, воспринимался как многослойный с кучей поинтов и смыслов. Может вы даже пытались его таким сделать.
Но для одного конкретного читателя он выглядит гораздо проще: есть C/C++, которые сплошной unsafe, и есть более безопасные языки с парой конкретных примеров оных. И все.
Ну вы же знаете, что будет дальше?
Нет, я не умею предсказывать будущее.
Ровно это может быть использовано как «причина» для
Для каких-то выводов, которые странным образом рождаются в вашей голове и которые я не понимаю. Вы строите какие-то свои взаимосвязи и выдаете это за якобы следствия из моих слов.
Вы мне пытаетесь донести, что причины — [в том числе] экономические, и я по-вашему этого не понимаю, несмотря на то, что мой первый же ответ вам на эту тему начинался с «В экономике»?
Нет, я не это вам пытаюсь донести. Но, как уже было сказано выше, простые вещи вы не понимаете. Это доказано уже не первым разговором.
А могли бы позвать многочисленных комментаторов с хабра (и заодно с опеннета) — они знают, как надо, и пишут безбажный код по их словам!
Мне кажется, что гораздо проще и гораздо точнее будет просто вас охарактеризовать как умного идиота.
Ну так и вы можете не сравнивать
Вас опять несет не в ту сторону. Мне, например, не нравятся такие языки, как Си, Java и Go. Но я не бегаю по Хабру и не доказываю, что есть языки лучше. И не страдаю от необходимости начинать новые проекты на них.
А вот вы страдаете. Ну так не страдайте.
Значит, читателю стоит в следующий раз читать внимательнее и разбираться, какой пример к какому языку относится.
Раз вы разобрались с нюансами C++ и освоили Haskell-и с Agda-ми, значит должны быть интеллектуально развиты. Но эта развитость удивительным образом не позволяет видеть простых вещей: вот есть ваш комментарий про более безопасные языки. Допустим, что читатель, который устал от проблем C++, смотрит на ваши примеры и хочет взять какой-либо из “более других” языков вместо C++. Ну, например, чтобы сделать свой Excel. Или сильно специализированную альтернативу nginx. И…
И в реальности он быстро обнаружит, что даже у Rust-а не будет безоговорочного преимущества. Не говоря уже про Haskell-и.
Получается, что если язык программирования для читателя – это не вещь в себе и не самоцель, а всего лишь инструмент, то ваш комментарий тупо лишен практического смысла. Поэтому и не нужно разбираться с тем, какие еще смыслы вы туда вкладывали и какая запятая к чему относилась.
Если у вас с аналогиями хуже, чем у идиота, то что это о вас говорит?
Обо мне это может говорить все что угодно. Например, что я в 10 раз тупее вас (что вполне соответствует действительности). Только вот каким бы ни был я сам, вы умным идиотом быть не перестанете.
Да, вместо этого вы бегаете по хабру конкретно за мной и на каждое второе упоминание более других языков начинаете возражать
Возражать?
Хватит уже приписывать мне то, чего я не говорил.
Перед этим выводом у вас такое количество неоправданных предположений, что я даже не знаю, с чего начать.
Начать можно с того, чтобы сделать анализ а что из этих ваших “неоправданных предположений” были высказанны мной в реальности.
Хабетс пишет: «В C всё является UB, компилятор сошел с ума». Но это ложь. Язык дал ему все инструменты безопасности, но он их игнорирует.
Современные стандарты C (C23) и C++ давно дали легальные, безопасные инструменты. Вместо опасных кастов указателей, от которых Хабетс страдает в своем коде, есть std::span или встроенные макросы выравнивания. Вместо наивных проверок знакового переполнения есть функции безопасной арифметики (ckd_add, ckd_mul), встроенные в стандарт на уровне ключевых слов.
– Концепция Профилей (Profiles): Сейчас в комитете по стандартизации ISO активно обсуждается внедрение так называемых «профилей безопасности» (Safety Profiles). Идея в том, чтобы программист мог прямо в коде написать #pragma safety или аналогичную директиву. Включая её, вы добровольно говорите компилятору: «В этом модуле UB запрещено, проверяй меня жестко».
– Внедрение типов-оберток: Вместо изменения базовых типов вроде int или *pointer, комитет добавляет в стандарт безопасные альтернативы (например, std::span, функции безопасной арифметики). Хочешь безопасности - используй их, не хочешь - пиши по старинке на свой страх и риск.
Undefined Behavior - это прямое воплощение закона формальной логики Ex falso sequitur quodlibet (Из лжи следует что угодно) в программировании.
Компилятор здесь выступает не как «сломанная программа», а как идеальная, математическая машина. Он берёт ложную предпосылку (программист не должен вызывать UB, но вызвал его) и на основе этой лжи выводит любую удобную ему оптимизацию.
В серьезных отраслях (авиация, космонавтика, автомобильный софт) существуют жесткие стандарты программирования, такие как MISRA C или AUTOSAR. Там правило номер один полный, абсолютный запрет на использование любых конструкций, способных вызвать UB.
Если программист пишет код для бортового компьютера самолета и оставляет там шанс для невыровненного указателя или знакового переполнения, надеясь, что «компилятор как-нибудь разберется» - этого человека с позором выгонят из индустрии. Это не просто низкая квалификация, это должностное преступление.
https://github.com/sakura1083841400/MISRA-C/
https://www.autosar.org/search
Guidelines for the use of the C++14 language in critical and safety-related systems https://www.autosar.org/fileadmin/standards/R19-03/AP/AUTOSAR_RS_CPP14Guidelines.pdf
UB (неопределенное поведение) в той или иной форме есть во всех языках программирования, за исключеним математических (Coq, Agda, итд). В управляемых языках это называют не UB, а «неожиданным / неочевидным поведением» (unexpected behavior).
На Хабре есть десятки статей на эту тему (например, циклы статей «Занимательная Java» или «Тонкости и ловушки C#»). Вот пара «идиотских», но 100% законных примеров, от которых у практикующих программистов «дергается глаз»:
***
1. Java: фокусы кэширования и автобоксинга
В Java есть прекрасный «безопасный» механизм автоматического превращения примитивов в объекты (автобоксинг). Смотрим на официальный, компилируемый
Integer a = 127;
Integer b = 127;
System.out.println(a == b); // Выведет: true
Integer x = 128;
Integer y = 128;
System.out.println(x == y); // Выведет: false!
Почему это «законное безумие»? С точки зрения любого вменяемого человека, 128 == 128. Но согласно спецификации Java (JLS), объекты классов-оберток сравниваются по ссылкам (==), а не по значению.Однако, чтобы язык не тормозил, создатели Java встроили в рантайм кэш для чисел от -128 до 127. Для чисел в этом диапазоне Java возвращает один и тот же объект из кэша (поэтому 127 == 127 — это true, ссылки совпали). А для числа 128 создаются два разных объекта в памяти.Программист пишет абсолютно safe-код, тестирует его на маленьких числах — всё работает. Программа выходит в релиз, числа растут до 128, и логика приложения тихо, без ошибок ломается, потому что 128 != 128.
2. C#: Коварная ленивость LINQ и замыкания
Классический пример из C# (до версии C# 5.0 это была главная боль, но и сейчас на эти грабли наступают в других циклах).
var actions = new List<Action>();
for (int i = 0; i < 5; i++) {
actions.Add(() => Console.WriteLine(i));
}
foreach (var action in actions) {
action();
}
// Ожидание: 0, 1, 2, 3, 4
// Реальность: 5, 5, 5, 5, 5!
Код абсолютно безопасен (safe), никаких указателей. Но компилятор C# делает здесь законное замыкание (closure). Вместо того чтобы скопировать значение переменной i в лямбда-выражение на каждом шаге цикла, компилятор захватывает ссылку на саму переменную i. Когда цикл завершается, значение переменной i становится равным 5.
И когда мы позже вызываем наши сохраненные функции, они все смотрят на одну и ту же ячейку памяти, где лежит 5. Для человека это идиотизм, а для компилятора C# — строгое выполнение стандарта.
и так далее
PS Сам Том - никогда не проектировал и не разработывал ни языки, ни компляторы, это типичный самоучка-“юниксоид” из 1990-х. Посмотрите на код Тома. Если так писать, - то “UB” точно не обберёшься. https://github.com/ThomasHabets/arping/blob/arping-2.x/src/arping.c
Зачем этот слоп на Хабре?
А чем "слоп" отличается от знаний?
Психоз вокруг С++ очень похож на психоз gotoненавистничества - Двухминуток ненависти (англ. Two Minutes Hate), когда не умеющие писать код граждане клеймили во всех бедах goto. Теперь козлом отпущения назначен Си.
PS Я хотя бы код Хабетса открыл и посмотрел как он пишет. Типичный прикладник, без квалификации в области разработки языков или компиляторов.
И вы, к примеру, в Яндекс отправьте на ревью и его код и эту феноменальную статью. Уверен, узнаете много интересного.
И ещё интересно было бы узнать, что Том думает об этих флагах компилятора? ну, зачем они? что делают?
-fsanitize=undefined
-fsanitize=thread
-fsanitize=memory
-fsanitize=address
Подтверждаю, такое может быть. "Глюкавость" ненешних компиляторов, "сред" разработки и "инструментов" - тайна Полишинеля. Взять те же профайлеры: работающих днём с огнём не сыщешь (из мниатюрных - нашёл только этот Very Sleepy http://www.codersnotes.com/sleepy/ ). А ведь в 1990-е - были!
Что до меня: в проектах только подмножество С++ (почти чистый С++98), POD структуры + подмножество STL, исключения - под запретом. Софты 20 лет без "падений" вообще. Поэтому аргументирую исходя из своего опыта. Я же не должен бежать за Томом, только из-за того, что у него иной жизненный опыт?
vtune? perf? Не, ерунда какая-то.
А Вы сами-то vtune пользовались? какая его последняя версия? от какого года? какой размер дистрибутива этого профайлера сейчас? какое "подразделение" Intel его делало (подскажу: его писали русские, которые здесь же на Хабре кроют собственный код матом :) )?
Полюбопытствуйте. А главное попробуйте поотлаживать на нём многопоточный код. Это невозможно. Он банально падает: раз через два.
perf - а что, есть нормальные версии "perf" под Винду? И да, мне нужен профайлер по Windows. Или что? Windows уже запретили?
Почему их множество не пересекается с множеством людей, пишущих опенсорс
Ну, наверное потому что у них хорошие должности, оклады, приятная работа. Зачем же им с "с множеством людей, пишущих опенсорс" пересекаться?
Так же предположу, что эти два множества не пересекаются исключительно в Вашей голове: дело в том, что критически важную часть всей open-source инфраструктуры (ядро Linux, Kubernetes, браузерные движки, облачные базы данных) создают и поддерживают инженеры на зарплате у корпораций (Google, Microsoft, AWS, Meta, Red Hat). И знаете, правильно настроенные сервера Linux и Винды работают без перезагрузки годами. Так что качественный "опенсорс, используемый на десятках миллионов машин" как раз и пишется теми людьми, которые по Вашим словам с ним "не пересекаются".
*
И не надо читать, то что написано на заборе. Opensource - это уже давно не некие "альтуристы", пишущие "за всё хорошее".
Linux, Kubernetes, Node.js, Hyperledger, Prometheus. Выручка фонда ~$311 млн. Из них более $133 млн — прямые членские взносы и донаты компаний. Mozilla Foundation, Браузер Firefox, Thunderbird, движок Gecko. Выручка коммерческого крыла (Mozilla Corporation) превышает $500–600 млн в год.
И так далее.
PS В связи с тем что мы живём в разных мирах (Вы на полном серьёзе используете "vtune" и "perf", и в Вашем - нет Windows), и у нас нет и не может быть общих интересов, предлагаю и далее не пересекаться.
Деление на нуль — это UB
только для целочисленного деления. В случае чисел с плавающей точкой существуют ±inf и NaN.
Бесконечности и НаНы не везде существуют, если рассматривать всю совокупность имеющихся платформ.
Или выброшенное исключение, причем программист обычно поведение контролировать не может
всё зависит от реализации команды DIV, для x86 генерирует int 0 и не меняет регистры, ARM возвращает 0, DSP/MIPS иногда (!) дают 0 или 0xFFF... максимум. Для плавающей запятой ещё есть число EPS, которое можно инъецировать в любые сомнительные дроби чтобы можно было досчитать симуляцию с гигавольтами и тераамперами.
Они существуют, если ваши “плавающие” числа составлены по IEEE754. Программистам на С/С++ никто не запрещает придумывать свои стандарты. Особенно если не open-source. Знаете ли, очень удобно 4-байтные флоты сравнивать на “больше-меньше” как 4-байтные целые. Одна (дешёвая!) инструкция процессора! И даже если они знаковые - можно в самый старший бит поместить знак. И сами числа как-то внутри (нестандартно!) преобразовать, чтобы целочисленное больше/меньше давало верный результат “здесь и сейчас”. А вот позже - когда лично Вам понадобилось какое-то число - его дружелюбно преобразуют в стандарт IEEE754. И даже если по ходу какие-то inf/nan возникли - их тоже могут предоставить ).
"С" сейчас - это самый совершенный аппаратно-независимый ассемблер, включая диалекты OpenMP/CL/CUDA. Вот тебе память вот тебе калькулятор, далее управляй сам, флажками, агентами, счётчиками - это всё явно и более чем работает. Это вы ещё UB в Verilog/VHDL не заглядывали, особенно когда тайминги и прочие аппаратно-зависимые фишки лезут. Так что ручное, псевдоручное и вайб-управление памятью это то что нужно для bare metal проектов. На всё остальное - Питон.
На Linux Alpha это иногда приводило к вызову аппаратного исключения […] На архитектуре SPARC это гарантированно приводило к SIGBUS.
Ну и где они теперь?
Кстати, SPARC делала Sun. Может, поэтому они и изобрели Java, где нет адресной арифметики?
Естественно, на x86/amd64 (далее просто x86) такой код проблем не вызовет. Да что тут говорить, это даже наверняка будет операцией атомарного чтения. Архитектура x86 известна своей крайней снисходительностью к ошибкам синхронизации кэша.
Это не совсем так. В x86 есть SIMD инстукции которые требуют выравнивания и кидают аппаратное исключение, если операнд не выровнен. Вы можете поставить аттрибут что операнд выровнен и включить оптимизацию с использованием SIMD и вы вполне вероятно получите именно такие инсрукции в коде.
Кроме того, начиная с 486 есть флаг AC в EFLAGS который будет кидать такие исключения по любому невыровненному операнду. Просто Линукс и Windows начали использовать платформу до 486го и посчитали это удобнее, или по причинам обратной совместимости оставили его в состоянии выключен.
Про атомарность - тоже не будет если операнд в двух строках кеша.
для среднего векторизуемого кода он не может ей воспользоваться
По идее, если значения в массиве выровнены, допустим, на 8 байт, а SIMD требует 16, компилятор может сказать “проверим реальный адрес, если надо - обработаем первое значение отдельно, дальше гоним SIMD”, что будет невозможно, если значения не выровнены вообще. Но допускаю, что это уже не совсем “средний векторизуемый код”, да.
Вайб "я хотел просто писать как на Бейсике - а приходится понимать как устроены регистры и знать сколько бит в int"
Ну да, приходится. Просто нужно воспринимать С не как "быстрый Бейсик/Питон", а как "платформонезависимый ассемблер": он только по синтаксису платформонезависимый, а по железу есть нюансы.
Да нет, сам C то совсем платформонезависимый. Просто не полагаться надо на детали конкретных платформ, а писать по стандарту.
Язык - да. Код - нет.
Вот то же выравнивание, например:
typedef struct {
unsigned char a;
unsigned int b;
unsigned int c;
} s;Сколько байт в памяти оно займет?
Что будет, если работать со структурой и ее элементами через указатели?
Например, передать как (unsigned char *) и обратиться x[1] - это куда попадаем?
А union с этой же структурой?
Ковырял как-то программку одну, написанную для x86 под DOS, чтобы запустить ее на ARM64 - вот как раз потому что она исправно собиралась, но падала.
Там как раз подобного много было - авторы явно не думали, что через много лет кому-то понадобится адаптировать это под совсем другую архитектуру.
Что будет, если работать со структурой и ее элементами через указатели? Например, передать как (unsigned char *) и обратиться x[1] - это куда попадаем?
В не определённое стандартом поведение. То есть, в то, что не считается корректным кодом на C.
Абсолютно корректный код.
Если это struct используйте его поля как `s.a, s.b, s.с` - и будет Вам счастье.
В памяти "оно" займёт ровно sizeof(s) - будете спорить?
"передать как (unsigned char *) " а почему её не передать как (double*)? или как (GodObject*)? Почему Вы тип `custom struct` вдруг решаете передать как char *? Где Вас этому учили?
Какое отношение "авторы явно не думали" имеет к С? кто за них будет "думать"?
Когда кто-то хочет сам работать с POD-структурами, пусть читает ABI. Как Вам такая идея?
#pragma pack(push, 1)
typedef struct {
uint8_t a;
uint8_t b;
uint8_t c;
} s;
#pragma pack(pop)
А тем кто не хочет/не способен разбираться в ABI может стоит просто придерживаться* стандартов С? (или найти более интересную, высокооплачиваемую и менее напряжённую работу).
PS Кстати, сам встречал случаи когда переменые в коде автор не инициализировал, просто полагаясь на то, что конкретный компилятор устновит их в нуль. Но это ведь не обязанность компилятора, правда?
В зависимости от опций компиляторы или присваивают не инициализированным контейнерам типа int нулевые значения, или нет. Несколько программ падали именно из-за этого. Установите правилом инициализировать переменные при их объявлении! https://habr.com/ru/articles/693660/
Ну вот, а те кто там тогда писал - не видели абсолютно никакой проблемы в том чтобы объединить в один блок совершенно разные структуры, потому что во времена PC XT/AT её и не было: какая разница, char + int или 3 char подряд? Которые потом можно байт за байтом записать куда-то там.
Работало же.
Это к тому, что С в принципе работает достаточно близко к железу, это не Питон, о чем и речь. Глупо его в этом обвинять, или обвинять программистов что они не учли возможность существенного изменения железа, когда писали под конкретный девайс.
В реальности мы как правило пишем код не под устаревшие или экзотические платформы, а всего под четыре: x86, x86-64, Arm, Arm64. Достаточно прогнать тесты на этих четырех случаях, убедиться, что все работает, и не раздувать проблему на ровном месте. И это уж точно не повод, чтобы переписывать всю кодовую базу на rust или что-там еще сейчас модно. Сколько уже появилось и сгинуло этих модных языков, а Си жил, жив и всех нас переживет.
Вот такое дивное UB поймали полтора года назад. В функции FindEntry последний цикл превратился в “вечный”. При этом разглядывание данных показало, что нет, хранилище таблицы не заполнено, по логике цикл ДОЛЖЕН был закончиться.
VALUE OpenHashTable_T<KEY,VALUE,HASHFUNC,ENTRY,STATE>::FindAndDelete ( KEY k )
{
ENTRY * pEntry = BASE::FindEntry(k);
assert(pEntry);
VALUE tRes = pEntry->m_Value;
BASE::DeleteEntry(pEntry);
return tRes;
}
…
template <typename KEY, typename ENTRY, typename HASHFUNC, typename STATE>
ENTRY * OpenHashTraits_T<KEY,ENTRY,HASHFUNC,STATE>::FindEntry ( KEY k ) const
{
if ( !m_iSize )
return nullptr;
int64_t iIndex = HASHFUNC::GetHash(k) & ( m_iSize-1 );
while ( m_pHash[iIndex].IsUsed(*this) )
{
ENTRY & tEntry = m_pHash[iIndex];
if ( tEntry.m_Key==k )
return &tEntry;
iIndex = ( iIndex+1 ) & ( m_iSize-1 );
}
return nullptr;
}
UB здесь возникло именно из-за совокупности двух функций. Обе функции в одном юните. Компилятор заинлайнил FindEntry внутрь FindAndDelete. А там логика такая: зовём FindEntry, получаем указатель на элемент, его разыменовываем… Если элемент не найден - FindEntry вернёт nullptr. Но ведь разыменовывать nullptr - это UB!
И дальше пошла жара…
assert там роли не играет (только больше запутывает). По факту - мы сразу разыменовываем указатель, возвращённый функцией. Значит - он НЕ МОЖЕТ быть nullptr (потому что это - ub. Можно разыменовать и упасть, а можно просто вообще устранить эту ветку. ub - значит, карт-бланш компилятору в этом случае!)
Шаг номер два - раз nullptr нам разыменовать не дают - значит, FindEntry тоже никогда не возвращает nullptr (ну, зачем держать мёртвый код…). Поэтому компилятор внимательно выкинул все ветви, которые приводят к возврату nullptr.
Самое критичное - он выкинул критерий завершения цикла. while ( m_pHash[iIndex].IsUsed(*this) ) превратилось в while ( true ) потому что иначе мы вылетим из цикла, а там return nullptr, а этого быть не может.
Ну и сам “выстрел” - попытка удалить несуществующий элемент. Разыменования nullptr при этом не происходит, всё просто остаётся в вечном цикле…
Вот эта инверсия принципа причинности - на самом деле несколько бесит. Компилятор начинает творчески интерпретировать вызванный ранее метод на основании кода, который написан ПОСЛЕ этого вызова. Ну то есть понятно, что для компилятора эти "до/после" - являются абстрактными понятиями. Но вот для человеческого ума - распространие ограничений "обратным ходом" - и редактирование логики вызванного метода "задним числом" - это "массаракш! Тридцать три раза - массаракш!" (С) Стругацкие.
Ну хорошо - разрешили компилятору делать что угодно в точке где допущено UB - хрен с ним, мы понимаем что не на всех платформах можно контролировать разыменование nullptr. Но зачем лезть от этого места дальше во все стороны - и радостно вычеркивать код под лозунгом "UB быть не может" ?!
ИМХО вот это уже камень в огород создателей компиляторов - они имеют право не детектировать UB и продолжать исполнение без гарантий консистентности состояния программы (хотя писать по адресу 0 стоило бы - возможно платформа умеет ронять программу с диагностикой и без поддержки компилятора). Но вот накладывать на весь остальной код ограничения вытекающие из лозунга "UB быть не может" - как бы не стоило... Впрочем - мы же понимаем зачем это сделано. Иначе в obj-файле останется стопятьсот лишних специализаций шаблонов, которые мы в C++ сгенерировали из-за SFINAE и не можем найти повод выкинуть...
в данном конкретном случае получился очень эффективный цикл. В ассемблере буквально 5 инструкций, из которых последняя - jump в начало цикла. Т.е. с точки зрения эффективности оно сильно выигрывает. С точки зрения отладки - где-то последние полтора года абстракция отладочных символов поднялась до уровня, где хорошо отслеживаются инлайны в самых разных местах; и даже несколько вложенных “вызовов” инлайн-функций вполне отображаются (в GDB 12+) как кадры стека (которых реально не существует. Отладчик просто знает, где, что и как за инлайнилось, и позволяет “шагать” по несуществующим кадрам, как будто там реально была передача управления). В самом корне - это ошибка программиста. Он не предусмотрел случай “удаляем несуществующий элемент”. Но зато сколько скиллов это дало мне (как исследователю бага) - я прямо рад. Случай нифига не банальный ). “а, ну тут всё ясно, будем думать дальше” - вообще не прокатило! Хорошее комбо, где ub в одном конкретном месте смешалось с шаблонами и инлайнами. Программист лопух, компилятор - молодец! (и это тот кейс, когда, возможно, помогли бы санитайзеры… Хотя если они динамические - им тоже сложно предусмотреть, именно кейс “элемента нет в таблице”. Это бы и ассерт поймал…)
Я просто счастлив, что установил стандартом не использовать nullptr - довольствуясь старым добрым NULL. :)
После Вашего случая подумаю, может и от bool в пользу BOOL отказаться :).
А assert (и вообще выбрасывание исключений) не считается препятствием для разыменовывания, получается?
Похоже ни автор статьи ни все комментирующие не понимают сути проблемы с UB в C/C++. Что ж, попробую объяснить весь расклад. Смотрите, проблема не в оптимизациях комплиятора (я наборот считаю что они недостаточно агреcсивны) - проблема в том что
1) сначала стандарт С++ (в си аналогично) разрешил имплементациям (они же компиляторы) - что в случае наступления любой опасной ситуации (которая попадает в список UB) компилятору разрешается не аварийно завершить работу программы (как единственное логичное действие) а тупо продолжить выполнение программы в неконсистетном режиме с нарушением всех возможных логических инвариантов, проверок и т.д. Вы только вдумайтесь насколько дикая ситуация если подумать. Вот определение UB из стандарта C++
3.65 [defns.undefined] undefined behavior behavior for which this document imposes no requirements [Note 1 to entry: Undefined behavior may be expected when this document omits any explicit definition of behavior or when a program uses an erroneous construct or erroneous data. Permissible undefined behavior ranges from ignoring the situation completely with unpredictable results, to behaving during translation or program execution in a documented manner characteristic of the environment (with or without the issuance of a diagnostic message), to terminating a translation or execution (with the issuance of a diagnostic message). Many erroneous program constructs do not engender undefined behavior; they are required to be diagnosed. Evaluation of a constant expression (7.7) never exhibits behavior explicitly specified as undefined in Clause 4 through Clause 15. — end note]
Перечитайте еще раз эту фразу
Permissible undefined behavior ranges from ignoring the situation completely with unpredictable results, to behaving during translation or program execution in a documented manner characteristic of the environment (with or without the issuance of a diagnostic message), to terminating a translation or execution (with the issuance of a diagnostic message)
и вдумайтесь - стандарт не обязывает от реализаций останавливать программу в случае возникновения UB, - он такой говорит - "ну вы типа можете выдать диагностику и остановить программу на этапе компиляции или в рантайме (и на том “спасибо”) но вообще-то чтобы получить лычку “standard-conforming” в случае возникновения UB вам как компилятору (который обеспечивает рантайм и отвественнен за выполнение) разрешается тихо проглотить это и продолжить выполнение в неконсистетном и непуправляемом состоянии с любыми возможными нарушениями". Разве это не дико? Я бы понял бы если бы стандарт разрешал бы подобную ситуацию в других режимах а но для получения лычки “standard-conforming” в обязательном порядке требовал бы от компилятора иметь опцию или режим в котором компилятор пусть ценой любого возможного замедления (путем вставки рантайм-проверок или вообще интерпретации) но все же гарантировал бы что в случае возникновения UB программа просто упадет но ни в коем случае не продолжит свое исполнение в неконсистеном режиме с разносом нарушающие все возможные инварианты
В общем моя единственная претензия к стандарту С++ заключается всего лишь в том что он не обязывает имплементации стандарта иметь режим при котором программа в момент UB (пусть ценой любого возможного замедления) все же остановится а вполне себе разрешает имплементациям всегда комплировать или исполнять программу так что программа продолжает тихо выполняться в неконсистетном состоянии
2) а потом компиляторы такие - “ага, ну раз разрешено в момент наступления UB делать что угодно, ну так давайте оптимизировать так как будто никаких UB нет” и это привело к ситуации что если UB происходит то программа действительно начинает выполняться в неконсистетном режиме из-за вырезанных if-ов и других инвариантов из кода и дальше я мог бы поискать список из несколько десятков статей на хабре с описанием этих ужасов которые происходят результате такой политики компиляторов, но мне лень :)
При чем тут надо провести четкое разделение - я не против самих оптимизаций, я наборот считаю что компиляторы еще недостаточно агрессивно оптимизируют код. Я просто считаю что компиляторы вместо того чтобы здраво смотреть на вещи они приняли за мантру "если стандарт разрешает вместо аварийной остановки тихо выполнять программу в неконсистетном режиме, так давайте это делать даже не предоставляя иной опции” вместо того чтобы сказать, мол “знаете, мы не согласны с такой позицией и вот вам режим в котором компилятор будет гарантировать что в момент UB программа упадет но точно не продолжит свое выполнение в неконсистетном режиме”
И дальше что касается оптимизаций, то смотрите какая интересная штука получается - как только включается этот режим то дальше компилятор встроив рантайм проверку (например проверка переполнения, проверка выхода за пределы массива и т.д с аварийным завершением работы и выдачей диагностики) может проводить самые агрессивные оптимизации которые только возможны и если он смог статически доказать (после whole-program анализа используя самые передовые data-flow, range-analysis, symbolic-execution и т.д техники) что заход в такой-то if невозможен то тем самым он сможет вполне безопасно вырезать эту рантайм проверку и тем самым ускорить программу.
Ну а если юзер использует раздельную компиляцию и не хочет предоставлять все исходники всех зависимых библиотек или если они закрыты (но слава богу в 2026 году это редкость и можно вполне строить проекты используя библиотеки или зависимости которые имеют на 100% открытые исходники) или если юзер не хочет ждать несколько часов (но по идее инкрементальный режим с анализом на каждое нажатие клавиши и ввода кода должен значительно ускорить анализ) - ну тогда естественно возможностей доказать что-то статически у компилятора значительно поуменьшатся и юзер соотвественно получит замедление кода.
Но главное это выбор, разве не так? Главное это возможность при необходимости таки обдумать трейд-оффы, понять чего мы лишаемся, понять чего нам стоит внедрить то или другое, может не так уж и сложно настроить whole-world сборку проекта полностью из исходников всех зависимостей (особенно если код этих зависимостей и так открыт) чтобы компилятор в момент статического анализа мог заглянуть в тело абсолютно любой вызываемой функции и т.д. Самое главное это выбор. А текущая политика компиляторов этого выбора юзеру не дает и не предоставляет режима при котором пусть с замедлением но все же будет гарантироваться остановка программы в момент UB вместо тихого продолжения выполнения в неконсистетном режиме
Исходный мандат UB был оборонительный - мы не можем гарантировать определенное поведение на всех платформах при разыменовании nullptr - поэтому мы сознательно не будем это записывать в стандарт. Опять же нудно замечу, что разработчикам стандарта стоило бы в этом месте написать - "результат разыменования nullptr зависит от платформы" - и дальше бы каждая платформа сама разобралась что у них там nulptr, и что в этом случае произойдет. И дала бы гарантии того, что будет генерировать компилятор в этом случае.
Но разработчики компиляторов стали пользоваться этим мандатом расширительно: "мы ЗНАЕМ что в программе не может быть UB, и можем оптимизировать ее на этом основании". Мандат "можно продолжать вычисления без диагностики и без гарантий консистентности" и "можно оптимизировать программу исходя из того что ситуации UB никогда не возникают" - это по-понятиям все-таки совсем не одно и то же! Хотя логически, да - второе может быть допустимым вариантом первого...
половина примеров - чисто вопросы к линкеру
Или вот другой вариант:
void set_it(std::atomic<int>* p) {
Статья про C внезапно перетекала в про C++.
Тем не менее в 2026 году написание кода на С или С++ без анализа на UB с помощью LLM уже следует расценивать как нарушение SOX, да и просто как банальную безответственность. Если уж разработчики OpenBSD более 30 лет не могут отыскать эти проблемы, то каковы шансы у всех остальных?
Такой подход может не масштабироваться на крупные кодовые базы, но конкретно в своих проектах я прошу LLM найти UB, а при необходимости объяснить и исправить. После этого я внимательно изучаю результат, пока не убеждаюсь, что баг реально присутствовал и был исправлен.
Это перевод, но всё же. Почему автор вообще считает, что LLM могут искать UB лучше людей вообще всех. Да, LLM оказались лучше конкретно этого человека, но это ведь не говорит о всех людях. На моей практике работы с LLM они пропускали UB (в C++ только), которые мне были видны сразу.
Если "эксперт" не может найти UB в примерах этой статьи, то эксперт ли это?
Почему автор вообще считает, что LLM могут искать UB лучше людей вообще всех
Как минимум потому, что проанализировать миллионы строк кода большого проекта способна только нейронка? Возможно она не найдёт всё или даст false positive, но уж точно больше человека, как мне кажется.
В С неопределённое поведение повсюду