Pull to refresh

Comments 72

Хабр торт!
Большое спасибо за статью. Очень напоминает проблемы strict aliasing, который многие выключают, чтобы не разбираться с этим вот всем. Потеря производительности в обмен на стабильность.
Какая опция компилятора показывает предупреждения сразу как только компилятор C++ «эксплуатирует неопределенное поведение» так как ему удобно?
Строго говоря никакая. Очень редко компилятор видит UB и ломает код, чаще он исходит из предположения, что программа корректна, и поэтому делает оптимизацию. А из неверной предпосылки выходит что угодно.
Есть множество статических анализаторов, в том числе и рекламируемых на хабре, которые находят многие случаи UB, плюс динамические вроде ubsan.
Ещё есть опции, запрещающие некоторые оптимизации. Например, -fno-strict-aliasing. Но единого выключателя для UB оптимизаций нет.
Но единого выключателя для UB оптимизаций нет.
Его нет, потому что даже выключение всех оптимизаций вообще ничего не может гарантировать. Пример — чуть ниже.
А что весёлого-то? С выключенными оптимизациями всё работает.

Я хотел показать, что не в оптимизации дело. А в том, что если у вас в программе UB — то, вообще говоря, нельзя говорить о том, что программа должна делать в принципе.

В каких-то частных случаях, для каких-то конкретных UB — да, можно. В общем случае — нельзя.
Так проблема не в том что работает, а в том что с оптимизацией оно работает не только быстрее, но вообще иначе. И в том потом такие грабли выловить тяжело. В этом примере всё локально, а теперь представте что подобное закопано в недрах многоэтажных шаблонов. И компилятор молча сгенерирует код исходя из не верных предположений. А еще и код может быть написан не одним человеком.
Всё так. Но вам, всё-таки, пришлось включать оптимизацию!
В моём примере — всё то же самое, только ещё и компиляция с -O0.
То есть, оптимизатору негде “разгуляться”. Он выключен.
Ну нельзя создать компилятор, который будет “правильно” компилировать все программы с UB. Просто нельзя.
Просто потому что для программы с UB нельзя сказать что такое “правильно”.
Вот я и спрашиваю как узнать после компиляции сколько UB компилятор использовал?
Никак. Компилятор не считает UB. Он исходит из простой посылки: UB в программе нет. Ни одного.
Вернее, из того, что ни одно из них не срабатывает во время работы (UB могут зависеть от входных данных).
Есть ubsan — он некоторые UB (не все) отлавливает во время исполнения.
Но, вообще говоря, «отлавливать UB» — это задача программиста, не компилятора. Это программист должен обеспечить, чтобы UB отсутствовали.
P.S. Вы же не спрашиваете «как узнать сколько раз компилятор использовал тот факт, что „+“ — это сложение, а „-“ — это вычитание»? А это, наверное, можно посчитать… но зачем? Если программист складывае с помощью оператора „-“ — то это ошибка. Её нужно исправить. И всё. Даже если сегодня ветка, где кто-то использует „-“ для сложения, не вызывается и всё работает — завтра это может измениться и программа работать перестанет. С UB — то же самое.
исходит из простой посылки: UB в программе нет. Ни одного.

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

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

ps: Я вообще сторонник того что бы можно было явно отключать разные функции языка. Вон не хотите что бы использовался double в коде. Запретили если где-то используется — ошибка. Или сними ограничение или исправляй.

Есть пару примеров простейшего UB. Например выход за границы массива:


cin >> x;
cout << vec[f(x)];

Каким образом компилятор должен узнать есть тут выход за границу или нет? А никак. Поэтому он просто возвращает разыменовывает указатель vec.data() + f(x) а куда он там указывает это уже забота программиста.

Да я разве против. Пусть падает в этом месте. Но когда он из того что знает что массив содержит скажем 1 элемент выводит что f(x) всегда равна 0 и выкидывает весь код f(x) и в остальных частях программы вставляет 0. При этом это делает втихаря. Меня как-то не устраивает.
А почему не устраивает-то, собственно? Это очевидным образом быстрее, чем вызывать f(x) и в правильно написанной программе это не вызовет изменений (если f, конечно, описана как чистая или её тело доступно и в чистоте можно удостовериться), а в неправильной это и неважно.

если f(x) чистая, то закэшировать значение идея хорошая. А вот выводить чему она равна из каких то граничных случаев, я слабо представляю как это на практике поможет. Все равно это какие то граничные кейсы на которых сэкономим меньше процента.

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

