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

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

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

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

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

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

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


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

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

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

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

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

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

Тип char служит для представления отдельного символа. По историческим причинам и для совместимости с C, char имеет размер ровно 1 байт — это не означает, что 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 при явной необходимости работы с конкретной кодировкой.

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

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

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

Точный размер этих типов не фиксирован стандартом и зависит от реализации. Например, на большинстве современных 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, если не требуется особая ёмкость или битовая точность — он наиболее эффективен и интуитивно понятен.

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

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

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

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

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

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

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

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

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

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


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

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

signed и unsigned

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

const

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

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

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

volatile

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

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, поскольку они служат исключительно для реализации, а не для хранения семантически значимого состояния.


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

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

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

Указатели

Указатель — одна из самых фундаментальных и мощных абстракций 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).

Ссылки

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

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

  • 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 = /* что угодно */; — универсальная ссылка, способная привязаться к любому выражению, продлевая время жизни временного объекта

Массивы

Массив — это упорядоченная последовательность элементов одного типа, размещённая в смежных ячейках памяти. Объявление вида 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)

Перечисление — это пользовательский тип, представляющий набор именованных констант. Классический 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)

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

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

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

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

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

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

Структуры (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.

Современный 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++ в контексте прикладной задачи — описания конфигурации сетевой службы.

#include <string>
#include <vector>
#include <chrono>
#include <optional>
#include <cstdint>

// Строгое перечисление — безопасное именование режимов
enum class Protocol : uint8_t {
HTTP,
HTTPS,
WebSocket
};

// Агрегатная структура: данные без логики, инициализируется списком
struct Endpoint {
std::string host;
uint16_t port;
Protocol proto;
};

// Класс с инкапсуляцией и управлением состоянием
class ServiceConfig {
std::string name_;
std::vector<Endpoint> endpoints_;
std::chrono::seconds timeout_;
mutable std::optional<size_t> hash_cache_; // кэш хеша — изменяем в const-методах

public:
// Конструктор с проверкой инварианта
ServiceConfig(
std::string name,
std::vector<Endpoint> endpoints,
int timeout_sec
)
: name_(std::move(name))
, endpoints_(std::move(endpoints))
, timeout_(std::chrono::seconds{timeout_sec})
{
if (name_.empty()) throw std::invalid_argument("name must not be empty");
if (endpoints_.empty()) throw std::invalid_argument("at least one endpoint required");
if (timeout_sec <= 0) throw std::invalid_argument("timeout must be positive");
}

// Доступ только для чтения через константные ссылки
const std::string& name() const { return name_; }
const std::vector<Endpoint>& endpoints() const { return endpoints_; }
std::chrono::seconds timeout() const { return timeout_; }

// Хеширование с кэшированием — mutable позволяет модифицировать cache в const-контексте
size_t hash() const {
if (!hash_cache_) {
size_t h = std::hash<std::string>{}(name_);
for (const auto& ep : endpoints_) {
// Простой комбинированный хеш
h ^= std::hash<std::string>{}(ep.host) + 0x9e3779b9
^ (ep.port << 16)
^ static_cast<size_t>(ep.proto);
}
hash_cache_ = h;
}
return *hash_cache_;
}

// Оператор равенства — значение-ориентированное сравнение
bool operator==(const ServiceConfig& other) const {
return name_ == other.name_
&& endpoints_ == other.endpoints_
&& timeout_ == other.timeout_;
}
};

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

  • 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() — позволяет анализировать строку на этапе компиляции

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

#include <chrono>

// 5_min → std::chrono::minutes{5}
constexpr std::chrono::minutes operator"" _min(unsigned long long m) {
return std::chrono::minutes{m};
}

// 3.5_s → std::chrono::duration<long double, std::ratio<1>>{3.5}
constexpr auto operator"" _s(long double s) {
return std::chrono::duration<long double>{s};
}

// 100_km → целое количество метров (для вычислений без плавающей точки)
constexpr long long operator"" _km(unsigned long long km) {
return km * 1000;
}

// Использование:
auto delay = 2_min + 0.5_s; // std::chrono::duration<double>
auto distance = 5_km + 300; // 5300 (метров)
auto flight_time = distance / 250.0; // без единиц — ошибка дизайна!

Обратите внимание: последняя строка показывает ограничение — пользовательские литералы не создают размерных типов, а лишь удобный способ конструирования. Для настоящей размерной системы (где 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

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

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

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

// Вспомогательный trait: имеет ли T метод void serialize() const?
template<typename T, typename = void>
struct has_serialize : std::false_type {};

template<typename T>
struct has_serialize<T, std::void_t<decltype(std::declval<const T&>().serialize())>>
: std::true_type {};

// Перегрузки:
template<typename T>
std::enable_if_t<has_serialize_v<T>> save(const T& obj) {
obj.serialize(); // безопасно — перегрузка доступна только если serialize существует
}

template<typename T>
std::enable_if_t<!has_serialize_v<T>> save(const T& obj) {
// fallback: например, бинарная запись
write_bytes(reinterpret_cast<const char*>(&obj), sizeof(obj));
}

Здесь 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) и аллокаторов.