Спецификации C \ C ++ оставляют большое количество вариантов поведения, открытых для компиляторов, чтобы реализовать их по-своему. Есть ряд вопросов, которые постоянно задают здесь о том же самом, и у нас есть несколько отличных постов об этом:
- https://stackoverflow.com/questions/367633/what-are-all-the-common-undefined-behaviour-that-ac-programmer-should-know-abo
- https://stackoverflow.com/questions/4105120/what-is-undefined-behavior
- https://stackoverflow.com/questions/4176328/undefined-behavior-and-sequence-points
Мой вопрос не о том, что такое неопределенное поведение, или это действительно плохо. Я знаю об опасностях и большинстве соответствующих неопределенных цитат поведения из стандарта, поэтому, пожалуйста, воздержитесь от публикации ответов о том, насколько это плохо. Этот вопрос касается философии, стоящей за тем, чтобы исключить так много вариантов поведения, открытых для реализации компилятора.
Я прочитал отличный пост в блоге, в котором говорится, что главной причиной является производительность. Мне было интересно, является ли производительность единственным критерием, позволяющим это сделать, или есть какие-то другие факторы, которые влияют на решение оставить вещи открытыми для реализации компилятора?
Если у вас есть какие-либо примеры того, как конкретное неопределенное поведение предоставляет достаточную возможность для оптимизации компилятором, перечислите их. Если вам известны какие-либо другие факторы, кроме производительности, пожалуйста, подкрепите свой ответ достаточно подробно.
Если вы не понимаете вопрос или у вас нет достаточных доказательств / источников, подтверждающих ваш ответ, пожалуйста, не публикуйте широко спекулятивные ответы.
источник
Ответы:
Во-первых, я отмечу, что хотя я упоминаю здесь только «C», то же самое в равной степени относится и к C ++.
Комментарий, в котором упоминался Годель, был частично (но только частично) точным.
Когда вы приступаете к этому, неопределенное поведение в стандартах C в значительной степени просто указывает на границу между тем, что стандарт пытается определить, и тем, что он не делает.
Теоремы Гёделя (их две) в основном говорят о том, что невозможно определить математическую систему, которая может быть доказана (по своим собственным правилам) как полной и последовательной. Вы можете сделать свои правила так, чтобы они были полными (дело, с которым он имел дело, были «нормальными» правилами для натуральных чисел), или вы можете сделать возможным доказать их непротиворечивость, но вы не можете иметь и то и другое.
В случае чего-то вроде C, это не относится напрямую - по большей части «доказуемость» полноты или согласованности системы не является первоочередной задачей для большинства разработчиков языков. В то же время, да, они, вероятно, находились под влиянием (по крайней мере, в некоторой степени), зная, что невозможно доказать «идеальную» систему - ту, которая доказуемо полная и последовательная. Знание того, что такое невозможно, возможно, сделало бы немного легче отступить назад, немного вздохнуть и принять решение о границах того, что они будут пытаться определить.
Рискуя (опять же) быть обвиненным в высокомерии, я бы охарактеризовал стандарт C как управляемый (частично) двумя основными идеями:
Первое означает, что если кто-то определит новый ЦП, то должна быть возможность обеспечить хорошую, надежную и пригодную для использования реализацию C для этого, если дизайн хотя бы достаточно близок к нескольким простым рекомендациям - в основном, если он следует за общим порядком модели фон Неймана и обеспечивает по крайней мере некоторый разумный минимальный объем памяти, которого должно быть достаточно для реализации на языке Си. Для «размещенной» реализации (той, которая выполняется в ОС) вам необходимо поддерживать некоторое понятие, которое достаточно близко соответствует файлам, и иметь набор символов с определенным минимальным набором символов (требуется 91).
Второе означает, что должна быть возможность писать код, который напрямую манипулирует аппаратным обеспечением, поэтому вы можете писать такие вещи, как загрузчики, операционные системы, встроенное программное обеспечение, которое работает без какой-либо ОС и т. Д. В конечном счете, в этом отношении существуют некоторые ограничения, так что почти любой Практическая операционная система, загрузчик и т. д., скорее всего, содержат хотя бы немного кода, написанного на ассемблере. Аналогично, даже небольшая встроенная система, вероятно, будет включать в себя, по крайней мере, какие-то предварительно написанные библиотечные процедуры для предоставления доступа к устройствам в хост-системе. Хотя точную границу трудно определить, цель состоит в том, чтобы зависимость от такого кода была сведена к минимуму.
Неопределенное поведение в языке в значительной степени обусловлено намерением языка поддерживать эти возможности. Например, язык позволяет вам конвертировать произвольное целое число в указатель и получать доступ к тому, что происходит по этому адресу. Стандарт не пытается сказать, что произойдет, когда вы это сделаете (например, даже чтение с некоторых адресов может иметь внешне видимые эффекты). В то же время, он не пытается помешать вам делать такие вещи, потому что вам нужно для некоторых видов программного обеспечения, которые вы должны писать на C.
Существует некоторое неопределенное поведение, обусловленное и другими элементами дизайна. Например, еще одна цель C - поддерживать отдельную компиляцию. Это означает (например), что предполагается, что вы можете «связать» части вместе, используя линкер, который примерно соответствует тому, что большинство из нас считает обычной моделью линкера. В частности, должна быть возможность объединить отдельно скомпилированные модули в единую программу без знания семантики языка.
Существует еще один тип неопределенного поведения (это гораздо чаще встречается в C ++, чем в C), который присутствует просто из-за ограничений технологии компилятора - вещи, которые мы в основном знаем, являются ошибками и, вероятно, хотели бы, чтобы компилятор диагностировал их как ошибки, но учитывая текущие ограничения на технологию компиляции, сомнительно, что они могут быть диагностированы при любых обстоятельствах. Многие из них основаны на других требованиях, таких как отдельная компиляция, так что это в значительной степени вопрос балансировки противоречивых требований, и в этом случае комитет обычно предпочитает поддерживать более широкие возможности, даже если это означает отсутствие диагностики некоторых возможных проблем, вместо того, чтобы ограничивать возможности, чтобы гарантировать, что все возможные проблемы диагностированы.
Эти различия в намерениях определяют большинство различий между C и чем-то вроде Java или системами на основе CLI от Microsoft. Последние довольно явно ограничены работой с гораздо более ограниченным набором оборудования или требованием программного обеспечения для эмуляции более конкретного оборудования, на которое они нацелены. Они также специально намерены предотвратить любые прямые манипуляции с оборудованием, вместо этого требуя, чтобы вы использовали что-то вроде JNI или P / Invoke (и код, написанный на чем-то вроде C), чтобы даже предпринять такую попытку.
Возвращаясь к теоремам Годеля на минуту, мы можем провести нечто вроде параллели: Java и CLI выбрали «внутренне согласованную» альтернативу, а C - «полную» альтернативу. Конечно, это очень грубая аналогия - я сомневаюсь , что кто -то пытается - х формальное доказательство либо внутренней согласованности или полноты в любом случае. Тем не менее, общее понятие довольно близко соответствует выбору, который они приняли.
источник
C обоснование объясняет
Важным является также преимущество для программ, а не только преимущество для реализации. Программа, которая зависит от неопределенного поведения, все еще может быть соответствующей , если она принята соответствующей реализацией. Существование неопределенного поведения позволяет программе использовать непереносимые функции, явно помеченные как таковые («неопределенное поведение»), не становясь несоответствующими. Обоснование примечания:
И в 1.7 это отмечает
Таким образом, эта маленькая грязная программа, которая прекрасно работает на GCC, все еще соответствует !
источник
Скорость работы является особенно проблемой по сравнению с C. Если бы C ++ делал некоторые вещи, которые могли бы быть разумными, например, инициализировал большие массивы примитивных типов, он потерял бы кучу тестов для кода C. Таким образом, C ++ инициализирует свои собственные типы данных, но оставляет типы C такими, какими они были.
Другое неопределенное поведение просто отражает реальность. Одним из примеров является сдвиг битов с числом больше, чем тип. Это на самом деле отличается между аппаратными поколениями одной семьи. Если у вас есть 16-битное приложение, один и тот же двоичный файл даст разные результаты для 80286 и 80386. Так что языковой стандарт говорит, что мы не знаем!
Некоторые вещи просто остаются такими, какими они были, например, порядок вычисления подвыражений не определен. Первоначально считалось, что это помогает авторам компиляторов оптимизировать лучше. В настоящее время компиляторы достаточно хороши, чтобы понять это в любом случае, но стоимость поиска всех мест в существующих компиляторах, которые используют в своих интересах свободу, слишком высока.
источник
В качестве одного примера, указатель доступа почти должен быть неопределенным и не обязательно только по соображениям производительности. Например, в некоторых системах загрузка определенных регистров с указателем вызовет аппаратное исключение. При обращении к SPARC неправильно выровненный объект памяти вызовет ошибку шины, но в x86 это будет «просто» медленно. На самом деле сложно определить поведение в этих случаях, поскольку базовое оборудование диктует, что произойдет, а C ++ переносим на многие типы оборудования.
Конечно, это также дает компилятору свободу использовать специфические для архитектуры знания. Для неуказанного примера поведения смещение вправо значений со знаком может быть логическим или арифметическим в зависимости от базового аппаратного обеспечения, что позволяет использовать любую доступную операцию смещения и не заставлять ее программно эмулироваться.
Я также считаю, что это значительно облегчает работу автора компилятора, но сейчас я не могу вспомнить пример. Я добавлю его, если вспомню ситуацию.
источник
Просто: скорость и портативность. Если C ++ гарантирует, что вы получите исключение при отмене ссылки на недопустимый указатель, то он не будет переносимым на встроенное оборудование. Если бы C ++ гарантировал некоторые другие вещи, такие как всегда инициализируемые примитивы, то это было бы медленнее, а во время создания C ++ медленнее было действительно очень плохо.
источник
C был изобретен на машине с 9-битными байтами и без единицы с плавающей запятой - предположим, что он предписал, чтобы байты были 9-битными, слова 18-битными, и что числа с плавающей запятой должны быть реализованы с использованием pre IEEE754 aritmatic?
источник
Я не думаю, что первым обоснованием для UB было предоставление возможности компилятору оптимизировать, а просто возможность использовать очевидную реализацию для целей в то время, когда архитектуры имели большее разнообразие, чем сейчас (помните, если C был разработан на PDP-11, который имеет несколько знакомую архитектуру, первым портом был Honeywell 635, который гораздо менее знаком - адресация по словам, с использованием 36-битных слов, 6 или 9-битных байтов, 18-битных адресов ... ну, по крайней мере, он использовал 2-х дополнение). Но если интенсивная оптимизация не была целью, очевидная реализация не включает в себя добавление проверок во время выполнения для переполнения, счетчика сдвигов по размеру регистра, который псевдоним в выражениях, изменяющих несколько значений.
Еще одна вещь, принятая во внимание, была простота реализации Компилятор AC в то время выполнял несколько проходов, используя несколько процессов, потому что с одним обработчиком процесса все было бы невозможно (программа была бы слишком большой). Запрашиваемая тщательная проверка согласованности была исключена, особенно когда в ней участвовало несколько БЧ. (Для этого использовалась другая программа, кроме компиляторов Си, lint).
источник
i
иn
, такие, чтоn < INT_BITS
иi*(1<<n)
не будут переполнены, я бы посчитал,i<<=n;
что более понятным, чемi=(unsigned)i << n;
; на многих платформах это будет быстрее и меньше, чемi*=(1<<N);
. Что получается, когда компиляторы запрещают это?Один из ранних классических случаев был подписан целочисленным сложением. На некоторых из используемых процессоров это может вызвать сбой, а на других он просто продолжит работу со значением (вероятно, подходящим модульным значением). Указание любого из этих случаев будет означать, что программы для машин с нежелательным арифметическим стилем должны иметь дополнительный код, включая условную ветвь, для чего-то похожего на целочисленное сложение.
источник
int
16 битов и сдвиги с расширенными знаками, дороги, может вычислять(uchar1*uchar2) >> 4
с использованием сдвигов без расширений знаков. К сожалению, некоторые компиляторы распространяют выводы не только на результаты, но и на операнды.Я бы сказал, что это было не столько в философии, сколько в реальности - C всегда был кроссплатформенным языком, и стандарт должен отражать это и тот факт, что в момент выпуска любого стандарта будет большое количество реализаций на множестве различного оборудования. Стандарт, запрещающий необходимое поведение, будет либо проигнорирован, либо будет создан конкурирующий орган по стандартизации.
источник
Некоторые виды поведения не могут быть определены каким-либо разумным способом. Я имею в виду доступ к удаленному указателю. Единственный способ обнаружить это - запрет значения указателя после удаления (запоминание его значения где-нибудь и запрет на его возврат любой функции выделения). Не только такое запоминание было бы излишним, но для программы, работающей долго, это привело бы к исчерпанию разрешенных значений указателей.
источник
weak_ptr
и обнулить все ссылки на указатель, который получаетdelete
... о, подождите, мы приближаемся к сборке мусора: /boost::weak_ptr
Реализация является довольно хорошим шаблоном для этого шаблона использования. Вместо того, чтобы отслеживать и обнулятьweak_ptrs
внешне,weak_ptr
просто вносит вклад вshared_ptr
слабый счетчик, а слабый счет - это, в основном, пересчет к самому указателю. Таким образом, вы можете аннулироватьshared_ptr
без необходимости немедленного удаления. Он не идеален (у вас все еще может быть много просроченныхweak_ptr
с сохранением базисаshared_count
без веской причины), но по крайней мере это быстро и эффективно.Я приведу вам пример, где практически нет разумного выбора, кроме неопределенного поведения. В принципе, любой указатель может указывать на память, содержащую любую переменную, за небольшим исключением локальных переменных, которые, как может знать компилятор, никогда не брали их адрес. Однако, чтобы получить приемлемую производительность на современном процессоре, компилятор должен копировать значения переменных в регистры. Работа полностью без памяти - это не стартер.
Это в основном дает вам два варианта:
1) Сбросить все из регистров перед любым доступом через указатель, на тот случай, если указатель указывает на память этой конкретной переменной. Затем загрузите все необходимое обратно в регистр, на случай, если значения были изменены через указатель.
2) Иметь набор правил для случаев, когда указателю разрешено псевдоним переменной, и когда компилятору разрешено предполагать, что указатель не имеет псевдонима переменной.
C выбирает вариант 2, потому что 1 будет ужасно для производительности. Но что произойдет, если указатель наложит псевдоним на переменную, запрещенную правилами C? Поскольку эффект зависит от того, действительно ли компилятор сохранил переменную в регистре, стандарт C не может окончательно гарантировать конкретные результаты.
источник
foo
значение 42, а затем вызывает метод, который использует незаконно измененный указатель для установки значенияfoo
44, я вижу преимущество в том, что до следующей «законной» записиfoo
попытки его чтения могут законно выведите 42 или 44, а выражение вродеfoo+foo
может даже вывести 86, но я вижу гораздо меньшую выгоду, позволяя компилятору делать расширенные и даже ретроактивные выводы, изменяя неопределенное поведение, правдоподобные «естественные» поведения которого были бы все доброкачественными, в лицензию генерировать бессмысленный код.Исторически неопределенное поведение имело две основные цели:
Чтобы не требовать, чтобы авторы компилятора генерировали код для обработки условий, которые никогда не предполагались.
Чтобы учесть возможность того, что в отсутствие кода для явной обработки таких условий реализации могут иметь различные виды «естественных» поведений, которые в некоторых случаях были бы полезны.
В качестве простого примера, на некоторых аппаратных платформах попытка сложить вместе два целых положительных знака, сумма которых слишком велика, чтобы поместиться в целое число со знаком, даст конкретное отрицательное целое число со знаком. В других реализациях это вызовет ловушку процессора. Для того, чтобы стандарт C предписывал любое поведение, требовалось бы, чтобы компиляторы для платформ, чье естественное поведение отличалось от стандарта, должны были генерировать дополнительный код для получения правильного поведения - код, который может быть более дорогим, чем код для фактического добавления. Хуже того, это означало бы, что программисты, которые хотели бы «естественного» поведения, должны были бы добавить еще больше дополнительного кода для его достижения (и этот дополнительный код снова был бы более дорогим, чем добавление).
К сожалению, некоторые авторы компиляторов приняли философию, согласно которой компиляторы должны изо всех сил пытаться найти условия, вызывающие неопределенное поведение, и, предполагая, что такие ситуации могут никогда не произойти, сделать из этого расширенные выводы. Таким образом, в системе с 32-разрядным
int
кодом приведен такой код:стандарт C позволил бы компилятору сказать, что если q равно 46341 или больше, выражение q * q даст слишком большой результат, чтобы его можно было вместить, в
int
результате чего возникнет неопределенное поведение, и в результате компилятор будет иметь право предполагать, что не может произойти и, следовательно, не требуется увеличивать,*p
если это происходит. Если вызывающий код использует*p
в качестве индикатора, который должен отбрасывать результаты вычислений, эффект оптимизации может состоять в том, чтобы взять код, который дал бы ощутимые результаты в системах, которые работают практически любым мыслимым образом с целочисленным переполнением (перехват может быть некрасиво, но, по крайней мере, было бы разумно), и превратило его в код, который может вести себя бессмысленно.источник
Эффективность - это обычное оправдание, но каким бы ни было оправдание, неопределенное поведение - ужасная идея для переносимости. В результате неопределенное поведение становится непроверенным, неустановленным предположением.
источник