Да и строить логику на заведомо ложном предположении это так себе идея. «Данная программа идеальна и не содержит ошибок и работает правильно во всех случаях» — это утверждение не возможно проверить или доказать. Но именно это лежит в основе подобных оптимизаций. Зато можно всё свалить на программистов, мол следите чтоб UB не было нигде (им же больше нечем заняться так ведь).
А в результате получаем что у семи нянек детё без глаза.
И мы вместо локализации UB мы имеем распространение и размножение ошибок.
Как я уже сказал: можете сделать лучше — делайте. В конце-концов всё то же самое ведь можно сделать и без всякого UB. В той же самой дорогостоящей компьютерной ошибке в истории никакого оптимизатора и UB не было — а всё вами описанное (ошибка в одном месте распространилась на весь код и всё поломала) — было.
Это, извините, просто свойство программных систем. UB тут ничем не отличается от путаницы между ньютонами и фунт-силами. И ошибки округления могут накапливаться — с очень плачевными последствиями.
«Данная программа идеальна и не содержит ошибок и работает правильно во всех случаях» — это утверждение не возможно проверить или доказать.
Во-первых иногда это таки можно доказать. Во-вторых — а как ещё вы можете проводить вообще хоть какие-нибудь преобразования с программой? Да, блин, если вы не знаете что имеет в виду программист когда пишет + — то ли сложение, то ли вычитание, а может вообще «логическое или»… как вы при таком подходе вообще что-то собрались порождать на выходе?
Зато можно всё свалить на программистов, мол следите чтоб UB не было нигде (им же больше нечем заняться так ведь).
Ещё раз: какие вы предлагаете альтернативы.
Ну вот просто:
Дано: Программист намесил какой-то лажи с кучей ошибок.
Надо: Сделать так, чтобы программа работала без ошибок.

Ну вот как вы себе представляете решение этой проблемы? Конструктивно? Нет, возможно когда-нибудь “сильный AI” и научится это делать. Только вот следующим шагом будет просто увольнение всех программистов, потому что пустая программа — частный случай программы с ошибками, а если компилятор из такой программы порождает правльный выхлоп… то нафига нужен программист?

Вещь, которую вы, почему-то отказываетесь понять, на самом деле проста: некоторые UB обработать иначе, чем они обрабатывают невозможно — и потому, для простоты все UB обрабатываются тем же способом.

Хотите чтобы часть UB перестали быть UB, а превратились в unspecified behavior или implementation-defined behavior? Вперёд: пишите proposal, стандарт меняется, за ним меняется компилятор.

Вы же, на самом деле, желая получить в некоторых местах вместо undefined behavior либо unspecified behavior, либо implementation-defined behavior вместо соотвествующих proposal'ов мечете громы и молнии на форумах, плачетесь в списках рассылки и так далее. В общем делаете всё, что угодно — только не то, что нужно делать.
Не вижу никакого смысла писать соответствующие proposal. Это чуть более чем бесполезно. Там курят слишком забористую траву.
(ошибка в одном месте распространилась на весь код и всё поломала). Это, извините, просто свойство программных систем.

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

Вещь, которую вы, почему-то отказываетесь понять, на самом деле проста: некоторые UB обработать иначе, чем они обрабатывают невозможно — и потому, для простоты все UB обрабатываются тем же способом.

Вообще ничего не понял.
Я просто хочу что бы компилятор не молчал когда видит UB и особенно когда на его основе меняет код.
Я просто хочу что бы компилятор не молчал когда видит UB
Вы хотите на каждую строку получить 20-30 строк диагностики? Ну кроме строк совсем без кода?

Вот возьмём такую ужасную функцию с тремя целочисленными массивами:
void foo(int a[], int b[], int c[]) {
   a[0] = b[0] + c[0];
}
Тут у вас примерно десяток потенциальных UB. b и c могут оказаться неинициализированными (это UB), результат сложения может не влезть в int, указатели могут указывать в неаллоцированную или освобождённую память (это UB), одна и та же память может быть разmmapела по разным адресам (это UB), по адресам b[0] и c[0] может данных не оказаться (это другое UB, не то, что с неинициализированными a и b) и так далее (там ешё много чего можно вспомнить… в частности проблемы алиасинга, обсуждаемые в статье тоже могут на такую функцию повлиять).

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

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

и особенно когда на его основе меняет код.
А он не меняет код “на основе”. Он просто предполагает, что у UB нет — и всё.

Нигде в компиляторе нет какого-либо учёта UB. Это абсурд. Как программист не думает над тем, что будет если a и b будут разными указателями, указывающими на одно место в памяти (потому что он такого ужаса не писал… хотя используемые ими библиотеки вполне могут такое сотворить… кто ж им запретит?), так и компилятор об этом “не думает” — потому что программист ему это пообещал.

