Неопределенное поведение в C++
При использовании языка, важно знать его стандарт. Многие компиляторы и интерпретаторы могут не сообщать о случаях, когда стандарт не определяет поведение какого-либо кода. Как следствие, код может делать что-то неожиданное, не делать ничего вовсе, или просто делать разные вещи на разных машинах, разных компиляторах или в зависимости от настроек одного компилятора.
Существует два случая, когда стандарт не определяет поведение кода. Они называются
- Неопределенное поведение (Undefined behavior)
В этом случае, стандарт явно утверждает, что для такого кода поведение может быть любым. Код может запустить ядерные ракеты, уничтожить всю жизнь на Земле, взорваться, или просто ничего не сделать. Конечно, на практике эффекты не столь удивительны.
- Неуточненное поведение (Unspecified behavior)
В этом случае так же говорят, что поведение зависит от реализации (implementation defined). Исполнение такого кода зависит от аппаратной платформы, на которой он исполняется. Это чаще всего связано с оптимизациями под конкретную платформу и характерно для низкоуровневых языков, таких как C и C++.
Я перечислю несколько примеров неопределенного поведения и покажу наиболее интересные случаи.
Указатели
Указатели в общем и целом – очень мощный инструмент. Но, как любой мощный инструмент, они позволяют делать много того, о чем можно пожалеть. Например, бензопила – тоже мощный инструмент.
К неопределенному поведению приводят следующие операции:
Разыменование нулевого указателя.
*(0)
Разыменование указателя на память нулевой длины.
*(new int[0])
Использование указателей на объекты, время жизни которых истекло:
#include <iostream> int* f() { int a; return &a; } int main() { int* a = f(); std::cout<<a<<std::endl; }
Разыменование указателя, который не был однозначно инициализован.
int *i; cout<<*i;
Арифметика с указателями, приводящая к выходу за пределы выделенной памяти.
int *i=new int[10]; int* j= i+1024;
Конвертирование указателей в объекты несовместимых типов
#include <iostream> using namespace std; int main() { int* i=new int; 15; *i=float* j = reinterpret_cast<float*>(i); cout<<* j<<endl; }
Использование memcpy для копирования перекрывающихся участков памяти
#include <iostream> #include <cstring> using namespace std; int main() { int* i=new int[10]; 10); memcpy(i,i,5]<<endl; cout<<i[ }
Целые
Все знают, что целые числа занимают ограниченный объем памяти. Не все знают, что выход за пределы этих границ часто является неопределенным поведением.
- Переполнение знакового целого
int i=std::numeric_limits<int>::max+1;
- Вычисление математически неопределенного выражение (например деления на ноль)
- Битовый сдвиг влево на отрицательное число (сдвиг вправо на отрицательное число, кстати – неуточненное поведение)
2<<-1
- Битовый сдвиг на число бит, равное или превосходящее битность числа
int32_t i=1; cout<<(i<<33)<<endl;
Типы, приведения и константность
Приведение числового значения к типу, который не может представить это число
#include <iostream> #include <limits> using namespace std; int main() { int i = numeric_limits<long>::max(); cout<<i<<endl; }
Использование переменной с автоматическим временем жизни до того, как она однозначно определена
int i; cout<<i;
Изменение строкового литерала или константного объекта
char* s= "asd"; s[2]='1';
. Кстати код приводит к прерыванию программы.
Функции и шаблоны
- Отсутствие возвращаемого значения из функции, которая должна возвращать значение (не
void
) - Вызов функции с использованием параметров или связывания отличными от параметров и связывания, определенных для функции (например
extern "C"
в одном заголовке и отсутствие в другом)
ООП
Каскадное удаление объектов со статическим временем жизни в разных единицах трансляции
Результат присвоения частично перекрывающимся объектам
Рекурсивный вход в функцию при инициализации ее статических объектов
#include <iostream> #include <limits> using namespace std; int f(int j=0) { if(j>10) return j; static int i = f(j+1); return i; } int main() { cout<<f()<<endl; }
Вызов чисто виртуальных функций объекта из его конструктора или деструктора
Обращение к нестатическим членам объектов, которые еще не созданы или уже уничтожены
Исходный код
Непустой файл исходных кодов, который не заканчивается новой строкой или заканчивается обратным слешем (до C++11)
Не определенные стандартом коды backslash-escape символов в строке
Численные значения препроцессора, которые невозможно представить как long int
Директива препроцессора с левой стороны определения функционального макроса
#define a 123 #define f(a,b) a+b
Другие
Порядок выполнения операций внутри выражения не определен. В частности, следующий код приводит к неуточненному поведению:
a[i] = i++;
Компилятор производит три операции:
a[i]
i++
(1)=(2)
При этом, хотя последняя операция всегда будет выполнена последней, первые две могут быть выполненны в произвольном порядке.
Более общий случай той же проблемы в том, что аргументы функции могут быть вычислены в любом порядке.
Прямым следствием является то, что значение переменной может быть присвоено не более одного раза в одном выражении. Таким образом,
i = ++i;
не определено (присвоение происходит дважды). Из той же серии i=i++ + ++i
Вопросы с подвохом
Простой вопрос с подвохом: можно ли использовать delete this
?
Чуть более сложный вопрос с подвохом: если да, то что можно делать после вызова delete this
?
Немного про Ruby
Ruby забавно работает с неопределенными переменными. Неопределенная переменная a
генерирует ошибку, неопределенная переменная b
генерирует ошибку, присвоение a=b
генерирует ошибку.
Но почему-то a=a
равно nil
.
Немного про JavaScript
> []+[]
''
> []+{}
'[object Object]'
> {}+[]
0
> {}+{}+[]
'NaN'
> Array(16).join("wat"-1) + " Batman!"
'NaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaNNaN Batman!'
JavaScript – странный язык.