View Categories

Структуры

В 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 байт. Структура с битовыми полями позволяет разбирать сложный протокол связи, превращая некий поток данных в именованные переменные. Но за удобство приходится платить - битовые поля работают сильно медленнее как целых переменных, так и ручной упаковки битов в байты при помощи сдвигов и прочей битовой математики. В случаях максимальной оптимизации скорости работы алгоритма на медленных процессорах лучше не использовать битовые поля.

Битовые поля медленнее на чтение и запись, чем ручная упаковка при помощи сдвигов и прочих битовых операций. В алгоритмах с высокими требованиями по производительности их лучше не использовать, или как минимум протестировать скорость выполнения

0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх