О чём лекция

Как исходный текст превращается в программу, из чего он состоит, какие бывают типы и что происходит при вычислении выражения, почему дробные числа неточны, и как выбрать между двумя действиями через if. Всё дальнейшее в курсе опирается на эти вещи, поэтому тонкости здесь важнее, чем кажется.

Маршрут

Порядок, в котором стоит разбираться, и куда идти за подробностями:

  1. Первая программа и сборка → Компиляция и сборка → Подключение заголовков
  2. Из чего состоит текст → Литералы → Выражения и операторы
  3. Типы: Целочисленные типы → Беззнаковые типы → Типы с плавающей точкой → Символьный тип char
  4. Переменные → Инициализация переменных
  5. Вычисление выражений → Арифметические преобразования типов → Целочисленное деление и остаток → static_cast
  6. Логика и биты → Логические операции и короткое замыкание → Побитовые операции
  7. Ветвление → Условный оператор if
  8. Чего опасаться → Неопределённое поведение
  9. Ввод-вывод → Потоки ввода и вывода
  10. Как оформлять → Кодстайл курса

1. Первая программа

Минимальная программа, которая уже компилируется и запускается:

int main() {}

main — функция, в которую передаётся управление при запуске. Слово int означает, что она возвращает целое число. Для main сделано исключение: если выполнение дошло до конца тела, это равносильно return 0;.

return завершает функцию и возвращает значение. Это не команда «напечатать число»:

int main() {
  return 2 + 2 * 2;
}

Выражение справа равно 6, но на экране ничего не появится. Шестёрка уедет среде, запустившей программу, как код завершения. В Linux его видно так:

g++ main.cpp -o main
./main
echo $?              # 6

Код завершения нужен, чтобы сообщать об успехе или ошибке; ноль означает успех. Как способ выдачи результата он не годится — через него не вернуть ни дробное число, ни строку, ни несколько значений сразу.

Для вывода берём стандартную библиотеку:

#include <iostream>
 
int main() {
  std::cout << 2 + 2 * 2 << '\n';
  return 0;
}
  • #include <iostream> — директива препроцессора: заголовок подставляется в файл до компиляции. Подробности — Подключение заголовков.
  • std::cout — стандартный поток вывода, << отправляет в него значение справа.
  • std:: — квалификатор пространства имён. Пространство имён — не файл и не папка, а способ различать имена; объявления одного пространства могут лежать в разных файлах. Писать std:: явно, а не убирать через using namespace std; — требование кодстайла.
  • '\n' — символ перевода строки. Без него следующее сообщение терминала окажется на той же строке, что и результат.

Кавычки меняют смысл. Компилятор не вычисляет формулу внутри строкового литерала:

std::cout << "2 + 2 * 2 = " << 2 + 2 * 2 << '\n';   // 2 + 2 * 2 = 6

Слева — текст как есть, справа — вычисленное значение.

2. Как исходный текст становится программой

Файл main.cpp сам по себе не программа: процессору нужны машинные инструкции, а не текст C++.

g++ -std=c++20 main.cpp -o main
./main

Первая команда запускает сборку, -std=c++20 выбирает версию языка, -o main задаёт имя результата. Вторая запускает уже полученный файл. Во время сборки тело main не выполняется — компиляция строки с std::cout сама по себе ничего не печатает.

Кнопка «запустить» в IDE прячет оба действия. Из-за этого легко не заметить, что новая версия не собралась, и запустить старый исполняемый файл. Редактор, компилятор и готовая программа — три разные вещи, даже если они за одним окном.

Этапы сборки

g++ управляет цепочкой, и её можно пройти по шагам:

g++ -std=c++20 -E main.cpp -o main.ii   # препроцессор
g++ -std=c++20 -S main.ii -o main.s     # трансляция в ассемблер
as main.s -o main.o                     # ассемблирование
g++ main.o -o main                      # компоновка

main.cpp → main.ii → main.s → main.o → main

