Как стать автором
Обновить

Опасность устарела: несколько важных нюансов в новых стандартах C++

Время на прочтение16 мин
Количество просмотров16K
Всего голосов 58: ↑56 и ↓2+60
Комментарии76

Комментарии 76

Здесь он предполагает, что за границей массива элемент будет в любом случае найден, поэтому возвращает true

Нет, компилятор предполагает, что программа корректна и выхода за границы массива не будет. Т.е. выполнение никогда не дойдёт до момента, когда i=4, а значит в какой-то предыдущей итерации выполнится return true.

В этом и есть главная проблема UB в плюсах и вообще плюсов. Язык спроектирован по принципу: "-- Доктор, я сломал ногу в нескольких местах. -- Ну так не ходите больше в те места."

Компилятор видит, что функция либо возвращает true, либо происходит UB.

Ответственность за недопущение UB лежит на программисте, поэтому компилятор предполагает, что UB не произойдет, а значит, функция всегда возвращает true.

Было бы проще, если бы в таких ситуациях компилятор выдавал предупреждения. Понятно, что не все UB можно предупредить, но такие яаные надо бы.

x += 10; // C++ 20 deprecated
x++; // C++ 20 deprecated

Увидел - вздрогнул ... ))

C++; //deprecated

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

Была статья когда-то на хабре, где объяснялось как так получается. Суть в том, что компиляция и оптимизация это итеративный процесс, в ходе которого компилятор преобразовывает ast и тот момент, где компилятор выкидывает цикл это момент, где информация о том, что это может быть ошибкой программиста уже не доступна.

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

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

Ну или пользоваться сторонними решениями. (Кивок в сторону мирно пасущегося единорога...)

НЛО прилетело и опубликовало эту надпись здесь

Злые языки говорят, что теорема Райса (вроде) не позволяет для общего случая разрешать любые нетривиальные свойства, к которым, в том числе, относится "наличие UB вот в этом месте". В таком случае и заморачиваться не стоит.

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

Проблема в том, что компилятор считает тебя умным. И при этом даёт тебе абсолютно все инструменты как можно описать почти любое поведение. Мол ты даже можешь перегружать функции отдельно для rvalue. Но реальность такова, что С++ программист это не тот кто знает стандарты, а тот кто умеет буквы писать и бизнес задачи решать. В целом как и в любом другом языке.

Я прочитал за всё время уже 10-20 статей, где приводятся примеры того, как компилятор С++ видит UB и решает выбросить стол в окно выполнить странную оптимизацию. А можно хотя бы один пример, где такая оптимизация приносит пользу, а не ломает программу? Если нет, то почему она вообще существует? Я понимаю, что есть случаи, когда действительно понятно на этапе компиляции, что, например, ветка if никодга не будет исполнена. Но вот пример с поиском элемента за границей массива это какая-то хрень. С чего бы это элемент должен там найтись? Почему вообще компилятор, заметив выход за границу массива, не выдал предупреждение? Тут либо peephole-оптимизация IR, которая может терять высокоуровневый конекст, либо уже никто толком не понимает, как работают GCC/LLVM, и начинают оправдывать работу оптимизатора заботой о скорости - этакий вариант стокгольмского синдрома.

Компилятор видит, что функция либо возвращает true, либо происходит UB.

Ответственность за недопущение UB лежит на программисте, поэтому компилятор предполагает, что UB не произойдет, а значит, функция всегда возвращает true.

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

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

Пример приведите.

#include <climits>
int main() {
  for (int i=0; i<INT_MAX; i++);
  return 0;
}

Цикл вполне можно выкинуть.

Пропустил комментарий. А как это ваще связано с UB? Есть пустое тело цикла, цикл можно развернуть и удалить.

Идея в том, что UB помогает доказать, что цикл завершится рано или поздно?

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

Основная причина заключается в том, чтобы разрешить агрессивную оптимизацию. Если вы заставляете все выражения иметь определенное поведение, вы ограничиваете возможности компилятора по оптимизации.

Например, некоторые алгебраические упрощения для целых чисел недопустимы, если вы требуете, чтобы переполнение знаковых целых арифметических выражений было четко определено.

Таким образом, запретив переполнение знаковых чисел, вы ускоряете конечный код.

Вы путаете UB и unspecified

Неожиданное утверждение. Обоснуете?

> Если вы заставляете все выражения иметь определенное поведение, вы ограничиваете возможности компилятора по оптимизации. Например, некоторые алгебраические упрощения для целых чисел недопустимы, если вы требуете, чтобы переполнение знаковых целых арифметических выражений было четко определено.

Вот это например. Unspecified означает, что переполнение знаковых целых может быть 0, 1, -2 миллиарда, 5, и вообще какое угодно число. Ну и делайте свои алгебраические упрощения.

UB означает, что переполнение знаковых целых может быть «отформатировать жесткий диск» или «запустить ядерные ракеты». Не подскажете, зачем вам это для алгебраических упрощений?

Так. Давайте по порядку. Unspecified behavior это то же самое, что implementation defined, то есть, оно хорошо определено и работает всегда одинаково, но разработчики компилятора (выделение важно́) не обязаны документировать поведение (в отличие от implementation-defined).

Undefined же не имеет вообще никаких ограничений по поводу того, что случится. И да, в том числе «запустить ядерные ракеты». Для алгебраических упрощений это может быть важным, поскольку компилятор не обязан гарантировать результат работы конкретной машины. То есть, конкретная машина может работать с дополнительным кодом, а может и нет.

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

Таким образом, я не думаю, что я путаю unspecified и undefined, ваше обоснование меня не убедило.

Компилятор в момент компиляции знает как представлены в на целевой машине знаковые числа — дополнительными кодами или как-то по другому.

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

Я не очень улавливаю вашу мысль. Она в том, что я не понимаю разницы между unspecified и undefined или мы уже о чём-то другом? Сформулируйте точно, пожалуйста.

Моё утверждение: введение концепта UB расширяет возможности оптимизатору, в том числе для работы на большем количестве железок.

Пусть у нас есть две операции op1 и op2 над двумя числами A и B. Вот я в коде пишу C = A op1 B. Оптимизатор компилятора хочет заменить эту инструкцию на другую, более дешёвую C = A op2 B. Но предположим, что такая замена эквиалентна только при условии непереполнения A op1 B. Если переполнение undefined, то компилятор имеет право сделать замену, поскольку это забота программиста не допустить UB. Если же переполнение unspecified, то замену делать нельзя.

> Если же переполнение unspecified, то замену делать нельзя.

Вот этого я не понимаю.

Если op2 во всех случаях возвращает C, которое является валидным интом, но который иногда не равен результату op1, то это подойдет.

Нет, не подойдёт. Код после оптимизации обязан быть эквивалентным исходному коду, и компилятор эту эквивалентность обязан доказать.

Если переполнение разрешено, пусть и в виде implementation defined/unspecified, то замена на неэквивалентную (в случае переполнения) операцию недопустима.

Давайте я выражу ту же самую мысль, но чуть-чуть другими словами.

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

К счастью, литературные качества нас не интересуют в случае машинных языков, поэтому есть довольно чёткие критерии эквивалентности кусков кода.

Поэтому переводчик обязан убедиться, что A op1 B и A op2 B дают одинаковые результаты для всех допустимых значений A и B.

Если переполнение разрешено стандартом (опять же, в любой форме, будь то unspecified, impelementation defined или standard-defined), то A и B могут быть любыми целыми числами, и в такой ситуации замена A op1 B на A op2 B недопустима, поскольку результаты двух операций будут отличаться на некоторых наборах A и B.

Если же переполнение UB, то компилятор исходит из предпосылки, что A и B таковы, что выражение A op1 B не переполняется, поскольку программист позаботился об этом. И тогда компилятор имеет полное право произвести замену.

Таким образом, механизм UB развязывает руки компилятору, перекладывая часть ответственности (в частности, по работе с большими числами) с компилятора на программиста, и тем самым ускоряя основную массу программ.

Если переполнение unspecified, то замена будет корректной. Даже если в каких-то случаях op2 падает.

