Типы struct, union и enum в Modern C++

в 12:17, , рубрики: c++, c++17

Язык C++ сильно изменился за последние 10 лет. Изменились даже базовые типы: struct, union и enum. Сегодня мы кратко пройдёмся по всем изменениям от C++11 до C++17, заглянем в C++20 и в конце составим список правил хорошего стиля.

Зачем нужен тип struct

Тип struct — фундаментальный. Согласно C++ Code Guidelines, struct лучше использовать для хранения значений, не связанных инвариантом. Яркие примеры — RGBA-цвет, вектора из 2, 3, 4 элементов или информация о книге (название, количество страниц, автор, год издания и т.п.).

Правило C.2: Use class if the class has an invariant; use struct if the data members can vary independently

struct BookStats
{
    std::string title;
    std::vector<std::string> authors;
    std::vector<std::string> tags;
    unsigned pageCount = 0;
    unsigned publishingYear = 0;
};

Он похож на class, но есть два мелких различия:

  • по умолчанию в struct действует видимость public, а в class — private
  • по умолчанию struct наследует члены базовых структур/классов как публичные члены, а class — как приватные члены

// поле data публичное
struct Base
{
    std::string data;
};

// Base унаследован так, как будто бы написано `: public Base`
struct Derived : Base
{
};

Согласно C++ Core Guidelines, struct хорошо применять для сокращения числа параметров функции. Этот приём рефакторинга известен как "parameter object".

Правило C.1: Organize related data into structures (structs or classes)

Кроме того, структуры могут сделать код более лаконичным. Например, в 2D и 3D графике удобнее считать в 2-х и 3-х компонентных векторах, чем в числах. Ниже показан код, использующий библиотеку GLM (OpenGL Mathematics)

// Преобразует полярные координаты в декартовы
// См. https://en.wikipedia.org/wiki/Polar_coordinate_system
glm::vec2 euclidean(float radius, float angle)
{
    return { radius * cos(angle), radius * sin(angle) };
}

// Функция делит круг на треугольники,
//  возвращает массив с вершинами треугольников.
std::vector<VertexP2C4> TesselateCircle(float radius, const glm::vec2& center, IColorGenerator& colorGen)
{
    assert(radius > 0);

    // Круг аппроксимируется с помощью треугольников.
    // Внешняя сторона каждого треугольника имеет длину 2.
    constexpr float step = 2;
    // Число треугольников равно длине окружности, делённой на шаг по окружности.
    const auto pointCount = static_cast<unsigned>(radius * 2 * M_PI / step);

    // Вычисляем точки-разделители на окружности.
    std::vector<glm::vec2> points(pointCount);
    for (unsigned pi = 0; pi < pointCount; ++pi)
    {
        const auto angleRadians = static_cast<float>(2.f * M_PI * pi / pointCount);
        points[pi] = center + euclidean(radius, angleRadians);
    }

    return TesselateConvexByCenter(center, points, colorGen);
}

Эволюция struct

В C++11 появилась инициализация полей при объявлении.

struct BookStats
{
    std::string title;
    std::vector<std::string> authors;
    std::vector<std::string> tags;
    unsigned pageCount = 0;
    unsigned publishingYear = 0;
};

Ранее для таких целей приходилось писать свой конструктор:

// ! устаревший стиль !
struct BookStats
{
    BookStats() : pageCount(0), publishingYear(0) {}

    std::string title;
    std::vector<std::string> authors;
    std::vector<std::string> tags;
    unsigned pageCount;
    unsigned publishingYear;
};

Вместе с инициализацией при объявлении пришла проблема: мы не можем использовать литерал структуры, если она использует инициализацию полей при объявлении:

// C++11, C++14: будет ошибка компиляции из-за инициализаторов pageCount и publishingYear
// C++17: компиляция проходит
const auto book = BookStats{
    u8"Незнайка на Луне",
    { u8"Николай Носов" },
    { u8"детская", u8"фантастика" },
    576,
    1965
};

В C++11 и C++14 это решалось вручную написанием конструктора с boilerplate кодом. В C++17 ничего дописывать не надо — стандарт явно разрешает литеральную инициализацию для структур с инициализаторами полей.

В примере написаны конструкторы, необходимые только в C++11 и C++14:

struct BookStats
{
    // ! устаревший стиль!
    BookStats() = default;

    // ! устаревший стиль!
    BookStats(
        std::string title,
        std::vector<std::string> authors,
        std::vector<std::string> tags,
        unsigned pageCount,
        unsigned publishingYear)
        : title(std::move(title))
        , authors(std::move(authors))
        , tags(std::move(authors)) // ;)
        , pageCount(pageCount)
        , publishingYear(publishingYear)
    {
    }