При этом большинство UB программист старательно изводит (например если два разных потока работают с этими массивами без синхронизации — вы же не будете требовать, чтобы программа работала?), но вот некоторые — почему-то не считает своим долгом править.

И как разрабочики компилятора должны “отделять зёрна от плевел”, извините? Какие UB считать “доброкачественными” (которые программист “изведёт” без истерик, а если нарвётся — будет винить себя… скажем обращение к освобождённой памяти) от “плохих” (которые программист считает возможным оставить в программе, а когда компилятор их “поиспользует” — у него случится истерика… скажем челочисленные переполнения и обращения к одному участку памяти то как к int, то как к unsigned int)?

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

Потому компиляторы (разработчики компиляторов, на самом деле) исходят из простого подхода: всё что стандарт называет UB (обращение к неинициализированной переменной, обращение к невыделенной или удалённой памяти, обращение к одному неатомарному объекту из двух потоков без специальных конструкций и так далее и тому подобное) — обрабатывается одинаково: считается что программист их извёл и всё. Иногда (там где программист явно написал какую-то лажу) выдаются предупреждения, но на все UB их выдавать нельзя, вы таким компилятором пользоваться не сможете.

Для самых популярных UB (которые стандарт называет UB, но которые програамисты не склонны считать UB) есть ubsan, но он, конечно, всё покрыть не может (в частности много раз упомянутый выше трюк с mmap он не отловит).

Давно уже строят надёжные системы из мене надёжных подсистем. Для этого делают дублирующие системы которые должны работать на отличающихся принципах, независимо, по разным алгоритмам или хотя бы написанных разными группами.
И как это относится к компилятору? Предлагаете компилировать программы 3-4 раза и выбирать реализацию функции случайным образом? Так они все на основе одного стандарта написаны, так что список UB, в частности, у них совпадает.

Тут программисту нужно писать приограмму на нескольких языках, извините. То есть всё вернулось туда, откуда начали…

Не вижу никакого смысла писать соответствующие proposal.
Ну не хотите, так не хотите, дело ваше, тогда будем считать, что имеющийся список UB вас устраивает. Другие, кстати, подобные proposal'ы писали — при должной агруменации они вполне могут быть приняты. Вот например или вот.

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

Может это вы называете “слишком забористой травой”?

Но это вот — реальный способ что-то изменить. А плакать на форумах по поводу козней компиляторщиков — нереальный. Как бы вам ни хотелось обратного.

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

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

Написание безопасного кода — отдельное дело. Слабо связанное с истериками отдельных разработчиков, которые истошно вопят, что им разработчики компиляторов обязаны “взять и обеспечить”… а если сделать это не позволяют законы математики и физики… то и пофиг: пусть меняют математику. Сложно, что ли? Пусть Евросоюз или Дума новый закон примет…

У тут даже с -O0 можно компилировать.
Я, собственно, это давным-давно продемонстировал.

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


Апд. продолжил мысль выше

Кстати нафига вообще может понадобится «aggressive-loop-optimization» я как то не могу придумать.
Позволяет избавиться от циклов. Например если цикл у вас имеет размер один и вы обращаетесь к его элементам, то ясно, что вы обратитесь только к нулевому элементу и цикл выполнится один раз.

Не понятно всё равно. Мы не можем узнать размер цикла по условию внутри for в компайл-тайм (если бы могли то думать не надо). Но каким то образом по телу цикла делаем вывод о его размере, и выводим что он размера один. Такой код имхо уже подозрительный.
Или я не правильно понял как оно работает? Т.е. есть вариант когда эта оптимизация "ошибочно" сработает (т.е. сработает для программы с UB) и при этом не выкинет warning?


Апд. наверное это имеет смысл для циклов с break… хм...

Но каким то образом по телу цикла делаем вывод о его размере, и выводим что он размера один.
Не по “телу цикла”. По размеру массива! Если у нас переменная — это массив (а не указатель), и у неё размер массива равен единице, то мы точно знаем, что обращение будет только к нулевому элементу — и ни к какому другому.

Т.е. есть вариант когда эта оптимизация «ошибочно» сработает (т.е. сработает для программы с UB) и при этом не выкинет warning?
Ах, это. Я не знаю, может ли эта оптимизация сработать с UB и без warning'а, если честно. Да это и неинтересно.

