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