1.6. Compile-time errors, runtime errors and undefined behaviour

Мы уже встречались с ошибками компиляции.

Ошибки можно условно разделить на лексические, синтаксические и семантические.

Мы уже затрагивали лексический парсер, он разбивает программу на токены, например std::cin >> x; разбивается на 6 токенов std :: cin >> x ;.

Далее происходит синтаксический разбор, и компилятор начинает заниматься семантикой.

Пример.

Ошибка времени выполнения или runtime error это когда программа успешно скомпилирована, но непредвиденно завершается во время выполнения(падает).

Одна из самых частых – segmentation fault.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v(10);
    v[50'000] = 1;
}

Возникает из-за обращения к памяти, которую мы не имеем права читать.

Floating point exception (FPE), например, деление на ноль, идёт от процессора.

Aborted, вызов функции abort() из libc, её вызов приводит к аварийному завершению.

Неопреденное поведение или undefined behaviour – некорректный код, который приводит к “непредсказуемым” последствиям во время выполнения, в том смысле, что компилятор не может дать никаких гарантий.

std::vector<int> v(10);
v[10] = 1; // UB
// Может упасть, а может и повредить очень важную область памяти
// у другой переменной

int x;
std::cout << x; // UB

x++ + ++x; // UB, как говорилось раньше, нет гарантий на порядок

Signed integer overflow это также UB. В том смысле, что компилятор вправе считать, что в коде нет переполнений знаковых чисел и делать оптимизации исходя из этого.

for (int i = 0; i < 300; ++i) {
    std::cout << i << ' ' << i * 12345678 << std::endl;
}

при компиляции с g++ -O2 получается вечный цикл. Но почему? Ведь при проверке не возникает переполнение

Компилятор считает, что выражение i * 12345678 не переполняется, а значит i < 174, а значит проверку можно убрать.

Также компилятор может убрать assert(a + 100 > a), так как он вправе считать, что a + 100 > a всегда.

Бесконечный цикл без побочных эффектов тоже является UB, компилятор имеет право сделать с ним что угодно, см C Compilers Disprove Fermat’s Last Theorem.

Помимо неопределенного поведения есть ещё unspecified behaviour. Это значит, что стандарт не говорит, что конкретно должно быть, например, порядок вычислений.

То есть f(a(), b(), c()) это не undefined behaviour, но unspecified, потому что неясен порядок вычислений.

Implementation-defined behaviour это то, что зависит от реализации и окружения, например, от компилятора, локали, архитектуры, etc.

За счёт undefined behaviour, компилятор может делать агрессивные оптимизации, что ускоряет “корректный” код, зато с “некорректным” компилятор может сделать что угодно.

Лирическое отступление.

Warning – замечание компилятора относительно вашего кода, не обязательно нарушение стандарта.

Примеры:

if (x = 0) {
    // do something
}

Это формально корректный код, и даже используемая идиома, но clang кидает предупреждение, и просит обернуть в скобки, если это сделано специально.


pid_t pid;

if ((pid = fork()) == -1) {
    // error while forking, should handle
}

Ещё один пример, это unused value. Компилятор предупреждает, не забыли ли вы случайно использовать значение, например f(); если f возвращает не void.

Некоторые флаги

Флаг -Wall позволяет предупреждать “обо всём”, -Wextra о том, о чём не предупреждает -Wall.

Флаг -pedantic говорит компилятору строго чтить стандарт и не использовать свои расширения, по типу VLA в C++.

Флаг -Werror превращает все предупреждения в ошибки.

Подробнее см. man gcc, info gcc(боже упаси) или онлайн-документацию.

II. Compound types

2.1. Pointers

Пусть есть какая-то переменная T x. У нее есть какой-то адрес, &x. Это не буквально её адрес на плашке оперативной памяти, но по этому числу программа может обращаться к памяти. Для этого не нужно вдаваться в подробности того, как работает операционная система.

int main() {
    int x;

    std::cout << &x << std::endl; // may be different at different runs

А какой тип у &x? Это T*. Ясно, что должна существовать обратная операция к взятию адреса, разыменование. *p по указателю T* возвращает то, что лежит под ним.

int x = 0;
int* p = &x;
*p = 2;

Важно. Несмотря на то, что * это часть типа, при объявлении нескольких указателей её надо каждый раз проставлять заново

int *a, *b, c;
// a, b are pointers, c is integer

Самое главное, к указателям можно прибавлять числа. Этим и отличаются типы T*, U*, если T и U разных размеров.

Указатель p + n ссылается “на n элементов типа T дальше, чем p”. То есть к адресу прибавляется n * sizeof(T).

std::vector<int> v = {1, 2, 3, 4, 5}; // std::vector guarantees that they're aligned in a row
int *p = &v[0];
std::cout << *(p + 3) << std::endl; // 4
std::cout << *++p << std::endl; //2

Указатели можно вычитать(конечно, если они одного типа), например &v[3] - &v[0] равно 3, а &v[0] - &v[4] равно -4.

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

Грубо говоря, разность это (address(p) - address(q)) / sizeof(T).

Ничего не мешает брать указатели на указатели.

На 64-битной архитектуре размер указателя это 8 байт. Размер указателя не зависит от типа, на который он указывает.

Унарная * это lvalue(объект), а & это rvalue(его адрес), и его аргумент должен быть lvalue(чтобы у него был адрес).

int a = 1;
int *p = &a;
{
    int b = 2;
    p = &b;
} // lifetime of b ends here, so p points to trash
std::cout << *p << std::endl; // UB, but most likely 2

Память может также переиспользоваться, если потом создать int c = 3;, например.

Если вы работаете с указателями разных типов, то скорее всего вы делаете что-то не то. Их нельзя даже сравнить, будет ошибка компиляции.

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

В C++ есть ключевое слово nullptr(константа NULL в Си, равная нулю), указатель в никуда. При его разыменовывании происходит неопреденное поведение.

2.2 Kinds of memory

data, text, stack

data – область памяти, в которой лежат глобальные переменные text – область памяти, куда загружена сама программа stack – область памяти, где хранятся локальные переменные

Она называется stack, потому что она работает как стек, когда программа входит в функцию, на стек кладутся аргументы, далее другие локальные переменные. После выхода из функции/области видимости они извлекаются из стека, и.т.д. В рамках одной локальной области компилятор может класть переменные на стек в произвольном порядке и делать промежутки.

При входе в функцию также кладется “адрес возврата”, откуда надо продолжить выполнять код, после того как мы выйдем из функции.