Переполнение делают ub, чтобы адекватно анализировать пересечения циклов и пересечения указателей.

Несогласен с первым пунктом, поскольку в таком сценарии вывод компилятора ломается непредсказуемо, что не согласуется с тем, что поведение op1 определено разработчиками компилятора.

UB по переполнению существует по множеству причин, например, чтобы класть 32 битные инты в 64 битные регистры.

Мне кажется, что вы введены в заблуждение схожестью слов unspecified и undefined. Чтобы не быть голословным, давайте я процитирую:

3 Terms and definitions[intro.defs]

3.63[defns.undefined]undefined behavior

behavior for which this document imposes no requirements

3.64[defns.unspecified]unspecified behavior

behavior, for a well-formed program ([defns.well.formed]) construct and correct data, that depends on the implementation

Unspecified ситуация всегда одинаково обрабатывается одной и той же версией компилятора. От implementation defined она отличается только необходимостью документации.

Если я правильно понимаю актуальную трактовку компиляторщиками, то поведение, как я описал.

Главное отличие ub - оно путешествует во времени. Т.е. программа может начать вести себя странно, ещё до того, как «произойдёт ub».

Unspecified ситуация всегда одинаково обрабатывается одной и той же версией компилятора

Если попробуете определить, что это значит, то окажется, что замена на op2 может быть корректной, или нет - по желанию реализации.

Но это всё эквилибристика. Реально нужно смотреть на то, как это компиляторщики интерпретируют.

Я пытаюсь подкреплять своё понимание ссылками на стандарт, иначе разговор неконструктивен. Если unspecified может себя вести как ни попадя, тогда и impementation-defined должен себя вести так же, см. различие только в требовании стандарта о документации поведения. И при этом мы говорим про well-formed программы.

UB не путешествует во времени. Просто поведение логических выводов компилятора для программы, допускающей UB, может быть неочевидно для программиста. Компилятор исходит из предпосылки, что UB не может происходить. Из этого следуют ограничения на интервалы допустимых переменных. Из этого следует удаление ненужных кусков кода (таких, как if false), а за этим могут быть любые сюрпризы.

Говоря школьным языком, если мы допустим, что пятый постулат нарушается (см. UB), это не означает, что Евклид (или Лобачевский) воскресли. Это просто означает, что при неверности пятого постулата мы попадаем в рамки неевклидовой геометрии.

UB не путешествует во времени. Просто поведение логических выводов компилятора для программы, допускающей UB, может быть неочевидно для программиста. 

Я понимаю, почему это происходит. Но я говорю об эффектах: наличие ub в программе может поменять её поведение, даже до его возникновения.

тогда и impementation-defined должен себя вести так же, см. различие только в требовании стандарта о документации поведения.

Собственно, наличие документации всё меняет. Например, если документация говорит: «results in two-complement overflow», то замена станет некорректной. А вот если говорит: «may result in any valid int value, or crash the program», то такая замена всё ещё оправдана.

В случае unspecified behaviour, приходится полагаться на худший случай.

P.S. Я скорее говорю о UB и прочем как о концептах построения языков и компиляторов. Если же разговор был про UB конкретно в стандарте плюсов - там немного другой разговор.

наличие ub в программе может поменять её поведение, даже до его возникновения.

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

А вот если говорит: «may result in any valid int value, or crash the program», то такая замена всё ещё оправдана.

И тут ошибка. Well-formed и well-defined, никакой двоякости трактования не допускается.

Про перемещение во времени мы говорим об одном и том же. Не вижу смысла продолжать.

И тут ошибка. Well-formed и well-defined, никакой двоякости трактования не допускается.

Не понял. Перефразируете?

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

Ну а документация не может быть трактуемой двояко: "результат вычисления 2+2 равен 5 или -3" - это не документация поведения, а мусор.

На этом, пожалуй, я действительно сворачиваю своё участие в ветке. Ссылки на стандарт я привёл, и, хотя он, к сожалению, написан на человеческом (а значит, позволяющем двоякое толкование) языке, нужные ключевые слова содержит. Кому интересно копать, тот докопается до того, что ему нужно.