Я описывал когда она срабатывает в программе без UB и работает “как задумано”.
Потому что программист сказал: «мамой клянусь нет UB».
Нет.
Вот тут вы мне и скажите с какого перепуга он решает что нет ни одного UB.
Потому что если в программе случается UB, то любое поведение программы, вот совершенно любое, считается допустимым:
if any such execution contains an undefined operation, this document places no requirement on the implementation executing that program with that input (not even with regard to operations preceding the first undefined operation)
Замечание в скобках — это тоже часть стандарта, не моё изобретение.
Может лучше подобную проверку должен делать компилятор, а не человек.
Как только вы создадите акой компилятор — можно будет о чём-то рассуждать.
Пока что желающих пользоваться такими компиляторами — наблюдается в количестве, желающих такое чудо создавать — нуль.
ps: Я вообще сторонник того что бы можно было явно отключать разные функции языка.
А я вообще сторонник того, чтобы можно было написать «сделайте мне клёво» и компилятор у меня в мыслях бы прочитал чего я хочу. Ну сделайте, а? Чего вам стоит?

Правда же заключается в том, что создавать программы без UB — возможно. Сложно, но возможно. А создать компилятор, который бы «правильно компилировал» любые программы без UB — невозможно. Просто потому что у разных людей разные мнения на тему «что такое правильная компиляция программы с UB».
Никакая, потом как это бессмысленно. Для почти любой оптимизации можно придумать программу, которая этой оптимизацией будет сломана.
Даже компиляция с -O0 ничего не гарантирует! Потому что, например, если взять какой-нибудь старенький компилятор, то будет что-нибудь типа такого работать:
int foo() {
  int i;
  i = 42;
}
it bar() {
  int i;
  return i;
}

int main() {
  foo();
  printf("%d\n", bar());
}
А в clang это не работает.

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

Было бы круто, да, но разработчики компьютеров подкачали: модуль телепатии туда пока не встраивают.

Потому разработчикам компиляторов, увы, приходится довольствоваться стандартом.
Однако, если сделать
return i;
в конце функции foo, всё работает в т.ч. и в шланге.

Верно, потому что невозврат значения из не-void функции — это UB.

Вы не поняли. Невойд функция без return вообще не должна компилироваться, это явно некорректная программа. Странно, что такое вообще пропускается компиляторами. Если вы на асме забудете RET, вообще хрен знает что будет, но с очень малой вероятностью произойдёт возврат из функции.
Но, если в foo добавить return, функция bar возвращает 42, которого там близко не было. Вот это уже действительно UB «на усмотрение компилятора».
Невойд функция без return вообще не должна компилироваться, это явно некорректная программа

Такие простые случаи действительно могут детектиться и вызывать ошибку компиляции. Но в общем случае задетектить выход из функции без return-а невозможно.
Можно было бы сделать как Java: если компилятор не может чего-то доказать — значит программа невалидна.
Но так не сделали. И в C (но не в C++!) вызывать функцию без return ещё и вполне законно (а вот использовать возвращаемое ей значение — таки нет).

P.S. Современные компиляторы, кстати, позволяют это запретить: gcc, clang. Но это не «поведение по умолчанию», потому как стандарт это таки разрешает.
Странно, что такое вообще пропускается компиляторами.
Ничего странного. Дело в том, что void — это относительно “недавнее” изобреение (ну как недавнее… C89… C к моменту его появления уже больше 10 лет существовал и использовался).
До того — void не существовал, а функции, ничего не возращавшие описывались как int… но в них можно было не делать return.
И да, ради совместимости этот стиль поддерживается в C и сегодня. Даже в C17.
Да это бред жи ну! Ни один современный компилятор не скомпилит ни один пример из оригинального K&R, где всё набрано КАПСОМ, типы аргументов функции описаны отдельно, между () и {}, и по умолчанию все переменные считаются int.
Зачем тогда вообще завозили [[noreturn]]?
Просто стандартами занимаются бюрократы из крупных корпораций, которые любой чих оценивают с позиции «а сколько это нам будет стоить».
Ни один современный компилятор не скомпилит ни один пример из оригинального K&R, где всё набрано КАПСОМ
Никогда такого не было. C был с момента создания Case-sensitive, чай не Pascal. Вот тут есть примеры 1972го года — нет там никакого КАПСА. Это ещё сильно до соответствующей книжки (она в 1978м вышла).

Может быть на каких-нибудь советских ЭВМ где были большие русские и латинские, а маленьких букв не было? Не знаю. У AT&T никогда никакого КАПСА не было…
типы аргументов функции описаны отдельно, между () и {}, и по умолчанию все переменные считаются int.
Ну да. Вот же:
Clang 11 — прекрасно
Gcc 10 — работает
Icc 19 — без проблем
MSVC 19 — замечательно.
Так эта… о каком компиляторе речь-то?
Зачем тогда вообще завозили [[noreturn]]?
Он тут причём???
Просто стандартами занимаются бюрократы из крупных корпораций, которые любой чих оценивают с позиции «а сколько это нам будет стоить».
Если бы. Нет, увы. Критерии там совсем другие. Иначе бы не занимало столько времени ни продвижение корутин, ни многие другие вещи. Но это уже другая история.

