Объединения (union). Битовые поля

Практический курс по C/C++: https://stepik.org/course/193691

На этом занятии речь пойдет об объединениях. Это еще один составной тип данных, синтаксически похожий на структуру и определяется следующим образом:

union [имя объединения] {
    [поля]
};

Например, в программе мы его можем объявить так:

union tag_var {
    char var_ch;
    int var_i;
    double var_d;
};

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

Но в этом случае значение одной переменной будет затирать значения двух других? Совершенно верно! Объединения не предназначены для одновременного хранения всех трех полей. Эту роль играют структуры. Роль объединения хранения только одного, последнего записанного значения. Тогда зачем все это надо? Давайте представим, что нам в программе потребовалось создать универсальную переменную, которая могла бы хранить или байтовые значения, или целочисленные, или вещественные. То есть, какое-то одно в один момент времени. Очевидно, для этого напрашивается использование объединения, а не структуры, так как объединение занимает меньше памяти: по размеру наибольшего из полей. Например, размер объединения tag_var составит 8 байт – размер типа double. А структура бы занимала, как минимум:

1 + 4 + 8 = 13 байт

Выигрыш в 5 байт. Но если бы были массивы из таких переменных, то экономия памяти была бы очевидной.

Далее, в программе в функции main() можно создать переменную на объединение следующим образом:

int main(void) 
{
    union tag_var var;
    return 0;
}

и записать туда какое-либо значение, например:

var.var_ch = 'C';

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

var.var_i = 45;

то значение переменной var_ch затрется и станет другим.

А теперь давайте передадим объединение некоторой функции, которая бы отображала последнее сохраненное значение. Запишем ее следующим образом:

void show_var(union tag_var v)
{
}

И дальше возникает вопрос: какое поле отображать в объединении v? Как функции show_var узнать, в какую переменную было записано последнее значение? В действительности, никак. Поэтому нам нужна еще одна переменная, которая бы обозначала номер актуального поля с данными. Для этого определим следующее перечисление:

typedef enum {
    union_var_none, union_var_ch, union_var_i, union_var_d
} TYPE_VAR;

И допишем функцию со вторым параметром этого типа:

void show_var(union tag_var v, TYPE_VAR type)
{
    switch(type) {
    case union_var_ch:
        printf("var_ch = %c\n", v.var_ch);
        break;
    case union_var_i:
        printf("var_i = %d\n", v.var_i);
        break;
    case union_var_d:
        printf("var_d = %.2f\n", v.var_d);
        break;
    case union_var_none:
        puts("Undefined type var");
        break;
    }
}

Воспользоваться ей можно следующим образом:

int main(void) 
{
    union tag_var var;
 
    var.var_ch = 'C';
    var.var_i = 45;
 
    show_var(var, union_var_i);
    
    return 0;
}

То есть, для отображения текущего значения из объединения нам явно нужно указать тип поля, в котором хранится актуальная информация.

Конечно, было бы правильно улучшить эту программу и объявить структуру, которая бы содержала в себе объединение и переменную типа TYPE_VAR, например, так:

typedef struct {
    union tag_var var;
    TYPE_VAR type;
} VAR;

А, затем уже она использовалась бы как универсальная переменная:

void show_var(VAR v)
{
...
}
 
int main(void) 
{
    VAR variable;
 
    variable.var.var_ch = 'C';
    variable.var.var_i = 45;
    variable.type = union_var_i;
 
    show_var(variable);
 
    return 0;
}

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

Конечно, в практике программирования существует множество других задач, где объединения играют свою особую роль. По мере погружения в тему IT вы с ними время от времени будете сталкиваться.

Битовые поля

Во второй части занятия мы с вами познакомимся с еще одним способом компактного представления данных – битовыми полями. Что это такое?

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

Давайте я сразу на конкретном примере покажу пример использования битовых полей. Предположим, нам нужно в программе хранить дату и время. Для этого можно определить следующую структуру:

struct date_time {
    unsigned day : 5;
    unsigned month : 4;
    unsigned year : 12;
    unsigned sec : 6;
    unsigned min : 6;
    unsigned hour : 5;
};

Напомню, что ключевое слово unsigned соответствует типу unsigned int, именно типа int должны быть переменные в качестве битовых полей согласно стандарту языка Си. Хотя, некоторые компиляторы допускают использование других типов. Но для переносимости программы лучше этого не делать. Кроме того, сам тип int лучше обозначать беззнаковым, опять же, для лучшей совместимости с разными компиляторами.

Итак, в нашей структуре имеются поля в виде беззнакового типа int, после которых стоят двоеточия и числа. Эти числа – количество бит, которое отводится для представления того или иного поля в структуре date_time. Например, для хранения дней достаточно 5 бит, так как 2^5 = 32 (а максимум дней 31). И так для всех остальных полей. В сумме получаем:

5 + 4 + 12 + 6 + 6 + 5 = 38 бит.

Так как данные в битовых полях кратны длине целочисленного типа int, который в наших примерах занимает 4 байта или 32 бита, общий размер структуры составит 8 байт (два раза по 4 байта). Мы в этом можем легко убедиться, создав такую переменную и отобразив ее размер:

int main(void) 
{
    struct date_time dt;
    printf("%d\n", sizeof(dt));
    
    return 0;
}

А вот если из структуры убрать любое поле размером 6 бит (чтобы получить ровно 32 бита), например, min, то размер структуры составит 4 байта. Дальнейшее использование этой структуры происходит абсолютно так же, как и с обычными структурами. Например:

int main(void)
{
    struct date_time dt = {3, 5, 2023, 11, 7, 10};
    printf("%02d/%02d/%d %02d:%02d:%02d\n", dt.day, dt.month, dt.year, dt.hour, dt.min, dt.sec);
    
    return 0;
}

Увидим результат:

03/05/2023 10:07:11

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

Практический курс по C/C++: https://stepik.org/course/193691

Видео по теме