Перейти к основному содержимому

Типы данных в C++

Разработчику Архитектору

Дальше: Работа с типами · Операторы · Справочник C++


Типы данных C++

Система типов

C++ — статически типизированный язык (тип фиксируется при объявлении, проверка до запуска).

По сравнению с Python или Java модель слабее: неявные numeric promotions, reinterpret_cast и работа с битовым представлением позволяют обойти абстракцию типа — источник крахов при ошибках.

Общие определения — типы данных, типизация.

В языке C++ тип данных задаёт множество допустимых значений, операции над ними и представление в памяти. Система типов статична: тип переменной, выражения и функции устанавливается при компиляции и в ходе выполнения не меняется. Это даёт производительность, предсказуемость и жёсткий контроль компилятора — важно в системном программировании.

Совместимость типов проверяется до запуска: операция над несовместимыми типами без явного приведения — ошибка компиляции. Строку нельзя присвоить целочисленной переменной без cast; указатель как bool допустим только через явные правила языка. В отличие от сильной динамической модели (Python), в C/C++ семейство исторически допускает неявные и низкоуровневые приведения — см. Типизация.

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


Play ITЗагрузка интерактивного демо…


Фундаментальные типы данных

Фундаментальные типы — это базис, из которого строятся все остальные типы в программе. Они классифицируются по семантическому назначению — логические, символьные, целочисленные, вещественные и служебные. Размер и точное поведение большинства из них зависят от реализации компилятора и целевой архитектуры, однако стандарт C++ устанавливает строгие минимальные требования и гарантии относительных размеров.


Play ITЗагрузка интерактивного демо…


Логический тип bool

Тип bool предназначен для представления логических значений — истина и ложь. В языке C++, начиная со стандарта C++98, bool является полноценным фундаментальным типом. Он принимает ровно два значения — true и false, которые не являются идентификаторами или макросами, а встроены в язык на уровне лексики.

Объявление переменной:
bool <имя>;

Инициализация переменной:
bool <имя> = <значение>;
где <значение> — это true или false.

Присваивание значения:
<имя> = <значение>;

Использование в условии:
if (<имя>) { /* действия */ }

Значение true интерпретируется как 1, false — как 0 при неявных преобразованиях в целочисленные типы, однако внутри программы рекомендуется использовать именно логические литералы, чтобы подчеркнуть намерение и повысить читаемость кода.

Код ITЗагрузка примера кода…

Разбор:

  • bool isReady = true; и bool isError = false; показывают явную инициализацию логических флагов без неявных преобразований.
  • std::cout << isReady выводит 1 или 0, потому что поток по умолчанию печатает bool в числовой форме.
  • Условие if (isReady) выполняет ветку только при значении true, что демонстрирует типичный контроль потока по флагу.
  • '\n' и std::endl оба переводят строку, но std::endl дополнительно принудительно сбрасывает буфер вывода.
  • return 0; завершает main успешным кодом возврата для операционной системы.

Важно понимать, что в C++ любое скалярное значение может быть неявно преобразовано к bool — нулевые значения всех арифметических типов, нулевые указатели и пустые объекты считаются ложными, все остальные — истинными. Это поведение часто используется в условиях, но при проектировании интерфейсов стоит избегать чрезмерного полагания на такое неявное преобразование, особенно в публичных API, поскольку оно может скрывать логические ошибки.

Неявное преобразование арифметических типов к bool:

Код ITЗагрузка примера кода…

Разбор:

  • Переменные x, y, z, c подобраны так, чтобы показать и ненулевые, и нулевые значения разных базовых типов.
  • Присваивания bool b1 = x; и аналогичные запускают неявное преобразование "скаляр -> bool".
  • Ненулевые int и double становятся true, а 0 и '\0' становятся false.
  • Вывод b1..b4 подтверждает результат преобразований в рантайме и помогает сопоставить правило с фактическим поведением.
  • Такой пример полезен для ревью условий, где в if (...) передаются не bool, а числа или символы.

Использование в условных выражениях:

Код ITЗагрузка примера кода…

Разбор:

  • int* ptr = nullptr; вместе с if (!ptr) демонстрирует безопасную проверку указателя перед потенциальным разыменованием.
  • emptyVec.empty() возвращает bool и является каноничным способом проверки пустоты контейнера.
  • Проверка filledVec.size() работает, но хуже читается, потому что опирается на неявное преобразование числа к логике.
  • Ветвление if (value) показывает классический "truthy" паттерн для ненулевого числа.
  • Фрагмент сопоставляет удобные и рекомендуемые варианты условий, подчёркивая стиль, а не только синтаксис.

Функции, возвращающие bool:

Код ITЗагрузка примера кода…

Функции, возвращающие bool, улучшают читаемость условий. Имена таких функций часто начинаются с is, has, can, should.


Символьные типы — char, wchar_t, char16_t, char32_t

Тип char служит для представления отдельного символа.

Объявление переменной:
<тип> <имя>;
где <тип> — один из символьных типов (char, wchar_t и т.д.).

Инициализация символом:
<тип> <имя> = '<символ>';

Инициализация числовым кодом:
<тип> <имя> = <число>;

Присваивание значения:
<имя> = '<символ>';
или
<имя> = <число>;

Использование в арифметике:
int <результат> = <имя> + <число>;

По историческим причинам и для совместимости с C, char имеет размер ровно 1 байт — это не означает, что char всегда кодирует один символ в смысле текстового представления.

Код ITЗагрузка примера кода…

Разбор:

  • Литералы 'A', '7', '@' инициализируют char символами, которые фактически хранятся как числовые коды.
  • Вывод через std::cout интерпретирует значения char как символы, а не как числа.
  • Выражение char nextLetter = letter + 1; использует арифметику над кодами символов и даёт следующий символ.
  • Такой приём полезен для простых задач, например, перебора латинских букв в ASCII-диапазоне.
  • Для сложной текстовой обработки лучше использовать строковые и Unicode-инструменты, а не ручную арифметику char.

На практике char используется для хранения как ASCII-символов, так и байтов произвольных данных — например, двоичных буферов или UTF-8-кодированных последовательностей. Поскольку char может быть как знаковым, так и беззнаковым в зависимости от компилятора и платформы, его нельзя однозначно использовать для арифметических операций без приведения; для этого следует предпочесть signed char или unsigned char, если необходима гарантия знаковости.

Типы wchar_t, char16_t и char32_t были введены для повышения переносимости при работе с многобайтовыми кодировками. Тип wchar_t появился в C++98 и исторически использовался для "широких" символов, однако его размер и семантика сильно варьировались: 2 байта в Windows (для UTF-16), 4 байта в большинстве Unix-систем (для UTF-32). Чтобы устранить эту неопределённость, стандарт C++11 ввёл char16_t (гарантированно 2 байта, предназначен для UTF-16) и char32_t (гарантированно 4 байта, для UTF-32), а также соответствующие строковые литералы — u"…", U"…", L"…". Сегодня рекомендуется избегать wchar_t в кроссплатформенном коде и использовать char с UTF-8 или char16_t/char32_t при явной необходимости работы с конкретной кодировкой.

Размеры символьных типов:

#include <iostream>