P.S. GCC, кстати, очень долгое время только на K&R вариант рассчтан был. Только версия 3.4, уже в XXI веке, позволила себе отказаться поддерживать старые компиляторы и стала требовать ANSI C. C89, конечно.
«На перфокартах такой фигни не было». Гуглите кодировку RADIX-50, она например была стандартной в PDP-11. Я читал K&R в русском переводе советского года издания, и да, Си, набранный капсом, имеет совершенно особенный привкус… бейсика:)
FIZZBUZZ() 
{
  FOR (I = 0; I < 100; ++I) {
    IF (I%3 == 0) PRINTF("FIZZ");
    IF (I%5 == 0) PRINTF("BUZZ");
 }
RETURN I;
}

Не знал, что компиляторы до сих пор поддерживают древний синтаксис с вынесенным описанием аргументов.
Ну, речь в любом случае о С++, нахрена это г… но мамонтов тащить туда?
Гуглите кодировку RADIX-50, она например была стандартной в PDP-11.
В PDP-11 она не могла быть стандартной, так как это зависело от ахитектуры операционки. Может вы про RSX-11 или RT-11? Так они какое отношение к C имеют? С был в Unix разработан, а Unix унаследовал ASCII от Multics. Так что там маленькие буквы были всегда.

Ну, речь в любом случае о С++, нахрена это г… но мамонтов тащить туда?
Речь в данном случае о программах с UB. Просто на C программы проще и показать как можно “самому себе насрать в карман” тоже проще.
Гуглите кодировку RADIX-50, она например была стандартной в PDP-11.

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


Но вот большие буквы в коде на C — факт… я ещё школьником видел это на ДВК и мы даже что-то пытались на этом писать :) хотя цирком было, что в KOI-7H2 {} превращались в ШЩ, ~ становилась Э, ну и так далее… программы конечно читались и писались, но с диким акцентом...

Скорее всего большие буквы вы получали, скорее всего, из-за использования КОИ-7.

И это хорошо если компилятор был свой или адаптированный, а если использовать импортный, то приходлось писать «ВХИЛЕ» и «ЖОИД».

Но это всё имеет мало отношения к тому C, которым пользовался весь остальной мир.
Ерунду не пишите. Я буквально совсем недавно исправлял баг в GNU gcal (падал на Big Sur на M1), там большая часть кода (и в частности этот utils.c) написана на K&R диалекте, и он прекрасно собирается clang'ом из последнего Xcode. А [[noreturn]] — он вообще про другое.
Ну речь о том, что компилятор в любом случае видит наличие return, по крайней мере в плюсах, где есть исключения. И вот не надо тут про проблему останова, любая функция имеет конечный размер. Также компилятор знает особые случаи, когда функция в принципе не возвращается (именно благодаря [[noreturn]]).
Речь о том, что возможность компиляции функций без return это явный баг в стандарте языка.
Зачем вообще городить систему типов, если функция может вернуть любой мусор, оставшийся в аккумуляторе?
Речь о том, что возможность компиляции функций без return это явный баг в стандарте языка.
Ну почему же баг-то? Фича. В C++, кстати, return обязателен, но там тоже есть одно маленькое исключение: If control flows off the end of the compound-statement of main, the effect is equivalent to a return with operand 0.

В C оно не нужно, так как значение же mail никто внутри программы не использует — а значит возращать значение не обязательно…

Зачем вообще городить систему типов, если функция может вернуть любой мусор, оставшийся в аккумуляторе?
Затем, что C (и C++) — небезопасные языки, предназначенные для написания безопасных программ.

Потому UB — это ограничения, налагаемые не на компилятор, но на программиста.

Это программист обязан обеспечить, чтобы “любой мусор, оставшийся в аккумеляторе” не влиял на поведение программы. Это программист должен озаботиться тем, чтобы к объекам не быдо доступа после вызова operator new. Это программист должен гарантировать, что вы не пытаетесь передать куда-нибудь “пустую обёртку” из под объекта, содержимое которого “уехало” куда-то.

