2.5. Составные типы данных

C++ позволяет конструировать новые типы данных на основе базовых типов. В этом параграфе мы познакомимся с перечислениями и структурами, а также рассмотрим конструкции std::pair и std::tuple из стандартной библиотеки.

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).

C

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

Кортежи и пары

В заголовочном файле 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 соответствующих типов и присваивает им значения из пары.

Отмечайте параграфы как прочитанные чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф2.4. Векторы и строки

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

Следующий параграф2.6. Ссылки, указатели, константность

Ссылки — это псевдонимы для переменных. Указатели хранят адреса других переменных в памяти. Ключевое слово const подчеркивает, что переменная используется только для чтения. Часто оно используется совместно с объявлением ссылок и указателей.