    std::string title;
    std::vector<std::string> authors;
    std::vector<std::string> tags;
    unsigned pageCount = 0;
    unsigned publishingYear = 0;
};

В C++20 литеральная инициализация обещает стать ещё лучше! Чтобы понять проблему, взгляните на пример ниже и назовите каждое из пяти инициализируемых полей. Не перепутан ли порядок инициализации? Что если кто-то в ходе рефакторинга поменяет местами поля в объявлении структуры?

const auto book = BookStats{
    u8"Незнайка на Луне",
    { u8"Николай Носов" },
    { u8"детская", u8"фантастика" },
    1965,
    576
};

В C11 появилась удобная возможность указать имена полей при инициализации структуры. Эту возможность обещают включить в C++20 под названием "назначенный инициализатор" ("designated initializer"). Подробнее об этом в статье Дорога к С++20.

// Должно скомпилироваться в C++20
const auto book = BookStats{
    .title = u8"Незнайка на Луне",
    .authors = { u8"Николай Носов" },
    .tags = { u8"детская", u8"фантастика" },
    .publishingYear = 1965,
    .pageCount = 576
};

Зачем нужен тип union

Вообще-то в C++17 он не нужен в повседневном коде. C++ Core Guidelines предлагают строить код по принципу статической типобезопасности, что позволяет компилятору выдать ошибку при откровенно некорректной обработке данных. Используйте std::variant как безопасную замену union.

Если же вспоминать историю, union позволяет переиспользовать одну и ту же область памяти для хранения разных полей данных. Тип union часто используют в мультимедийных библиотеках. В них разыгрывается вторая фишка union: идентификаторы полей анонимного union попадают во внешнюю область видимости.

// ! этот код ужасно устрарел !
// Event имет три поля: type, mouse, keyboard
// Поля mouse и keyboard лежат в одной области памяти
struct Event
{
    enum EventType {
        MOUSE_PRESS,
        MOUSE_RELEASE,
        KEYBOARD_PRESS,
        KEYBOARD_RELEASE,
    };

    struct MouseEvent {
        unsigned x;
        unsigned y;
    };

    struct KeyboardEvent {
        unsigned scancode;
        unsigned virtualKey;
    };

    EventType type;
    union {
        MouseEvent mouse;
        KeyboardEvent keyboard;
    };
};

Эволюция union

В C++11 вы можете складывать в union типы данных, имеющие собственные конструкторы. Вы можете объявить свой констуктор union. Однако, наличие конструктора ещё не означает корректную инициализацию: в примере ниже поле типа std::string забито нулями и вполне может быть невалидным сразу после конструирования union (на деле это зависит от реализации STL).

// ! этот код ужасно устрарел !
union U
{
   unsigned a = 0;
   std::string b;

   U() { std::memset(this, 0, sizeof(U)); }
};

// нельзя так писать - поле b может не являться корректной пустой строкой
U u;
u.b = "my value";

В C++17 код мог бы выглядеть иначе, используя variant. Внутри variant использует небезопасные конструкции, которые мало чем отличаются от union, но этот опасный код скрыт внутри сверхнадёжной, хорошо отлаженной и протестированной STL.

#include <variant>

struct MouseEvent {
    unsigned x = 0;
    unsigned y = 0;
};

struct KeyboardEvent {
    unsigned scancode = 0;
    unsigned virtualKey = 0;
};

using Event = std::variant<
    MouseEvent,
    KeyboardEvent>;

Зачем нужен тип enum

Тип enum хорошо использовать везде, где есть состояния. Увы, многие программисты не видят состояний в логике программы и не догадываются применить enum.

Ниже пример кода, где вместо enum используют логически связанные булевы поля. Как думаете, будет ли класс работать корректно, если m_threadShutdown окажется равным true, а m_threadInitialized — false?

// ! плохой стиль !
class ThreadWorker
{
public:
    // ...

private:
    bool m_threadInitialized = false;
    bool m_threadShutdown = false;
};

Мало того что здесь не используется atomic, который скорее всего нужен в классе с названием Thread*, но и булевы поля можно заменить на enum.

class ThreadWorker
{
public:
    // ...

private:
    enum class State
    {
        NotStarted,
        Working,
        Shutdown
    };

    // С макросом ATOMIC_VAR_INIT вы корректно проинициализируете atomic на всех платформах.
    // Менять состояние надо через compare_and_exchange_strong!
    std::atomic<State> = ATOMIC_VAR_INIT(State::NotStarted);
};

Другой пример — магические числа, без которых якобы никак. Пусть у вас есть галерея 4 слайдов, и программист решил захардкодить генерацию контента этих слайдов, чтобы не писать свой фреймворк для галерей слайдов. Появился такой код:

