О чём лекция
Как исходный текст превращается в программу, из чего он состоит, какие бывают типы и что происходит при вычислении выражения, почему дробные числа неточны, и как выбрать между двумя действиями через
if. Всё дальнейшее в курсе опирается на эти вещи, поэтому тонкости здесь важнее, чем кажется.
Маршрут
Порядок, в котором стоит разбираться, и куда идти за подробностями:
- Первая программа и сборка → Компиляция и сборка → Подключение заголовков
- Из чего состоит текст → Литералы → Выражения и операторы
- Типы: Целочисленные типы → Беззнаковые типы → Типы с плавающей точкой → Символьный тип char
- Переменные → Инициализация переменных
- Вычисление выражений → Арифметические преобразования типов → Целочисленное деление и остаток → static_cast
- Логика и биты → Логические операции и короткое замыкание → Побитовые операции
- Ветвление → Условный оператор if
- Чего опасаться → Неопределённое поведение
- Ввод-вывод → Потоки ввода и вывода
- Как оформлять → Кодстайл курса
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>; на привычных системах это восемь.
| Тип | Минимальная ширина | Часто встречается |
|---|---|---|
short | 16 бит | 16 бит |
int | 16 бит | 32 бита |
long | 32 бита | 32 или 64 бита |
long long | 64 бита | 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)не проверяет то, что кажется; - защитить деление проверкой делителя и объяснить, почему
&вместо&&эту защиту ломает; - назвать четыре способа получить неопределённое поведение в двадцати строках кода.