int main() {
std::cout << "sizeof(char) = " << sizeof(char) << " byte\n";
std::cout << "sizeof(wchar_t) = " << sizeof(wchar_t) << " bytes\n";
std::cout << "sizeof(char16_t) = " << sizeof(char16_t) << " bytes\n";
std::cout << "sizeof(char32_t) = " << sizeof(char32_t) << " bytes\n";

return 0;
}

На большинстве Unix-систем (например, Linux/macOS) вывод будет:

sizeof(char) = 1 byte
sizeof(wchar_t) = 4 bytes
sizeof(char16_t) = 2 bytes
sizeof(char32_t) = 4 bytes

На Windows:

sizeof(char) = 1 byte
sizeof(wchar_t) = 2 bytes
sizeof(char16_t) = 2 bytes
sizeof(char32_t) = 4 bytes

Это показывает, почему wchar_t не подходит для кроссплатформенного кода: его размер зависит от ОС.

Использование char для ASCII и UTF-8:

Код ITЗагрузка примера кода…

Здесь char используется как для текста (включая UTF-8), так и для неинтерпретируемых данных. Обратите внимание: при работе с бинарными данными предпочтительнее unsigned char, чтобы избежать неопределённости со знаком.

Использование char16_t и char32_t:

Код ITЗагрузка примера кода…

Типы char16_t и char32_t имеют чётко определённый размер и предназначены для работы с UTF-16 и UTF-32 соответственно. Однако стандартная библиотека C++ не предоставляет удобных средств для их вывода в консоль без дополнительной обработки (например, преобразования в UTF-8).

Преобразование между типами (простой пример):

Код ITЗагрузка примера кода…

Прямые приведения допустимы, но теряют смысл при работе с не-ASCII символами. Для корректной транскодировки используются специализированные средства (std::wstring_convert, ICU, или сторонние библиотеки).


Целочисленные типы — short, int, long, long long

Целочисленные типы предназначены для хранения значений без дробной части.

Объявление переменной:
<модификатор> <тип> <имя>;
где <модификатор> — необязательное слово unsigned,
а <тип> — один из — short, int, long, long long.

Инициализация переменной:
<тип> <имя> = <целое_число>;

Присваивание значения:
<имя> = <целое_число>;

Арифметическая операция:
<тип> <результат> = <имя1> <оператор> <имя2>;
где <оператор> — один из — +, -, *, /, %.

Инкремент/декремент:
<имя>++;
--<имя>;

В C++ они образуют иерархию, в которой каждый следующий тип не короче предыдущего:

  • short (или short int) — короткое целое, как минимум 16 бит
  • int — целое, как минимум столько же бит, сколько short, и обычно соответствует "естественному" размеру слова процессора (на 32- и 64-битных платформах — 32 бита)
  • long (или long int) — длинное целое, как минимум 32 бита, не короче int
  • long long (или long long int) — расширенное длинное целое, введено в C++11, как минимум 64 бита

Целочисленные типы хранят числа без дробной части. Они различаются по диапазону допустимых значений и размеру в памяти.

Код ITЗагрузка примера кода…

Точный размер этих типов не фиксирован стандартом и зависит от реализации. Например, на большинстве современных x86-64 систем под Linux и Windows:

  • short = 2 байта
  • int = 4 байта
  • long = 4 байта (Windows) или 8 байт (Linux/macOS)
  • long long = 8 байт везде

Для написания переносимого кода, где важен точный размер, следует использовать фиксированные целочисленные типы из заголовка <cstdint>int8_t, uint16_t, int32_t, uint64_t и другие. Однако при повседневной разработке предпочтение отдаётся int, если не требуется особая ёмкость или битовая точность — он наиболее эффективен и интуитивно понятен.

Размеры встроенных целочисленных типов:

Код ITЗагрузка примера кода…

Этот код показывает реальные размеры на текущей платформе. Обратите внимание — long может быть 4 байта (Windows) или 8 байт (Linux/macOS), что делает его непереносимым для задач, требующих точного размера.

Целочисленные типы могут быть знаковыми (signed) или беззнаковыми (unsigned). По умолчанию типы short, int, long, long long считаются знаковыми, то есть способны хранить как положительные, так и отрицательные значения, используя дополнительный код. Беззнаковая версия (например, unsigned int) расширяет диапазон положительных значений за счёт невозможности представления отрицательных чисел. Арифметические операции с беззнаковыми типами выполняются по модулю 2N, где N — количество бит; это гарантирует отсутствие неопределённого поведения при переполнении, в отличие от знаковых типов, где переполнение — неопределённое поведение (undefined behavior), и компилятор имеет право оптимизировать код, исходя из предположения, что оно не происходит.

Знаковые и беззнаковые типы:

Код ITЗагрузка примера кода…

Здесь демонстрируется, что присваивание отрицательного значения беззнаковому типу приводит к "оборачиванию" по модулю (2^N). Это определённое поведение, в отличие от переполнения знакового типа.

Переполнение:

Код ITЗагрузка примера кода…

Беззнаковая арифметика безопасна с точки зрения стандарта: результат всегда предсказуем. Знаковая арифметика при переполнении вызывает неопределённое поведение, что может привести к ошибкам, которые трудно отловить.

Использование фиксированных типов из <cstdint>:

Код ITЗагрузка примера кода…

Фиксированные типы обеспечивают переносимость. Если требуется именно 32-битное целое, используйте std::int32_t, а не int или long.

Когда использовать int, а когда фиксированные типы?

Код ITЗагрузка примера кода…

Правило:

  • Используйте int для счётчиков, индексов, простых вычислений.
  • Используйте std::intN_t/std::uintN_t при взаимодействии с внешними системами, где важен точный размер.
  • Используйте size_t для размеров и индексов контейнеров (хотя при небольших размерах допустимо и int с приведением).

Вещественные типы — float, double

Типы с плавающей точкой предназначены для приближённого представления действительных чисел.

Объявление переменной:
<тип> <имя>;
где <тип>float, double или long double.

Инициализация переменной:
<тип> <имя> = <вещественное_число><суффикс>;
Суффикс обязателен только для float (f или F). Для double и long double суффикс не требуется, но может быть указан как L для long double.

Присваивание значения:
<имя> = <вещественное_число><суффикс>;

Арифметическая операция:
<тип> <результат> = <имя1> <оператор> <имя2>;
где <оператор> — один из — +, -, *, /.

Использование математической функции:
<тип> <результат> = <функция>(<имя>);
например: double x = sqrt(value);

В C++ поддерживаются три таких типа:

  • float — одинарная точность, обычно соответствует 32-битному формату IEEE 754 — 1 бит знака, 8 бит порядка, 23 бита мантиссы
  • double — двойная точность, обычно 64-битный IEEE 754: 1 + 11 + 52 бита
  • long double — расширенная точность, размер и формат зависят от платформы: 80-битный (x87), 128-битный (IEEE 754 quadruple) или просто алиас для double

Вещественные типы предназначены для хранения чисел с дробной частью. Они применяются при вычислениях, требующих представления десятичных дробей.

Код ITЗагрузка примера кода…

Литералы типа float помечаются суффиксом f или F. Тип double используется по умолчанию для вещественных литералов. Тип long double обеспечивает наибольшую точность среди вещественных типов, хотя его фактическая реализация зависит от компилятора и платформы.