// ! плохой стиль !
void FillSlide(unsigned slideNo)
{
    switch (slideNo)
    {
        case 1:
            setTitle("...");
            setPictureAt(...);
            setTextAt(...);
            break;
        case 2:
            setTitle("...");
            setPictureAt(...);
            setTextAt(...);
            break;
        // ...
    }
}

Даже если хардкод слайдов оправдан, ничто не может оправдать магические числа. Их легко заменить на enum, и это по крайней мере повысит читаемость.

enum SlideId
{
    Slide1 = 1,
    Slide2,
    Slide3,
    Slide4
};

Иногда enum используют как набор флагов. Это порождает не очень наглядный код:

// ! этот код - сомнительный !
enum TextFormatFlags
{
    TFO_ALIGN_CENTER = 1 << 0,
    TFO_ITALIC = 1 << 1,
    TFO_BOLD = 1 << 2,
};

unsigned flags = TFO_ALIGN_CENTER;
if (useBold)
{
    flags = flags | TFO_BOLD;
}
if (alignLeft)
{
    flag = flags & ~TFO_ALIGN_CENTER;
}
const bool isBoldCentered = (flags & TFO_BOLD) && (flags & TFO_ALIGN_CENTER);

Возможно, вам лучше использовать std::bitset:

enum TextFormatBit
{
    TextFormatAlignCenter = 0,
    TextFormatItalic,
    TextFormatBold,

    // Значение последней константы равно числу элементов,
    //  поскольку первый элемент равен 0, и без явно
    //  указанного значения константа на 1 больше предыдущей.
    TextFormatCount
};

std::bitset<TextFormatCount> flags;
flags.set(TextFormatAlignCenter, true);
if (useBold)
{
    flags.set(TextFormatBold, true);
}
if (alignLeft)
{
    flags.set(TextFormatAlignCenter, false);
}
const bool isBoldCentered = flags.test(TextFormatBold) || flags.test(TextFormatAlignCenter);

Иногда программисты записывают константы в виде макросов. Такие макросы легко заменить на enum или constexpr.

Правило Enum.1: предпочитайте макросам перечислимые типы

// ! плохой стиль - даже в C99 этого уже не требуется !
#define RED   0xFF0000
#define GREEN 0x00FF00
#define BLUE  0x0000FF
#define CYAN  0x00FFFF

// стиль, совместимый с C99, но имена констант слишком короткие
enum ColorId : unsigned
{
    RED = 0xFF0000,
    GREEN = 0x00FF00,
    BLUE = 0x0000FF,
    CYAN = 0x00FFFF,
};

// стиль Modern C++
enum class WebColorRGB
{
    Red = 0xFF0000,
    Green = 0x00FF00,
    Blue = 0x0000FF,
    Cyan = 0x00FFFF,
};

Эволюция enum

В С++11 появился scoped enum, он же enum class или enum struct. Такая модификация enum решает две проблемы:

  • область видимости констант enum class — это сам enum class, т.е. снаружи вместо Enum e = EnumValue1 вам придётся писать Enum e = Enum::Value1, что гораздо нагляднее
  • enum конвертируется в целое число без ограничений, а в enum class для этого потребуется static cast: const auto value = static_cast<unsigned>(Enum::Value1)

Кроме того, для enum и scoped enum появилась возможность явно выбрать тип, используемый для представления перечисления в сгенерированном компилятором коде:

enum class Flags : unsigned
{
    // ...
};

В некоторых новых языках, таких как Swift или Rust, тип enum по умолчанию является строгим в преобразованиях типов, а константы вложены в область видимости типа enum. Кроме того, поля enum могут нести дополнительные данные, как в примере ниже

// enum в языке Swift
enum Barcode {
    // вместе с константой upc хранятся 4 поля типа Int
    case upc(Int, Int, Int, Int)
    // вместе с константой qrCode хранится поле типа String
    case qrCode(String)
}

Такой enum эквивалентен типу std::variant, вошедшему в C++ в стандарте C++ 2017. Таким образом, std::variant заменяет enum в поле структуры и класса, если этот enum по сути обозначает состояние. Вы получаете гарантированное соблюдение инварианта хранимых данных без дополнительных усилий и проверок. Пример:

struct AnonymousAccount
{
};

struct UserAccount
{
    std::string nickname;
    std::string email;
    std::string password;
};

struct OAuthAccount
{
    std::string nickname;
    std::string openId;
};

using Account = std::variant<AnonymousAccount, UserAccount, OAuthAccount>;

Правила хорошего стиля

Подведём итоги в виде списка правил:

Из таких мелочей строится красота и лаконичность кода в телах функций. Лаконичные функции легко рецензировать на Code Review и легко сопровождать. Из них строятся хорошие классы, а затем и хорошие программные модули. В итоге программисты становятся счастливыми, на их лицах расцветают улыбки.

Автор: Сергей Шамбир

Источник

Поделиться

* - обязательные к заполнению поля