Главная / Курсы / C++ по спирали / Глава 14. Типы / Фундаментальные типы
# Глава 14.1. Фундаментальные типы Тип есть у любого значения, выражения или функции. Если он не задан явно, то выводится компилятором. ```cpp {.example_for_playground .example_for_playground_001} // Тип результата деления - double std::println("{}", 34.2 / 8); ``` ``` 4.275 ``` Так, в этом примере тип литерала 34.2 — `double`, а тип литерала 8 — `int`. И по [правилам математических преобразований,](https://timsong-cpp.github.io/cppwp/n4950/expr.arith.conv) если в математической операции участвует тип с плавающей точкой, то и результат — тоже тип с плавающей точкой. Тип определяет: - Сколько памяти выделяется под переменную или результат выражения. - Как компилятор трактует последовательность бит в этой памяти. - Какие значения могут в ней храниться. - Какие над ними допустимы операции. В C++ типы данных [делятся](https://timsong-cpp.github.io/cppwp/n4868/basic.types) на две категории: - [Фундаментальные типы](https://timsong-cpp.github.io/cppwp/n4868/basic.fundamental) (fundamental types): `int`, `bool` и другие. Это кирпичики, из которых строятся составные типы. - [Составные типы](https://timsong-cpp.github.io/cppwp/n4868/basic.compound) (compound types) основаны на других типах. Яркий пример — структуры. ![Типы в C++](https://raw.githubusercontent.com/senjun-team/senjun-courses/refs/heads/cpp-chapter-14/illustrations/cpp/types.jpg) {.illustration} **Фундаментальные типы** также называют встроенными (built-in), ведь они считаются неотъемлемой частью языка. Их можно назвать удобной оберткой над представлением данных в архитектуре компьютера. Операции с фундаментальными типами как правило сводятся к единственной инструкции на ассемблере и выполняются максимально эффективно. Размер этих типов фиксирован и зависит от реализации компилятора, ОС и целевой платформы. Выяснить размер можно с помощью оператора [sizeof](https://en.cppreference.com/w/cpp/language/sizeof.html), который принимает тип и возвращает выделяемое под него количество байт: ```cpp {.example_for_playground .example_for_playground_002} const std::size_t n = sizeof(int); std::println("{}", n); ``` ``` 4 ``` К фундаментальным типам относятся: - [Арифметические типы.](https://timsong-cpp.github.io/cppwp/n4868/basic.fundamental#12) Например, `int` и `char`. - Тип с пустым набором значений `void`. Вы уже использовали его как тип возвращаемого значения функции, которая ничего не возвращает. У `void` есть и другие применения, о которых вы скоро узнаете. - Тип для обозначения нулевого указателя `std::nullptr_t`. Про указатели вы узнаете чуть позже. Имена фундаментальных типов являются ключевыми словами (keywords) языка. Для их использования не надо подключать никаких хедеров или модулей. Исключение — добавленный в C++11 `std::nullptr_t`. ![Фундаментальные типы](https://raw.githubusercontent.com/senjun-team/senjun-courses/refs/heads/cpp-chapter-14/illustrations/cpp/types_fundamental.jpg) {.illustration} С `void` и несколькими арифметическими типами вы уже [знакомы.](/courses/cpp/chapters/cpp_chapter_0020/#block-fundamental-types) Пора узнать про остальные. **Арифметические типы** описывают: - Целые числа ([integral types](https://timsong-cpp.github.io/cppwp/n4868/basic.fundamental#11) или integer types). - Числа с плавающей точкой (floating-point types). ![Арифметические типы](https://raw.githubusercontent.com/senjun-team/senjun-courses/refs/heads/cpp-chapter-14/illustrations/cpp/types_arithmetic.jpg) {.illustration} ## Целые числа на базе int Основной целочисленный тип для работы со знаковыми целыми — это `int`. Стандарт гарантирует, что он имеет ширину как минимум 16 бит. То есть под переменные типа `int` выделяется _не меньше_ 16 бит памяти. В 32/64-битных архитектурах ширина `int` почти всегда начинается от 32-х бит. На базе `int` легко получить другой целочисленный тип. Для этого к типу добавляются _модификаторы_ — ключевые слова, задающие: - Минимальную ширину. - `short` — 16 бит и больше. - `long` — 32 бита и больше. - `long long` — 64 бита и больше. - Является ли тип знаковым. - `signed` — знаковый тип. - `unsigned` — беззнаковый тип. ```cpp {.example_for_playground .example_for_playground_003} // Беззнаковое целое шириной минимум 32 бита unsigned long int max_recursive_calls; ``` [Диапазон знакового целого числа](https://timsong-cpp.github.io/cppwp/n4868/basic.fundamental) — от `−2^(N - 1)` до `2^(N - 1) − 1` включительно, где `N` — ширина типа в битах. {.task_text} Напишите через пробел два целых числа, описывающих диапазон значений типа `short` в нашем плэйграунде. Вы можете узнать его, открыв [плэйграунд](https://senjun.ru/playground/cpp/) и применив оператор `sizeof`. {.task_text} ```consoleoutput {.task_source #cpp_chapter_0141_task_0010} ``` Вам нужно узнать, сколько бит занимает тип `short`. Затем подставьте это знаение в формулу `2^(N - 1)`. Здесь `^` означает возведение в степень. Получившееся значение с отрицательным знаком — нижняя граница диапазона. А с положительным знаком и на единицу меньше — верхняя граница. Напишите их через пробел. {.task_hint} ```cpp {.task_answer} -32768 32767 ``` Модификаторы из разных категорий можно комбинировать, а из одной и той же — нельзя: ```cpp {.example_for_playground .example_for_playground_004} signed long int mantissa_with_sign; // ок long short int strange; // ошибка компиляции ``` Модификаторы и ключевое слово `int` могут идти в любом порядке: ```cpp {.example_for_playground .example_for_playground_005} // У sysno и interval один и тот же тип signed long int sysno; long int signed interval; ``` Но все же модификаторы принято ставить вначале: ```cpp long int interval; ``` По умолчанию целочисленный тип знаковый, даже без модификатора `signed`: ```cpp short int min_int_in_pascal = -32768; ``` Если указан любой из модификаторов, `int` можно опустить: ```cpp // Эквивалентно типу unsigned long long int unsigned long long threshold_bytes; ``` Кстати, для удобства чтения разряды целого числа можно разделять: ```cpp unsigned mln = 1'000'000; ``` Номер [порта](https://ru.wikipedia.org/wiki/%D0%9F%D0%BE%D1%80%D1%82_(%D0%BA%D0%BE%D0%BC%D0%BF%D1%8C%D1%8E%D1%82%D0%B5%D1%80%D0%BD%D1%8B%D0%B5_%D1%81%D0%B5%D1%82%D0%B8)) — это число в диапазоне от `1` до `2^16 - 1` (65 535). {.task_text} Какой тип подойдет для его хранения больше всего? {.task_text} ```consoleoutput {.task_source #cpp_chapter_0141_task_0020} ``` Диапазон беззнакового целого — от `0` до `2^N − 1` включительно, где `N` — ширина типа в битах. Диапазон номеров портов — от `1` до `2^16 - 1`. Для хранения числа из этого диапазона будет достаточно 16 бит. Столько занимает `short`. Так как порт не может быть отрицательным, то лучше всего подходит тип `unsigned short`. {.task_hint} ```cpp {.task_answer} unsigned short ``` ### Целочисленное переполнение Целочисленное переполнение (integer overflow) — это ситуация, при которой значение не помещается в тип данных. Обработка переполнения для знаковых и беззнаковых чисел отличается. **Переполнение беззнакового типа** приводит к отбрасыванию старших битов числа. Это гарантирует цикличность значений, как если бы мы применяли к числу деление по модулю `2^N`, где `N` — ширина типа в битах. Сохраним в переменную `x` максимальное для ее типа значение. Его можно получить вызовом шаблонной функции [std::numeric_limits<T>::max()](https://en.cppreference.com/w/cpp/types/numeric_limits/max.html). Инкрементируем это значение, вызвав тем самым переполнение: ```cpp {.example_for_playground .example_for_playground_006} unsigned x = std::numeric_limits<unsigned>::max(); std::println("{}", x); ++x; // переполнение сверху std::println("{}", x); ``` ``` 4294967295 0 ``` Из-за цикличности значений беззнаковых типов за максимальным значением последовало минимальное, то есть 0. А теперь переполним число снизу, а не сверху: ```cpp {.example_for_playground .example_for_playground_007} unsigned x = -1; // переполнение снизу std::println("{}", x); std::println("{}", x == std::numeric_limits<unsigned>::max()); ``` ``` 4294967295 true ``` Мы вновь убедились в цикличности значений беззнаковых типов. **Переполнение знакового типа** — это UB. Большинство реализаций комплияторов обрабатывают его как переполнение беззнакового целого, но полагаться на это нельзя. UB нужно избегать. Типичные причины возникновения переполнения: - Неправильно подобранные типы. - Суммирование или перемножение чисел с сохранением результата в переменную недостаточной ширины. - Неявное приведение типов. Реализуйте функцию `checked_sum()`, которая производит сложение двух целых чисел без переполнения. Если сложение может привести к переполнению, то функция возвращает: {.task_text} - `std::numeric_limits<int>::max()` для положительных слагаемых. - `std::numeric_limits<int>::min()` для отрицательных слагаемых. ```cpp {.task_source #cpp_chapter_0141_task_0030} int checked_sum(int a, int b) { } ``` Для положительных чисел переполнение возникает, если выполняется условие`a > std::numeric_limits<int>::max() - b`. Для отрицательных переполнение возникает, если выполняется условие`a < std::numeric_limits<int>::min() - b`. Достаточно проверять на знак только число `b`. {.task_hint} ```cpp {.task_answer} int checked_sum(int a, int b) { if (b > 0 && a > std::numeric_limits<int>::max() - b) return std::numeric_limits<int>::max(); if (b < 0 && a < std::numeric_limits<int>::min() - b) return std::numeric_limits<int>::min(); return a + b; } ``` В реализации компилятора у нас на площадке переполнение знакового типа обрабатывается так же, как и беззнакового: ```cpp {.example_for_playground .example_for_playground_008} short val = 1'000'000; // сужающее преобразование std::println("{}", val); std::size_t len_bits = sizeof(short) * 8; std::size_t mod = std::pow(2, len_bits); // возведение в степень std::println("{}", val == 1'000'000 % mod); ``` ``` 16960 true ``` Перед вами функция `naive_hash()` из [Путеводителя C++ программиста по неопределенному поведению.](https://github.com/Nekrolm/ubbook/blob/master/numeric/overflow.md) Она неправильно считает хеш от строки. В ней допущено UB, а конкретно — переполнение знакового целого. Переполнение происходит моментально, например при вызове `naive_hash("*")`. В переменную `h` типа `short` записывается слишком большое значение. Откройте задачу в плэйграунде и запустите проект. Так вы убедитесь в некорректном поведении функции. {.task_text} Опробуйте способ для _выявления_ переполнения. Замените приводящее к переполнению выражение `h += h * 15'983 + c` на [универсальную инициализацию:](/courses/cpp/chapters/cpp_chapter_0131/#block-uniform-initialization) `h = {h + h * 15'983 + c}`. Это приведет к запрету сужающего преобразования. Компилятор выдаст ошибку, подсказывающую, что в коде допущено переполнение! {.task_text} А теперь избавьтесь от переполнения. Замените возвращаемый функцией тип и соответственно тип переменной на более подходящий для хеша длинной строки и исключающий UB при переполнении. {.task_text} ```cpp {.task_source #cpp_chapter_0141_task_0040} short naive_hash(std::string s) { short h = 15; for (char c : s) { h += h * 15'983 + c; } return h; } ``` Возьмите беззнаковый тип максимальной ширины. {.task_hint} ```cpp {.task_answer} unsigned long long naive_hash(std::string s) { unsigned long long h = 15; for (char c : s) { h += h * 15'983 + c; } return h; } ``` ### Целые числа в разных системах счисления По умолчанию все числа имеют десятичное основание. Для литералов с другим основанием нужно использовать префикс. Он предназначен _только для целых чисел_ и подсказывает компилятору, какая выбрана система счисления. - `0b` или `0B` — двоичная: `0b1101`, `0B01`. - `0` — восьмеричная: `073`, `0513`. - `0x` или `0X` — шестнадцатеричная: `0x01f`, `0XF3AAB`. ```cpp {.example_for_playground .example_for_playground_009} int oct = 011; std::println("{}", oct); ``` ``` 9 ``` Обратите внимание: начинающиеся с нуля литералы компилятор трактует как числа в восьмеричной системе: ```cpp {.example_for_playground .example_for_playground_010} int oct = 09; std::println("{}", oct); ``` ``` error: invalid digit '9' in octal constant ``` При форматировании строк или печати в консоль числа выводятся в десятичной системе счисления. Чтобы это переопределить, используются [спецификаторы форматирования.](https://en.cppreference.com/w/cpp/utility/format/spec.html) Это символы, идущие в строке форматирования внутри фигурных скобок после двоеточия: ```cpp {.example_for_playground .example_for_playground_011} unsigned rgb_pink = 0xf004ed; // Спецификатор x указывает, что число нужно // вывести в 16-ой системе счисления std::println("{:x}", rgb_pink); ``` ``` f004ed ``` Через спецификаторы форматирования можно тонко настраивать вид строки, в том числе отступы, выравнивание и многое другое. ## Символьные типы Как и следует из названия, основное предназначение символьных типов — хранить код символа. Он может быть представлен в виде целого числа: ```cpp {.example_for_playground .example_for_playground_012} char symbol = 'Q'; char ascii_code = 81; std::println("{}", symbol == ascii_code); ``` ``` true ``` Всего стандарт описывает семь символьных типов: `signed char`, `unsigned char`, `char`, `wchar_t`, `char8_t`, `char16_t` и `char32_t`. Рассмотрим, какая между ними разница. Типы `signed char` и `unsigned char` нужны для знакового и беззнакового представления числа. Например, для хранения ASCII-кодов. Тип `unsigned char` также популярен для работы с данными в бинарном виде: ```cpp std::vector<unsigned char> raw_bytes; ``` Тип `char` может иметь или не иметь знак в зависимости от целевой платформы, компилятора и его [настроек.](https://clang.llvm.org/docs/ClangCommandLineReference.html#cmdoption-clang-fsigned-char) Поэтому `char` довольно опасен как тип для работы с числами. **Никогда** не полагайтесь на наличие и отсутствие знака у `char`. При сборке под архитектуры x86-64 `char` как правило знаковый, а под ARM — беззнаковый. На что вы можете полагаться — так это на то, что размер `char` _всегда_ равен 1 байту. Как и размер знакового и беззнакового `char`: ```cpp sizeof(char) == sizeof(unsigned char) == sizeof(signed char) == 1 ``` Значит, диапазон знакового `char` — от -128 до 127, а беззнакового — от 0 до 255. Конечно, при условии, что в байте 8 бит. А стандарт этого **не обещает.** Чисто гипотетически байт может быть [больше.](https://isocpp.org/wiki/faq/intrinsic-types#bits-per-byte) Например, состоять из 9, 36 или 64 бит. Но помнить об этом стоит только если ваш код запускается на экзотических и архаичных архитектурах. Знаковые и беззнаковые символы можно преобразовывать туда-обратно с уверенностью, что исходное значение не исказится: ```cpp {.example_for_playground .example_for_playground_013} unsigned char a = 251; signed char b = static_cast<signed char>(a); unsigned char c = static_cast<unsigned char>(b); std::println("{}", a == c); ``` ``` true ``` Эта гарантия работает также для преобразований `char` <-> `signed char` и `char` <-> `unsigned char`. Но откуда она взялась, если у знаковых и беззнаковых символов разные диапазоны значений? Все дело в том, что у этих типов совпадает размер и битовое представление в памяти. Просто в зависимости от типа переменной компилятор по-разному интерпретирует записанную в нее последовательность бит: ```cpp {.example_for_playground .example_for_playground_023} unsigned char a = 251; signed char b = static_cast<signed char>(a); std::println("unsigned char. Bits: {}. Value: {}", bits_str(a), a); std::println(" signed char. Bits: {}. Value: {}", bits_str(bs), b); ``` ``` unsigned char. Bits: 11111011. Value: 251 signed char. Bits: 11111011. Value: -5 ``` Взгляните на класс `CharTable` из [Путеводителя C++ программиста по неопределенному поведению.](https://github.com/Nekrolm/ubbook/blob/master/numeric/char_sign_extension.md) Что будет, если вызвать метод `is_printable()` от значения типа `char`, равного 128? {.task_text} Если вы внимательно прочли этот раздел главы, то уже поняли, что в коде есть проблемы. Откройте задачу в плэйграунде, в `CMakeLists.txt` уберите флаг компилятора `-Werror` и запустите проект. Вместо ожидаемого вывода `false` вы увидите произвольную строку. {.task_text} Первая проблема этого кода в том, что он полагается на беззнаковость `char`. Но в плэйграунде `char` знаковый, и значение 128 приводит к переполнению знакового целого, то есть к UB. Компилятор в плэйграунде обрабатывает переполнение, сохраняя в переменную типа `char` отрицательное значение. С другим компилятором мы могли бы получить совершенно другое поведение. {.task_text} Вторая проблема — это неявное преобразование знакового `char` к беззнаковому `std::size_t` в момент взятия элемента массива по индексу: `m_is_printable[c]`. Оператор `[]` работает с индексом типа `std::size_t`, к которому и приводится `char`. Из отрицательного числа мы получаем очень большое положительное и, соответственно, выход за границы массива. Это тоже UB. {.task_text} Исправьте этот код, поменяв типы на более подходящие. {.task_text} ```cpp {.task_source #cpp_chapter_0141_task_0050} class CharTable { public: CharTable() { m_is_printable.fill(false); } bool is_printable(char c) { return m_is_printable[c]; } private: std::array<bool, 256> m_is_printable; }; void test_chartable() { char c = 128; CharTable table; std::println("character raw val {}", short(c)); std::println("{}", table.is_printable(c)); } ``` Чтобы в переменную `c` корректно записалось значение 128, ее тип нужно сделать беззнаковым. Чтобы метод `is_printable()` был безопасен для всех входных данных, тип принимаемого параметра также должен быть беззнаковым. {.task_hint} ```cpp {.task_answer} class CharTable { public: CharTable() { m_is_printable.fill(false); } bool is_printable(unsigned char c) { return m_is_printable[c]; } private: std::array<bool, 256> m_is_printable; }; void test_chartable() { unsigned char c = 128; CharTable table; std::println("character raw val {}", short(c)); std::println("{}", table.is_printable(c)); } ``` Тот факт, что `char` занимает ровно 1 байт, служит отправной точкой для еще нескольких гарантий: ```cpp 1 == sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long) ``` Типы `char`, `signed char` и `unsigned char` могут вместить любой символ из кодировки [ASCII.](https://en.wikipedia.org/wiki/ASCII) А для работы с семейством UTF предназначены другие типы: - `wchar_t` (wide character). Имеет тот же размер и наличие знака, что и _один из целочисленных типов_. На практике под Windows `wchar_t` поддерживает UTF-16, имеет ширину 16 бит и используется для работы с [WinAPI](https://en.wikipedia.org/wiki/Windows_API). На остальных системах он может поддерживать UTF-32 и иметь ширину в 32 бита. Так как размер `wchar_t` определяется компилятором, не используйте его для хранения Unicode-текста в кросс-платформенных приложениях. - `char8_t` — этот тип добавлен в C++20 для работы с UTF-8. - `char16_t` и `char32_t` — соответственно нужны для работы с UTF-16 и UTF-32. ## Типы вещественных чисел В C++ для вещественных чисел используется представление с [плавающей точкой.](https://ru.wikipedia.org/wiki/%D0%A7%D0%B8%D1%81%D0%BB%D0%BE_%D1%81_%D0%BF%D0%BB%D0%B0%D0%B2%D0%B0%D1%8E%D1%89%D0%B5%D0%B9_%D0%B7%D0%B0%D0%BF%D1%8F%D1%82%D0%BE%D0%B9) Это компромисс между диапазоном значений и точностью. Стандарт описывает три типа с плавающей точкой и гарантирует, что каждый последующий имеет размер и точность _не меньшие,_ чем предыдущий: - `float` — [одинарная](https://en.wikipedia.org/wiki/Single-precision_floating-point_format) точность. Чаще всего занимает 4 байта и обладает точностью около 7 знаков после запятой. - `double` — [двойная](https://en.wikipedia.org/wiki/Double-precision_floating-point_format) точность. Как правило занимает 8 байт и имеет точность примерно в 15 знаков после запятой. - `long double` — [расширенная точность.](https://en.wikipedia.org/wiki/Extended_precision) Реализации этого типа сильно разнятся в зависимости от компилятора и целевой архитектуры. Где-то `long double` ничем не отличается от `double`, а где-то занимает 10 байт и обладает точностью примерно 19 знаков. Задумывайтесь об использовании `long double`, если точности `double` не хватает, а целевая платформа поддерживает расширенную точность. Перечислите через пробел три целых числа: размер типов `float`, `double` и `long double` в байтах, который они принимают в плэйграунде. Для этого откройте [плэйграунд](https://senjun.ru/playground/cpp/) и воспользуйтесь оператором `sizeof`. {.task_text} ```consoleoutput {.task_source #cpp_chapter_0141_task_0060} ``` Пример получения размера типа в байтах: `sizeof(int)`. {.task_hint} ```cpp {.task_answer} 4 8 16 ``` ### Специфика представления чисел с плавающей точкой Числа с плавающей точкой имеют важную особенность. Заключается она в том, что на _ограниченный_ набор чисел с плавающей точкой проецируется _бесконечное_ множество вещественных чисел. Из-за этого многие вещественные числа получают неточное представление с плавающей точкой. Представьте, что бесконечность вещественных чисел проецируется на сетку значений с неравномерным размером ячеек. Эта сетка достаточно плотная для небольших значений и разреженная — для больших. То есть промежутки между соседними числами с плавающей точкой малы, если сами числа малы. И достаточно велики, если числа большие. Иными словами, на больших значениях абсолютная точность чисел с плавающей точкой _ниже,_ чем на малых. Убедитесь в этом: ```cpp {.example_for_playground .example_for_playground_024} float a = 123456789.0; float b = 0.123456789; std::println("{}\n{}", a, b); ``` ``` 123456792 0.12345679 ``` Сравните погрешность, полученную при сохранении в `float` большого и маленького чисел. Она отличается на десять порядков! Откройте этот пример в плэйграунде и посмотрите, как поведет себя тип для двойной точности `double`. ### Бесконечность и нечисло У вещественных чисел есть два особых значения: - `INFINITY` — бесконечность. - `NAN` — нечисло (not a number). Нечислом может быть результат таких операций как деление на ноль или получение логарифма от числа, меньшего или равного нулю. У `INFINITY` и `NAN` тип `float`, но оба значения могут быть присвоены и переменной типа `double`. Для их использования нужно подключить хедер [cmath](https://en.cppreference.com/w/cpp/header/cmath.html), даже если импортирован стандартный модуль. Дело в том, что оба значения — это определенные [директивой](/courses/cpp/chapters/cpp_chapter_0100/#block-macro) `#define` макросы, а не полноценные константы, которые могли бы быть экспортированы из модуля. ```cpp {.example_for_playground} #include <cmath> import std; int main() { std::println("{} {}", INFINITY, NAN); } ``` ``` inf nan ``` Современный C++ не поощряет использование макросов. Вместо `INFINITY` и `NAN` лучше применять шаблонные функции [std::numeric_limits<T>::infinity()](https://en.cppreference.com/w/cpp/types/numeric_limits/infinity.html) и [std::numeric_limits<T>::quiet_NaN()](https://en.cppreference.com/w/cpp/types/numeric_limits/quiet_NaN.html): ```cpp {.example_for_playground} import std; int main() { std::println("{} {}", std::numeric_limits<double>::infinity(), std::numeric_limits<double>::quiet_NaN()); } ``` ``` inf nan ``` В отличие от бесконечности, нечисло _никогда_ не равно самому себе. ```cpp {.example_for_playground .example_for_playground_014} auto inf = std::numeric_limits<double>::infinity(); auto nan = std::numeric_limits<double>::quiet_NaN(); std::println("{} {}", inf == inf, nan == nan); ``` ``` true false ``` Поэтому напрямую сравнивать значения с `NAN` нельзя: любое сравнение вернет `false`. Вместо этого пользуйтесь функцией [std::isnan()](https://en.cppreference.com/w/cpp/numeric/math/isnan.html). ```cpp {.example_for_playground .example_for_playground_015} if (std::isnan(samples_avg)) std::println("Couldn't calculate average"); ``` По аналогии с `std::isnan()` существует функция [std::isinf()](https://en.cppreference.com/w/cpp/numeric/math/isinf.html) для более удобного сравнения с бесконечностью. ### Сравнение вещественных чисел Будьте осторожны при сравнении чисел с плавающей точкой. Во-первых, всегда уточняйте, могут ли они по логике программы принимать значения бесконечности и нечисла. Если да, то добавляйте соответствующие проверки через `std::isinf()` и `std::isnan()`. Во-вторых, из-за специфики представления таких чисел в памяти компьютера их нельзя сравнивать оператором `==` напрямую. Например, будет ли равно единице значение, полученное прибавлением `0.1` к нулю десять раз? ```cpp {.example_for_playground .example_for_playground_016} double plan = 1.0; double delta = 0.1; double sum = 0.0; for (auto i = 0; i < 10; ++i) sum += delta; std::println("plan == sum : {}\n", plan == sum); // Выводим plan с точностью в 1 знак после запятой, // а sum и delta - в 20 знаков. std::println("plan : {:.1f}\nsum : {:.20f}\ndelta : {:.20f}\n", plan, sum, delta); ``` ``` plan == sum : false plan : 1.0 sum : 0.99999999999999988898 delta : 0.10000000000000000555 ``` Как видите, полученное значение `sum` не равно ожидаемому `plan`. Если вам известна погрешность, в рамках которой числа считаются равными, то сравнение можно организовать следующим образом: ```cpp {.example_for_playground .example_for_playground_017} double eps = 1e-4; // погрешность bool eq = std::abs(a - b) <= eps; ``` Функция [std::abs()](https://en.cppreference.com/w/cpp/numeric/math/abs) возвращает модуль числа. Такой подход к сравнению сгодится, например, для координат на земном шаре. Если две широты и долготы совпадают друг с другом вплоть до 7-го знака, то координаты считаются совпадающими. Напишите функцию `is_eq()`, которая проверяет числа `a` и `b` на равенство с точностью до `eps` включительно. Функция шаблонная, и ее параметры могут иметь любой тип, описывающий вещественные числа. {.task_text} Если хотя бы одно из чисел равно бесконечности или нечислу, функция должна вернуть `false`. {.task_text} ```cpp {.task_source #cpp_chapter_0141_task_0070} template<class T> bool is_eq(T a, T b, T eps) { } ``` Вызовите `std::isinf()` и `std::isnan()` для сравнения чисел с бесконечностью и нечислом. {.task_hint} ```cpp {.task_answer} template<class T> bool is_eq(T a, T b, T eps) { if (std::isinf(a) || std::isinf(b) || std::isnan(a) || std::isnan(b)) return false; return std::abs(a - b) <= eps; } ``` Если значения колеблются в широком диапазоне, то погрешность `eps` становится частью данных. Вы должны вычислять ее в зависимости от величин сравниваемых чисел. Взгляните на способ, который хорош для сравнения чисел одинакового знака. В нем используется функция `std::numeric_limits<T>::epsilon()`, которая возвращает _машинный эпсилон_ — разность между `1.0` и следующим за ним числом типа `T`: ```cpp {.example_for_playground .example_for_playground_018} double abs_max = std::max(std::abs(a), std::abs(b)); double eps = std::numeric_limits<double>::epsilon() * abs_max; bool eq = std::abs(a - b) <= eps; ``` Приведенный способ не годится, если одно из чисел отрицательное, а другое — положительное. К тому же, при сложных вычислениях может накапливаться погрешность, из-за которой константа `std::numeric_limits<T>::epsilon()` окажется слишком маленькой и перестанет подходить. Универсального способа для сравнения чисел с плавающей точкой просто не существует. Подбирайте его, отталкиваясь от природы данных. И помните, что зачастую погрешность сравнения должна вычисляться в зависимости от величин сравниваемых значений, а не быть константой. ## Псевдонимы типов {#block-alias} В C++ можно задавать псевдоним (alias) для уже существующего типа. В стандартной библиотеке определено множество псевдонимов фундаментальных типов, облегчающих жизнь разработчика. ### Ключевые слова typedef и using Псевдонимы создаются двумя способами: через спецификатор `typedef` и ключевое слово `using`. У `using` есть и другие применения, о которых вы скоро узнаете. В общем виде объявление псевдонима через [typedef](https://en.cppreference.com/w/cpp/language/typedef.html) выглядит так: ```cpp typedef source_type alias; ``` Пример: ```cpp {.example_for_playground} import std; typedef int RetCode; typedef unsigned long Index; int main() { const RetCode res = -1; const Index batch_idx = 135450; std::println("Batch #{}. RetCode: {}", batch_idx, res); } ``` ``` Batch #135450. RetCode: -1 ``` Более современный вариант задания псевдонима — через ключевое слово [using](https://en.cppreference.com/w/cpp/language/type_alias.html). Семантически этот способ [не отличается](https://timsong-cpp.github.io/cppwp/dcl.typedef#2) от `typedef`, просто имеет другой синтаксис: ```cpp using alias = source_type; ``` Определим псевдоним `UserId`, а на базе него — псевдоним `UserIdArr`: ```cpp {.example_for_playground .example_for_playground_019} using UserId = short; using UserIdArr = std::vector<UserId>; UserIdArr get_blocked_users() { // ... } ``` Как правило, в новом коде предпочтение отдают варианту с `using`, а `typedef` оставляют для фрагментов, которые должны быть совместимы с Си. Объявление псевдонимов через `using` более читабельное. На простых типах это незаметно, поэтому разницу лучше продемонстрировать на шаблонных классах: ```cpp typedef std::map<std::string, std::vector<std::pair<int, int>> my_map; ``` ```cpp using my_map = std::map<std::string, std::vector<std::pair<int, int>>; ``` Псевдонимы полезны для абстрагирования логики проекта от конкретных типов. Допустим, в коде приложения активно используется такая сущность как id транзакции. Изначально предполагалось, что в качестве типа сгодится `int`. Но требования поменялись, и тип нужно заменить на `unsigned long long`. Без псевдонима пришлось бы найти _все_ объявления переменных этого типа, и поменять его. А с псевдонимом замена делается единожды, по месту его объявления. Вторая причина для введения псевдонимов — более ясный код. ```cpp // Непонятно, что возвращает функция: // код возврата, id процесса, количество // обработанных байт, что-то еще? unsigned int run_task(); ``` Сравните: ```cpp using ProcessId = unsigned int; ProcessId run_task(); ``` Псевдонимы хорошо ложатся на решение [практики](/courses/cpp/practice/cpp_lru_cache/) «LRU кеш», в котором нужно работать со списком пар ключ-значение и словарем, в котором значение — итератор на элемент этого списка: ```cpp std::list<std::pair<int, std::string>> list; std::unordered_map<int, std::list<std::pair<int, std::string>>::iterator> map; ``` Добавление псевдонима _хотя бы_ для пары ключ-значение уже делает код более читабельным: ```cpp using KeyVal = std::pair<int, std::string>; std::list<KeyVal> list; std::unordered_map<int, std::list<KeyVal>::iterator> map; ``` ### Целочисленные типы фиксированной ширины Стандарт не определяет точную ширину целочисленных типов. Размер таких типов как `int`, `short`, `long long` зависит от реализации. Однако в ряде случаев гарантия конкретного размера просто необходима. Например, это важно при [сериализации](https://ru.wikipedia.org/wiki/%D0%A1%D0%B5%D1%80%D0%B8%D0%B0%D0%BB%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D1%8F) данных, когда набор значений превращается в поток подряд идущих байт. Затем этот поток пересылается по сети или сохраняется в файл. Чтобы прочитать его, нужно знать, в каком порядке идут значения и сколько бит они занимают. Если при записи `int` занимал 64 бита, а при чтении — 32, то мы не сможем корректно извлечь данные. На помощь приходят [типы фиксированной ширины:](https://en.cppreference.com/w/cpp/types/integer.html) - `std::int8_t`, `std::int16_t`, `std::int32_t`, `std::int64_t` — знаковые целые размером 8, 16, 32 и 64 бита. - `std::uint8_t`, `std::uint16_t`, `std::uint32_t`, `std::uint64_t` — их беззнаковые вариации. ```cpp {.example_for_playground .example_for_playground_020} std::uint32_t fingerprint32(std::string data) { // ... } std::uint64_t fingerprint64(std::string data) { // ... } ``` Под капотом это всего лишь псевдонимы фундаментальных типов, подбираемые в зависимости от системы таким образом, чтобы тип точно попадал в заявленный размер. В реализации стандартной библиотеки это может выглядеть примерно так: ```cpp namespace std { typedef unsigned char uint8_t; typedef unsigned int uint32_t; typedef signed short int int16_t; // ... } ``` Жизненный пример использование типа `std::uint32_t` — для хранения [адресов IPv4,](https://en.wikipedia.org/wiki/IPv4#Addressing) например `127.0.0.1`. Каждый из 4-х октетов адреса принимает значение от `0` до `255` и занимает ровно байт. Первый октет сохраняется в в старший байт `std::uint32_t`, последний — в младший. Поясним это на примере преобразования адреса `172.16.254.1` в число 2886794753. Так оно выглядит в двоичном виде: ![Пример](https://raw.githubusercontent.com/senjun-team/senjun-courses/refs/heads/cpp-chapter-14/illustrations/cpp/ipv4.jpg) {.illustration} Напишите функцию `convert_ip_v4()`, которая принимает ip-адрес в виде строки, разделенной точками на октеты. Функция должна вернуть целочисленное представление адреса. {.task_text} Вам помогут: {.task_text} - Функция [std::stoi()](https://en.cppreference.com/w/cpp/string/basic_string/stol), которая конвертирует строку в число. - Оператор `<<` для [побитового сдвига](/courses/cpp/practice/cpp_div_without_div/#block-bitwise) и оператор `|` для [побитового «ИЛИ».](https://en.cppreference.com/w/cpp/language/operator_arithmetic.html) ```cpp {.task_source #cpp_chapter_0141_task_0080} std::uint32_t convert_ip_v4(std::string ip) { } ``` Воспользуйтесь методом `find()` строки, чтобы определить в ней индексы точек. Точки разделяют октеты адреса. Получите значение каждого из октетов методом строки `substr()`. Переведите их из строки в число функцией `std::stoi()`. Заведите переменную для десятичного представления ip-адреса и проинициализируйте ее нулем. Затем заполните ее четырьмя октетами. Добавление к число октета означает сдвиг этого числа на 8 бит влево и заполнение младших 8-ми бит значением октета. {.task_hint} ```cpp {.task_answer} // Добавляет к числу ip октет из строки ip_str, ограниченный индексами std::uint32_t add_octet(std::uint32_t ip, std::string ip_str, std::size_t i_start, std::size_t i_end) { const std::string octet_str = ip_str.substr(i_start, i_end - i_start); const std::uint32_t octet = std::stoi(octet_str); // Сдвигает число ip на 8 бит влево и заполняет младшие 8 бит // числом octet return (ip << 8) | octet; } std::uint32_t convert_ip_v4(std::string ip) { std::uint32_t ip_int = 0; std::size_t start = 0; std::size_t end_i = ip.find('.'); while (end_i != std::string::npos) { ip_int = add_octet(ip_int, ip, start, end_i); start = end_i + 1; end_i = ip.find('.', start); } ip_int = add_octet(ip_int, ip, start, ip.size()); return ip_int; } ``` ### Тип size_t С типом `std::size_t` вы уже [знакомы.](/courses/cpp/chapters/cpp_chapter_0020/#block-size_t) Это беззнаковое целое, которое может хранить индекс элемента контейнера, его длину, счетчик цикла, размер переменной. Пора узнать, что на самом деле `std::size_t` — всего лишь псевдоним над одним из беззнаковых целых. Оно выбирается таким образом, чтобы вместить размер любого объекта в байтах. Ведь оператор [sizeof](https://en.cppreference.com/w/cpp/language/sizeof.html) возвращает именно тип `std::size_t`. В старом коде вы можете заметить, что вместо `std::size_t` часто используется `int`. В современном C++ это считается плохой практикой, потому что приводит к беззнаковому переполнению на больших значениях. ```cpp std::vector<std::string> v = read_data(); // Если длина v превысит максимальное для int // значение, будет переполнение for(int i = 0; i < v.size(); ++i) { std::println("{} {}", i, v[i]); // UB при переполнении } ``` Циклы с `int` в качестве счетчика попадаются и в новом коде. Так выглядит ненадежный подход к итерированию в обратном порядке. Ненадежный — опять же из-за возможного переполнения на больших значениях счетчика. ```cpp std::vector<std::string> v = read_data(); // Когда i станет равным -1, произойдет выход из цикла for(int i = v.size(); i >= 0; --i) { std::println("{} {}", i, v[i]); } ``` Просто заменить `int` на `std::size_t` в этом примере нельзя, ведь декремент нуля приведет к переполнению снизу. Вслед за нулем счетчик цикла примет максимальное для `std::size_t` значение. Чтобы проитерироваться в обратном порядке, вместо счетчика типа `int` вспомните про [обратные итераторы.](/courses/cpp/chapters/cpp_chapter_0060/#block-reverse-iterators) Другой вариант — библиотека [ranges](https://en.cppreference.com/w/cpp/ranges.html) (диапазоны), появившаяся в C++20. В этом курсе мы ее тоже рассмотрим. Перед вами функция `build_inverted_index()`, которая принимает коллекцию отсортированных по возрастанию популярности документов. Функция строит по ним [обратный индекс](https://ru.wikipedia.org/wiki/%D0%98%D0%BD%D0%B2%D0%B5%D1%80%D1%82%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%BD%D1%8B%D0%B9_%D0%B8%D0%BD%D0%B4%D0%B5%D0%BA%D1%81). В данном случае это просто словарь, соотносящий имени документа его позицию в коллекции. Если в ней встречаются документы с одинаковым именем, то в словарь попадает только самый популярный из них. {.task_text} Функция реализована циклом со счетчиком типа `int` при обработке больших коллекций вызывает UB. Перепишите ее через обратные итераторы и замените тип индекса документа с `int` на `std::size_t`. {.task_text} Вам помогут: {.task_text} - Функция [std::distance()](/courses/cpp/chapters/cpp_chapter_0060/#block-distance). - Метод [base()](/courses/cpp/chapters/cpp_chapter_0060/#block-base) обратного итератора. ```cpp {.task_source #cpp_chapter_0141_task_0100} using DocName = std::string; using DocIdx = int; struct Doc { DocName name; std::string text; }; std::unordered_map<DocName, DocIdx> build_inverted_index(std::vector<Doc> docs) { std::unordered_map<DocName, DocIdx> inverted_index; for(int i = docs.size(); i >= 0; --i) { inverted_index.try_emplace(docs[i], i); } return inverted_index; } ``` Будьте внимательны: метод `base()` обратного итератора возвращает обычный итератор. Он на один элемент ближе к концу контейнера, чем обратный. {.task_hint} ```cpp {.task_answer} using DocName = std::string; using DocIdx = std::size_t; struct Doc { DocName name; std::string text; }; std::unordered_map<DocName, DocIdx> build_inverted_index(std::vector<Doc> docs) { std::unordered_map<DocName, DocIdx> inverted_index; for (auto rit = docs.rbegin(); rit != docs.rend(); ++rit) { const std::size_t i = std::distance(docs.begin(), rit.base()) - 1; inverted_index.try_emplace(rit->name, i); } return inverted_index; } ``` ## Суффиксы литералов У любого литерала в C++ есть тип по умолчанию, например: ```cpp {.example_for_playground .example_for_playground_021} auto skip_dependencies = false; // bool auto raw_estimate_s = 11.4; // double ``` Для целочисленных литералов подбирается наименьший из типов, способный вместить значение. Выбор происходит между `int`, `long`, `long long` и их беззнаковыми вариациями. ```cpp {.example_for_playground .example_for_playground_022} auto zero = 0; // int auto bitrate_kbps = 5'800; // int auto seed = 1844674407370999999; // long ``` Порой тип литерала требуется поменять. Вы уже столкнулись с этим в [практике](/courses/cpp/practice/cpp_div_without_div/) «Деление без деления» при сдвиге значения `1` на `n` бит влево. При этом единица должна была интерпретироваться как литерал типа `std::size_t`, а не `int`. ```cpp 1 << n // 1 - это int ``` Можно было бы воспользоваться [прямой](/courses/cpp/chapters/cpp_chapter_0131/#block-direct-initialization) или [универсальной](/courses/cpp/chapters/cpp_chapter_0131/#block-uniform-initialization) инициализацией: ```cpp std::size_t(1) << n // прямая инициализация std::size_t{1} << n // универсальная ``` Но вместо этого мы выбрали более лаконичный способ — добавили к литералу суффикс `uz`, который был введен в C++23 специально для обозначения `std::size_t`: ```cpp 1uz << n // 1 - это std::size_t ``` Всего есть три суффикса: - `l` или `L` — добавление к типу модификатора `long`. - `u` или `U` — выбор беззнаковой вариации типа. - `z` или `Z` — выбор знаковой вариации типа, псевдонимом которого является `std::size_t`. Суффиксы можно комбинировать между собой, например суффикс `LL` добавляет к типу модификатор `long long`. Какой суффикс нужно добавить к целочисленному литералу, чтобы получить беззнаковое целое шириной от 64 бита? {.task_text} ```consoleoutput {.task_source #cpp_chapter_0141_task_0090} ``` Целевой тип — `unsigned long long`. {.task_hint} ```cpp {.task_answer} ull ``` ## Какой тип выбрать? Напоследок несколько советов по работе с фундаментальными типами: - Старайтесь не смешивать знаковые и беззнаковые типы в арифметических выражениях. Из-за [неявного приведения типов](/courses/cpp/chapters/cpp_chapter_0131/#block-implicit-conversion) в таком коде легко допустить ошибку. - Используйте типы фиксированной ширины, если критичен точный размер переменной. В остальных случаях предпочитайте обычные типы. - Подбирайте тип таким образом, чтобы не допустить переполнения. ## Домашнее задание Откройте свое решение [практики](/courses/cpp/practice/cpp_lru_cache/) «LRU кеш» и добавьте в него псевдонимы. Сравните старый и новый варианты кода: стало ли решение более лаконичным и понятным? ---------- ## Резюме - Ширина целочисленного типа — это размер, занимаемый переменными этого типа в памяти. - Модификаторы `signed`, `unsigned`, `short` и `long` нужны, чтобы на базе `int` выводить другой целочисленный тип. - Стандарт не определяет точную ширину типа `int` и его производных, полученных с помощью модификаторов. - Переполнение _знакового_ целого типа — это UB. Переполнение _беззнакового_ приводит к отбрасыванию старших битов. - Оператор `sizeof` возвращает размер типа в байтах. - Псевдонимы `std::int8_t`, ..., `std::int64_t` и их беззнаковые вариации определяют целочисленные типы фиксированной ширины. - Ключевые слова `typedef` и `using` задают псевдоним уже существующего типа. - Префиксы целочисленных литералов нужны, чтобы указать систему счисления. Например, `0x` для шестнадцатеричного значения. - Суффиксы `l`, `u`, `z` или их сочетание явно задают тип литерала.

Следующие главы находятся в разработке

Отправка...
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!