Ключевое свойство вещественных типов — конечная точность. Не все десятичные дроби могут быть точно представлены в двоичной системе; например, значение 0.1 хранится лишь приближённо. Вследствие этого сравнение вещественных чисел на равенство через == обычно небезопасно — следует использовать сравнение с допуском ("эпсилон"), либо пересмотреть логику программы так, чтобы избежать прямого сравнения.

Операции с плавающей точкой подчиняются стандарту IEEE 754, если реализация его поддерживает, что обеспечивает совместимость и предсказуемость — определены значения +∞, −∞, NaN ("не число"), а также поведение при делении на ноль, переполнении и денормализованных числах. Однако важно помнить, что выполнение операций с плавающей точкой не ассоциативно: (a + b) + c может отличаться от a + (b + c) из-за накопления погрешностей. Этот факт критичен при численных расчётах и параллельных вычислениях.

Неточное представление десятичных дробей:

Код ITЗагрузка примера кода…

Этот пример показывает, что даже простая дробь 0.1 не может быть точно представлена в двоичной системе. Сумма десяти таких приближений не равна 1.0 в точности.

Безопасное сравнение с использованием эпсилона:

Код ITЗагрузка примера кода…

Прямое сравнение через == ненадёжно. Используйте абсолютную или относительную погрешность в зависимости от масштаба чисел.

Специальные значения (бесконечность и NaN):

Код ITЗагрузка примера кода…

Стандарт IEEE 754 определяет поведение при исключительных ситуациях. Бесконечность и NaN — часть нормальной арифметики с плавающей точкой.

Неассоциативность сложения:

Код ITЗагрузка примера кода…

Из-за ограниченной точности порядок операций влияет на результат. Это критично при параллельных вычислениях или рефакторинге выражений.

Размеры и представление типов с плавающей точкой:

Код ITЗагрузка примера кода…

На большинстве платформ:

  • float — ~7 десятичных цифр точности
  • double — ~15–17 десятичных цифр

long double может быть 80-битным (x86), 128-битным (SPARC) или просто синонимом double (MSVC).

Когда использовать float, а когда double?

Код ITЗагрузка примера кода…

Правило:

  • Используйте double по умолчанию — он точнее и часто не медленнее на современных CPU.
  • Используйте float только при ограничениях памяти или при работе с API, требующим одинарной точности (например, OpenGL, Vulkan).
  • Избегайте long double, если не требуется максимальная точность и вы не контролируете платформу.

Служебный тип void

Тип void — особый случай. Он означает отсутствие значения и не может быть использован для объявления переменных. Однако он имеет важное применение:

  • как возвращаемый тип функции — указывает, что функция ничего не возвращает
  • в указателях — void* обозначает указатель на "некий" объект неизвестного типа; такой указатель может быть преобразован в любой другой указатель на объект, но не на функцию
  • в параметрах функции (в C++ — избыточно) — void f(void) эквивалентно void f(), в отличие от C, где первая форма означает "ровно ноль аргументов", а вторая — "неизвестное число аргументов"

void играет роль "нулевого элемента" в системе типов и часто используется в обобщённом программировании и метапрограммировании для обозначения отсутствия результата.

void как возвращаемый тип функции:

Код ITЗагрузка примера кода…

Функции с возвращаемым типом void используются для выполнения побочных эффектов:

  • вывода;
  • изменения состояния;
  • записи в файл;
  • т.п.

Указатель void* — универсальный указатель на данные:

Код ITЗагрузка примера кода…

void* часто используется в низкоуровневом коде — аллокаторах памяти (malloc в C), callback-интерфейсах, сериализации. В современном C++ его применение сокращается в пользу типобезопасных альтернатив (std::any, std::variant, шаблонов).

Параметры функции:

Код ITЗагрузка примера кода…

В отличие от C, где void f(); означает "количество и типы аргументов неизвестны", в C++ обе формы строго означают "нет параметров". Стиль f(void) считается избыточным и редко используется в C++.

void в обобщённом программировании:

Код ITЗагрузка примера кода…

Хотя void не может быть типом переменной, он легально используется в контексте типов — как аргумент шаблона, в decltype, в метафункциях (std::is_void_v<T>). Это позволяет писать универсальные алгоритмы, которые корректно обрабатывают как функции с возвратом, так и без.

Проверка типа void с помощью type traits:

Код ITЗагрузка примера кода…

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


Квалификаторы и спецификаторы типов

Помимо базовых фундаментальных типов, C++ предоставляет набор спецификаторов, влияющих на интерпретацию, изменяемость и поведение данных.


signed и unsigned

Эти спецификаторы применимы к любым целочисленным типам, включая char. Они уточняют, должен ли тип интерпретироваться как знаковый или беззнаковый. Например, unsigned int, signed char. Отдельно стоит отметить, что char, signed char и unsigned char — это три различных типа с разными свойствами, несмотря на то, что char может совпадать по представлению с одним из них. Это особенно важно при перегрузке функций и специализации шаблонов.

Код ITЗагрузка примера кода…

Этот пример показывает, что char, signed char и unsigned char — три независимых типа. Это важно при перегрузке функций и шаблонной специализации.


const

Спецификатор const указывает, что значение, на которое он ссылается, не может быть изменено после инициализации. Он может применяться как к переменным, так и к указателям, ссылкам, параметрам функций, возвращаемым типам и членам классов. Например:

  • const int x = 5; — значение x фиксировано
  • int* const p = &x; — указатель константен (адрес нельзя изменить), но значение по адресу — можно
  • const int* p = &x; — значение константно (нельзя изменить через p), но указатель — можно переназначить
  • const int* const p = &x; — и указатель, и значение неизменяемы

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

Различные формы const с указателями:

Код ITЗагрузка примера кода…

Чтобы запомнить: читайте справа налево.

  • const int* → указатель на const int
  • int* constconst указатель на int

const-методы и логическая константность:

Код ITЗагрузка примера кода…

const-методы гарантируют, что они не изменяют наблюдаемое состояние объекта. mutable позволяет обойти это ограничение для служебных полей.


volatile

Спецификатор volatile указывает компилятору, что значение переменной может изменяться внешними по отношению к программе факторами — например, аппаратным прерыванием, другим потоком выполнения или сопроцессором. Это запрещает компилятору выполнять оптимизации, полагающиеся на предсказуемость изменения значения: каждое чтение и запись должны выполняться непосредственно в память, без кэширования в регистрах. volatile не обеспечивает потокобезопасности и не создаёт барьеров памяти — для синхронизации между потоками следует использовать std::atomic, мьютексы и другие средства из <thread> и <atomic>.

Доступ к "изменчивым" данным:

Код ITЗагрузка примера кода…

volatile гарантирует, что каждое обращение к переменной будет выполнено как фактическое чтение/запись в память. Он не обеспечивает атомарности и не создаёт барьеров памяти, поэтому не подходит для синхронизации между потоками.


mutable

Этот спецификатор применим только к нестатическим членам класса. Он разрешает изменение такого члена даже внутри const-методов. Обычно mutable используется для вспомогательных полей, не влияющих на логическое состояние объекта — например, кэшей, счётчиков обращений или mutex’ов, защищающих внутреннее состояние в thread-safe классах. Пример:

