В прошлый раз мы остановились на пункте Declarations, definitions and scopes

Объявления

int a;

void f(int x);

class C;
struct S;
enum E;
union U;

namespace N {
    int x;
}

namespace N {
    int y;
}

Чтобы сделать элемент видимым из другого namespace, можно использовать using

int main() {
    // std::cout << x << std::endl // CE
    std::cout << N::x << std::endl // OK
    using N::x; // теперь объявление x есть в нашей области видимости(scope)
    std::cout << x << std::endl; // OK
}

Также using можно также добавить в глобальную область видимости и в другой namespace Можно использовать сразу все пространство имен через using namespace N.

Один из примеров использования: using namespace std, но это крайне не рекомендуется, по крайней мере глобально.

Причина тому то, что std крайне огромен, соответственно в нем очень много имен, особенно опасно привнести функцию с одинаковым именем и другими типами, например, std::distance.

Еще один вид конструкции using это алиасинг типов, например, некоторые из смертных грехов

using vi = std::vector<int>;
using ll = long long;

Стоит понимать, что using не создает новый тип. using также можно использовать с шаблонами

template <typename T>
using v = std::vector<T>;

Ключевые слова typedef и using семантически делают одно и то же, но typedef это C-style, а значит, например, не умеет в шаблоны.

Напомним, что ключевыми словами называются слова, смысл которых закреплен компилятором, и как следствие, их нельзя использовать, например, в качестве имён.

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

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

#include <iostream>

int x = 0;

int main() {
    int x = 1;
    for (int i = 0; i < 10; ++i) {
        int x = i;
        // int x = 5; // CE, redeclaration in same scope
        std::cout << x << std::endl; // i
        std::cout << ::x << std::endl; // 0, if global doesn't exist will throw CE
    }
}

Обратим внимание, что всегда можно создать блок и затемнить объекты

int x = 5;

{
    int x = 6;
    // now x is 6
}

Стоит обратить внимание на следующую “подлянку”:


namespace N {
    int x = 7;
}

using namespace N;

// WARNING: uncommenting this line will result to 0 as output
// By standard this is not redeclaration but using N::x is
// int x = 0;

int main() {
    int x = 1;
    {
        int x = 5;
        std::cout << x << std::endl; // will print 7, as global scope includes x
    }
}

Если x объявлен в двух различных пространствах N, M, подключенных через using namespace N; using namespace M;, то при попытке использования x компилятор выкинет ошибку ambigious reference to variable x.

В первом приближении есть условно два типа, которые разрешаются в следующем приоритете: 1. Имена объявленные “явно”, через T x; или using N::x. 2. Имена подключенные из другого пространства имен через using namespace N;

Также можно получить ошибку different kind of entity, если определить символ как тип и как переменную одновременно, например

using N::x; // int
using x = std::vector<int>; // Type

Если начать заниматься еще большей херней, например int x = x;, то из-за такой концепции как point of declaration, данный код эквивалентен int x;, соответственно в x будет лежать мусор, а совсем не то, что будет в высшей области видимости или пространстве имен.

One definition rule(ODR). Каждая сущность в программе должна быть определена ровно один раз. Это верно в C, но не совсем верно для классов. Класс можно определить несколько раз, если все определения абсолютно идентично.

Объявлений функций может быть сколько угодно, но определение ровно одно. Любое определение является объявлением.

C++ разрешает более одной функции с одним и тем же именем.

void f();
int f(int x) {
    return x + 1;
}
int f(double x) {
    return x + 2;
}

f(0.5) = 2, так как вызовется от double, f(0) = 1, так как вызовется от int.

f(0.0f) = 2, ведь float -> double – promotion, а float -> int – conversion. promotion лучше, чем стандартный conversion, лучше чем тот, что определен пользователем.

Если бы определили int f(float x);, а вызывали бы f(0.0) // no suffix defines double, то компилятор бы выкинул ошибку, так как он не может выбрать между двумя conversion.

Следующий код также вызовет ошибку компиляции

void f();
int f();

Это переобъявление(redeclaration) с одинаковым типом принимаемых значений, но разным возвращаемых.

Необходимо различать (не)квалифицированные идентификаторы.

x; // unqualified-id
N::x; // qualified-id

По стандарту int x; также считается определением, только если не стоит ключевое слово extern. Выражение extern int x; является объявлением, но не считается определением, линковщик будет искать этот символ. static делает переменную видимой только в данном translation unit, но также является определением(которое затемняет другие).

1.4 Expressions and operators

В локальных областях видимости появляются такие сущности, как выражения(expressions) и инструкции(control flow statements), про последние мы поговорим в следующем параграфе.

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

Пример. x + 5 или cout << x являются выражениями.

Все мы знаем (бинарные) операторы +, -, *, /, %, <<, >>, |, &, ^, &&, ||, <, >, !=, ==, <=, >=, <=>(since C++-20)

Побитовые операторы(&, |, ^) вычисляют выражение побитово.

Логические операторы(&&, ||) принимают bool и возвращают bool, и притом вычисляются лениво. Стандарт гарантирует, что если a ложно, то в выражении a && b выражение b вычислено не будет. Аналогично, если a истинно, то в выражении a || b выражение b вычислено не будет.

Пример. Выражение v.size() >= 5 && v[4] == 1 безопасно и не вызывает UB из-за ленивости &&.

Рассмотрим оператор присваивания = и операторы составного присваивания +=, -=, *=, /=, %=, <<=, >>=, |=, ^=, &=.

x = y возвращает y. (x = y) = z возвращает z, все они lvalue.

На интуитивном уровне, lvalue можно что-то присвоить, а rvalue нет, то есть x + y это rvalue, а x = y это lvalue.