Сначала работает препроцессор: обрабатывает директивы, в первую очередь #include. Если открыть main.ii, вместо нескольких написанных строк там окажутся тысячи строк из подключённых заголовков — заголовки сами подключают другие заголовки, и такое разрастание нормально.

Дальше текст переводится в ассемблерный код. Файл main.s ещё текстовый, но описывает программу на уровне инструкций целевой машины. Ассемблер превращает его в объектный файл main.o с машинным кодом, который в общем случае ещё нельзя запустить: обращения к другим частям программы и к библиотекам не связаны с реализациями. Этим занимается компоновщик (линковщик) — он объединяет объектные файлы и библиотечные компоненты, разрешает ссылки и создаёт исполняемый файл.

Вручную так делать не нужно, но понимать этапы полезно при разборе ошибок. Подробнее — Компиляция и сборка.

Что ещё умеет препроцессор

Кроме подключения заголовков — макроподстановки:

#define TWO 2
 
int main() {
  return TWO + TWO * TWO;
}

После обработки директивы в выражении окажутся обычные двойки. TWO — не переменная: у неё нет ни объекта, ни адреса, ни меняющегося во время выполнения значения. Подстановка работает с элементами исходного текста, а не заменяет любые совпавшие подстроки: TWO внутри "TWO" останется частью строки. На курсе макросы вряд ли понадобятся.

Свои заголовки подключают кавычками, стандартные — угловыми скобками:

#include "my_header.h"
#include <iostream>

Ошибка компиляции, компоновки или выполнения

Пропущенная скобка или необъявленное имя обнаружатся при компиляции. Если программа собралась, запустилась и только потом упала — это проблема времени выполнения. Худший случай: собралась, не падает, но считает не то.

Успешная сборка не доказывает правильность программы. Компилятор проверяет правила языка, а не соответствие кода замыслу.

3. Из чего состоит исходный код

Имена и ключевые слова

В записи int main() слова имеют разную природу: int — ключевое слово, его смысл задан языком; main — идентификатор, то есть имя.

Идентификатор может содержать латинские буквы, цифры и подчёркивания, но не может начинаться с цифры. Регистр важен. Ключевое слово именем быть не может.

vovo4ka           // OK
vovo4ka_smirnov   // OK
Vovo4ka           // другой идентификатор
4vova             // не идентификатор: начинается с цифры
vovo4ka-smirnov   // не идентификатор: дефис
return            // ключевое слово
Return            // идентификатор: регистр важен

Есть ещё правила резервирования имён за реализацией, поэтому свои имена пишем без начальных подчёркиваний. Запоминать весь список ключевых слов не нужно — полезнее различать слово языка и имя, смысл которого задаёт программа.

Литералы

Значение, записанное прямо в тексте программы, — литерал. Одно и то же целое число записывается в разных системах счисления:

123        // десятичное: 123
0123       // восьмеричное: 83
0x123      // шестнадцатеричное: 291
0b101      // двоичное: 5

Начальный ноль — не украшение: 123 и 0123 это разные значения. Литералы бывают не только целыми — дробные, символьные, строковые, логические, нулевой указатель. Одинарные и двойные кавычки не взаимозаменяемы: 'v' — символ, "v" — строка, у них разные типы и разное представление в памяти. Подробнее — Литералы.

Оператор, операция, выражение

В русской терминологии легко запутаться, поэтому договоримся различать:

  • оператор (инструкция, statement) описывает действие — return 0;;
  • выражение (expression) задаёт вычисление — 2 + 2 * 2;
  • операция действует на операнды и входит в выражение — + и *.

Литерал 2 сам по себе тоже выражение: знак операции необязателен. Выражение с точкой с запятой образует оператор-выражение, и 2 + 2; внутри main вполне допустимо — просто результат никуда не идёт. Подробнее — Выражения и операторы.

Комментарии и оформление

// Комментарий до конца строки.
 
/* Комментарий
   на несколько строк. */