class Loggable {
mutable std::mutex mtx_;
mutable int access_count_ = 0;

public:
void log() const {
std::lock_guard lock(mtx_); // захват мьютекса в const-методе
++access_count_;
// ... запись в лог
}
};

Здесь изменение access_count_ и mtx_ не нарушает контракт const, поскольку они служат исключительно для реализации, а не для хранения семантически значимого состояния.

mutable в thread-safe классе:

Код ITЗагрузка примера кода…

Здесь const-методы обеспечивают логическую константность (состояние "сообщения" семантически не меняется при запросе), но внутренние механизмы (mutex, кэш) могут обновляться благодаря mutable.


Производные типы данных

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

Строгого формального разделения "производный тип" — "не производный" в стандарте нет, однако в педагогической и инженерной практике к производным принято относить следующие категории — массивы, указатели, ссылки, перечисления (enum), объединения (union), структуры (struct) и классы (class). Важно понимать, что границы между ними подвижны — например, класс может содержать массив, указатель на который возвращается методом, а ссылка на этот указатель используется в шаблоне. Тем не менее, каждая конструкция имеет историческое происхождение, определённое поведение по умолчанию и зону ответственности.


Указатели

Указатель хранит адрес переменной в памяти и позволяет косвенно обращаться к её значению.

Код ITЗагрузка примера кода…

Разбор:

  • int* ptr = &value; берёт адрес переменной и сохраняет его в указателе типа int*.
  • Вывод ptr показывает адрес, а *ptr — значение по этому адресу (разыменование).
  • Операция *ptr = 100; меняет исходную переменную value через косвенный доступ.
  • Этот паттерн лежит в основе передачи данных по адресу и работы с динамическими структурами.
  • Корректность требует, чтобы указатель всегда ссылался на валидный объект до разыменования.

Указатели также могут быть nullptr, что означает отсутствие адреса:

int* nullPtr = nullptr;
if (nullPtr == nullptr) {
std::cout << "Указатель не ссылается ни на что." << std::endl;
}

Объявление указателя:
<тип>* <имя>;

Инициализация указателя адресом переменной:
<тип>* <имя> = &<переменная>;

Инициализация нулевым указателем:
<тип>* <имя> = nullptr;

Разыменование указателя:
*<имя> = <значение>;
или
<тип> <результат> = *<имя>;

Передача указателя в функцию:
<возвращаемый_тип> <функция>(<тип>* <параметр>);

Указатель — одна из самых фундаментальных и мощных абстракций C++. Это объект, значение которого представляет собой адрес другого объекта (или функции) в памяти. Тип указателя всегда включает в себя тип того, на что он указывает — int*, double*, char*, void* и так далее. Это позволяет компилятору знать, сколько байт занимает целевой объект и как его интерпретировать при разыменовании.

Указатель может находиться в одном из трёх состояний:

  • указывает на существующий объект — тогда операция разыменования (*p) корректна
  • равен nullptr (или NULL, или 0 в старом коде) — явный признак отсутствия объекта
  • имеет неопределённое значение — например, после объявления без инициализации; использование такого указателя приводит к неопределённому поведению

Особенность арифметики указателей — при прибавлении целого числа n к указателю p типа T*, результатом будет адрес, смещённый на n * sizeof(T) байт. Это делает указатели естественным инструментом для итерации по массивам и реализации контейнеров.

Указатели не владеют памятью — они лишь ссылаются на неё. Ответственность за выделение (new) и освобождение (delete) памяти лежит на программисте. Именно эта особенность делает указатели одновременно гибкими и опасными — двойное освобождение, использование после освобождения (dangling pointer), утечки памяти — типичные ошибки в C-подобных языках. Современный C++ рекомендует заменять "голые" указатели на умные указатели (std::unique_ptr, std::shared_ptr, std::weak_ptr), которые автоматизируют управление временем жизни объектов и значительно повышают надёжность.

Тем не менее, указатели остаются незаменимы для:

  • реализации полиморфизма через виртуальные функции (требуется ссылка или указатель на базовый класс),
  • передачи массивов и больших объектов без копирования,
  • работы с интерфейсами C и низкоуровневой памятью (например, memory-mapped I/O).

Ссылки

Ссылка — это альтернативное имя для уже существующего объекта. В отличие от указателя, ссылка не является отдельным объектом и не занимает места в памяти (на уровне абстракции; физически компилятор обычно реализует её как указатель, но скрывает это). После инициализации ссылка не может быть "перенаправлена" на другой объект — она пожизненно привязана к тому, на что была заведена.

Объявление и инициализация ссылки:
<тип>& <имя> = <существующая_переменная>;

Использование ссылки:
<имя> = <новое_значение>;
или
<тип> <результат> = <имя>;

Передача ссылки в функцию:
<возвращаемый_тип> <функция>(<тип>& <параметр>);

Возврат ссылки из функции:
<тип>& <функция>();

Ссылка — это псевдоним для уже существующей переменной. После инициализации ссылку нельзя изменить.

Код ITЗагрузка примера кода…

Разбор:

  • int& ref = original; создаёт ссылку-псевдоним на уже существующий объект без выделения отдельной памяти для данных.
  • Чтение ref даёт то же значение, что и чтение original, потому что это одно и то же хранимое значение.
  • Запись ref = 20; изменяет original, а не "переназначает ссылку".
  • Ссылка обязана быть инициализирована сразу при объявлении, в отличие от указателя.
  • Такой механизм часто используют в параметрах функций для изменения аргумента без копирования.

Ссылки часто используются в параметрах функций для передачи без копирования:

void increment(int& x) {
x++;
}

int main() {
int n = 5;
increment(n);
std::cout << n << std::endl; // 6
}

Существует два вида ссылок:

  • lvalue-ссылки (T&) — привязываются к именованным объектам (lvalues), например, переменным
  • rvalue-ссылки (T&&) — введены в C++11, привязываются к временным объектам (rvalues), например, возвращаемым значениям функций

Семантически ссылка безопаснее указателя: она не может быть нулевой, не требует разыменования и не допускает неявного переопределения цели. Поэтому предпочтительный способ передачи аргументов в функции — по константной ссылке (const T&), если объект не нужно модифицировать и копирование дорого. Это избегает накладных расходов на копирование без рисков, присущих указателям.

Rvalue-ссылки открыли путь для семантики перемещения (move semantics): возможность "перехватить" ресурсы временного объекта вместо их копирования. Это особенно важно для классов, управляющих динамической памятью, файловыми дескрипторами или другими внешними ресурсами. Например, std::vector при возврате из функции может передать владение своим внутренним буфером напрямую, минуя аллокацию и копирование данных.

Важно различать:

  • T& r = x; — lvalue-ссылка на x
  • T&& rr = std::move(x); — rvalue-ссылка, позволяющая "разобрать" x, если это безопасно
  • const T& cr = /* что угодно */; — универсальная ссылка, способная привязаться к любому выражению, продлевая время жизни временного объекта

Play ITЗагрузка интерактивного демо…


Массивы

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

Объявление массива фиксированного размера:
<тип> <имя>[<размер>];

Инициализация при объявлении:
<тип> <имя>[<размер>] = {<значение1>, <значение2>, ..., <значениеN>};

Автоматическое определение размера:
<тип> <имя>[] = {<значение1>, <значение2>, ..., <значениеN>};