Хотите чего-то другого? К вашим услугам масса разных других языков. Java, Rust, да хоть Visual Basic… А C и C++ — они вот такие вот. Тут вы в ответе за всё (ну не совсем за всё уж… но очень много вещей, за которыми, как кажется, мог бы следить и компилятор… в C и C++ следит именно программист).
У меня нет претензий к Си, это такой «компилируемый в уме макро-ассемблер». Что написано, то в 2-3-10 инструкций и компилируется. Дальше уже оптимизатор работает, а если он лажает, всегда можно посмотреть профилировщиком и переписать на асме. Как собственно и делают те же разработчики винды.
Но С++ претендует на большее. Вы не можете сказать, что делает f(42), не зная контекста. Это может быть что угодно, от инициализации переменной до вызова f.operator().
Сегодня уже никому не нужны «просто программы». Всем нужны корректные программы. Блин, сегодня рекламщики пишут свои анальные зонды на тупоскрипте со строгим контролем типов. А в С++, на котором пишут прошивки для, например, блока управления тормозами автомобиля, функция всё её может вернуть неинициализированную переменную. Охренеть, блин. И почему это Rust и Go такими темпами взлетают?
И почему это Rust и Go такими темпами взлетают?
Вы имеете в виду почему при таком количестве хайпа Go до сих пор в десятку не попал, а Rust едва-едва в тридцатке? Притом, что обоим уже больше 10 лет?

Блин, сегодня рекламщики пишут свои анальные зонды на тупоскрипте со строгим контролем типов.
Что не мешает им порождать кучи глючащего и тормозного дерьма.

Да, за счёт обилия UB писать на C и C++ не так просто, как том же тупоскрипте. Но вызов UB в программе — это плюс-минус такая же логическая ошибка, как и использование знака „+“ вместо „-“. А уж эту-то ошибку никакая «система типизации» не ловит (за исключением выроденных, спецально написанных примеров).

У меня нет претензий к Си, это такой «компилируемый в уме макро-ассемблер».
Ну какой это это макроассемблер? Пример kovsergа там работает точно так же, как в C++.
И вот не надо тут про проблему останова, любая функция имеет конечный размер. Также компилятор знает особые случаи, когда функция в принципе не возвращается (именно благодаря [[noreturn]]).

В общем случае это не так просто. Возьмем следующий код:
int foo(int arg)
{
    if (arg) {
        return 0;
    }

    bar(); /* Определена в другом модуле, всегда вызывает exit() */
}

[[noreturn]] тут не подходит, но и UB здесь нет, ибо flowing off the end of a function иначе чем через return никогда не происходит. Писать лишний return для красоты, лишь бы компилятор был доволен и сгенерировал код, который никогда не будет вызван? Ну такое — даже в наш век Electron'а и прочего мусора существуют области, где борьба до сих пор идет в буквальном смысле за каждый байт и за каждый такт. Будет предупреждение от компилятора, но его можно выключить или проигнорировать, и получить вполне корректный ad hoc код.
Зачем вообще городить систему типов, если функция может вернуть любой мусор, оставшийся в аккумуляторе?

Она и так может его вернуть, пожалуйста:
int foo()
{
    int i;
    return i;
}

Это как бы немного ортогонально системе типов. Заставлять всегда инициализировать все переменные фундаментальных типов при их объявлении, даже когда существует вероятность, что они потом не будут использованы? Отрицательно скажется на производительности и поломает обратную совместимость. В C и C++ принцип «ты не платишь за то, что не используешь» вообще является чуть ли не краеугольным камнем.
Да не пишите ерунду. В Javascript (Javascript, Карл!) завезли let для объявления переменных блочного уровня видимости. Равно как и undefined для неинициализированных переменных. И то и другое гарантирует, что функция не вернёт мусор без пометки «мусор».
У вас в ABI не пролезает специальное значение для undefined? У процессора нет флагов, которые можно было бы использовать для индикации ошибок? Ну так используйте ещё один регистр или выбросьте этот процессор на свалку. Почему ABI в MS-DOS и CP/M семьдесят лохматого года ширше и лучше, чем в линупсе 2021-го? Машины, в которых nullptr имел ненулевое значение, уже полвека не используются, но мы продолжаем тащить груз совместимости — а вдруг кто-нибудь захочет запустить на них браузер. (ну да, я знаю, что в С++17 чтоле nullptr таки приравняли ((void*)0) )
Равно как и undefined для неинициализированных переменных.

Только оно в них тоже не из воздуха появляется.
выбросьте этот процессор на свалку

Отлично, отлично. Ладно, пошел разговор ни о чем, «за все хорошее и против всего плохого, за мир во всем мире» :) Бывает, что я и в таких участвую, но сегодня я лучше книжку почитаю, извините :)
Только оно в них тоже не из воздуха появляется.

