Язык 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 };
В C++17 появился structured binding, также известный как "декомпозиция при
объявлении". Этот механизм работает со структурами, с std::pair и std::tuple и дополняет агрегатную инициализацию.
// композиция структуры const auto book = BookStats{ u8"Незнайка на Луне", { u8"Николай Носов" }, { u8"детская", u8"фантастика" }, 576, 1965 }; // декомпозиция структуры const auto [title, authors, tags, pagesCount, publishingYear] = book;
В сочетании с классами STL эта фишка может сделать код элегантнее:
#include <string> #include <map> #include <cassert> #include <iostream> int main() { std::map<std::string, int> map = { { "hello", 1 }, { "world", 2 }, { "it's", 3 }, { "me", 4 }, }; // пример №1 - разложение пары [iterator, bool] auto [helloIt, helloInserted] = map.insert_or_assign("hello", 5); auto [goodbyeIt, goodbyeInserted] = map.insert_or_assign("goodbye", 6); assert(helloInserted == false); assert(goodbyeInserted == true); // пример №2 - разложение пары [key, value] for (auto&& [ key, value ] : map) std::cout << "key=" << key << " value=" << value << '\n'; }
Зачем нужен тип 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>;
Правила хорошего стиля
Подведём итоги в виде списка правил:
- C.1: организуйте логически связанные данные в структуры или классы
- C.2: используйте class если данные связаны инвариантом; используйте struct если данные могут изменяться независимо
- используйте декомпозицию при объявлении переменных со структурами,
std::pairиstd::tuple:auto [a, b, c] = std::tuple(32, "hello"s, 13.9)
- вместо out-параметров возвращайте из функции структуру или кортеж
- указывайте инициализаторы полей, без них вы получите неинициализированные поля с мусором
- не инициализируйте поля нулями в конструкторах, полагайтесь на инициализаторы полей
- в общем случае не пишите конструкторы структур, используйте агрегатную инициализацию
- используйте
std::variantкак безопасную замену union вместо структуры или класса, если данные находятся строго в одном из нескольких состояний, и в некоторых состояниях некоторые поля теряют смысл - используйте
enum classилиstd::variantдля представления внутреннего состояния объектов
- предпочитайте
std::variant, если в разных состояниях класс способен хранить разные поля данных
- предпочитайте
- используйте
enum classвместоenumв большинстве случаев
- используйте старый
enumесли вам крайне важна неявная конвертация enum в целое число - используйте
enum classилиenumвместо магических чисел - используйте
enum class,enumилиconstexprвместо макросов-констант
- используйте старый
Из таких мелочей строится красота и лаконичность кода в телах функций. Лаконичные функции легко рецензировать на Code Review и легко сопровождать. Из них строятся хорошие классы, а затем и хорошие программные модули. В итоге программисты становятся счастливыми, на их лицах расцветают улыбки.