Польза от самой оптимизации. Я там выше написал. Оптимизирующий компилятор трансформирует ast, так что к моменту когда по UB-коду генерируется машинный, функция может быть уже не функцией (встроена), а цикл - не циклом (развёрнут).

Потому что без UB невозможна вообще никакая оптимизация. Даже вы сами не сможете ничего оптимизировать в коде не используя понятие UB.

Рассмотрим что-нибудь простое и очевидное. Ну вот, например, FizzBuzz:

for (int i=1; i<=3; i++)
{
       printf("%d\n", i);
}

А теперь сделаем loop unrolling:

       printf("%d\n", 1);
       printf("%d\n", 2);;
       printf("%d\n", 3);;

Является ли эти две программы эквивалентными? С одной стороны, да, это вроде бы очевидно.
С другой стороны, если в языке определено поведение в любой ситуации, то у нас нет никаких гарантий что функция printf не перепишет значение переменной i, поэтому программы не эквивалентны.

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

Конкретно в вашем примере: если компилятор докажет отсутствие сайд-эффектов на i при вызове printf (а туда передаётся копия i, не адрес), то вполне можно раскрыть.

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

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

Это либо языки без сырых указателей, либо их авторы лишь притворяются что у них нет UB.

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

Откуда и фраза "если докажет". Если этот самый printf компилируется одновременно с вашим циклом, то не исключено, что компилятор и сумеет доказать.

Разве что при анализе программы целиком, и то не факт. Любой непроанализированный кусок программы может переписать адрес буфера вывода.

Ещё раз, я с мыслью согласен, а вот формулировка понятна наверняка не всем :)

Нет, потому что это ещё и зависит от трейса, который привёл к коду.

Например, какая-нибудь функция до нас могла записать адрес I в глобальную переменную, или вообще переслать в другой поток. Активация той функции уже закончилась, но…

Плюс обращу внимание, что это потребует всего кода процесса (т.е. мы запрещаем шареные либы, unfork, CreateRemoteThread и прочие системные вызовы), а также анализ псевдонимов, вроде, делается за экспоненциальное время…

От трейса не зависит, поскольку доказательство ведётся не совсем так. Разумеется, для смелых предположений оптимизатора ему нужен весь контекст целиком. И если кто-то мог скопировать адрес вашей переменной раньше, компилятор смелых предположений делать не будет.

Про экспоненциальное время - не в тему, существующие ныне компиляторы очень сильны в оптимизации, но при этом очень близоруки. Если им не помогать активно, то они ни черта доказать (и как следствие, оптимизировать) не смогут.

Не очень понимаю. Вы предлагаете делать глобальный анализ?

Я ничего не предлагаю. Я рассказываю, как работают компиляторы сегодня.

Ну тогда реальные консервативные компиляторы без ub не умеют ничего доказывать.

и это вы написали в ветке, в которой первая фраза @mayorovp была буквально «потому что без UB невозможна вообще никакая оптимизация» :)

Если языки будут полагать, что локальную для цикла переменную на стеке может поменять вызываемая функция, то никакие оптимизации будут не возможны, в том числе размещение счётчика в регистре.

Ну да, я про это и пишу. А основания полагать-то есть (при отсутствии UB), ведь адрес этой переменной может быть уже записан в каком-нибудь указателе.

Такое поведение может быть и без UB...

При отсутствии UB у вас не может где-то заранее быть записан указатель на участок стека с новообъявленной переменной.

Ну, достаточно добавить одну инструкцию в тело цикла перед вызовом printf. UB нет, адрес есть. Доказательство отсутствия сайд-эффектов вызова printf провалено, loop unrolling не происходит.

При отсутствии UB у вас не может где-то заранее быть записан указатель на участок стека с новообъявленной переменной.

Почему не может?

int* shared_ptr;

void foo() {
    int i;
    shared_ptr = &i;
}

void baz() {
    int i = 1;
    *shared_ptr = 2; // упс, i изменилась
}

void main() {
    foo();
    bar();
}

s/упс, i изменилась/UB/

Так UB же отсутствует в языке.

Тут явно путаница с отсутствием ситуации UB конкретно в си и с отсутствием механизма UB в гипотетическом языке.

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

