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