Язык 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.
// ! плохой стиль - даже в 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::variant
как безопасную замену union вместо структуры или класса, если данные находятся строго в одном из нескольких состояний, и в некоторых состояниях некоторые поля теряют смысл - используйте
enum class
илиstd::variant
для представления внутреннего состояния объектов- предпочитайте
std::variant
, если в разных состояниях класс способен хранить разные поля данных
- предпочитайте
- используйте
enum class
вместоenum
в большинстве случаев- используйте старый
enum
если вам крайне важна неявная конвертация enum в целое число - используйте
enum class
илиenum
вместо магических чисел - используйте
enum class
,enum
илиconstexpr
вместо макросов-констант
- используйте старый
Из таких мелочей строится красота и лаконичность кода в телах функций. Лаконичные функции легко рецензировать на Code Review и легко сопровождать. Из них строятся хорошие классы, а затем и хорошие программные модули. В итоге программисты становятся счастливыми, на их лицах расцветают улыбки.
Автор: Сергей Шамбир