Вы таки удивитесь, насколько это дёшево реализуется. Грубо говоря, это один из NaN. Т.е. двоичный вид 0x7fffffffffffxyz0 на 64 битах или 0x7fffxyz0 на 32
Я таки не удивлюсь, я в курсе, что в некоторых js-движках используется «объединение» чисел с плавающей точкой и указателей, и значения типа undefined и null там представлены, грубо говоря, разновидностями NaN'ов. Но по-моему V8 такое не использует (хотя, честно говоря, я за этим не слежу, может и использует, но еще пару лет назад не использовал, только pointer tagging). Впрочем, и в том, и в другом случае значение, как я уже сказал, берется не из воздуха, нужны конкретные операции разной степени дешевости. Туда лишняя инициализация, сюда лишняя проверочка, пихнем все подряд в кучу, приправим щепоткой сборщика мусора, и все — на JS «прошивку блока управления тормозами» писать еще рискованнее, чем на C/C++: тебе тормозить надо, а оно мусор собирает, и пусть весь мир подождет.
Вы таки удивитесь, насколько это дёшево реализуется.
Web-сайты почему тормозят? Если всё это так просто и быстро?
В Javascript (Javascript, Карл!) завезли let для объявления переменных блочного уровня видимости.
И как? Помогло? Программы в браузерах перестали глючить и тормозить, да? Вот тут у людей в соседней статье другое мнение.

Почему ABI в MS-DOS и CP/M семьдесят лохматого года ширше и лучше, чем в линупсе 2021-го?
Что значит «ширше и лучше»? Файл размеров 8GiB они открыть могут хотя бы?

ABI MS-DOS или CP/M компактнее, да… но «ширше и лучше»… я бы помолчал.
А при чём здесь файлы вообще? Я про ABI, а не про API. Соглашении о вызовах, если вам так понятнее. Соглашение о том, как (где) передавать аргументы в функцию и где ожидать результат. В MSDOS используется флаг переноса для индикации возврата ошибки. В Си/UNIX для этого есть специальная статическая глобальная переменная errno, общая для всех вызовов, что особенно удобно в асинхронных, корутинных и прочих пакетно-неблокирующих программах.
Ну т.е. понятно что в 1972 флаги были не на всех архитектурах, да и регистров могло быть раз, два, расчёт окончен, но мы до сих пор продолжаем с этим жить.
А веб тормозит из-за повсеместной моды на функциональщину и иммутабельность. Если написать на плюсах программу, которая на каждое движение мышкой делает полную копию своей памяти вместо исправления пары байтов по месту, тормозить будет ничуть не меньше.
В Си/UNIX для этого есть специальная статическая глобальная переменная errno, общая для всех вызовов, что особенно удобно в асинхронных, корутинных и прочих пакетно-неблокирующих программах.
Вы уж определитесь о чём вы говорите. В прошлый раз вы про Linux говорили. То бишь ядро. В ядре нет никакой глобальной переменной errno. А с системной библиотеке она thread-local.
Ну т.е. понятно что в 1972 флаги были не на всех архитектурах, да и регистров могло быть раз, два, расчёт окончен, но мы до сих пор продолжаем с этим жить.
В современных MIPS и RISC-V флагов тоже нет, кстати.

Если написать на плюсах программу, которая на каждое движение мышкой делает полную копию своей памяти вместо исправления пары байтов по месту, тормозить будет ничуть не меньше.
Ну а кто просит всё копировать-то? Haskell вполне себе добивается скорости, сравнимой с C/C++ не отказываясь от иммутабельности.

Просто нужно программы писать используя мозги, а не генератор случайных чисел.

Сейчас же работает не принцип о "преждевременной оптимизации", а принцип о "преждевременном использовании мозгов" :)

Это да. Причём забавно, что статья, откуда пришли знаменитые слова Кнута, которыми весь этот ужас оправдывается, также содержит и критерий, когда оптимизациями пренебрегать не стоит: в развитой инжереной дисциплине улучшение в 12%, легко получаемое, никогда не рассматривается как несущественное — и я верю, что подобный же подход должен возобладать и в програмимровании.

А сама статья посвящена, внезапно, тому как “разворачивать циклы” (руками, так как компиляторы в те времена этого делать не умели).

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

Нет, Кнут это не имел в виду, когда говорил о том, что иногда лучше оставить цикл просто циклом.
Речь о том, что возможность компиляции функций без return это явный баг в стандарте языка.

Вот пример валидной программы на C++:


/*[[noreturn]]*/ void abort_programm();

struct A
{
    static A make();

private:

    A() = default;
};

 template<typename T>
 T create_object(std::function<T()> maker)
 {
    if (maker)
        return maker();
    abort_programm();                            
}

