Перечисления (enum). Директива typedef

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

На этом занятии познакомимся с новым типом данных – перечислением. С его помощью в программе удобно определять наборы целочисленных констант, например, следующим образом:

enum colors {red, green, blue};

Здесь enum – это ключевое слово для объявления перечислимого типа; colors – идентификатор перечисления; red, green, blue – целочисленные константы типа int.

Прежде чем двигаться дальше, я думаю, нужно сразу ответить на вопрос, который, наверное, одолевает многих из вас: зачем нужно это перечисление? Неужели нельзя список констант определить или через директивы #define:

#define REG     1
#define GREEN   2
#define BLUE    3

или через константные переменные:

const int c_red = 1;
const int c_green = 2;
const int c_blue = 3;

На самом деле константы в перечислениях отличаются от всего того, что нам до сих известно. От константных переменных их отличает то, что их значения формируются в момент компиляции программы, то есть, они являются константами времени компиляции. И это очень важный момент. В частности, по этой причине их можно использовать в метках условного оператора switch. А от директив константы в перечислениях отличает безопасность их использования в различных конструкциях языка Си. Именно поэтому такие константы записывают, как правило, малыми буквами, а не большими, как макроимена, так как они не приводят к каким-либо скрытым ошибкам в коде программы. Все это делает перечисления незаменимым инструментом для объявления и использования целочисленных констант.

Давайте вначале посмотрим на способы объявления перечислений. Самый простой из них мы уже видели:

enum colors {red, green, blue};

При этом константы red, green, blue соответствуют типу int и принимают значения 0, 1 и 2 соответственно. Мы их даже можем вывести на экран с помощью функции printf():

#include <stdio.h>
enum colors {red, green, blue};
 
int main(void) 
{
    printf("red = %d, green = %d, blue = %d\n", red, green, blue);
    return 0;
}

После выполнения программы увидим строчку:

red = 0, green = 1, blue = 2

Но, при необходимости, значения констант можно задавать вручную, например, такими способами:

enum keys {vk_enter=27, vk_space, vk_del=30};
enum {
    go = 0x1f00, 
    stop = 0x0001, 
    forward = go,
    run = 0x00a2,
    back = run -1
};

В перечислении keys константа vk_enter принимает значение 27, следующая константа vk_space на единицу больше, то есть, 28, и последняя vk_del равна 30. А перечисление без идентификатора (имени) задает константы, причем некоторые из них определены на основе других, ранее объявленных и с использованием арифметических операций. Так тоже можно делать. Главное, чтобы выражение использовало данные времени компиляции. Последнее перечисление без имени обычно прописывают, если целью является только объявление целочисленных констант.

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

enum keys k_var;

Что это за тип? На уровне машинных кодов компилятор представляет переменную k_var как целочисленную типа int. А раз так, значит, мы вполне можем заменить тип enum keys на int? Формально да, можем, и программа будет работать абсолютно также. Но есть один нюанс. Тип enum keys для компилятора не в точности соответствует типу int. Отличие я покажу на конкретном примере. Пусть переменная k_var используется в операторе switch следующим образом:

#include <stdio.h>
 
enum keys {vk_enter=27, vk_space, vk_del=30};
 
int main(void) 
{
    enum keys k_var = vk_enter;
 
    switch(k_var) {
    case vk_enter:
        puts("vk_enter");
        break;
    case vk_space:
        puts("vk_space");
        break;
    }
 
    return 0;
}

Если скомпилировать эту программу с указанием флага –Wall (отображение всех предупреждений), то увидим сообщение:

warning: enumeration value 'vk_del' not handled in switch

Компилятор предупреждает нас, что мы в операторе switch указали не все варианты значений, заданные в перечислении enum keys. При этом программа компилируется и запускается. Это предупреждение появилось из-за того, что тип переменной k_var соответствует enum keys, а не просто int. Если мы пропишем int:

int k_var = vk_enter;

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

enum keys k_var = vk_enter * 2 - vk_space * 100;

Хотя, так делать не стоит и лучше присваивать константы, записанные в соответствующем перечислении (в данном случае в keys).

Переменные перечислимого типа можно задавать при объявлении перечисления. Например, так:

enum colors {red, green, blue} wnd_colors;
 
enum {
    go = 0x1f00, 
    stop = 0x0001, 
    forward = go,
    run = 0x00a2,
    back = run -1
} actions, commands;