Доступ к элементам

Чтение или запись по индексу:
<имя>[<индекс>] = <значение>;
<тип> <переменная> = <имя>[<индекс>];

Индекс начинается с нуля и должен быть меньше <размер>.

Объявление двумерного массива:
<тип> <имя>[<строки>][<столбцы>];

Инициализация двумерного массива:
<тип> <имя>[<строки>][<столбцы>] = { {<элементы_строки1>}, {<элементы_строки2>}, ... };

Доступ к элементу:
<имя>[<номер_строки>][<номер_столбца>] = <значение>;

Преобразование имени массива в указатель:
<тип>* <указатель> = <имя_массива>;

Арифметика указателей:
*(<имя_массива> + <смещение>) эквивалентно <имя_массива>[<смещение>]

Передача массива в функцию (через указатель):
<возвращаемый_тип> <функция>(<тип> <параметр>[])
или
<возвращаемый_тип> <функция>(<тип>* <параметр>)

При передаче массив теряет информацию о размере, поэтому размер часто передаётся отдельным параметром:
void process(int arr[], int size);

Вычисление количества элементов:
sizeof(<имя>) / sizeof(<имя>[0])

Этот приём работает только внутри той области видимости, где массив объявлен как статический. Не работает для параметров функций.


Статический массив (размер известен на этапе компиляции)

Код ITЗагрузка примера кода…

Можно не указывать размер явно — компилятор определит его по количеству инициализаторов:

double prices[] = {19.99, 5.50, 100.0}; // размер — 3

Многомерный массив

Код ITЗагрузка примера кода…


Массив символов (C-style строка)

Код ITЗагрузка примера кода…


Указатель на массив и арифметика указателей

#include <iostream>

int main() {
int arr[4] = {7, 14, 21, 28};
int* ptr = arr; // имя массива преобразуется в указатель на первый элемент

std::cout << "Через указатель: " << *(ptr + 2) << std::endl; // 21

// Эквивалентность: arr[i] == *(arr + i)
std::cout << "arr[3] == " << arr[3] << ", *(arr+3) == " << *(arr + 3) << std::endl;

return 0;
}

Важно — Статические массивы в C++ имеют фиксированный размер, не могут быть скопированы присваиванием (arr1 = arr2 — ошибка), и их размер нельзя изменить во время выполнения.

Массив — это упорядоченная последовательность элементов одного типа, размещённая в смежных ячейках памяти. Объявление вида int arr[10]; создаёт стековый массив из 10 целых чисел. Размер массива должен быть константным выражением, известным на этапе компиляции — это ограничение делает классические массивы непригодными для случаев, когда размер определяется динамически.

Массивы обладают рядом особенностей, затрудняющих их использование:

  • при передаче в функцию они деградируют в указатель: void f(int a[5]) эквивалентно void f(int* a)
  • оператор sizeof возвращает общий размер только в том же блоке, где массив объявлен; в функции он вернёт размер указателя
  • отсутствует встроенная проверка выхода за границы
  • невозможность присваивания массивов напрямую: arr1 = arr2; — ошибка компиляции

Именно поэтому в современном C++ рекомендуется использовать std::array<T, N> — обёртку над статическим массивом из заголовка <array>. Она предоставляет интерфейс, совместимый с STL (методы size(), begin(), end()), поддерживает копирование и присваивание, не деградирует в указатель при передаче по значению и совместима с шаблонными алгоритмами.

Для динамических массивов — когда размер неизвестен заранее — следует использовать std::vector<T>. Он инкапсулирует указатель, счётчик размера и ёмкости, автоматически управляет памятью, предоставляет безопасный доступ и гибкие операции вставки/удаления. Прямое использование new T[N] и delete[] считается устаревшим подходом, за исключением очень специфических сценариев (например, написание собственного аллокатора).


Перечисления (enum)

Перечисление — это пользовательский тип, представляющий набор именованных констант.

Объявление перечисления:
enum <имя> { <константа1>, <константа2>, ... };

Объявление перечисления с явными значениями:
enum <имя> { <константа1> = <значение1>, <константа2> = <значение2>, ... };

Объявление типобезопасного перечисления:
enum class <имя> { <константа1>, <константа2>, ... };

Использование значения перечисления:
<имя> <переменная> = <имя>::<константа>;

Перечисление определяет набор именованных целочисленных констант.

Код ITЗагрузка примера кода…

Можно явно задать значения:

enum HttpStatus {
OK = 200,
NOT_FOUND = 404,
INTERNAL_ERROR = 500
};

Для большей типовой безопасности используется enum class:

enum class Direction {
UP, DOWN, LEFT, RIGHT
};

int main() {
Direction d = Direction::UP;
// std::cout << d; // ошибка: нет неявного преобразования в int
return 0;
}

Классический C-стиль (enum) имеет серьёзные недостатки:

  • константы "утекают" в окружающую область видимости, что может вызывать коллизии имён
  • перечисление не имеет собственного типа — оно неявно конвертируется в целочисленный тип и обратно
  • размер и знаковость лежат в ведении реализации

Пример проблемы:

enum Color { Red, Green, Blue };
enum Status { Red, Active }; // ошибка: Red уже объявлен
int x = Red; // допустимо — Red неявно int

В C++11 введены строгие перечисления (enum class или enum struct), которые решают эти проблемы:

enum class Color { Red, Green, Blue };
enum class Status { Red, Active }; // допустимо — пространства имён разные

Color c = Color::Red;
int x = c; // ошибка компиляции — нет неявного преобразования

Строгие перечисления имеют собственный тип, не приводятся к целым без static_cast, и их значения доступны только через квалифицированное имя (Color::Red). По умолчанию базовым типом является int, но его можно явно задать:

enum class Flags : uint8_t { Read = 1, Write = 2, Exec = 4 };

Это особенно полезно при работе с битовыми флагами, поскольку позволяет контролировать размер и обеспечивать переносимость.


Объединения (union)

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

Объявление объединения:

union <имя> {
<тип1> <поле1>;
<тип2> <поле2>;
// ...
};

Создание экземпляра объединения:
<имя> <переменная>;

Доступ к полю объединения:
<переменная>.<поле> = <значение>;

Код ITЗагрузка примера кода…

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

Объединение — это специальный агрегатный тип, все члены которого разделяют одну и ту же область памяти. Размер объединения равен размеру его самого большого члена (плюс выравнивание). В каждый момент времени в объединении может быть активен только один член — запись в один член делает значение других неопределённым.

Классическое объединение из C не имеет конструкторов, деструкторов и не отслеживает, какой член активен. Это делает его крайне небезопасным — при разрушении объединения с нетривиальными типами (например, std::string) возникает неопределённое поведение, поскольку деструктор вызывается не для того объекта, который был создан.

В C++11 появилась поддержка объединений с нетривиальными членами, но программист обязан вручную управлять временем жизни объектов с помощью placement new и явного вызова деструктора — и всё равно отслеживать активный член. Из-за сложности и рисков такие объединения используются редко.

На практике вместо "голых" объединений рекомендуется применять типы-объединения из стандартной библиотеки:

  • std::variant<T1, T2, ...> — типобезопасное объединение, отслеживающее активный альтернативный тип, поддерживающее посещение (std::visit) и выбрасывающее исключение при некорректном доступе
  • std::optional<T> — частный случай объединения "значение или отсутствие значения", идеален для возврата из функций, где результат может быть не определён