Структуру блоков задают фигурные скобки, а не отступы — но писать без отступов значит обманывать самого себя. Точку с запятой тоже не ставят машинально после каждой строки: она завершает определённые конструкции, а не строку в редакторе. После return 0 она нужна, после #include <iostream> — нет.

4. Типы данных

Тип определяет множество представимых значений, допустимые операции и способ интерпретации данных в памяти. Знак деления одинаковый, а результаты разные именно поэтому:

std::cout << 11 / 4 << '\n';     // 2
std::cout << 11.0 / 4 << '\n';   // 2.75

Из фундаментальных типов понадобятся целочисленные short, int, long, long long; с плавающей точкой float, double, long double; символьный char; логический bool. Есть также void — отсутствие значения — и std::nullptr_t, тип литерала nullptr. void не означает «объект нулевого размера»: обычную переменную такого типа создать нельзя.

Расхождение с материалами занятия

В комментариях к main.cpp написано sizeof(void) == 1. По стандарту sizeof от неполного типа — ошибка компиляции; единицу выдаёт GCC как собственное расширение, и на это опираться нельзя.

Сколько памяти занимает значение

std::cout << sizeof(int) << '\n';
std::cout << sizeof(char) << '\n';   // 1

Результат — в байтах C++. Байт здесь минимальная адресуемая единица памяти, а sizeof(char) равен единице по определению. Число бит в байте задаёт CHAR_BIT из <climits>; на привычных системах это восемь.

ТипМинимальная ширинаЧасто встречается
short16 бит16 бит
int16 бит32 бита
long32 бита32 или 64 бита
long long64 бита64 бита

Размеры упорядочены от short к long long, но соседние типы могут совпадать. Слово long не гарантирует, что тип шире int именно на вашей системе: sizeof(long) на Windows и Linux отличается. Дальше в примерах считаем байт восьмибитным, short — 16-битным, int — 32-битным, long long — 64-битным. Это рабочая модель, а не обещание языка про каждый компьютер.

У каждого типа есть знаковый и беззнаковый вариант: int, signed int и signed — один тип, unsigned int (или просто unsigned) — соответствующий беззнаковый. Для знакового целого ширины N диапазон это −2^(N−1) … 2^(N−1) − 1, для беззнакового 0 … 2^N − 1. То есть 32-битный int хранит от −2 147 483 648 до 2 147 483 647, а 32-битный unsigned int — от 0 до 4 294 967 295. Беззнаковый вариант не получает больше памяти: у него то же количество представимых значений, только без отрицательной части.

Разбор по группам — Целочисленные типы, Беззнаковые типы, Типы с плавающей точкой.

Когда нужна конкретная ширина

#include <cstdint>
 
std::int32_t count = 0;
std::int64_t total = 0;
std::uint32_t mask = 0;

Число в имени — ширина в битах. Границы конкретного типа берут из <limits>, а не переписывают руками:

#include <limits>
 
std::cout << std::numeric_limits<int>::min() << '\n';
std::cout << std::numeric_limits<int>::max() << '\n';

У литерала тоже есть тип

Тип числа возникает не в момент записи в переменную — он уже есть у литерала. Для десятичного целого без суффикса выбирается первый подходящий тип из int, long, long long. Поэтому 10 имеет тип int, а тип достаточно большого литерала зависит от реализации.

10      // int              0.1     // double
10U     // unsigned int     0.1F    // float
10L     // long             0.1L    // long double
10LL    // long long
10UL    // unsigned long
10ULL   // unsigned long long

Суффиксы пишем заглавными, чтобы L не путалась с единицей. У short своего суффикса нет: short x = 1; создаёт переменную типа short, но 1 остаётся литералом типа int и преобразуется при инициализации.

Расхождение с материалами занятия

В комментариях к main.cpp упомянут суффикс 0.1lf. Такого суффикса в C++ нет — lf это спецификатор формата из printf. Для long double суффикс L, как в лонгриде.

