В C/C++ есть возможность создавать составные пользовательские типы данных - struct
(структура). Структура позволяет объединить под одним именем несколько переменных любых типов, в том числе вложенных структур и массивов. Объявить структуру можно где угодно: глобально, внутри функции или внутри класса. Синтаксис такой:
struct имя_типа {
тип_данных переменная;
// ...
};
Переменная внутри структуры называется членом структуры (member), иногда также называют свойством (property) структуры
Такая запись создаёт именно тип данных - переменные не создаются, память не выделяется. Это - просто каркас, по нему уже можно создать экземпляр структуры по правилам создания переменных:
имя_типа имя_экземпляра;
Обратиться ко вложенным членам структуры можно при помощи оператора точка .
:
имя_экземпляра.переменная;
Пример:
struct MyStruct {
int a;
float f;
long arr[5];
};
MyStruct s;
s.a = 123;
s.f = 3.14;
s.arr[0] = 12345;
Сразу создать экземпляр #
Можно создать экземпляр сразу после объявления структуры, указав имя перед ;
структуры:
struct Struct {
int a;
} myStruct;
myStruct.a = 123;
Анонимная структура #
Синтаксис позволяет создавать некоторые сущности (структуры, перечисления, объединения) анонимно, то есть без названия имени типа - сразу сделать переменную: struct {} имя_переменной
. Пример:
struct {
int a;
} myStruct;
myStruct.a = 123;
Инициализация #
В примере выше члены структуры не инициализированы и будут вести себя как переменные: при глобальном создании экземпляра переменные будут автоматически инициализированы стандартными значениями. При создании локально - нет.
Члены структуры можно инициализировать вручную при объявлении - они будут соответственно инициализированы при создании экземпляра, и глобально и локально:
struct MyStruct {
int a = 0;
float f = 3.14;
long arr[5] = {0};
};
MyStruct s; // инициализированы
int main() {
MyStruct s2; // инициализированы
}
Если члены структуры не инициализированы вручную при объявлении - то их можно инициализировать во время создания экземпляра при помощи синтаксиса, похожего на инициализацию массивов.
struct MyStruct {
int a;
float f;
long arr[5];
};
MyStruct s = {}; // инициализировать нулями
Также можно задать конкретные значения - они указываются в фигурных скобках по порядку членов в структуре. Если там есть массив - он тоже попадает под инициализацию. Все члены инициализировать не обязательно, главное - порядок:
MyStruct s = {12, 3.14, 1, 2};
// s.a == 12
// s.f == 3.14
// arr == {1, 2, 0, 0, 0}
MyStruct s2 = {12};
Можно встретить и другой синтаксис, он тоже корректный:
struct Color {
uint8_t r, g, b;
};
Color color{1, 2, 3};
Color color = {1, 2, 3};
Color color = (Color){1, 2, 3};
Color color = {.r = 1, .g = 2, .b = 3};
Color color = {r: 1, g: 2, b: 3};
Примечание: последние два примера инициализации выше называются designated initializers. В языке C каких-то версий возможно указывать члены в произвольном порядке. В C++ - нельзя, члены должны идти именно в том порядке, в котором они располагаются в структуре. Такая запись просто повышает читаемость программы, даже без подсказок IDE
Присвоение #
Один экземпляр структуры можно присвоить к другому - все члены перепишутся, как это и ожидается:
MyStruct s;
MyStruct s2 = s;
Уже существующий экземпляр можно пересоздать полностью с новыми значениями следующим образом:
MyStruct s;
s = (MyStruct){12, 3.14};
Сравнение #
Структура - составной пользовательский тип данных, он не поддерживает сравнение и прочие похожие операции. В C++ эту поддержку можно дописать самостоятельно, но об этом - в уроке про классы.
MyStruct s1, s2;
s1 == s2; // ошибка
Указатель на структуру #
Как и на любой другой тип данных, на структуру можно сослаться по указателю или ссылке. Если доступ по ссылке в синтаксисе ничего не меняет, то у доступа к члену по указателю на экземпляр вместо точки используется оператор косвенного обращения - ->
:
MyStruct s;
// ссылка
MyStruct& ref = s;
ref.a = 123;
// указатель
MyStruct* ptr = &s;
ptr->a = 123;
(*ptr).a = 456; // либо так
Передача в функцию #
Структуру можно передать в функцию точно так же, как данные любого другого типа. Тут важно вспомнить такой момент, как передача по значению, по ссылке и по указателю:
// по значению
void byVal(MyStruct s) {
// здесь s - независимая локальная копия структуры
}
// по ссылке
void byRef(MyStruct& s) {
// здесь s - "зеркальное" отражение структуры
}
// по указателю
void byPtr(MyStruct* s) {
// здесь s - "зеркальное" отражение структуры
}
MyStruct s;
byVal(s);
byRef(s);
byPtr(&s);
При передаче структуры по значению создаётся независимая копия, выделяется память под полный размер структуры. Все действия, проделанные с ней, не отражаются на оригинальной структуре. При передаче по ссылке или указателю используется меньше памяти, программа работает быстрее, функция имеет полный доступ к оригинальному экземпляру.
Вложенная структура #
Синтаксис вложенных структур весьма логичный:
struct Color {
uint8_t r, g, b;
};
struct Foo {
int a;
Color col;
};
Foo foo;
foo.a = 123;
foo.col.r = 255;
Возврат из функции #
Структуру можно вернуть из функции:
MyStruct foo(int var) {
MyStruct s;
s.a = var;
return s;
}
MyStruct s = foo(123);
Массив структур #
Можно создать массив из данных любого типа, в том числе структур:
struct Color {
uint8_t r, g, b;
};
Color colors[5];
colors[0].r = 123;
Инициализировать также можно:
Color colors[5] = {
{1, 2, 3},
{255, 255, 0},
};
colors[0].r = 123;
Выравнивание #
Пара слов о том, как экземпляр структуры хранится в памяти: члены лежат в памяти друг за другом в том порядке, в котором указаны в структуре. Но не всё так просто: имеет место быть битовое выравнивание - переменные выравниваются в памяти так, чтобы переменная не пересекала границу выравнивания, это нужно для оптимизации скорости доступа к данным. Выравнивание зависит от архитектуры и может быть например 2 байта (AVR), 4 байта (esp8266, esp32). Дальше будет понятно на примере: представим, что первая переменная будет храниться по адресу 0:
// выравнивание 2 байта
MyStruct {
uint8_t v1; // 0
uint8_t v2; // 1
uint16_t v3; // 2
uint8_t v4; // 4
uint16_t v5; // 6 - вот тут пропустили байт!
};
// выравнивание 4 байта
MyStruct {
uint16_t v0; // 0
uint32_t v1; // 4 - пропустили 2 байта
uint8_t v2; // 8
uint32_t v3; // 12 - пропустили 3 байта
};
Это к тому, что структура занимает в памяти не столько места, сколько кажется на первый взгляд при простом сложении весов членов. Измерить вес структуры всегда можно при помощи sizeof(имя)
. Понимая, как работает выравнивание, можно составить структуру так, чтобы она занимала меньше места в памяти.
Также также это означает, что на разных архитектурах данные могут храниться в памяти по разному, и при передаче структуры между устройствами в "сыром" виде данные могут записаться некорректно.
Упаковка #
Для автоматического решения этой проблемы можно дать команду компилятору, чтобы он принудительно упаковал структуру без пропусков. Она может работать чуть то медленнее, но будет занимать меньше места и будет корректно передаваться между архитектурами с разным выравниванием. Есть два варианта:
struct __attribute__((packed)) Foo1 {
char a;
long b;
char c;
};
#pragma pack(push, 1)
struct Foo2 {
char a;
long b;
char c;
};
#pragma pack(pop)
Обе структуры занимают в памяти 6 байт, несмотря на выравнивание.
Битовые поля #
В языке есть ещё один очень полезный инструмент, позволяющий по сути создавать переменные с произвольным размером с шагом в 1 бит, а не байт - битовые поля (bit fields). Битовые поля работают только в структурах и классах, синтаксис такой: тип_данных имя : количество_бит;
. Переменные с битовыми полями нужно "упаковывать" в стандартные целочисленные типы данных вручную.
- Только стандартные целочисленные типы данных могут быть битовыми полями:
struct Fields {
// Разделим байт на 5+3 бит переменные
uint8_t b1 : 5;// 5 бит (0.. 32)
uint8_t b2 : 3;// 3 бита (0.. 7)
};
// используется как обычно
Fields f;
f.b1 = 10;
f.b2 = 5;
- Порядок расположения в памяти - от младшего к старшему:
struct Fields {
uint8_t b1 : 5; // биты 0-4
uint8_t b2 : 3; // биты 5-7
};
- Указанный тип данных должен идти по порядку для всех полей, прерывание типа изменит выравнивание:
struct Fields {
uint16_t b1 : 10; // 10 бит (0.. 1023)
// оставшиеся свободные 6 бит "пропадут" - мы начали новый тип полей
uint8_t b2 : 6; // 6 бита (0.. 63)
//uint16_t b2 : 6; // надо было так
};
- Поле нулевого размера заставляет перейти к следующей границе выравнивания:
struct Fields {
uint16_t b1 : 10; // 10 бит (0.. 1023)
uint16_t : 0; // завершить текущее выравнивание
uint16_t b2 : 10; // 10 бит (0.. 1023)
};
- Безымянные поля могут использоваться как "заполнители", чтобы подвинуть следующее поле на несколько бит:
struct Fields {
uint16_t b1 : 10;
uint16_t : 4; // пропуск 4 битов
uint16_t b2 : 2;
};
- Поля с шириной 1 бит должны объявляться как
unsigned
:
struct Fields {
uint8_t b1 : 1;
uint8_t b2 : 1;
};
- Чтение и запись в переменную с битовым полем осуществляется с использованием некоторого дополнительного кода, который генерируется компилятором. Поэтому эти операции выполняются медленнее, чем с обычной переменной. Также у переменной с битовом полем нельзя взять адрес или создать на неё ссылку
Использование битовых полей позволяет экономить место, например 16 однобитных флагов займут 2 байта вместо обычных bool
- 16 байт. Структура с битовыми полями позволяет разбирать сложный протокол связи, превращая некий поток данных в именованные переменные. Но за удобство приходится платить - битовые поля работают сильно медленнее как целых переменных, так и ручной упаковки битов в байты при помощи сдвигов и прочей битовой математики. В случаях максимальной оптимизации скорости работы алгоритма на медленных процессорах лучше не использовать битовые поля.
Битовые поля медленнее на чтение и запись, чем ручная упаковка при помощи сдвигов и прочих битовых операций. В алгоритмах с высокими требованиями по производительности их лучше не использовать, или как минимум протестировать скорость выполнения