Эти типы инкапсулируют всю сложность управления состоянием и делают код надёжным без потери производительности.


Play ITЗагрузка интерактивного демо…


Структуры (struct) и классы (class)

Структуры и классы — основные механизмы пользовательской абстракции в C++. С точки зрения языка, между struct и class существует единственное различие: уровень доступа по умолчанию для членов и базовых классов.

  • В struct по умолчанию — public
  • В class по умолчанию — private

Во всём остальном — синтаксис, поддержка наследования, виртуальных функций, шаблонов, операторов — они идентичны. Это означает, что выбор между struct и class — вопрос стиля и семантики, а не технической возможности.

Общепринятая практика:

  • struct используется для агрегатных типов — простых контейнеров данных без инвариантов, логики и управления ресурсами. Например, Point { int x, y; }, Config { std::string host; int port; }. Такие типы часто инициализируются агрегатной инициализацией (Point p{1, 2};) и не требуют конструкторов.
  • class применяется для инкапсулированных сущностей с инвариантами, поведением, управлением жизненным циклом. Например, FileHandle, DatabaseConnection, ThreadPool.

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

Объявление структуры:

struct <имя> {
<тип1> <поле1>;
<тип2> <поле2>;
// ...
<возвращаемый_тип> <метод>(<параметры>);
};

Создание экземпляра структуры:
<имя> <переменная>;
или
<имя> <переменная> = { <значение1>, <значение2>, ... };

Доступ к полю или методу:
<переменная>.<поле> = <значение>;
<переменная>.<метод>(<аргументы>);

Код ITЗагрузка примера кода…

Структуры могут содержать функции:

struct Point {
double x, y;

double distanceSquaredToOrigin() const {
return (x * x + y * y);
}
};

int main() {
Point p{3.0, 4.0};
std::cout << "Квадрат расстояния до начала координат: " << p.distanceSquaredToOrigin() << std::endl;
}

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

Объявление класса:

class <имя> {
private:
<тип> <приватное_поле>;
public:
<имя>(<параметры_конструктора>);
<возвращаемый_тип> <публичный_метод>(<параметры>);
<тип> <геттер>() const;
void <сеттер>(<тип> <значение>);
};

Определение конструктора вне класса:
<имя>::<имя>(<параметры>) : <поле1>(<значение1>), ... { /* тело */ }

Создание объекта класса:
<имя> <объект>(<аргументы_конструктора>);

Вызов метода объекта:
<объект>.<метод>(<аргументы>);

Доступ к свойству через геттер/сеттер:
<объект>.<геттер>();
<объект>.<сеттер>(<значение>);

Код ITЗагрузка примера кода…

Классы являются основой объектно-ориентированного программирования в C++.

Современный C++ поощряет value semantics — классы, ведущие себя как встроенные типы — поддерживающие копирование, перемещение, сравнение. Для этого следует явно определять или использовать правила "большой пятерки" — конструктор копирования, оператор присваивания копированием, конструктор перемещения, оператор присваивания перемещением, деструктор — или полагаться на автоматическую генерацию, если это безопасно.

Важно — если класс управляет ресурсом (памятью, файлом, сокетом), он должен следовать правилу трёх/пяти и правилу нуля:

  • Правило трёх — если определён хотя бы один из деструктора, копирующего конструктора или копирующего присваивания — скорее всего, нужно определить все три
  • Правило пяти: в C++11 добавляются перемещающие операции
  • Правило нуля — лучше вообще не определять ни одной из них, если можно делегировать управление ресурсом другим объектам (например, std::unique_ptr, std::vector) — тогда компилятор сгенерирует безопасные версии автоматически

Это позволяет создавать классы, которые легко использовать, сложно неправильно использовать и которые гармонично вписываются в экосистему STL.


Взаимосвязь типов — композиция, наследование, агрегация

Производные типы редко существуют изолированно. Они взаимодействуют через:

  • агрегацию — один тип содержит экземпляр другого (например, struct Person { Address home; })
  • композицию — более тесная форма агрегации, где часть не существует без целого
  • наследованиеclass Derived : public Base — механизм для расширения интерфейса и реализации полиморфизма
  • параметризациюtemplate<typename T> class Stack { … }; — обобщение по типу

Особую роль играет полиморфизм: возможность обращаться с объектами разных типов через единый интерфейс. В C++ он реализуется через виртуальные функции и требует использования указателей или ссылок на базовый класс. Абстрактные базовые классы (с чисто виртуальными функциями, = 0) задают контракт, который обязаны реализовать наследники.

Современные тенденции всё чаще предпочитают полиморфизм на основе понятий (concepts, C++20) и типов-обёрток (std::function, std::any) вместо иерархий наследования, особенно когда речь идёт о гибкости и компиляции в виде заголовков. Однако наследование остаётся незаменимым в системах с плагинами, фреймворками и объектными моделями.


А строки?

В языке C++ нет встроенного строкового типа на уровне ядра языка, как, например, int или bool. Однако это не означает отсутствия строк как таковых — они реализованы на уровне стандартной библиотеки.

Строки в C++ — это составной тип данных, предназначенный для хранения и обработки последовательностей символов. В языке существует несколько способов представления строк: через массивы символов (C-style строки) и через класс std::string из стандартной библиотеки. Ниже приведены простые примеры и алгоритмические шаблоны для работы со строками.

Строка в C++ — это либо:

  • массив символов с завершающим нулём (\0), известный как C-style строка (унаследован из языка Си);
  • либо объект класса std::string (или std::wstring, std::u8string и др.), предоставляемый стандартной библиотекой C++ и инкапсулирующий управление памятью, длину, операции сравнения, конкатенации и другие функции.

C-style строки (массивы символов)

C-style строки — это массивы типа char, завершающиеся нулевым символом \0. Они наследуются из языка Си и широко используются в системном программировании.

C-style строки:

  • Это одномерный массив типа char (или wchar_t для широких символов).
  • Последний элемент всегда содержит нулевой символ \0, сигнализирующий конец строки.
  • Управление памятью полностью лежит на программисте.
  • Операции выполняются через функции из <cstring>strlen, strcpy, strcat, strcmp.

Пример:

char text[] = "Привет";

Здесь компилятор автоматически добавляет \0 в конец.

Объявление строки фиксированного размера:
char <имя>[<размер>];

Инициализация строкового литерала:
char <имя>[] = "<текст>";

Копирование строки:
strcpy(<цель>, <источник>);

Конкатенация строк:
strcat(<цель>, <добавляемая_строка>);

Определение длины строки:
strlen(<имя>);

Сравнение строк:
strcmp(<строка1>, <строка2>);
— возвращает 0, если строки равны; отрицательное число, если первая меньше; положительное — если больше.

Чтение строки из потока (без переполнения):
fgets(<буфер>, <макс_длина>, stdin);

Код ITЗагрузка примера кода…

Важно: при работе с C-style строками необходимо самостоятельно следить за размером буфера, чтобы избежать переполнения.


Строки через std::string

Класс std::string предоставляет удобный и безопасный интерфейс для работы со строками. Он автоматически управляет памятью и поддерживает множество операций.