Апостроф внутри числа (2'000'000'000) — разделитель разрядов, появился в C++14. На значение не влияет, но спасает от ошибки в количестве нулей.

5. Переменные и хранение значений

Переменная — именованная область памяти. Определение задаёт её тип, имя и, возможно, начальное значение:

int x = 1;
int y = 2;
std::cout << x + 3 * y << '\n';   // 7

В простой модели память — последовательность пронумерованных байтов, номер места называется адресом. Объект типа int занимает несколько байтов, а имя x позволяет обращаться к нему без ручного указания адреса.

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

Четыре слова, которые путают

int x;
x = 0;
x = 1;
 
int y = 2;
  • int x; — объявление и определение: объект уже существует, но полезного начального значения у него нет.
  • x = 0; и x = 1; — присваивания: запись значения в существующий объект. Первое присваивание не становится определением только потому, что оно первое.
  • int y = 2; — инициализация: начальное значение задаётся при создании.

Знак = в инициализации не означает операцию присваивания. Записи похожи внешне, но описывают разные действия.

Инициализацию можно записать несколькими способами:

int a = 1;
int b(2);
int c{3};
int d{};        // 0

Значения получаются 1, 2, 3 и 0. Формы не всегда взаимозаменяемы, различия понадобятся позже; сейчас важно узнавать их в коде и не принимать круглые или фигурные скобки за другую операцию. Подробнее — Инициализация переменных.

Почему нельзя читать незаданное значение

int x;
std::cout << x << '\n';   // UB в C++20

Популярный ответ — «какое-то случайное число» — обещает больше, чем гарантирует язык. У обычной локальной переменной int здесь нет значения, которое разрешено прочитать: это Неопределённое поведение. Программа при этом обычно «работает», и именно поэтому ошибка живучая.

Записать в такой объект можно:

int x;
x = 10;
std::cout << x << '\n';   // 10

Но проще сразу написать int x = 10;. Правило кодстайла: одна переменная на строку, имя в snake_case.

Ввод данных

#include <iostream>
 
int main() {
  int x = 0;
  int y = 0;
  std::cin >> x >> y;
  std::cout << x + y << '\n';
  return 0;
}

std::cin — стандартный поток ввода, >> читает значение в переменную справа. Операция пропускает пробелы и переводы строк, поэтому 17 5 и 17\n5 для неё одно и то же. Если программа ждёт ввод, отсутствие нового вывода ещё не значит, что она зависла.

Здесь предполагается, что введены два корректных целых числа и сумма помещается в int. Это ограничение примера, а не свойство std::cin; проверку успешности чтения добавим, когда появится if. Подробнее — Потоки ввода и вывода.

6. Как вычисляются выражения

Арность, приоритет, ассоциативность

Арность — количество операндов: унарный минус в -x имеет один, вычитание в x - y — два. Приоритет определяет группировку:

2 + 2 * 2       // 2 + (2 * 2) == 6
(2 + 2) * 2     // 8

При одинаковом приоритете работает ассоциативность. Вычитание группируется слева направо, присваивание — справа налево:

20 - 5 - 3      // (20 - 5) - 3 == 12
x = y = 7;      // x = (y = 7)

Группировка выражения и порядок вычисления операндов — не одно и то же. Скобки в (a + b) * (c + d) сами по себе не требуют вычислить сначала именно левую сумму. Пока выражения только читают значения, это незаметно; с побочными эффектами различие станет существенным.

Целочисленное деление

Главная ловушка занятия: если оба операнда целые, то и деление целое. Дробная часть отбрасывается в сторону нуля, остаток даёт %:

11 / 4        // 2
11 % 4        // 3
-11 / 4       // -2
-11 % 4       // -3

В допустимых случаях результаты связаны равенством a == (a / b) * b + a % b.

Тип переменной слева ничего не меняет:

double result = 11 / 4;      // 2, а не 2.75

Сначала 11 / 4 вычисляется в целых и даёт 2, потом двойка превращается в double. Дробная часть уже потеряна. Нужный тип должен участвовать в самой операции:

double result = 11.0 / 4;                          // 2.75
const double exact = static_cast<double>(x) / y;   // то же для переменных

Приведение обязано стоять до деления: static_cast<double>(x / y) не спасёт. См. static_cast и Целочисленное деление и остаток.

Переполнение и неопределённое поведение

Два числа, каждое из которых помещается в 32-битный int:

int x = 2'000'000'000;
int y = 2'000'000'000;
std::cout << x + y << '\n';   // UB

Математическая сумма — четыре миллиарда, в int она не помещается. Знаковое переполнение в C++ — Неопределённое поведение. Это не значит «компилятор обязан выдать ошибку» и не значит «результат гарантированно станет отрицательным». Язык просто не задаёт никаких требований к поведению программы: один запуск покажет знакомое отрицательное число, другой — иной результат, а оптимизация может изменить поведение сильнее.

UB — не способ получить случайное число и не особый режим арифметики. Это ошибка программы.

Целочисленное деление и остаток при нулевом делителе — тоже UB. Ещё один граничный случай: деление минимального знакового значения на −1, когда положительный результат не представим в том же типе. Проверять только ненулевой делитель для произвольных знаковых данных недостаточно.

Сообщение оболочки Floating point exception после целочисленного деления на ноль не означает, что использовались дробные числа или сработало исключение C++. Это сообщение конкретной среды о сбое, а не определение поведения операции.

Почему более широкая переменная не спасает

int x = 2'000'000'000;
int y = 2'000'000'000;
long long result = x + y;   // всё ещё UB

Тип выражения x + y определяется типами операндов. Сложение по-прежнему идёт в int, и до записи в result дело доходит уже после недопустимой операции. Либо сразу хранить операнды в long long, либо преобразовать до сложения:

long long result = static_cast<long long>(x) + y;   // 4000000000

То же правило видно на литерале:

long long first = 0LL + x + y;    // OK
long long second = x + y + 0LL;   // UB

В первой строке широкая арифметика начинается с первого сложения, во второй x + y вычисляется раньше, чем long long появляется в цепочке. Тип нужно выбирать не только для ответа, но и для промежуточных вычислений.

Продвижение и общий тип

short x = 1;
short y = 2;
 
std::cout << sizeof(x) << '\n';       // 2
std::cout << sizeof(x + y) << '\n';   // 4

Переменные не выросли. Перед арифметикой их значения прошли целочисленное продвижение (integral promotion): short и char продвигаются до int, если int представляет все значения исходного типа, иначе до unsigned int. bool продвигается в int: false → 0, true → 1.

sizeof(x + y) не выполняет сложение, а исследует тип выражения — поэтому им удобно наблюдать продвижение. Но один размер не восстанавливает тип однозначно: разные типы могут занимать одинаковое число байтов.

Дальше операнды приводятся к общему типу. Если оба знаковые — берётся тип большего ранга (int, long, long long), для пары беззнаковых так же.

Расхождение с материалами занятия

В комментариях к main.cpp записано правило «если один из операндов беззнаковый, а второй знаковый, оба приведутся к беззнаковому». Лонгрид прямо называет такую формулировку неверной, и правы здесь материалы курса: при смешивании знакового с беззнаковым результат зависит от рангов, а не всегда получается беззнаковым. Разбор — Арифметические преобразования типов.

Беззнаковая арифметика

Вычисления в беззнаковом типе ширины N идут по модулю 2^N, и переход через границу диапазона определён языком:

unsigned int x = 0;
std::cout << x - 1 << '\n';   // 4294967295, а не -1

Для 32-битного unsigned int 3 000 000 000 + 3 000 000 000 ≡ 1 705 032 704 (mod 2³²). Отсюда же бесконечный цикл for (unsigned i = n; i >= 0; --i).

Но «поведение определено» не значит «алгоритм правильный»: если мы считали объекты и после прибавления единицы получили ноль, это всё ещё ошибка. Беззнаковый тип также не делает безопасным деление на ноль. Подробно — Беззнаковые типы.

Расхождение с материалами занятия

В комментариях к main.cpp сказано, что беззнаковая арифметика идёт по модулю 2^sizeof(type) − 1. Верная формулировка — по модулю 2^N, где N это ширина типа в битах: у sizeof единица измерения байт, и лишней единицы в показателе там нет.

7. Числа с плавающей точкой

Даже между нулём и единицей бесконечно много действительных чисел, а в фиксированном количестве бит — конечное число комбинаций. Значит, точно представить каждое действительное число нельзя: float, double и long double дают конечное множество значений, остальное приближается.

Число устроено через значащую часть и степень двойки, и некоторые привычные десятичные дроби — например 0.1 — в двоичной записи бесконечны. Их приходится округлять ещё до начала сложения. На распространённых системах float занимает четыре байта, double — восемь; размер long double зависит от реализации, считать его всегда шестнадцатибайтовым нельзя.

std::cout << std::boolalpha;
std::cout << (0.1 + 0.2 == 0.3) << '\n';   // false

Слева складываются приближения к 0.1 и 0.2, и результат тоже округляется. Справа хранится приближение к 0.3. Совпасть эти два пути не обязаны, а == проверяет точное равенство полученных значений. std::boolalpha меняет только формат вывода: вместо 1 и 0 печатаются true и false.

Правильный вопрос — достаточно ли результат близок к ожидаемому:

#include <cmath>
#include <iostream>
 
int main() {
  double actual = 0.1 + 0.2;
  double expected = 0.3;
  const double epsilon = 0.0001;
 
  std::cout << std::boolalpha
            << (std::abs(actual - expected) < epsilon) << '\n';
}

При этом 0.0001 — не универсальное заклинание. Допустимая ошибка определяется задачей и масштабом величин: иногда нужен абсолютный допуск, иногда относительный, иногда оба. Проблема не в том, что double запрещено сравнивать на равенство, а в том, что результат приближённого вычисления нельзя без обоснования считать точным.

С дробными числами работают +, -, *, /. Побитовые операции и % для них не определены, типа unsigned double в C++ нет. В реализациях с арифметикой IEEE 754 есть ещё inf, -inf и NaN — «не число». Поэтому дробное деление на ноль ведёт себя иначе, чем целочисленное, и переносить результат 1.0 / 0.0 на 1 / 0 нельзя. Подробнее — Типы с плавающей точкой.

8. Символы

char letter = 'v';
std::cout << letter << '\n';                      // v
std::cout << static_cast<int>(letter) << '\n';    // 118 в ASCII

В памяти нет маленькой нарисованной буквы — там числовое представление символа. В кодировке, совместимой с ASCII, латинской v соответствует код 118, поэтому char letter = 118; задаст тот же символ. При выводе char поток интерпретирует значение как символ, а после преобразования к int мы видим число: тип влияет не только на диапазон, но и на смысл операции вывода.

Символы участвуют в арифметике:

char lower = 'v';
char upper = static_cast<char>(lower - ('a' - 'A'));
std::cout << upper << '\n';   // V

Вычитание идёт после продвижений, потом результат возвращается в char. Формула опирается на устройство кодировки и не является универсальным способом менять регистр произвольного текста. Подробнее — Символьный тип char.

9. Сравнения, логика и биты

Результат сравнения — значение

Сравнение не обязано сразу попадать в условие, оно само выражение типа bool:

bool less = 5 < 6;
std::cout << std::boolalpha << less << '\n';   // true

У bool два значения, но это не значит, что объект занимает один бит: часто sizeof(bool) равен единице, а точный размер определяет реализация.

== проверяет равенство, = присваивает:

int x = 5;
bool first = (x == 0);    // false; x по-прежнему 5
bool second = (x = 0);    // false; x теперь 0

Вторая строка допустима: сначала присваивание, потом преобразование значения к bool — ноль даёт false, любое ненулевое true. В обратную сторону true продвигается в 1, false в 0.

Результат сравнения зависит от преобразований операндов:

std::cout << (-1 < 1U) << '\n';   // false

Здесь int преобразуется к unsigned int, −1 превращается в максимальное значение этого типа и уже не меньше единицы. Ошибка была бы не в работе сравнения, а в предположении, что оно сравнит исходные математические числа.

Объединение условий

&& — И, || — ИЛИ, ! — отрицание. Математическая запись двойного неравенства напрямую не работает:

int x = 42;
std::cout << (0 < x < 10) << '\n';        // true (!)
std::cout << (0 < x && x < 10) << '\n';   // false

Первое выражение разбирается как (0 < x) < 10: результат true после продвижения становится единицей, а единица меньше десяти. Проверка получилась совсем не та.

Короткое замыкание

У встроенных && и || сначала вычисляется левый операнд, а правый — только если он нужен для результата:

int x = 12;
int y = 0;
bool large = y != 0 && x / y > 2;   // false, деления не было

Поменять части местами нельзя: в x / y > 2 && y != 0 опасная операция произойдёт до проверки. Это не возможная оптимизация компилятора, а гарантия языка. Подробнее — Логические операции и короткое замыкание.

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

&, |, ^ работают с каждым разрядом отдельно. Для 6 = 110₂ и 3 = 011₂:

  110     110       110
& 011   | 011     ^ 011
-----   -----     -----
  010     111       101

Получаются 2, 7 и 5. Побитовое отрицание ~ меняет все биты значения после продвижения, а не только нарисованные три разряда: ~6 равно −7, а ~0U даёт максимальное значение unsigned int.

& и | не дают короткого замыкания. Замена && на & в защитном условии с делением превращает корректный код в UB.

Сдвиги перемещают биты:

6U << 1    // 12
6U >> 1    // 3
7U >> 1    // 3

Здесь сдвиг влево эквивалентен умножению на два, вправо — целочисленному делению на два, но универсально заменять арифметику сдвигами нельзя. Количество позиций должно быть неотрицательным и меньше ширины типа левого операнда после продвижения — нарушение это UB. И в C++20 сдвиг отрицательного числа вправо округляет вниз, а деление — к нулю: -7 >> 1 даёт −4, -7 / 2 даёт −3.

В std::cout << x те же символы выполняют вывод, а не сдвиг: смысл операции определяют операнды, а не значок. Подробнее — Побитовые операции.

10. Условный оператор if

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

if (y != 0) {
  std::cout << x / y << '\n';
} else {
  std::cout << "Error\n";
}

Условие преобразуется к bool. Ветка else необязательна. После if и else синтаксически стоит по одному оператору, а фигурные скобки делают этим оператором целый блок — скобки ставим даже для одной инструкции.

Несколько вариантов — цепочка, условия проверяются сверху вниз, после выбранной ветви остальные пропускаются:

if (value == 0) {
  std::cout << "Zero\n";
} else if (value == 1) {
  std::cout << "One\n";
} else {
  std::cout << "Other\n";
}

else if — не отдельное ключевое слово, а ещё один if в ветви else. Несколько независимых if означают другое: там выполнятся оба блока, потому что второй независимо проверит уже изменённое значение.

Две классические ловушки. Лишняя точка с запятой:

if (x > 0);
{
  std::cout << "Positive\n";
}

Сообщение выводится независимо от знака x: ; образовала пустой оператор, именно его и выполняет if, а блок ниже существует сам по себе. И потерявшийся else:

if (true)
  if (false) std::cout << "A\n";
else std::cout << "B\n";

Будет выведено B: else относится к ближайшему if без else, то есть к внутреннему. Отступ синтаксис не меняет. Подробнее — Условный оператор if.

11. Ввод-вывод: форматирование

Не из лекции

Этого раздела нет ни в лонгриде, ни в коде занятия — он остался с первой версии конспекта. Материал верный и пригодится в задачах, но на занятии его не давали, так что в контрольных вопросах курса на него не опираемся.

Печатать перевод строки лучше через '\n': std::endl дополнительно сбрасывает буфер, и в цикле это заметно медленнее.

Ширина поля и точность — из <iomanip>:

#include <iomanip>
#include <iostream>
 
int main() {
  constexpr double kPi = 3.14159265358979;
 
  std::cout << std::fixed << std::setprecision(4) << kPi << '\n';   // 3.1416
  std::cout << std::setfill('.') << std::setw(12) << "итого" << 100 << '\n';
  return 0;
}

std::setw действует ровно на один следующий вывод, остальные манипуляторы липкие. Разбор — Форматирование вывода.

12. Константы

Не из лекции

const и constexpr в материалах занятия не разбирались: const встречается в примере сравнения дробных чисел, но без объяснения. Раздел остался с первой версии конспекта — он полезен и соответствует кодстайлу курса, но темой первого занятия не был.

Число посреди выражения ничего не говорит читателю. Именованная константа говорит:

constexpr int kSecondsInHour = 3600;
const int hours = total_seconds / kSecondsInHour;

const — «не изменится после инициализации», constexpr — «известно на этапе компиляции». Имена констант по кодстайлу — kCamelCase. Подробнее — const и constexpr.

Разобранные примеры

  • L01 - Разбор - main.cpp — код, который писали на занятии; исходник лежит в 30-Код/C++/L01 - main.cpp

Грабли

  • Переполнение знакового целого нельзя проверить экспериментом. Увиденное один раз отрицательное число ничего не доказывает: это UB, а не «арифметика по кругу». По кругу — только у беззнаковых.
  • double x = 0.0f; — литерал создаётся как float и расширяется до double. Работает, но сообщает читателю неправду о типе.
  • static_cast<int> не округляет, а отбрасывает дробную часть в сторону нуля: из −2.9 получается −2. Если после отбрасывания результат не помещается в целевой тип — это UB.
  • int y{3.9}; не компилируется — списочная инициализация запрещает сужение, в отличие от int y = 3.9;. Это не недостаток, а защита.
  • 0123 это не 123. Ведущий ноль делает литерал восьмеричным.
  • Компилятор знает больше, чем говорит по умолчанию. Переполнение литералов, сравнение знакового с беззнаковым, неиспользуемая переменная — всё это видно, если добавить -Wall -Wextra к команде сборки.

Не из лекции

Последний пункт про -Wall -Wextra и пример с int y{3.9} в материалах занятия не встречались.

Вопросы на подумать

Из последнего раздела лонгрида. Сначала ответить без запуска программы и объяснить результат правилами, а уже потом проверять.

Пусть int 32-битный, обе переменные содержат два миллиарда. Какие строки корректны и почему?

int x = 2'000'000'000;
int y = 2'000'000'000;
 
long long a = x + y;
long long b = static_cast<long long>(x + y);
long long c = static_cast<long long>(x) + y;
long long d = x + 0LL + y;

Что будет выведено в каждой строке и почему видимый текст и размер литерала — не одно и то же?

std::cout << "A\0B" << '\n';
std::cout << sizeof("A\0B") << '\n';
std::cout << sizeof('A') << '\n';

После этой лекции должно получаться

  • собрать программу по шагам и сказать, что делает каждый этап;
  • объяснить, почему 11 / 4 даёт 2, а -11 % 4 даёт −3;
  • предсказать, что напечатает unsigned int x = 0; std::cout << x - 1;, и почему для знакового int такого предсказания сделать нельзя;
  • объяснить, почему sizeof(x + y) для двух short равен 4;
  • сказать, почему (0 < x < 10) не проверяет то, что кажется;
  • защитить деление проверкой делителя и объяснить, почему & вместо && эту защиту ломает;
  • назвать четыре способа получить неопределённое поведение в двадцати строках кода.