C++ позволяет конструировать новые типы данных на основе базовых типов. В этом параграфе мы познакомимся с перечислениями и структурами, а также рассмотрим конструкции std::pair
и std::tuple
из стандартной библиотеки.
Перечисления
Предположим, что мы пишем программу для обработки изображений и хотим работать с цветами. Для каждого цвета заводить отдельную константу не очень удобно. Воспользуемся перечислением — специальным типом данных, который состоит из конечного набора именованных констант:
1enum class Color {
2 White,
3 Red,
4 Orange,
5 Blue,
6};
Мы описали новый тип данных Color
с четырьмя допустимыми значениями. Теперь к каждому цвету можно обращаться через префикс Color::
:
1int main() {
2 Color color1 = Color::Red;
3 Color color2 = Color::Blue;
4}
Фактически перечисления — это удобный способ описывать однотипные именованные константы. По умолчанию перечисления хранятся как тип int
, а их значения последовательно нумеруются с нуля. И тип, и конкретное значение можно поменять.
Преобразовать перечисление в число и обратно можно с помощью оператора static_cast
:
1int value = static_cast<int>(color2); // 3
2Color color3 = static_cast<Color>(2); // Color::Orange
Немного истории.
Раньше в C++ перечисления объявлялись вот так, без слова class
:
1enum Color {
2 White,
3 Red,
4 Orange,
5 Blue,
6};
7
8enum { // можно даже без названия
9 Apple,
10 Orange,
11 Banana
12};
Этот способ остался в языке. Но в таком случае все имена внутри перечислений являются глобальными, и могут происходить конфликты имён (Orange
в примере). Такая программа просто не скомпилируется.
Структуры
Часто хочется собрать «под одной крышей» несколько переменных. В таких случаях можно использовать структуры. Например, давайте опишем структуру точек из трёхмерного пространства:
1struct Point {
2 double x = 0.0;
3 double y = 0.0;
4 double z = 0.0;
5 Color color; // пусть у нас будет цветная точка
6};
В данном случае мы описали новый тип данных — Point
, который содержит в себе четыре переменные.
Давайте поработаем с этой структурой:
1int main() {
2 Point point1; // по умолчанию координаты будут нулевыми, а color никак не будет проинициализирован
3 point1.color = Color::Blue;
4
5 Point point2 = {1.4, -2.2, -3.98, Color::Red};
6 // x = 1.4, y = -2.2, z = -3.98, color = Color::Red
7
8 point2.z = 32; // обращаться к полям можно через точку
9 point2.x += 2; // и вообще работать с ними как с обычными переменными
10}
В С++20 появилась новая форма инициализации структур — designated initializers:
1int main() {
2 Point point3 = {.x = 1.4, .y = -2.2, .z = -3.98};
3 Point point4 = {.color = Color::Orange};
4}
Такой способ записи понятнее: сразу видно, какое поле структуры каким значением инициализируется. Важно, что поля должны быть перечислены в том же порядке, в каком они указываются при описании структуры (причину этого мы узнаем в параграфе про конструкторы и жизненный цикл объекта). Пропущенные поля будут инициализироваться значением по умолчанию. Так, point3.color
будет равно Color::White
— нулевому значению перечисления Color
.
Выравнивание
Теперь давайте поговорим про размеры перечислений и структур:
1int main() {
2 std::cout << sizeof(double) << "\n"; // 8
3 std::cout << sizeof(Color) << "\n"; // 4 (фактически это int)
4 std::cout << sizeof(Point) << "\n"; // 32
5}
Получается, что размер структуры Point
(32 байта) не равен сумме размеров её частей (8 + 8 + 8 + 4 = 28). Всё дело в выравнивании: компилятору не очень удобно работать со структурой в 28 байт при условии, что внутри этой структуры есть переменные, размер которых — 8 байт (так как 28 не кратно 8). Поэтому компилятор резервирует за структурой несколько лишних байтов (в нашем случае — 4).
Можно явно попросить компилятор не выделять мнимых байтов, но в таком случае пострадает скорость — потому что если данные в памяти выровнены, то их легче достать и проще обрабатывать.
Кортежи и пары
В заголовочном файле utility
есть шаблонная структура std::pair
с полями first
и second
. Из названия просто догадаться, что она хранит два объекта:
1#include <iostream>
2#include <utility>
3
4int main() {
5 // в угловых скобках нужно указывать два типа:
6 std::pair<int, double> p = {42, 3.14};
7
8 // обращаться к полям можно через .first и .second:
9 std::cout << p.first << "\n"; // 42
10 std::cout << p.second << "\n"; // 3.14
11}
Однако у std::pair
есть проблема — её поля обезличены, и не очень ясно, какую смысловую нагрузку несёт first
, а какую — second
. Из-за этого мы советуем не злоупотреблять данной структурой, кроме случаев, когда она используется в функциях стандартной библиотеки.
Обобщением пары на несколько переменных является кортеж — std::tuple
, объявленный в заголовочном файле tuple
:
1#include <iostream>
2#include <tuple>
3
4struct Point; // определена выше
5
6int main() {
7 std::tuple<int, double, Point> t = {42, 3.14, {.color = Color::Orange}};
8
9 // тут уже нет полей .first и .second,
10 // но есть стандартная функция std::get<>,
11 // которая принимает в угловых скобках индекс элемента (индексация с нуля):
12 std::cout << std::get<0>(t) << "\n"; // 42
13 std::cout << std::get<1>(t) << "\n"; // 3.14
14 std::cout << std::get<2>(t).x << "\n"; // 0.0
15
16 // вызов std::get может появляться и слева от присваивания:
17 std::get<2>(t).color = Color::Red;
18}
Важно понимать, что типы элементов пары или кортежа, а также размер кортежа фиксируются на этапе компиляции.
Пару, кортеж или структуру можно «распаковать» с помощью structured binding.
1#include <string>
2#include <utility>
3
4int main() {
5 std::pair<std::string, int> p = {"hello", 42};
6 auto [word, freq] = p; // word = "hello"; freq = 42;
7}
Здесь конструкция auto [word, freq] = p
вводит две новые переменные word
и freq
соответствующих типов и присваивает им значения из пары.