std::string:

  • Это шаблонный класс std::basic_string<char>, определённый в заголовке <string>.
  • Хранит символы в динамически выделенной памяти.
  • Автоматически управляет размером, поддерживает безопасное изменение длины.
  • Поддерживает операторы (+, ==, []), методы (length(), substr(), find() и т.д.).
  • Гарантирует непрерывность хранения символов (начиная с C++11).

Пример:

#include <string>
std::string message = "Здравствуйте";

Класс std::string предоставляет интерфейс, близкий к поведению фундаментального типа — его можно копировать, присваивать, передавать в функции без ручного управления памятью.

Подключение заголовка:
#include <string>

Объявление строки:
std::string <имя>;

Инициализация строки:
std::string <имя> = "<текст>";
или
std::string <имя>("<текст>");

Присваивание значения:
<имя> = "<новый_текст>";

Конкатенация строк:
<результат> = <строка1> + <строка2>;
или
<строка1> += <строка2>;

Получение длины строки:
<имя>.length() или <имя>.size()

Доступ к символу по индексу:
<имя>[<индекс>]
(индексация начинается с 0)

Изменение символа:
<имя>[<индекс>] = '<новый_символ>';

Поиск подстроки:
<имя>.find("<подстрока>");
— возвращает позицию или std::string::npos, если не найдено.

Извлечение подстроки:
<имя>.substr(<начало>, <длина>);

Чтение строки из стандартного ввода (включая пробелы):
std::getline(std::cin, <имя>);

Сравнение строк:
if (<строка1> == <строка2>) { /* ... */ }
Поддерживаются все операторы сравнения — ==, !=, <, >, <=, >=.

Код ITЗагрузка примера кода…


Чтение строк из ввода

#include <iostream>
#include <string>

int main() {
std::string input;

std::cout << "Введите строку: ";
std::getline(std::cin, input); // читает всю строку, включая пробелы

std::cout << "Вы ввели: " << input << std::endl;

return 0;
}

Практические примеры — типы данных в действии

Прежде чем перейти к продвинутым механизмам, полезно рассмотреть, как типы данных комбинируются в реальном коде. Ниже приведён фрагмент, иллюстрирующий типичную "плотность" типовой системы C++ в контексте прикладной задачи — описания конфигурации сетевой службы.

Код ITЗагрузка примера кода…

Этот пример демонстрирует несколько ключевых идей:

  • enum class обеспечивает типобезопасность и изоляцию имён.
  • std::vector<Endpoint> заменяет сырой массив, обеспечивая безопасность границ и автоматическое управление памятью.
  • std::chrono::seconds — типизированное представление временного интервала, исключающее путаницу между секундами, миллисекундами и тактовыми циклами.
  • std::optional<size_t> явно выражает "значение может отсутствовать", избегая "магических" значений вроде -1 или 0.
  • mutable позволяет кэшировать вычисления без нарушения логической константности интерфейса.
  • std::move в инициализаторе обеспечивает эффективную передачу владения строками и векторами.
  • Инварианты проверяются в конструкторе — объект создаётся только в корректном состоянии.

Важно — этот код не использует new, delete, голые указатели, C-строки или неявные преобразования. Он опирается на value semantics, RAII и типобезопасные абстракции — именно так строится современный C++.


Пользовательские литералы

Пользовательские литералы — механизм, введённый в C++11, позволяющий расширять синтаксис языка за счёт определения собственных суффиксов для литералов. Это повышает выразительность и снижает вероятность ошибок, связанных с единицами измерения или кодировками.

Синтаксически пользовательский литерал — это операторная функция с именем вида operator"" _suffix. Различают несколько категорий в зависимости от типа входного литерала:

  • Целочисленные литералы: operator"" _km(unsigned long long)
  • Вещественные литералы: operator"" _s(long double)
  • Строковые литералыoperator"" _raw(const char*, size_t) — устаревший, заменён на шаблонную версию в C++20
  • Шаблонные строковые литералы (C++20): template<char...> auto operator"" _sym() — позволяет анализировать строку на этапе компиляции

Пример: определение единиц времени и расстояния.

Код ITЗагрузка примера кода…

Обратите внимание: последняя строка показывает ограничение — пользовательские литералы не создают размерных типов, а лишь удобный способ конструирования. Для настоящей размерной системы (где km / hour даёт km/h) нужны более сложные шаблонные конструкции (например, Boost.Units или mp-units в C++23/C++26). Тем не менее, даже простые литералы резко повышают читаемость:
sleep_for(5000)sleep_for(5_s) — разница между "магическим числом" и выразительным кодом.

Важно — пользовательские литералы должны начинаться с подчёркивания (например, _s, _kg). Имена без подчёркивания зарезервированы за стандартной библиотекой.


Псевдонимы типов — typedef и using

Псевдонимы типов позволяют вводить альтернативные имена для существующих типов. Это упрощает чтение, рефакторинг и обобщённое программирование.

До C++11 использовался только typedef, унаследованный от C. Его синтаксис нелогичен при работе с указателями и шаблонами:

typedef int* IntPtr; // OK
typedef void (*FuncPtr)(); // указатель на функцию — уже сложно
typedef std::map<std::string, std::vector<int>> StringIntMap; // громоздко

C++11 ввёл унифицированный синтаксис через using, который читается как "это есть то":

using IntPtr = int*;
using FuncPtr = void(*)();
using StringIntMap = std::map<std::string, std::vector<int>>;

Преимущества using:

  • единообразие: Alias = Type
  • поддержка шаблонных псевдонимов — невозможна с typedef:
template<typename T>
using Vec = std::vector<T, CustomAllocator<T>>;

Vec<int> v; // эквивалентно std::vector<int, CustomAllocator<int>>
  • совместимость с decltype, auto, constexpr-контекстами.

Шаблонные псевдонимы особенно полезны в метапрограммировании — они позволяют избегать многословных typename T::template Nested<U>::type, заменяя их на лаконичные alias<T, U>.

Пример из стандартной библиотеки — std::byte, введённый в C++17:

using byte = unsigned char;

Этот псевдоним ничего не меняет на уровне машинного кода, но семантически отделяет "байт данных" от "символа", запрещая арифметику (поскольку std::byte имеет свой набор операторов) и повышая безопасность при работе с сырой памятью.


Type Traits — рефлексия на этапе компиляции

Type traits — набор шаблонных классов в заголовке <type_traits>, предоставляющих информацию о свойствах типов на этапе компиляции. Они лежат в основе обобщённого программирования, позволяя писать код, адаптирующийся под возможности типа без его явного знания.

Каждый trait — это шаблон, инстанцируемый типом, и предоставляющий static constexpr члены (обычно value) или вложенные типы (type). Примеры:

  • std::is_integral_v<T>true, если T — целочисленный тип
  • std::is_same_v<T, U> — проверка на идентичность типов
  • std::remove_const_t<T> — убирает const, например, const intint
  • std::decay_t<T> — имитирует "деградацию" при передаче аргумента по значению (массив → указатель, ссылка → тип, функция → указатель на функцию)
  • std::enable_if_t<B, T> — условный тип — если B истинно, даёт T, иначе — ошибка подстановки (используется в SFINAE)