В результате получим объявление глобальных переменных wnd_colors, actions и commands.

Директива typedef

Во второй части занятия рассмотрим директиву typedef, которая позволяет задавать пользовательское имя типа. Попросту говоря, она любой тип данных позволяет представить другим именем. Например, так:

typedef unsigned char BYTE;

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

typedef unsigned char BYTE;
 
int main(void) 
{
    BYTE byte;
    BYTE ch, var_ch = '0';
 
    return 0;
}

Компилятор вместо слова BYTE подставит unsigned char и программа будет работать с объявленными переменными, как с беззнаковыми байтовыми.

На первый взгляд кажется, что этот же фокус можно проделать и с помощью директивы препроцессора #define следующим образом:

#define BYTE unsigned char

И, действительно, программа скомпилировалась без проблем и отработала бы абсолютно также. Но все же, между определением через директиву #define и директиву typedef есть существенная разница. Директива typedef обрабатывается не препроцессором, а самим компилятором, а потому это более тонкое и безопасное действие. Покажу это на следующем примере:

#include <stdio.h>
 
#define PTR_INT int*
 
typedef int* PTR;
 
int main(void) 
{
    PTR_INT a, b;      // int* a, b;
    PTR ptr_a, ptr_b;  // int *ptr_a, *ptr_b;
    printf("*a = %d, *b = %d\n", *a, *b);
    printf("*ptr_a = %d, *ptr_b = %d\n", *ptr_a, *ptr_b);
 
    return 0;
}

Мы двумя способами переопределяем тип int* для объявления целочисленных указателей. Затем, в функции main() объявляем два указателя a, b с помощью макровызова PTR_INT и еще два указателя ptr_a, ptr_b с помощью введенного типа PTR. В итоге, после обработки текста программы, мы получим следующий эквивалент объявлений:

int* a, b;
int *ptr_a, *ptr_b;

Как говорится, почувствуйте разницу. Благодаря тому, что typedef – это директива уровня компилятора, он корректно применяет подмененный тип и каждую переменную делает указателем. А макровызов PTR_INT срабатывает на уровне препроцессора, который «в лоб» на уровне текста программы делает подмену. В итоге получаем не совсем корректный результат.

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

#include <stdio.h>
 
typedef enum {
    buffer_size = 2048,
    element_size = 12,
    window_size = 400
} SIZE_CONSTS;
 
int main(void) 
{
    SIZE_CONSTS sizes = window_size;
    printf("%d\n", sizes);
    printf("%d\n", buffer_size);
 
    return 0;
}

Обратите внимание, в этом случае имя перечисления прописывать не имеет особого смысла. В дальнейшем все равно предполагается использовать имя типа SIZE_CONSTS для объявления переменных перечислимого типа.

После всех этих примеров, у вас все равно может остаться вопрос, зачем все это нужно? Только лишь для удобства? Или есть другие причины? Да, причины есть, по крайней мере, еще одна. Помните, когда мы с вами рассматривали некоторые функции, то тип данных у них имел вид size_t. Например, такой тип имеет параметр функции malloc():

void* malloc(size_t size);

В действительности, это переопределенный тип с помощью оператора typedef. В моей реализации это следующая замена:

typedef unsigned int size_t;

Какую роль играет это переопределение? Смотрите, когда компьютеры были максимум 32-разрядные, то типа unsigned int было вполне достаточно для выделения максимально возможного блока памяти. При переходе к 64-разрядным системам этого размера может быть уже недостаточно и потребуется другое переопределение, например:

typedef unsigned long long size_t;

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

Наконец, оператор typedef можно использовать для упрощения записи сложных типов данных, например, указателей на функции или многомерные массивы:

#include <stdio.h>
#include <stdlib.h>
 
int is_even(int x)
{
    return x % 2 == 0;
}
 
typedef int (*PTR_EVEN)(int);
typedef char (*PTR_AR_2D)[4];
 
int main(void) 
{
    char ar_2d[5][4] = {0};
    PTR_AR_2D ptr_2d = ar_2d;
    ar_2d[1][2] = 5;
   
    PTR_EVEN func_even = is_even;
 
    printf("%d\n", func_even(2));
    printf("%d\n", func_even(3));
 
    printf("%d\n", ptr_2d[1][2]);
 
    return 0;
}

Как видите, при замене программа становится более читаемой, поэтому, в ряде случаев, такие замены вполне могут быть использованы.

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

Видео по теме