C++
Неопределенное поведение
Поиск…
Вступление
Что такое неопределенное поведение (UB)? Согласно стандарту ISO C ++ (§1.3.24, N4296), это «поведение, для которого настоящий международный стандарт не предъявляет никаких требований».
Это означает, что когда программа встречает UB, ей разрешено делать все, что захочет. Это часто означает сбой, но он не может просто ничего не делать, делают демоны вылетают из вашего носа , или даже по всей видимости, работать должным образом!
Излишне говорить, что вам следует избегать написания кода, который вызывает UB.
замечания
Если программа содержит неопределенное поведение, стандарт C ++ не устанавливает ограничений на его поведение.
- Возможно, он работает как разработчик, но может также привести к сбою или получить странные результаты.
- Поведение может варьироваться в зависимости от прогонов одной и той же программы.
- Любая часть программы может работать некорректно, включая строки, которые идут до строки, содержащей неопределенное поведение.
- Реализация не требуется для документирования результата неопределенного поведения.
Реализация может документировать результат операции, которая производит неопределенное поведение в соответствии со стандартом, но программа, которая зависит от такого документированного поведения, не переносима.
Почему существует неопределенное поведение
Интуитивно неопределенное поведение считается плохой, так как такие ошибки не могут быть обработаны любезно, скажем, обработчиками исключений.
Но оставляя какое-то поведение неопределенным, на самом деле является неотъемлемой частью обещания C ++ «вы не платите за то, что не используете». Неопределенное поведение позволяет компилятору предположить, что разработчик знает, что он делает, и не вводит код для проверки ошибок, указанных в приведенных выше примерах.
Поиск и устранение неопределенного поведения
Некоторые инструменты могут использоваться для обнаружения неопределенного поведения во время разработки:
- У большинства компиляторов есть предупреждающие флаги, предупреждающие о некоторых случаях неопределенного поведения во время компиляции.
- Более новые версии gcc и clang включают в себя так называемый флаг «Undefined Behavior Sanitizer» (
-fsanitize=undefined
), который будет проверять неопределенное поведение во время выполнения при стоимости исполнения. -
lint
подобные инструменты могут выполнять более тщательный неопределенный анализ поведения.
Неопределенное, неопределенное и определяемое реализацией поведение
Из стандарта C ++ 14 (ISO / IEC 14882: 2014), раздел 1.9 (Исполнение программы):
Семантические описания в этом международном стандарте определяют параметризованную недетерминированную абстрактную машину. [РЕЗАТЬ]
Некоторые аспекты и операции абстрактной машины описаны в этом Международном стандарте в качестве реализации (например,
sizeof(int)
). Они составляют параметры абстрактной машины . Каждая реализация должна включать документацию, описывающую ее характеристики и поведение в этих отношениях. [РЕЗАТЬ]Некоторые другие аспекты и операции абстрактной машины описаны в этом Международном стандарте как неопределенные (например, оценка выражений в новом-инициализаторе, если функция распределения не выделяет память). Там, где это возможно, настоящий международный стандарт определяет набор допустимых видов поведения. Они определяют недетерминированные аспекты абстрактной машины. Таким образом, экземпляр абстрактной машины может иметь более одного возможного исполнения для данной программы и заданного ввода.
Некоторые другие операции описаны в этом Международном стандарте как неопределенные (или пример, эффект попытки изменения объекта
const
). [ Примечание : этот международный стандарт не налагает никаких требований на поведение программ, которые содержат неопределенное поведение. - конечная нота ]
Чтение или запись через нулевой указатель
int *ptr = nullptr;
*ptr = 1; // Undefined behavior
Это неопределенное поведение , потому что нулевой указатель не указывает на какой-либо действительный объект, поэтому в *ptr
для записи нет объекта.
Хотя это чаще всего вызывает ошибку сегментации, оно не определено, и все может случиться.
Нет оператора возврата для функции с непустым возвратным типом
Опускание оператора return
в функции, которая имеет тип возврата, который не является void
является неопределенным поведением .
int function() {
// Missing return statement
}
int main() {
function(); //Undefined Behavior
}
Большинство современных компиляторов дня выдают предупреждение во время компиляции для такого рода неопределенного поведения.
Примечание: main
правило является единственным исключением из правила. Если main
не имеет оператора return
, компилятор автоматически вставляет return 0;
для вас, поэтому его можно безопасно исключить.
Изменение строкового литерала
char *str = "hello world";
str[0] = 'H';
"hello world"
- строковый литерал, поэтому его изменение дает неопределенное поведение.
Инициализация str
в приведенном выше примере официально устарела (запланирована для удаления из будущей версии стандарта) в C ++ 03. Ряд компиляторов до 2003 года может выдать предупреждение об этом (например, подозрительное преобразование). После 2003 года компиляторы обычно предупреждают об отказоустойчивом преобразовании.
Вышеприведенный пример является незаконным и приводит к диагностике компилятора в C ++ 11 и более поздних версиях. Подобный пример может быть сконструирован так, чтобы демонстрировать неопределенное поведение, явно разрешая преобразование типа, например:
char *str = const_cast<char *>("hello world");
str[0] = 'H';
Доступ к индексу вне пределов
Неопределенное поведение позволяет получить доступ к индексу, который выходит за рамки для массива (или стандартного библиотечного контейнера, поскольку все они реализованы с использованием необработанного массива):
int array[] = {1, 2, 3, 4, 5};
array[5] = 0; // Undefined behavior
Разрешено иметь указатель, указывающий на конец массива (в этом случае array + 5
), вы просто не можете его разыменовать, так как он не является допустимым элементом.
const int *end = array + 5; // Pointer to one past the last index
for (int *p = array; p != end; ++p)
// Do something with `p`
В общем, вам не разрешено создавать указатель вне пределов. Указатель должен указывать на элемент внутри массива или один за концом.
Целочисленное деление на ноль
int x = 5 / 0; // Undefined behavior
Разделение на 0
является математически неопределенным, и, как таковое, имеет смысл, что это неопределенное поведение.
Тем не мение:
float x = 5.0f / 0.0f; // x is +infinity
Большинство реализации реализуют IEEE-754, который определяет деление с плавающей запятой на ноль, чтобы вернуть NaN
(если числитель равен 0.0f
), infinity
(если числитель положителен) или -infinity
(если числитель отрицательный).
Подписанное переполнение целых чисел
int x = INT_MAX + 1;
// x can be anything -> Undefined behavior
Если во время оценки выражения результат не определяется математически или нет в диапазоне представляемых значений для его типа, поведение не определено.
(C ++ 11 Стандарт, пункт 5/4)
Это один из самых неприятных, поскольку он обычно дает воспроизводимое, не сбивающее с толку поведение, поэтому разработчики могут испытывать соблазн сильно полагаться на наблюдаемое поведение.
С другой стороны:
unsigned int x = UINT_MAX + 1;
// x is 0
хорошо определен, поскольку:
Беззнаковые целые числа, объявленные без знака, должны подчиняться законам арифметики по модулю
2^n
гдеn
- количество бит в представлении значений этого конкретного размера целого числа.
(C ++ 11 Стандарт, пункт 3.9.1 / 4)
Иногда компиляторы могут использовать неопределенное поведение и оптимизировать
signed int x ;
if(x > x + 1)
{
//do something
}
Здесь, поскольку знаковое целочисленное переполнение не определено, компилятор может предположить, что он может никогда не произойти, и, следовательно, он может оптимизировать блок «if»
Использование неинициализированной локальной переменной
int a;
std::cout << a; // Undefined behavior!
Это приводит к неопределенному поведению , поскольку a
не инициализируется.
Часто неверно заявляется, что это потому, что значение «неопределено» или «какое бы значение в этом месте памяти раньше». Тем не менее, это действие доступа к значению a
в приведенном выше примере, которое дает неопределенное поведение. На практике печать «значения мусора» является общим симптомом в этом случае, но это только одна возможная форма неопределенного поведения.
Хотя на практике это маловероятно (поскольку оно зависит от конкретной аппаратной поддержки), компилятор может одинаково хорошо сжечь программиста при компиляции вышеприведенного примера кода. При такой поддержке компилятора и аппаратного обеспечения такой ответ на неопределенное поведение заметно увеличил бы среднее (живое) программирование понимание истинного значения неопределенного поведения, а именно, что стандарт не ограничивает результирующее поведение.
Использование неопределенного значения типа unsigned char
не приводит к неопределенному поведению, если значение используется как:
- второй или третий операнд тернарного условного оператора;
- правый операнд встроенного оператора запятой;
- операнд преобразования в
unsigned char
; - правый операнд оператора присваивания, если левый операнд также имеет тип
unsigned char
; - инициализатор для объекта
unsigned char
;
или если значение отбрасывается. В таких случаях неопределенное значение просто распространяется на результат выражения, если это применимо.
Обратите внимание, что static
переменная всегда инициализируется нулем (если возможно):
static int a;
std::cout << a; // Defined behavior, 'a' is 0
Несколько неидентичных определений (правило одного определения)
Если класс, enum, встроенная функция, шаблон или член шаблона имеют внешнюю связь и определены в нескольких единицах трансляции, все определения должны быть идентичными или поведение не определено в соответствии с Правилом Единого определения (ODR) .
foo.h
:
class Foo {
public:
double x;
private:
int y;
};
Foo get_foo();
foo.cpp
:
#include "foo.h"
Foo get_foo() { /* implementation */ }
main.cpp
:
// I want access to the private member, so I am going to replace Foo with my own type
class Foo {
public:
double x;
int y;
};
Foo get_foo(); // declare this function ourselves since we aren't including foo.h
int main() {
Foo foo = get_foo();
// do something with foo.y
}
Вышеупомянутая программа демонстрирует неопределенное поведение, поскольку она содержит два определения класса ::Foo
, который имеет внешнюю связь, в разных единицах перевода, но эти два определения не идентичны. В отличие от переопределения класса внутри той же единицы перевода, эта проблема не требуется для диагностики компилятором.
Неправильное сопряжение выделения и освобождения памяти
Объект может быть освобожден только delete
если он был назначен new
и не является массивом. Если аргумент для delete
не был возвращен new
или является массивом, поведение не определено.
Объект может быть освобожден только путем delete[]
если он был назначен new
и является массивом. Если аргумент для delete[]
не был возвращен new
или не является массивом, поведение не определено.
Если аргумент free
не был возвращен malloc
, поведение не определено.
int* p1 = new int;
delete p1; // correct
// delete[] p1; // undefined
// free(p1); // undefined
int* p2 = new int[10];
delete[] p2; // correct
// delete p2; // undefined
// free(p2); // undefined
int* p3 = static_cast<int*>(malloc(sizeof(int)));
free(p3); // correct
// delete p3; // undefined
// delete[] p3; // undefined
Таких проблем можно избежать, полностью избегая malloc
и free
в программах на C ++, предпочитая интеллектуальные указатели стандартной библиотеки поверх raw new
и delete
и предпочитая std::vector
и std::string
поверх raw new
и delete[]
.
Доступ к объекту как неправильному типу
В большинстве случаев незаконно обращаться к объекту одного типа, как если бы он был другого типа (без учета cv-квалификаторов). Пример:
float x = 42;
int y = reinterpret_cast<int&>(x);
Результатом является неопределенное поведение.
Есть некоторые исключения из этого правила строгого сглаживания :
- К объекту типа класса можно получить доступ, как если бы он был типа, который является базовым классом фактического типа класса.
- Любой тип может быть доступен как
char
илиunsigned char
, но обратное неверно: массив символов недоступен, как если бы он был произвольным типом. - Подписанный целочисленный тип можно получить как соответствующий неподписанный тип и наоборот .
Связанное правило состоит в том, что если нестатическая функция-член вызывается в объекте, который фактически не имеет того же типа, что и определяющий класс функции, или производный класс, то происходит неопределенное поведение. Это верно, даже если функция не имеет доступа к объекту.
struct Base {
};
struct Derived : Base {
void f() {}
};
struct Unrelated {};
Unrelated u;
Derived& r1 = reinterpret_cast<Derived&>(u); // ok
r1.f(); // UB
Base b;
Derived& r2 = reinterpret_cast<Derived&>(b); // ok
r2.f(); // UB
Переполнение плавающей точки
Если арифметическая операция, которая дает тип с плавающей точкой, выдает значение, которое не находится в диапазоне представляемых значений типа результата, поведение не определено в соответствии со стандартом C ++, но может быть определено другими стандартами, которые может соответствовать машине, таких как IEEE 754.
float x = 1.0;
for (int i = 0; i < 10000; i++) {
x *= 10.0; // will probably overflow eventually; undefined behavior
}
Вызов (чистых) виртуальных членов из конструктора или деструктора
Стандарт (10.4) гласит:
Функции-члены могут быть вызваны из конструктора (или деструктора) абстрактного класса; эффект виртуального вызова (10.3) на чистую виртуальную функцию, прямо или косвенно для создаваемого (или уничтоженного) объекта из такого конструктора (или деструктора), не определен.
В более общем плане некоторые авторитеты C ++, например Скотт Майерс, предлагают никогда не вызывать виртуальные функции (даже не чистые) от конструкторов и конструкторов.
Рассмотрим следующий пример, измененный из приведенной выше ссылки:
class transaction
{
public:
transaction(){ log_it(); }
virtual void log_it() const = 0;
};
class sell_transaction : public transaction
{
public:
virtual void log_it() const { /* Do something */ }
};
Предположим, мы создали объект sell_transaction
:
sell_transaction s;
Это неявно вызывает конструктор sell_transaction
, который сначала вызывает конструктор transaction
. Когда конструктор transaction
вызывается хотя, объект еще не является типом sell_transaction
, а скорее только transaction
типа.
Следовательно, вызов в transaction::transaction()
в log_it
не будет делать то, что может показаться интуитивным, а именно call sell_transaction::log_it
.
Если
log_it
является чисто виртуальным, как в этом примере, поведение не определено.Если
log_it
является чистым виртуальным, будет вызыватьсяtransaction::log_it
.
Удаление производного объекта с помощью указателя на базовый класс, который не имеет виртуального деструктора.
class base { };
class derived: public base { };
int main() {
base* p = new derived();
delete p; // The is undefined behavior!
}
В разделе [expr.delete] §5.3.5 / 3 стандарт говорит, что если delete
вызывается на объекте, статический тип которого не имеет virtual
деструктора:
если статический тип подлежащего удалению объекта отличается от его динамического типа, статический тип должен быть базовым классом динамического типа подлежащего удалению объекта, а статический тип должен иметь виртуальный деструктор или поведение не определено.
Это имеет место независимо от того, добавил ли производный класс какие-либо члены данных в базовый класс.
Доступ к справочной ссылке
Недопустимо обращение к ссылке на объект, который вышел из сферы действия или иначе уничтожен. Такая ссылка называется свисающей, поскольку она больше не относится к действительному объекту.
#include <iostream>
int& getX() {
int x = 42;
return x;
}
int main() {
int& r = getX();
std::cout << r << "\n";
}
В этом примере локальная переменная x
выходит из области действия при getX
. (Обратите внимание, что продление жизни не может продлить время жизни локальной переменной за пределы области, в которой она определена.) Поэтому r
является оборванной ссылкой. Эта программа имеет неопределенное поведение, хотя в некоторых случаях она может работать и печатать 42
.
Расширение пространства имен `std` или` posix`
Стандарт (17.6.4.2.1 / 1) обычно запрещает расширение пространства имен std
:
Поведение программы на C ++ не определено, если оно добавляет объявления или определения в пространство имен std или в пространство имен в пространстве имен std, если не указано иное.
То же самое касается posix
(17.6.4.2.2 / 1):
Поведение программы на C ++ не определено, если оно добавляет объявления или определения в пространство имен posix или в пространство имен в пространстве имен posix, если не указано иное.
Рассмотрим следующее:
#include <algorithm>
namespace std
{
int foo(){}
}
Ничто в стандарте не запрещает algorithm
(или один из его заголовков), определяющий одно и то же определение, и поэтому этот код будет нарушать правило одного определения .
Так, в общем, это запрещено. Однако есть определенные исключения . Возможно, наиболее полезно, чтобы добавлять специализации для определенных пользователем типов. Так, например, предположим, что ваш код имеет
class foo
{
// Stuff
};
Тогда все нормально.
namespace std
{
template<>
struct hash<foo>
{
public:
size_t operator()(const foo &f) const;
};
}
Переполнение во время преобразования в тип или с плавающей запятой
Если при конверсии:
- целочисленный тип для типа с плавающей точкой,
- тип с плавающей точкой для целочисленного типа или
- тип с плавающей точкой для более короткого типа с плавающей точкой,
значение источника находится вне диапазона значений, которые могут быть представлены в типе назначения, результатом является неопределенное поведение. Пример:
double x = 1e100;
int y = x; // int probably cannot hold numbers that large, so this is UB
Недействительный статический приведение базы к производным
Если static_cast
используется для преобразования указателя (соответствующей ссылки) в базовый класс в указатель (соотв. Ссылку) на производный класс, но операнд не указывает (соответственно ссылаться) на объект типа производного класса, поведение не определено. См. Раздел База для производного преобразования .
Вызов функции с помощью неправильного типа указателя функции
Чтобы вызвать функцию через указатель функции, тип указателя функции должен точно соответствовать типу функции. В противном случае поведение не определено. Пример:
int f();
void (*p)() = reinterpret_cast<void(*)()>(f);
p(); // undefined
Изменение объекта const
Любая попытка изменить объект const
приводит к неопределенному поведению. Это относится к const
переменным, членам const
объектов и членам класса, объявленным const
. (Тем не менее, mutable
член const
объекта не const
.)
Такая попытка может быть выполнена через const_cast
:
const int x = 123;
const_cast<int&>(x) = 456;
std::cout << x << '\n';
Компилятор обычно встраивает значение объекта const int
, поэтому возможно, что этот код компилирует и печатает 123
. Компиляторы также могут помещать значения const
объектов в постоянную память, поэтому может возникнуть ошибка сегментации. В любом случае, поведение не определено, и программа может что-то сделать.
Следующая программа скрывает гораздо более тонкую ошибку:
#include <iostream>
class Foo* instance;
class Foo {
public:
int get_x() const { return m_x; }
void set_x(int x) { m_x = x; }
private:
Foo(int x, Foo*& this_ref): m_x(x) {
this_ref = this;
}
int m_x;
friend const Foo& getFoo();
};
const Foo& getFoo() {
static const Foo foo(123, instance);
return foo;
}
void do_evil(int x) {
instance->set_x(x);
}
int main() {
const Foo& foo = getFoo();
do_evil(456);
std::cout << foo.get_x() << '\n';
}
В этом коде getFoo
создает одноэлемент типа const Foo
а его член m_x
инициализируется до 123
. Затем do_evil
и значение foo.m_x
по-видимому, изменено на 456. Что пошло не так?
Несмотря на свое имя, do_evil
не делает ничего особо злого; все, что он делает, это вызвать сеттер через Foo*
. Но этот указатель указывает на объект const Foo
хотя const_cast
не использовался. Этот указатель был получен с помощью конструктора Foo
. Объект const
не становится const
до тех пор, пока его инициализация не будет завершена, поэтому в конструкторе this
имеет тип Foo*
, а не const Foo*
.
Поэтому неопределенное поведение происходит, хотя в этой программе нет явно опасных конструкций.
Доступ к несуществующему члену через указатель на члена
При доступе к нестатическому члену объекта через указатель на член, если объект фактически не содержит элемент, обозначенный указателем, поведение не определено. (Такой указатель на член может быть получен через static_cast
.)
struct Base { int x; };
struct Derived : Base { int y; };
int Derived::*pdy = &Derived::y;
int Base::*pby = static_cast<int Base::*>(pdy);
Base* b1 = new Derived;
b1->*pby = 42; // ok; sets y in Derived object to 42
Base* b2 = new Base;
b2->*pby = 42; // undefined; there is no y member in Base
Недопустимое преобразование на основе базы данных для указателей на членов
Когда static_cast
используется для преобразования TD::*
в TB::*
, указанный указатель должен принадлежать классу, который является базовым классом или производным классом B
В противном случае поведение не определено. См. « Производное преобразование базы» для указателей на участников
Недопустимая арифметика указателя
Следующие виды использования арифметики указателя вызывают неопределенное поведение:
Добавление или вычитание целого числа, если результат не принадлежит к одному и тому же объекту массива, как и операнд указателя. (Здесь элемент один за концом считается все еще принадлежащим массиву.)
int a[10]; int* p1 = &a[5]; int* p2 = p1 + 4; // ok; p2 points to a[9] int* p3 = p1 + 5; // ok; p2 points to one past the end of a int* p4 = p1 + 6; // UB int* p5 = p1 - 5; // ok; p2 points to a[0] int* p6 = p1 - 6; // UB int* p7 = p3 - 5; // ok; p7 points to a[5]
Вычитание двух указателей, если они не принадлежат к одному и тому же объекту массива. (Опять же, элемент один за концом считается принадлежащим массиву.) Исключением является то, что два нулевых указателя могут быть вычтены, что дает 0.
int a[10]; int b[10]; int *p1 = &a[8], *p2 = &a[3]; int d1 = p1 - p2; // yields 5 int *p3 = p1 + 2; // ok; p3 points to one past the end of a int d2 = p3 - p2; // yields 7 int *p4 = &b[0]; int d3 = p4 - p1; // UB
Вычитание двух указателей, если результат переполняется
std::ptrdiff_t
.Любая арифметика указателя, где любой тип указателя операнда не соответствует динамическому типу объекта, на который указывает (игнорируя cv-qualification). Согласно стандарту, «в частности, указатель на базовый класс не может использоваться для арифметики указателя, когда массив содержит объекты производного типа типа».
struct Base { int x; }; struct Derived : Base { int y; }; Derived a[10]; Base* p1 = &a[1]; // ok Base* p2 = p1 + 1; // UB; p1 points to Derived Base* p3 = p1 - 1; // likewise Base* p4 = &a[2]; // ok auto p5 = p4 - p1; // UB; p4 and p1 point to Derived const Derived* p6 = &a[1]; const Derived* p7 = p6 + 1; // ok; cv-qualifiers don't matter
Смещение недопустимым числом позиций
Для встроенного оператора сдвига правый операнд должен быть неотрицательным и строго меньше ширины бита продвинутого левого операнда. В противном случае поведение не определено.
const int a = 42;
const int b = a << -1; // UB
const int c = a << 0; // ok
const int d = a << 32; // UB if int is 32 bits or less
const int e = a >> 32; // also UB if int is 32 bits or less
const signed char f = 'x';
const int g = f << 10; // ok even if signed char is 10 bits or less;
// int must be at least 16 bits
Возврат из функции [[noreturn]]
Пример из стандарта, [dcl.attr.noreturn]:
[[ noreturn ]] void f() {
throw "error"; // OK
}
[[ noreturn ]] void q(int i) { // behavior is undefined if called with an argument <= 0
if (i > 0)
throw "positive";
}
Уничтожение объекта, который уже был уничтожен
В этом примере деструктор явно вызывается для объекта, который впоследствии будет автоматически уничтожен.
struct S {
~S() { std::cout << "destroying S\n"; }
};
int main() {
S s;
s.~S();
} // UB: s destroyed a second time here
Аналогичная проблема возникает, когда std::unique_ptr<T>
делается для указания на T
с автоматической или статической продолжительностью хранения.
void f(std::unique_ptr<S> p);
int main() {
S s;
std::unique_ptr<S> p(&s);
f(std::move(p)); // s destroyed upon return from f
} // UB: s destroyed
Другой способ уничтожить объект дважды - это сделать два shared_ptr
которые управляют объектом, не разделяя права собственности друг на друга.
void f(std::shared_ptr<S> p1, std::shared_ptr<S> p2);
int main() {
S* p = new S;
// I want to pass the same object twice...
std::shared_ptr<S> sp1(p);
std::shared_ptr<S> sp2(p);
f(sp1, sp2);
} // UB: both sp1 and sp2 will destroy s separately
// NB: this is correct:
// std::shared_ptr<S> sp(p);
// f(sp, sp);
Бесконечная рекурсия шаблона
Пример из стандарта, [temp.inst] / 17:
template<class T> class X {
X<T>* p; // OK
X<T*> a; // implicit generation of X<T> requires
// the implicit instantiation of X<T*> which requires
// the implicit instantiation of X<T**> which ...
};