int test()
{
    A a = create_object<A>(&A::make);
}

Компилятор не видит что делает функция abort_programm, она может вызвать exit, abort, кинуть исключение или еще что-то т.е. прервать поток выполнения и не возвратить управление вызывающей стороне и в этом случае программа валидная. А может и возвратить, тогда это UB. Мы не можем здесь написать ни какой return, потому что не знаем как создать объект. Максимум что может сделать компилятор — это предупредить и обратить внимание на потенциальную проблему, сгенерировав предупреждение.


control reaches end of non-void function [-Wreturn-type]

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


Помечать валидную программу ошибкой по умолчанию — не правильно.

Для таких особых случаев ничего не мешает обязать программиста добавить в конце вашего create_object
throw std::bad_function_call{};/* will never happen */

И это в 100500 раз лучше, чем где-то там в доках писать километры комментариев, почему так.

Это не эквивалентная замена.
Во-первых, исключения могут быть отключены.
Во-вторых, abort_programm может нести дополнительную нагрузку и выполнять полезную работу до завершения выполнения.
В-третьих, я могу объявить функции с noexpect, например

Вашу функцию можно объявить ещё и как noexcept(noexcept(maker())), что гораздо ценнее.

Ибо несмотря на название (zero cost exceptions) вся эта необходимость поддерживать таблицы здорово мешает оптимизатору. noexcept не зря появился.

а [[noreturn]] пропагируется наверх?


[[noreturn]] void abort(); // real abort from system
void abort_program() {
  cout << "пока\n";
  abort();
}

знает ли компилятор что abort_program тоже noreturn?

Нет, не знает. Но если эта функция будет встроена в другую (inlining), то после её встраивания компилятор будет видеть какой код может исполняться, какой нет.
Небольшие функции современные компилторы могут встраивать даже если они не описаны явно как inline.

Получается пометить надо самому. Но если пометить функцию которая не вызывает std::exit то компилятор сгенерирует варнинг. Т.е оно на уровне аннотаций работает аналогично как и в расте боттом на уровне типов. Теперь мне интересно как определить саму std::exit без варнинга.

std::exit обычно либо вызывает, либо является ::exit, который реализован на ассемблере.
Там другая жизнь и другие warningи.
Это не совсем так, в C невозврат значения из non-void функции — это не UB само по себе. Цитата из C99:
If the } that terminates a function is reached, and the value of the function call is used by the caller, the behavior is undefined.

В C++ — там таки да, оговорку про использование возвращаемого значения убрали:
Flowing off the end of a constructor, a destructor, or a non-coroutine function with a cv void return type is equivalent to a return with no operand. Otherwise, flowing off the end of a function other than main (6.9.3.1) or a coroutine (9.5.4) results in undefined behavior.

Т.е. если компилировать вышеприведенный код C компилятором, то foo() и ее вызов написаны вполне корректно, никакого UB нет. UB только в bar().
Т.е. если компилировать вышеприведенный код C компилятором
Собственно потому моя ссылка и использует C, а не C++.
Ссылку на стандарт, пжлста. Потому как я могу найти и показать, чо UB будет использовать значение такой функции.
А вот написание такой функции — вполне корректно.
P.S. Обратите внимание, что пример таки на C, не C++.

Спасибо за такой подробный обзор, наконец-то удалось что-то понять!
Сейчас пробуем добавить -fstrict-vtable-pointers, -fwhole-program-vtables при сборке Clickhouse:
https://github.com/ClickHouse/ClickHouse/pull/20151


Но что-то пошло не так и lld-11 упал при линковке (из-за LTO): https://clickhouse-builds.s3.yandex.net/20151/7b3481322edb4ed1f0f1f76128c3283d87fd4fbf/clickhouse_build_check/build_log_889491481_1612624311.txt

Можно попробовать с такими сочетаниями собрать: -fforce-emit-vtable -fstrict-vtable-pointers или -fvisibility=hidden -fwhole-program-vtables.

При написании компилятора собственного языка при реализации виртуальных функций обнаружил проблему с девиртуализацией вызовов, описанную в данной статье. При этом обнаружил такую странность — даже если в сигнатуре внешне-реализованных методов this константен и в llvm коде помечен как readonly, оптимизатор llvm всё равно считает, что указатель на таблицу виртуальных функций мог измениться и оставляет все вызовы, кроме первого, виртуальными. Видимо, единственный вариант реализовать девиртуализацию — через описанный в данной статье механизм (с invariant group и strip/launder).

то есть в стандарт потихоньку протаскиваются вещи, более удобные для конкретного компилятора?

Да, но называть это костылями они не спешат.
Sign up to leave a comment.

Articles