Допускает ли стандарт C ++ неинициализированный bool для сбоя программы?


500

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

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

Я перепробовал несколько вещей, чтобы воспроизвести проблему и максимально упростить ее. Вот выдержка из функции Serialize, которая будет принимать параметр bool и копировать строку trueили falseв существующий целевой буфер.

Была бы эта функция в обзоре кода, не было бы никакого способа сказать, что она на самом деле могла бы аварийно завершиться, если бы параметр bool был неинициализированным значением?

// Zero-filled global buffer of 16 characters
char destBuffer[16];

void Serialize(bool boolValue) {
    // Determine which string to print based on boolValue
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    const size_t len = strlen(whichString);

    // Copy string into destination buffer, which is zero-filled (thus already null-terminated)
    memcpy(destBuffer, whichString, len);
}

Если этот код выполняется с оптимизацией clang 5.0.0 +, он может / может дать сбой.

Ожидаемый троичный оператор boolValue ? "true" : "false"выглядел достаточно безопасным для меня, я предполагал: «Независимо от того, в каком значении мусора находится значение, это boolValueне имеет значения, так как оно все равно будет иметь значение true или false».

Я настроил пример Compiler Explorer, который показывает проблему при разборке, вот полный пример. Примечание: чтобы воспроизвести проблему, я обнаружил, что сработала комбинация с использованием Clang 5.0.0 с оптимизацией -O2.

#include <iostream>
#include <cstring>

// Simple struct, with an empty constructor that doesn't initialize anything
struct FStruct {
    bool uninitializedBool;

   __attribute__ ((noinline))  // Note: the constructor must be declared noinline to trigger the problem
   FStruct() {};
};

char destBuffer[16];

// Small utility function that allocates and returns a string "true" or "false" depending on the value of the parameter
void Serialize(bool boolValue) {
    // Determine which string to print depending if 'boolValue' is evaluated as true or false
    const char* whichString = boolValue ? "true" : "false";

    // Compute the length of the string we selected
    size_t len = strlen(whichString);

    memcpy(destBuffer, whichString, len);
}

int main()
{
    // Locally construct an instance of our struct here on the stack. The bool member uninitializedBool is uninitialized.
    FStruct structInstance;

    // Output "true" or "false" to stdout
    Serialize(structInstance.uninitializedBool);
    return 0;
}

Проблема возникает из-за оптимизатора: было достаточно умно сделать вывод, что строки «истина» и «ложь» различаются только по длине на 1. Поэтому вместо реального вычисления длины он использует значение самого bool, которое должно технически это может быть 0 или 1, и выглядит так:

const size_t len = strlen(whichString); // original code
const size_t len = 5 - boolValue;       // clang clever optimization

Хотя это, так сказать, «умно», мой вопрос таков : позволяет ли стандарт C ++ компилятору предполагать, что bool может иметь только внутреннее числовое представление «0» или «1» и использовать его таким образом?

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


200
Это отличный вопрос. Это убедительная иллюстрация того, что неопределенное поведение - это не просто теоретическая проблема. Когда люди говорят, что в результате UB может произойти что-либо, это «что-нибудь» может быть действительно удивительным. Можно предположить, что неопределенное поведение все еще проявляется предсказуемым образом, но в наши дни с современными оптимизаторами это совсем не так. ОП нашел время, чтобы создать MCVE, тщательно исследовал проблему, осмотрел разборку и задал четкий, прямой вопрос об этом. Не могу просить больше.
Джон Кугельман

7
Обратите внимание, что требование «ненулевые оценки для true» является правилом о булевых операциях, включая «присваивание bool» (которое может неявно вызывать a в static_cast<bool>()зависимости от специфики). Однако это не требование о внутреннем представлении boolвыбранного компилятором.
Евро Мицелли

2
Комментарии не для расширенного обсуждения; этот разговор был перемещен в чат .
Самуэль Лев

3
На очень связанной ноте это забавный источник двоичной несовместимости. Если у вас есть ABI A, который обнуляет значения перед вызовом функции, но компилирует функции так, что он предполагает, что параметры дополняются нулями, а ABI B противоположен (не нулю, но не принимает ноль) с добавленными параметрами), это будет в основном работать, но функция, использующая B ABI, вызовет проблемы, если она вызовет функцию, использующую A ABI, которая принимает «маленький» параметр. IIRC у вас есть это на x86 с Clang и ICC.
TLW

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

Ответы:


285

Да, ISO C ++ позволяет (но не требует) реализации сделать этот выбор.

Но также обратите внимание, что ISO C ++ позволяет компилятору генерировать код, который вылетает намеренно (например, с недопустимой инструкцией), если программа встречает UB, например, как способ помочь вам найти ошибки. (Или потому, что это DeathStation 9000. Строго соответствующего соответствия недостаточно для того, чтобы реализация C ++ была полезна для любых реальных целей). Таким образом, ISO C ++ позволил бы компилятору создавать сбой asm (по совершенно другим причинам) даже в аналогичном коде, который читает неинициализированный uint32_t. Даже при том, что это должен быть тип с фиксированной компоновкой без представления ловушек.

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


Вы компилируете для x86-64 System V ABI , который указывает, что a boolкак функция arg в регистре представлена ​​битовыми комбинациями false=0иtrue=1 в младших 8 битах регистра 1 . В памяти bool- это 1-байтовый тип, который снова должен иметь целочисленное значение 0 или 1.

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

ISO C ++ не определяет его, но это решение ABI широко распространено, потому что оно делает преобразование bool-> int дешевым (просто с нулевым расширением) . Я не знаю ни одного ABI, которые не позволяют компилятору принимать 0 или 1 boolдля любой архитектуры (не только x86). Это позволяет оптимизировать, например, !myboolс помощью xor eax,1переворачивания младшего бита: любой возможный код, который может переворачивать бит / целое число / bool между 0 и 1 в одной инструкции CPU . Или компилирование a&&bв побитовое И для boolтипов. Некоторые компиляторы действительно используют булевы значения как 8-битные в компиляторах. Операции на них неэффективны? ,

В общем, правило «как если» позволяет компилятору использовать преимущества, которые являются истинными на целевой компилируемой платформе , потому что конечным результатом будет исполняемый код, который реализует то же внешне видимое поведение, что и исходный код C ++. (Со всеми ограничениями, которые Undefined Behavior накладывает на то, что на самом деле является «внешне видимым»: не с помощью отладчика, а из другого потока в правильно сформированной / легальной программе C ++.)

Компилятор, безусловно , позволило в полной мере воспользовался гарантией ABI в его код-ген, и сделать код , как вы нашли , который оптимизирует strlen(whichString)к
5U - boolValue.
(Кстати, эта оптимизация довольно умная, но может быть недальновидной, а не разветвленной и встроенной memcpyкак хранилище непосредственных данных 2. )

Или компилятор мог бы создать таблицу указателей и проиндексировать ее целочисленным значением bool, опять же, предполагая, что это 0 или 1. ( Эта возможность - то, что предложил ответ @ Barmar .)


Ваш __attribute((noinline))конструктор с включенной оптимизацией привел к лягушке, просто загружающей байт из стека для использования в качестве uninitializedBool. Он освободил место для объекта в mainwith push rax(что меньше и по разным причинам примерно так же эффективно, как и sub rsp, 8), поэтому любой мусор, который был в AL при входе в, mainявляется значением, которое он использовал uninitializedBool. Вот почему вы на самом деле получили ценности, которые были не просто 0.

5U - random garbageможет легко переносить большие значения без знака, что приводит к тому, что memcpy попадает в неотображенную память. Место назначения находится в статическом хранилище, а не в стеке, поэтому вы не перезаписываете адрес возврата или что-то еще.


Другие реализации могут сделать другой выбор, например, false=0и true=any non-zero value. Тогда, вероятно, clang не создаст код, который вылетает для этого конкретного экземпляра UB. (Но это все равно было бы разрешено, если бы захотелось.) Я не знаю каких-либо реализаций, которые выбирают что-то другое для чего предназначен x86-64 bool, но стандарт C ++ допускает многие вещи, которые никто не делает или даже не хочет делать аппаратные средства это что-то вроде современных процессоров.

ISO C ++ оставляет неопределенным, что вы найдете, когда изучите или измените объектное представление abool . (Например, с memcpyпомощью boolв unsigned char, что вы можете сделать, потому что char*может псевдоним все. И unsigned charгарантированно не будет битов заполнения, поэтому стандарт C ++ формально позволяет вам hexdump представления объекта без каких-либо UB. Приведение указателя для копирования объекта char foo = my_boolКонечно, представление отличается от присвоения , так что логическое значение 0 или 1 не произойдет, и вы получите представление необработанного объекта.)

Вы частично «спрятали» UB на этом пути выполнения от компилятораnoinline . Однако даже если он не встроен, межпроцедурная оптимизация может сделать версию функции зависимой от определения другой функции. (Во-первых, clang создает исполняемый файл, а не разделяемую библиотеку Unix, где может происходить взаимное расположение символов. Во-вторых, определение внутри class{}определения, поэтому все единицы перевода должны иметь одно и то же определение. Как и с inlineключевым словом.)

Таким образом, компилятор может выдавать просто retили ud2(недопустимую инструкцию) в качестве определения для main, потому что путь выполнения, начинающийся с вершины, mainнеизбежно встречает Undefined Behavior. (Что может видеть компилятор во время компиляции, если он решил следовать по пути через не встроенный конструктор.)

Любая программа, которая сталкивается с UB, полностью не определена в течение всего ее существования. Но UB внутри функции или if()ветви, которая фактически никогда не запускается, не повреждает остальную часть программы. На практике это означает, что компиляторы могут решить выдать недопустимую инструкцию, или a ret, или не выдавать что-либо и попасть в следующий блок / функцию, для всего базового блока, который может быть доказан во время компиляции, чтобы содержать или привести к UB.

GCC и Clang на практике же на самом деле иногда выделяют ud2на УБ, а даже пытается генерировать код для путей выполнения , которые не имеют никакого смысла. Или для случаев, таких как падение voidфункции, gcc иногда пропускает retинструкцию. Если вы думаете, что «моя функция просто вернется с мусором в RAX», вы сильно ошибаетесь. Современные компиляторы C ++ больше не рассматривают язык как переносимый язык ассемблера. Ваша программа действительно должна быть верной C ++, не делая предположений о том, как автономная не встроенная версия вашей функции может выглядеть в asm.

Еще один забавный пример: почему невыравниваемый доступ к памяти mmap иногда вызывает ошибку на AMD64? , x86 не ошибается на невыровненных целых числах, верно? Так почему uint16_t*проблема смещения будет проблемой? Потому что alignof(uint16_t) == 2и нарушение этого предположения приводило к segfault при автоматической векторизации с SSE2.

Смотрите также, что должен знать каждый программист на C о неопределенном поведении # 1/3 , статья разработчика Clang.

Ключевой момент: если компилятор заметил UB во время компиляции, он мог бы «прервать» (испустить удивительный asm) путь через ваш код, который вызывает UB, даже если он нацелен на ABI, где любой битовый шаблон является допустимым представлением объекта bool.

Ожидайте полной враждебности ко многим ошибкам со стороны программиста, особенно о том, о чем предупреждают современные компиляторы. Вот почему вы должны использовать -Wallи исправлять предупреждения. C ++ не является дружественным к пользователю языком, и что-то в C ++ может быть небезопасным, даже если это будет безопасно в asm для цели, для которой вы компилируете. (например, переполнение со знаком - это UB в C ++, и компиляторы предполагают, что этого не произойдет, даже при компиляции для дополнения x86 для 2, если вы не используете его clang/gcc -fwrapv.)

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

Не быть чрезмерно драматичным; часто компиляторы позволяют вам сойтись с некоторыми вещами и генерировать код, как вы ожидаете, даже когда что-то не так. Но, возможно, это будет проблемой в будущем, если разработчики компиляторов реализуют некоторую оптимизацию, которая получает больше информации о диапазонах значений (например, переменная неотрицательна, возможно, позволяя оптимизировать расширение знака для свободного расширения нуля на x86- 64). Например, в текущих gcc и clang выполнение tmp = a+INT_MINне оптимизируется a<0как всегда ложное, только это tmpвсегда отрицательно. (Потому что INT_MIN+ a=INT_MAXотрицателен для этой цели дополнения 2, и aне может быть выше этого.)

Таким образом, gcc / clang в настоящее время не возвращается для получения информации о диапазоне для входных данных вычисления, а только на основе результатов, основанных на предположении об отсутствии переполнения со знаком : пример для Godbolt . Я не знаю, намеренно ли "пропущена" эта оптимизация во имя удобства для пользователя или как.

Также обратите внимание, что реализации (или компиляторы) могут определять поведение, которое ISO C ++ оставляет неопределенным . Например, все компиляторы, которые поддерживают встроенные функции Intel (например, _mm_add_ps(__m128, __m128)для ручной векторизации SIMD), должны разрешать формирование неправильно выровненных указателей, что является UB в C ++, даже если вы не разыменовываете их. __m128i _mm_loadu_si128(const __m128i *)выполняет выровненные нагрузки, принимая не выровненный __m128i*аргумент, а не void*или char*. Является ли `reinterpret_cast`ing между аппаратным указателем вектора и соответствующим типом неопределенным поведением?

GNU C / C ++ также определяет поведение сдвига влево отрицательного числа со знаком (даже без -fwrapv) отдельно от обычных правил UB со знаком переполнения. ( Это UB в ISO C ++ , в то время как правое смещение чисел со знаком определяется реализацией (логическое или арифметическое); реализации хорошего качества выбирают арифметику на HW, которая имеет арифметические правые сдвиги, но ISO C ++ не определяет). Это задокументировано в разделе Integer руководства GCC вместе с определением поведения, определяемого реализацией, которое стандарты C требуют, чтобы реализации определяли так или иначе.

Определенно есть проблемы с качеством реализации, о которых заботятся разработчики компиляторов; они, как правило, не пытаются сделать компиляторы намеренно враждебными, но использование всех пробелов UB в C ++ (кроме тех, которые они выбирают для оптимизации) иногда может быть почти неразличимым.


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

( Другие АБИСЫ сделать сделать различные варианты здесь . Некоторые из них требуют целых узких типов быть нулевыми или знаковым расширением для заполнения регистра при передаче или вернулись из функций, таких как MIPS64 и PowerPC64 см. Последний раздел этого x86-64 ответа который сравнивается с теми более ранними МСА .)

Например, вызывающий абонент мог рассчитать a & 0x01010101в RDI и использовать его для чего-то еще, перед вызовом bool_func(a&1). Вызывающая сторона может оптимизировать, &1потому что она уже сделала это для младшего байта как часть and edi, 0x01010101, и она знает, что вызываемая сторона должна игнорировать старшие байты.

Или, если bool передается как 3-й аргумент, возможно, вызывающий, оптимизирующий по размеру кода, загружает его mov dl, [mem]вместо movzx edx, [mem]сохранения 1 байта за счет ложной зависимости от старого значения RDX (или другого эффекта частичного регистра, в зависимости от на модели процессора). Или для первого аргумента, mov dil, byte [r10]а не movzx edi, byte [r10]потому, что оба в любом случае требуют префикса REX.

Именно поэтому лязг излучает movzx eax, dilв Serialize, вместо sub eax, edi. (Для целочисленных аргументов clang нарушает это правило ABI, вместо этого в зависимости от недокументированного поведения gcc и clang до нуля или знака расширяет узкие целые числа до 32 бит. Требуется ли расширение знака или нуля при добавлении 32-битного смещения к указателю для ABI x86-64? Так что мне было интересно увидеть, что он не делает то же самое для bool.)


Сноска 2: После ветвления у вас будет 4-байтовое movпромежуточное хранилище или 4-байтовое + 1-байтовое хранилище. Длина указана в значениях ширины магазина + смещения.

OTOH, glibc memcpy сделает две 4-байтовые загрузки / хранилища с перекрытием, зависящим от длины, так что это действительно в конечном итоге делает все это свободным от условных ветвей в логическом значении. Смотрите L(between_4_7):блок в memcpy / memmove glibc. Или, по крайней мере, используйте тот же способ для логического значения в ветвлении memcpy, чтобы выбрать размер куска.

Если вы используете встраивание, вы можете использовать 2x mov-immediate + cmovи условное смещение или оставить строковые данные в памяти.

Или при настройке на Intel Ice Lake ( с функцией Fast Short REP MOV ) фактическая rep movsbможет быть оптимальной. glibc memcpyможет начать использовать rep movsb для небольших размеров на процессорах с этой функцией, сохраняя много ветвления.


Инструменты для обнаружения UB и использования неинициализированных значений

В gcc и clang вы можете скомпилировать, -fsanitize=undefinedчтобы добавить инструментарий времени выполнения, который будет предупреждать или выдавать ошибку в UB, что происходит во время выполнения. Это не поймает унифицированные переменные, хотя. (Потому что он не увеличивает размеры шрифта, чтобы освободить место для «неинициализированного» бита).

См. Https://developers.redhat.com/blog/2014/10/16/gcc-undefined-behavior-sanitizer-ubsan/

Чтобы найти использование неинициализированных данных, есть Address Sanitizer и Memory Sanitizer в clang / LLVM. https://github.com/google/sanitizers/wiki/MemorySanitizer показывает примеры clang -fsanitize=memory -fPIE -pieобнаружения неинициализированных операций чтения из памяти. Это может работать лучше, если вы компилируете без оптимизации, поэтому все чтения переменных в конечном итоге фактически загружаются из памяти в asm. Они показывают, что он используется -O2в случае, когда нагрузка не оптимизируется. Я сам не пробовал. (В некоторых случаях, например, не инициализируя аккумулятор перед суммированием массива, clang -O3 будет выдавать код, который суммируется в векторный регистр, который он никогда не инициализировал. Так что с оптимизацией вы можете иметь случай, когда нет чтения памяти, связанной с UB . Но-fsanitize=memory изменяет сгенерированный asm и может привести к проверке.)

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

MemorySanitizer реализует подмножество функций, найденных в Valgrind (инструмент Memcheck).

Это должно работать в этом случае, потому что вызов glibc memcpyс lengthвычисленной из неинициализированной памяти (в библиотеке) приведет к ответвлению на основе length. Если бы он указывал версию, которая полностью использовалась без разветвлений cmov, индексирование и два хранилища, он мог бы не работать.

Valgrind такжеmemcheck будет искать такую ​​проблему, опять же, не жалуясь, если программа просто копирует неинициализированные данные. Но он говорит, что обнаружит, когда «условный переход или перемещение зависит от неинициализированных значений», чтобы попытаться уловить любое внешне видимое поведение, которое зависит от неинициализированных данных.

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


2
Я видел худший случай, когда переменная принимала значение не в диапазоне 8-битного целого числа, а только всего регистра ЦП. А у Itanium еще хуже, использование неинициализированной переменной может привести к сбою.
Иисус Навин

2
@ Джошуа: о, хорошо, хорошая точка зрения, явное предположение Itanium будет помечать значения регистров эквивалентом «не число», так что при использовании значения ошибки.
Питер Кордес

11
Кроме того, это также иллюстрирует, почему UB Featurebug был впервые представлен в дизайне языков C и C ++: потому что он дает компилятору именно такую ​​свободу, которая теперь позволяет самым современным компиляторам выполнять эти высококачественные оптимизации, которые делают C / C ++ такими высокопроизводительными языками среднего уровня.
The_Sympathizer

2
Итак, война между авторами компиляторов C ++ и программистами C ++, пытающимися писать полезные программы, продолжается. Этот ответ, полностью исчерпывающий при ответе на этот вопрос, можно также использовать как убедительную копию объявления для поставщиков инструментов статического анализа ...
davidbak

4
@The_Sympathizer: UB был включен, чтобы реализации могли вести себя так, как это было бы наиболее полезно для их клиентов . Не предполагалось, что все виды поведения должны рассматриваться как одинаково полезные.
суперкат

56

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

Таким образом, если вам не удастся инициализировать a boolили если вам удастся перезаписать его с помощью какого-либо указателя другого типа, то предположения компилятора будут неверными, и последует неопределенное поведение. Вы были предупреждены:

50) Использование значения bool способами, описанными в этом международном стандарте как «неопределенные», например, путем проверки значения неинициализированного автоматического объекта, может привести к тому, что он будет вести себя так, как если бы он не был ни истинным, ни ложным. (Сноска к пункту 6 §6.9.1, Основные типы)


11
« trueЗначение не обязательно должно совпадать с целым числом 1», что вводит в заблуждение. Конечно, фактическая битовая комбинация может быть чем-то другим, но при неявном преобразовании / повышении (единственный способ увидеть значение, отличное от true/ false), trueвсегда 1и falseвсегда0 . Конечно, такой компилятор также не сможет использовать хитрость, которую пытался использовать этот компилятор (используя тот факт, что boolфактическая битовая комбинация может быть только 0или 1), так что это как бы не имеет отношения к проблеме OP.
ShadowRanger

4
@ShadowRanger Вы всегда можете проверить представление объекта напрямую.
TC

7
@shadowranger: я хочу сказать, что за реализацию отвечает. Если он ограничивает допустимые представления trueбитовым шаблоном 1, это его прерогатива. Если он выбирает какой-то другой набор представлений, то он действительно не может использовать отмеченную здесь оптимизацию. Если он выберет именно это представление, то сможет. Это должно быть только внутренне согласованным. Вы можете проверить представление a bool, скопировав его в байтовый массив; это не UB (но это определяется реализацией)
rici

3
Да, оптимизирующие компиляторы (т. Е. Реальная реализация C ++) часто иногда генерируют код, который зависит от boolналичия битового шаблона 0или 1. Они не перезагружают boolкаждый раз, когда читают его из памяти (или регистра, содержащего функцию arg). Вот что говорит этот ответ. Примеры : gcc4.7 + можно оптимизировать , return a||bчтобы or eax, ediв функции возвращения bool, или MSVC можно оптимизировать a&bдля test cl, dl. x86 testявляется побитовым and , поэтому if cl=1и dl=2test устанавливают флаги в соответствии с cl&dl = 0.
Питер Кордес

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

52

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

Ошибка заключается в вызывающей функции, и она может быть обнаружена путем проверки кода или статического анализа вызывающей функции. Используя ссылку на ваш компилятор, компилятор gcc 8.2 обнаруживает ошибку. (Может быть, вы могли бы подать отчет об ошибке в Clang, что он не находит проблему).

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

NB. Ответ на вопрос «Может ли неопределенное поведение вызвать _____?» всегда "да". Это буквально определение неопределенного поведения.


2
Верно ли первое предложение? Есть ли простое копирование неинициализированного boolтриггера UB?
Джошуа Грин

10
@JoshuaGreen см. [Dcl.init] / 12 «Если в результате оценки получено неопределенное значение, поведение не определено, за исключением следующих случаев:» (и ни в одном из этих случаев нет исключения для bool). Копирование требует оценки источника
MM