Гарантия есть. Адрес переменной i никогда не брали, она вообще может его не иметь. И кстати и не будет.

Но все ещё непонятно, как это связано с понятием UB и его наличием в языке.

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

Ну например в Rust и Fortran есть гарантия non-aliasing — компилятор имеет право считать, что ничего не поменяет значение переменной, кроме записи в эту самую переменную. Это значительно проще, а также дает больший выигрыш по оптимизации.

А что происходит, когда это правило нарушается? UB.

Интересно, это языки действительно без UB или не упоминающие о UB в своей спецификации?

А разве в примере с фором по знаковому индексу с применением ссайз не будет ворнинга на преобразование знакового индекса в сайзт в операторе сабскрипт?

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

Сами проверьте, что написать тернарник с проверкой размера массива при инициализации индекса в форе намного проще чем все эти касты и никакой ссайз не нужен.

for (auto i = arr.empty()?-1:0u; i < arr.size()-1; i++)

Но да когда завезут где то в с++32 знаковые сабскрипты вот тогда и начнем использовать ссайз как в вашем примере без лишних приседаний.

out of bounds write — переполнение буфера, оно же stack overflow.

эээ...

Видимо имеется ввиду, что stack overflow тоже приводит к out of bounds write

Stack overflow это частный случай out of bounds write, наиболее известный большинству читателей.

Ну, если уж вы так вольно трактуете, то тогда "stack overflow, оно же out of bouds write", а не как вы написали :)

Кажется, функция

void CheckAndCreate(const std::filesystem::path& p) {
  if (!std::filesystem::exists(p)) {
      std::fstream f(p.string(), std::ios_base::in | std::ios_base::out);
      f << "data" << std::endl;
  }
}

не эквивалентна функции

void CheckAndCreateNoRace(const std::filesystem::path& p)
{
  std::fstream f(p.string(),
    std::ios_base::in |
    std::ios_base::out |
    std::ios_base::noreplace);
  f << "data" << std::endl;
}

Нужна еще проверка, что файла действительно не было. Такой метод в классе есть?

Есть оператор приведения к булеву типу. Но в данном случае проверка не нужна, потому что запись в закрытый поток будет игнорироваться.

Деприкейт volatile в С++20.

Задеприкейтили сложное присваивание и инкремент / декремент

Сложное присваивание вернули в C++23.

С его deprecation в C++20 была глупая история. С одной стороны, известно, что volatile не даёт атомарности и в основном нужен для MMIO. С другой стороны, volatile ошибочно используют вместо atomic, что в сознании масс сократилось до "volatile - зло" и похожую ошибку, видимо, сам комитет допустил.

Дискуссию о возвращении вели на реддите и возвращение встречали критично: эмбеддеры с возу - кобыле легче.

Кстати, ортогональность volatile и atomic наводит на мысль, что компилятор имеет право оптимизировать atomic (об этом же написал автор "Deprecating volatile"), но судя по godbolt никто из компиляторов не рискует применять оптимизации.

Спасибо за комментарий. Про возвращение присваивания не знал, интересная получилась история))

Выскажу непопулярное мнение. Проблема UB в C++ сильно преувеличена. Исторически стандарт C++ не представляет собой парадигму «все что не запрещено, то разрешено», он лишь описывает как надо применять язык. Если ситуация явно не описана, то делать так лучше не надо.

А если делаешь, то на свой страх и риск. Проверь на разных компиляторах и архитектурах, расставь defines, укажи в system requirements. Обычно с таким сталкиваются крупные проекты, которые могут себе позволить тратить на это ресурсы.

Язык C++ создавался во времена, когда просто добавление макросов в язык делало его хитом. Никому в голову не приходило создавать что-то настолько строго определенное как например Rust. Contracts, constrains, const variables, муниципальный фильтр - вызвали бы непонимание у программистов тех времен.

Я бы «закопал стюардессу». Новые версии стандарта, на мой взгляд, пытаются изменить парадигму языка не меняя сам язык. Это во-первых выглядит сомнительно, а во-вторых отвлекает от реальных сложностей разработки на C++.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий