Вы находитесь на странице: 1из 20

СТАНДАРТ С++

Часть вторая: неопределённое поведение

К. Владимиров, Intel, 2020


mail-to: konstantin.vladimirov@gmail.com
Поведение программ
• Синтаксически некорректные
• Синтаксически корректные
• well-formed (в дальнейшем просто "программы")
• strictly conforming behavior (в стандарте C++ этого нет)
• implementation-defined, locale-specific and conditionally supported behavior
• unspecified behavior
• undefined behavior
• ill-formed
• no diagnostics required

2
Undefined behavior
• Поведение, не регламентируемое стандартом
• [intro.abstract] A conforming implementation executing a well-formed program
shall produce the same observable behavior as one of the possible executions of
the corresponding instance of the abstract machine with the same program and
the same input. However, 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)
• Это очень сильное дополнение

3
Первым делом мы испортим самолёты
• На всех известных мне компиляторах эта программа выведет 42
#include <iostream>
int foo(bool c) {
int x, y;
y = c ? x : 42; // при c == true операция y = x undefined
// поэтому проверка с может не проводиться
return y;
}
int main() {
std::cout << foo(true) << std::endl;
}

4 https://godbolt.org/z/7jzEY3
Точка зрения компилятора
• Неопределённое поведение это простор для оптимизаций
int elt = arr[idx]; // выход за границы массива это UB
// значит мы его не учитываем
• Видите логику?
y = c ? x : 42; // возможность, что c == true это UB
// значит мы её не рассматриваем
• Удивительно, но компилятор сознательно слеп в отношении UB
• Каррух (см. видео ниже) называет это "узким контрактом"

5 https://www.youtube.com/watch?v=yG1OZ69H_-o
Удивительные последствия слепоты
• Итак, компилятор сознательно не рассматривает возможное UB
• Тогда во что может скомпилироваться этот цикл?
#include <iostream>
int main() {
for (int i = 0; i < 10; ++i)
std::cout << 1'000'000'000 * i << std::endl;
}
• При ответе учтите, что целочисленное переполнение это UB

6 https://godbolt.org/z/Kq1PGb
Точка зрения стандарта
• С точки зрения стандарта
• [defns.undefined] undefined behavior may be expected when this document
omits any explicit definition of behavior or when a program uses an erroneous
construct or erroneous data
• И это очень интересно. Получается так:
• Если некое поведение подчёркнуто как UB то это оно и есть
• Если некое поведение не определено в стандарте, это тоже UB

7
Пример: указатели
• У нас есть много явно определённого UB с указателями, например
• [basic.stc] when the end of the duration of a region of storage is reached, the
values of all pointers representing the address of any part of that region of storage
become invalid pointer values ([basic.compound]). Indirection through an invalid
pointer value and passing an invalid pointer value to a deallocation function have
undefined behavior
• Это вполне понятная вещь
int a = 0;
int *p = &a;
{ int x; p = &x; } // p became invalid
int t = *p; // UB as specified

8
Загадочный null pointer
• Тем не менее из стандарта довольно сложно достать ответ на вопрос:
int *a = nullptr; *a = 3;
• Нигде нет явного clause вроде "nullptr indirection is undefined behavior"
• Что, смешно, в стандарте есть косвенное указание
• [dcl.ref] note: In particular, a null reference cannot exist in a well-defined program,
because the only way to create such a reference would be to bind it to the "object"
obtained by indirection through a null pointer, which causes undefined
behavior
• Но это вообще в пункте не относящемся к указателям. Как доказать
выделенное?
9
Внезапный контрпример
• Но внезапно в некоторых случаях разыменование нулевого указателя это как
раз вполне определённое поведение
• [expr.typeid] if the glvalue is obtained by applying the unary * operator to a
pointer and the pointer is a null pointer value ([basic.compound]), the typeid
expression throws an exception ([except.throw]) of a type that would match a
handler of type std::bad_typeid exception ([bad.typeid])
• Внезапно. То есть иногда можно. Но тем интереснее когда же нельзя?

10 https://godbolt.org/z/qGf1an
Какие бывают указатели
• Рассмотрим что мы вообще знаем об указателях?
• Указатели бывают следующими:
• [basic.compound]
(3.1) a pointer to an object or function (the pointer is said to point to the object or
function), or
(3.2) a pointer past the end of an object (7.6.6), or
(3.3) the null pointer value for that type, or
(3.4) an invalid pointer value
• В свою очередь, операция unary* определена для... а вот это интересно