Type traits позволяют реализовывать условную компиляцию без макросов и if на этапе выполнения. Пример: функция, принимающая только арифметические типы:

#include <type_traits>

template<typename T>
auto square(T x) -> std::enable_if_t<std::is_arithmetic_v<T>, T> {
return x * x;
}

Если T — не арифметический тип, подстановка шаблона завершится неудачей, и перегрузка будет отброшена (см. SFINAE ниже).

Современный подход — использовать if constexpr (C++17) внутри шаблонных функций, что делает код чище:

template<typename T>
T process(T value) {
if constexpr (std::is_integral_v<T>) {
return value * 2;
} else if constexpr (std::is_floating_point_v<T>) {
return value * 1.5;
} else {
static_assert(sizeof(T) == 0, "Unsupported type");
}
}

static_assert с sizeof(T) == 0 гарантирует ошибку компиляции для необработанных типов, и сообщение будет понятным.


SFINAE — Substitution Failure Is Not An Error

Исторический приём

В новом коде (C++20+) предпочтительны концепты (см. ниже): понятнее ошибки компиляции и проще сигнатуры. SFINAE и std::enable_if остаются в legacy-библиотеках и при поддержке старых стандартов.

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

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

Классический пример — проверка наличия метода serialize():

Код ITЗагрузка примера кода…

Здесь std::void_t (C++17) — удобная обёртка, превращающая любое корректное выражение в void. Если obj.serialize() не вызываемо, подстановка во вторую специализацию has_serialize провалится, и будет выбрана базовая — false_type.

SFINAE — мощный, но хрупкий инструмент. Ошибки в trait’ах трудно отлаживать (сообщения компилятора многословны), а код быстро становится непрозрачным. Поэтому в C++20 ему на смену пришли концепты.


Концепты (Concepts, C++20)

Концепты — декларативный механизм ограничения шаблонных параметров. Они позволяют явно выразить требования к типу — "этот тип должен быть регулярным", "он должен поддерживать оператор <", "он должен быть контейнером".

Синтаксис:

template<std::integral T>
T add(T a, T b) {
return a + b;
}

или

template<typename T>
requires std::integral<T>
T add(T a, T b) {
return a + b;
}

Стандартная библиотека предоставляет множество концептов:

  • std::integral, std::floating_point — категории арифметических типов
  • std::equality_comparable, std::totally_ordered — требования к операторам сравнения
  • std::copyable, std::movable, std::semiregular, std::regular — модели поведения объектов
  • std::input_iterator, std::random_access_range — для алгоритмов и контейнеров

Пример — функция, работающая с любым диапазоном, элементы которого можно сравнить:

#include <concepts>
#include <ranges>

template<std::ranges::input_range R>
requires std::equality_comparable<std::ranges::range_value_t<R>>
bool contains(const R& range, const std::ranges::range_value_t<R>& value) {
for (const auto& elem : range) {
if (elem == value) return true;
}
return false;
}

Преимущества концептов:

  • понятные ошибки компиляции: вместо "подстановка шаблона не удалась в 23 уровнях вложенности" — "тип MyClass не удовлетворяет концепту std::integral"
  • перегрузка по концептам: несколько шаблонов с разными requires — компилятор выберет наиболее специфичный
  • документирование интерфейса: требования становятся частью сигнатуры

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


Модели памяти и представление типов

Знание того, как типы представлены в памяти, необходимо для написания эффективного, переносимого и безопасного кода — особенно в системном программировании, сериализации, взаимодействии с оборудованием и межъязыковых интерфейсах.


Выравнивание (alignment)

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

Стандарт гарантирует:

  • alignof(char) == 1
  • alignof(T) делит sizeof(T)
  • для агрегатов выравнивание — максимум из выравниваний членов

Пример:

struct A { char c; int i; }; // sizeof(A) == 8 (1 + 3 padding + 4)
struct B { int i; char c; }; // sizeof(B) == 8 (4 + 1 + 3 padding)
struct C { char c1; char c2; }; // sizeof(C) == 2 — без padding

Управление выравниванием:

  • alignas(N) — указывает минимальное выравнивание: alignas(64) char buffer[1024]; для SIMD
  • std::aligned_storage, std::aligned_union — устарели в C++23 в пользу alignas и placement new
  • #pragma pack — компиляторно-зависимый способ уменьшить padding (используется в сериализации, но нарушает стандартное выравнивание — осторожно!)

Strict Aliasing Rule

Правило строгого псевдонима запрещает обращаться к объекту через указатель или ссылку на несовместимый тип. Исключение — char* и unsigned char*, через которые можно читать любой объект (для сериализации и отладки).

Некорректно:

int x = 0x12345678;
float f = *reinterpret_cast<float*>(&x); // нарушение strict aliasing

Корректно:

int x = 0x12345678;
std::memcpy(&f, &x, sizeof(f)); // безопасно — memcpy освобождён от этого правила

Нарушение strict aliasing приводит к неопределённому поведению: компилятор может "не увидеть" изменение значения и оптимизировать код некорректно.


Object Model и жизненный цикл

В C++ объект создаётся инициализацией. Этапы:

  1. Выделение памяти (operator new, стек, статическая область)
  2. Инициализация — вызов конструктора (или агрегатная инициализация) → объект существует
  3. Использование
  4. Разрушение — вызов деструктора
  5. Освобождение памяти (operator delete)

До вызова конструктора и после вызова деструктора память занята, но объекта в ней нет. Попытка вызвать нестатический метод на такой памяти — неопределённое поведение.

Placement new позволяет отделить шаги 1 и 2:

alignas(MyClass) char buffer[sizeof(MyClass)];
MyClass* obj = new (buffer) MyClass(); // конструирование в буфере
obj->~MyClass(); // разрушение вручную
// память не освобождается — buffer на стеке

Этот механизм лежит в основе всех контейнеров (std::vector, std::optional) и аллокаторов.


Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.


Как изучать типы без перегруза

Практичный порядок чтения и практики:

  1. Фундаментальные типы (bool, целые, вещественные).
  2. Квалификаторы (const, volatile, mutable).
  3. Производные типы (указатели, ссылки, массивы, struct/class).
  4. Современные инструменты (optional, variant, type traits, concepts).

После каждого шага полезно писать по 10-20 строк кода и проверять поведение на компиляторе, а не читать теорию "в отрыве".


Частые ловушки в теме типов

ЛовушкаПочему возникаетКак действовать
Смешение int и size_tразные знаковость и диапазоныиспользовать size_t для размеров контейнеров
Сравнение double через ==двоичное представление десятичных дробейсравнивать через эпсилон
Использование char как "числа со знаком"знаковость char платформенно-зависимабрать signed char или unsigned char явно
Ручное управление массивом в кучериск утечек и UBприменять std::vector/std::array

См. также


Кейс из практики — ошибка из-за смешения int и size_t

Ситуация: цикл по std::vector работал локально, но в редком случае зацикливался.

for (int i = vec.size() - 1; i >= 0; --i) { ... }

Проблема: vec.size() возвращает size_t (беззнаковый тип).
При пустом контейнере выражение vec.size() - 1 даёт большое беззнаковое число.

Безопасный вариант:

for (std::size_t i = vec.size(); i > 0; --i) {
auto& item = vec[i - 1];
// ...
}

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

Содержание