Первая группа операторов возвращает lvalue, а вторая rvalue.

Операторы присваивания правоассоциативны, то есть x = y = z эквивалентно x = (y = z). Вроде как все операторы из первой группы левоассоциативны, то есть x + y + z эквивалентно (x + y) + z.

Для float уже есть разница в каком порядке их складывать из-за точности(лучше в отсортированном).

Лево(право)ассоциативность сохраняется в независимости от того, как оператор был перегружен.

До C++-17 операторы &&, || при перегрузке теряет ленивость и гарантию на порядок вычисления. Начиная с C++-17 гарантируется, что при вычислении a && b выражение b будет вычислено после выражения a.

Ещё один класс операторов – это декременты и инкременты, a++, ++a, --a, --a.

Разница между a++, ++a заключается в том, что первый(постфиксный) создает новое значение и кладет туда результат, тем самым возвращает rvalue. Второй(префиксный) увеличивает переменную “на месте” и возвращает lvalue. Аналогично a--, --a.

Инкременты и декременты применимы только к lvalue, то есть выражение --(4 + 5) некорректно, а ++(x=5) корректно.

По стандарту постфиксные операции приоритетнее, и ++a++ эквивалентно ++(a++), а следовательно некорректно.

Важный пример. Чему эквивалентно выражение a+++++b? Лексический парсер(лексер) всегда пытается взять наибольший “осмысленный” токен, поэтому он разобъет его следующим образом: ((a++)++)+b, но выражение (a++) является rvalue, а инкремент можно применить только к lvalue.

Тернарный оператор. a ? b : c. Пытается преобразовать a к bool, если истинно, то вычисляет b и возвращает его, иначе вычисляет c и возвращает его. Гарантируется, что будет вычисляться ровно один из них.

Вид возвращаемого value зависит от вида value b и c. Но вид value, как и тип, должен быть известен на этапе компиляции, поэтому если хоть одно из них rvalue, то вид всего выражения это rvalue, иначе lvalue.

Пример. (false ? a++ : ++a) = 1, даже несмотря на то, что оно всегда равно ++a, которое lvalue, из рассуждения выше левая часть все равно будет rvalue, откуда присваивание некорректно.

Если для b и c не найдется общий тип, но будет ошибка компиляции, иначе тип выражения равен общему типу.

Пример. f(b ? 1 : 0.5) всегда вызовет f(double).

Оператор , делает следующее: выражение a, b вычисляет сначала a, потом b, и возвращает b. Тип value такой же как у b.

Не всякая запятая как символ является оператором. Запятая в void f(int, int) очевидно не оператор, ведь это объявление, а не выражение, равно как и < в #include <header.h>, которая вообще является командой препроцессора.

Важное замечание. T x = y это не оператор присваивания и не выражения, это синтаксис объявления.

Важный пример. При вызове функции f(x, y, z) нет никакой гарантии на порядок вычисления аргументов, x, y, z это не выражение, а перечисление аргументов. А если написать f((x, y, z)), то это будет эквивалентно вызову функции f от одного аргумента z, выражения x, y, z будут вычислены именно в таком порядке.

Оператор sizeof. Возвращает количество байт, которое занимает переменная/типа. Так как тип переменной x известен во время компиляции, то sizeof(x) также известен во время компиляции.

sizeof от VLA(variable length array) является неопределенным поведением. VLA есть в стандарте C99, но нет в стандарте C++, тем не менее, компиляторы его поддерживают.

Замечание. sizeof(v) это не v.size(). sizeof(arr), где arr – статически выделенный массив, это сумма размеров его элементов, но от указателя он равен размеру самого указателя непосредственно.

Приоритеты операторов

Порядок выполнения

Ниже приведены некоторые примеры:

Нельзя сказать, чему равно значение x++ * y++ + ++x. Несмотря на то, что умножение “выполняется” раньше, чем сложение, порядок вычисления операндов не гарантируется.

int f() { std::cout << 1; return 1;}
int g() { std::cout << 2; return 2;}
int h() { std::cout << 3; return 3;}

В таком случае значение f() * g() + h() имеет однозначно определенное значения, но вот результат его вычисления не определен.

В выражении a*b + c*d + e*f нет никаких гарантий, что сначала вычислится результат всех умножений, а только потом они будут складываться.

Есть шедевральная глава “Sequenced before” rules, которое задает некоторые правила порядка вычислений внутри одного потока, более точно, транзитивное антисимметричное бинарное отношение.

Порядок выражений стал более строгим начиная с C++-17, тем самым некоторые выражения, вызывающие неопределнное поведение до C++-17, хорошо определены после.

Control statements if:

if (/* bool-expression */) {

} else if (...) {

} else {

}

switch:

switch (/* bool-expression */) {
    case 1:
        std::cout << "AAAA";
        /* FALLTHROUGH */
    case 2:
        std::cout << "BBBB";
        break;
    case 3:
        std::cout << "CCCC";
    default:
        std::cout << "DEFAULT";
}

Замечание. Cases in switch fall through, например при expression == 1 он выведет AAAABBBB, на 2 выведет BBBB, на 3 выведет CCCCDEFAULT, иначе DEFAULT. loops:

// First check, then do
while (/* bool-expression */) {

}

// First do, then check
do {

} while (/* bool-expression */);

// first follows declaration, then check expression, and if true
// evaluates expression and inner block and repeats it
for (declaration | expression; bool-expression; expression) {

}
// empty bool-expression is always true

Начиная с C++-17 можно делать объявление в ife: if (int x = 0; y > x).

Метки:

label:

// do some stuff

if (bool-expression) {
    goto label;
}

Нельзя прыгать с пропуском инициализации.