11
Неявное неопределённое поведение
• [expr.unary.op] the unary * operator performs indirection: the expression to which
it is applied shall be a pointer to an object type, or a pointer to a function type and
the result is an lvalue referring to the object or function to which the
expression points
• Итак, всё упирается в lvalue, получающееся в результате разыменования
• Операция разыменования не определена в тех случаях, когда нет никакого
lvalue, ссылающегося на объект или функцию
• Заметим: она не определена технически
• Можно было бы внести явный clause который определял бы её как UB, но
зачем?

12
Обсуждение
• В языке C++ есть превосходный механизм offsetof (макрос в stdlib.h)
• Обычно это билтин компилятора. Но предположим я захотел определить:
using cvc = const volatile char;
#define offsetof(s,m) \
(size_t) &reinterpret_cast<cvc&>((((s *)nullptr)->m))
• Является ли поведение этого макроса undefined?
struct S {int x, y; }; int n = offsetof(S, x); // UB?
• [expr.ref] the expression E1->E2 is converted to the equivalent form
(*(E1)).E2

13
А что если constexpr?
template <typename T, typename M> M get_member_type(M T::*);
template <typename T, typename M> T get_class_type(M T::*);
template <typename T, typename R, R T::*M>
constexpr std::size_t offset_of() {
return reinterpret_cast<std::size_t>(&(((T*)0)->*M));
}
#define OFFSET_OF(m) offset_of<decltype(get_class_type(m)), \
decltype(get_member_type(m)), m>()
• Теперь мы могли бы сослаться на:
• [defns.undefined] evaluation of a constant expression never exhibits behavior
explicitly specified as undefined
14 stackoverflow question
А что если constexpr?
• Тут мы имеем первую интересную проблему
• GCC компилирует: https://godbolt.org/z/36cePn
• Clang отказывается: https://godbolt.org/z/xxEo5b
• Может ли reinterpret_cast быть частью константного выражения?
• Стандарт говорит:
• [expr.const] an expression E is a core constant expression unless the evaluation of
E, following the rules of the abstract machine [intro.execution], would evaluate
one of the following:
• (5.15) -- a reinterpret_cast

15 https://gcc.gnu.org/bugzilla/show_bug.cgi?id=49171
Почему это так?
• Мы видим феноменальную вещь: все разработчики gcc много лет знали что
компилятор ведёт себя не по стандарту и не собирались это чинить
• Но дело в том, что
• constant expression never exhibits behavior explicitly specified as undefined
• В данном случае нарушается неявное правило и получившееся выражение
выпадает из трансляционной семантики языка
• Диагностика по стандарту не требуется
• Разновидностью возможного поведения будет корректный reinterpret_cast
• Но само правило было внесено чтобы облегчить компилятор

16
И ещё немного про reinterpret_cast
• Что если мы хотим провернуть следующую операцию?
aligned_storage<sizeof(int), alignof(int)>::type data;
new(&data) int;
int *p = reinterpret_cast<int*>(&data); *p = 5;
• Законно ли мы действуем?

17
И ещё немного про reinterpret_cast
• Что если мы хотим провернуть следующую операцию?
aligned_storage<sizeof(int), alignof(int)>::type data;
new(&data) int; // формально тут закончен lifetime of data
int *p = reinterpret_cast<int*>(&data); *p = 5; // UB
• Законно ли мы действуем?
• [basic.life] an object o1 is transparently replaceable by an object o2 if:
• (8.2) o1 and o2 are of the same type (ignoring the top-level cv-qualifiers)
• Что здесь интересно так это формулировка can be used

18
Ещё более скрытое UB
• [basic.life] If, after the lifetime of an object has ended and before the storage
which the object occupied is reused or released, a new object is created at the
storage location which the original object occupied, a pointer that pointed to the
original object, a reference that referred to the original object, or the name of the
original object will automatically refer to the new object and, once the lifetime of
the new object has started, can be used to manipulate the new object, if the
original object is transparently replaceable
• Мы используем объект который can not be used
• Программа well-formed, стандарт ничего не говорит нам о семантике
исполнения
• Вывод? Неопределённое поведение

19
Небольшой бонус: отмывка указателя
aligned_storage<sizeof(int), alignof(int)>::type data;
new(&data) int; // формально тут закончен lifetime of data
int *p = std::launder(reinterpret_cast<int*>(&data));
*p = 5; // OK!
• Чисто по человечески надо понимать почему это работает
• То есть понятно, что это работает по стандарту (найдите пункт) но реально
это сделано с целью облегчить жизнь компиляторам
• Компилятору удобно закладываться, что указатели разного типа указывают в
разные места. Launder ставит разумный барьер этому анализу

20

Вам также может понравиться