8
@JoshuaGreen И причина этого в том, что у вас может быть платформа, которая вызывает аппаратный сбой, если вы получаете доступ к некоторым недопустимым значениям для некоторых типов. Их иногда называют «представлениями ловушки».
Дэвид Шварц

7
Itanium, хотя и неясен, является процессором, который все еще находится в производстве, имеет значения ловушек и имеет как минимум два полу-современных компилятора C ++ (Intel / HP). Она в буквальном смысле есть true, falseи not-a-thingзначение для булевых.
MSalters

3
С другой стороны, ответ на вопрос «Требует ли стандарт, чтобы все компиляторы обрабатывали что-то определенным образом», обычно «нет», даже / особенно в тех случаях, когда очевидно, что любой качественный компилятор должен это делать; чем более очевидным является что-то, тем меньше должно быть необходимости, чтобы авторы Стандарта фактически сказали это.
суперкат

23

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

Как правило, реализация будет использовать целое число 0для falseи 1для true, чтобы упростить преобразования между boolи int, и будет if (boolvar)генерировать тот же код, что и if (intvar). В этом случае можно представить, что код, сгенерированный для троичной переменной в присваивании, будет использовать значение в качестве индекса в массиве указателей на две строки, то есть оно может быть преобразовано во что-то вроде:

// the compile could make asm that "looks" like this, from your source
const static char *strings[] = {"false", "true"};
const char *whichString = strings[boolValue];

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


1
@SidS Спасибо. Теоретически, внутренние представления могут быть противоположны тому, как они приводятся к целым числам, но это было бы неверно.
Бармар

1
Вы правы, и ваш пример тоже рухнет. Однако в обзоре кода «видно», что вы используете неинициализированную переменную в качестве индекса массива. Кроме того, он будет аварийно завершать работу даже при отладке (например, некоторые отладчики / компиляторы будут инициализироваться с определенными шаблонами, чтобы было легче видеть, когда происходит сбой). В моем примере удивительным является то, что использование bool невидимо: оптимизатор решил использовать его в вычислениях, которых нет в исходном коде.
Ремз

3
@Remz Я просто использую массив, чтобы показать, чему сгенерированный код может быть эквивалентен, не предполагая, что кто-то на самом деле это напишет.
Бармар

1
@Remz Сформулировать boolк intс *(int *)&boolValueи распечатать его для отладки, смотрите , если это что - то другое , чем 0или 1когда он выходит из строя. Если это так, то это в значительной степени подтверждает теорию, что компилятор оптимизирует inline-if как массив, который объясняет причину сбоя.
Хавенард

2
@MSalters: std::bitset<8>не дает мне хороших имен для всех моих разных флагов. В зависимости от того, что они, это может быть важно.
Мартин Боннер поддерживает Монику

15

Резюмируя ваш вопрос, вы спрашиваете: позволяет ли стандарт C ++ компилятору предполагать, что a boolможет иметь только внутреннее числовое представление «0» или «1» и использовать его таким образом?

Стандарт ничего не говорит о внутреннем представлении bool. Он только определяет, что происходит при приведении a boolк int(или наоборот). Главным образом, из-за этих интегральных преобразований (и того факта, что люди довольно сильно на них полагаются), компилятор будет использовать 0 и 1, но это не обязательно (хотя он должен уважать ограничения любого ABI более низкого уровня, который он использует ).

Таким образом, компилятор, когда он видит a, boolимеет право считать, что сказанное boolсодержит либо « true, либо false» битовые шаблоны, и делать все, что ему нравится. Так что, если значения дляtrue и falseравны 1 и 0 соответственно, компилятору действительно разрешено оптимизировать strlenдо 5 - <boolean value>. Другие забавные поведения возможны!

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

  • Ваш код работает так, как вы ожидали
  • Ваш код не работает в случайное время
  • Ваш код вообще не запускается.

Посмотрите, что каждый программист должен знать о неопределенном поведении

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.