Преамбула
Эта статья была написана и опубликована мной на своем сайте более десяти лет назад, сам сайт с тех пор канул в лету, а я так и не начал писать что-то более вразумительное в плане статей. Все ниже описанное является результатом исследования C как языка двадцатилетним парнем, а, следовательно, не претендует на звание учебного пособия, несмотря на стиль изложения. Тем не менее, я искренне надеюсь, что она побудит молодых разработчиков погрузиться в эксперименты с C также, как когда-то делал это я.
Предупреждение
Эта короткая статья, окажется абсолютно бесполезной для опытных программистов C/C++, но кому-то из начинающих, возможно, позволит сэкономить время. Хочу подчеркнуть, что в большинстве хороших книг по C/C++ данная тема рассмотрена в достаточной степени.
Динамическая и статическая типизация
Во многих интерпретируемых языках используется динамическая
типизация. Такой подход позволяет хранить в переменной с одним
именем значения разных типов. В языке C используется
строгая типизация, что, на мой взгляд более, чем правильно. Однако
бывают случаи (хоть и не так часто), когда гораздо удобней было бы
использовать динамическую типизацию. Зачастую, такая потребность
напрямую связана с некачественным проектированием, но не всегда. Не
зря же в Qt присутствует тип QVariant
.
Здесь мы поговорим про язык C, хотя все, что описано ниже, применимо и к C++.
Магия указателя пустоты
На самом деле, никакой динамической типизации в C нет и быть не
может, однако существует универсальный указатель, тип которому
void *
. Объявление переменной такого типа, скажем, в
качестве аргумента функции, позволяет передавать в нее указатель на
переменную любого типа, что может быть крайне полезно. И вот он
первый пример:
#include <stdio.h>int main(){void *var;int i = 22;var = &i;int *i_ptr = (int *)(var);if(i_ptr)printf("i_ptr: %d\n", *i_ptr);double d = 22.5;var = &d;double *d_ptr = (double *)(var);if(d_ptr)printf("d_ptr: %f\n", *d_ptr);return 0;}
Вывод:
i_ptr: 22d_ptr: 22.500000
Здесь мы одному и тому же указателю присвоили указатели
(простите за тавтологию) как на тип int
, так и на
double
.
Примечание: в некоторых источниках говорится о
том, что присвоение указателю типа void *
следует
производить также с приведением типа. Возможно, это особенности
конкретных компиляторов, GCC же без ругательств обработал
предыдущий пример. Но, если возникли ошибки, попробуйте:
void *var;int i = 22;var = (void *)(&i);
Так точно должно работать.
Первый пример не нес никакой полезной нагрузки. Попробуем ее поискать во втором примере:
#include <stdio.h>int lilround(const void *arg, const char type){if(type == 0) // если передан intreturn *((int *)arg); // просто возвращаем значение целого аргумента// если передан doubledouble a = *((double *)arg);int b = (int)a;return b == (int)(a - 0.5) // если дробная часть >= 0.5? b + 1 // округляем в плюс: b; // отбрасываем дробную часть}int main(){int i = 12;double j = 12.5;printf("round int: %d\n", lilround(&i, 0)); // пытаемся округлить целое числоprintf("round double: %d\n", lilround(&j, 1)); // пытаемся округлить число двойной точностиreturn 0;}
Вывод:
round int: 12round double: 13
Здесь мы создали, можно сказать, универсальную функцию для округления как целых чисел (которым оно не требуется, конечно), так и для чисел двойной точности. Следует понимать, что функция может выполнять и что-то более полезное, в зависимости от типа аргумента.
Для тех, кому хочется слегка поломать мозг альтернативная
реализация функции lilround()
:
int lilround(const void *arg, const char type){return type == 0? *((int *)arg): ((int)*((double *)arg) == (int)(*((double *)arg) - 0.5)? (int)(*((double *)arg)) + 1: (int)(*((double *)arg)));}
Но для того, чтобы функция знала с чем имеет дело мы передаем в
нее второй аргумент. Если он равен 0
, то первый
интерпретируется как указатель на int
, если нет как
указатель на double
. Такой подход может во многих
случаях сгодиться, но, в основном, смысл использования
универсального указателя как раз-таки в том, чтобы не указывать тип
передаваемого параметра.
Предположим, что у нас две или более структур
(struct
), которые содержат различный набор полей. Но
так уж получилось, что нужно передать их одной и той же функции.
Почему так вышло рассуждать не будем.
Что же делать? Ответ почти очевиден: передавать их в виде
указателя неопределенного типа. И, все ничего, но как же тогда наша
функция узнает об их типе? Все просто: в самое начало структуры
добавим поле type
, в которое будем записывать
идентификатор структуры, по которому наша функция и будет
определять ее тип, предварительно приведя неопределенный указатель
к любой из структур. Идентификатором может быть поле любого типа,
хоть еще одна структура, но оно должно стоять первым в каждой из
структур и иметь один и тот же тип. Такое условие следует из
способа расположения структур в памяти компьютера. Если написать
так:
typedef struct {char type;int value;} iStruct;typedef struct {char type;double value;} dStruct;
То все сработает корректно. Но если написать так:
typedef struct {char type;int value;} iStruct;typedef struct {double value;char type;} dStruct;
То программа соберется, но во время работы выдаст неверный вариант, так как, в зависимости от того к какой структуре приведем указатель, в случае обращения программа попытается считать первый байт из double value или, вообще, неизвестно откуда.
А вот и пример использования такого подхода:
#include <stdio.h>#pragma pack(push, 1)typedef struct {char type; // идентификатор типа структурыint value; // целочисленное значение} iStruct;#pragma pack(pop)#pragma pack(push, 1)typedef struct {char type; // идентификатор типа структурыdouble value; // значение двойной точности} dStruct;#pragma pack(pop)int lilround(const void *arg){iStruct *s = (iStruct *)arg;if(s->type == 0) // если передан intreturn s->value; // просто возвращаем значение целого аргумента// если передан doubledouble a = ((dStruct *)arg)->value;int b = (int)a;return b == (int)(a - 0.5) // если дробная часть >= 0.5? b + 1 // округляем в плюс: b; // отбрасываем дробную часть}int main(){iStruct i;i.type = 0;i.value = 12;dStruct j;j.type = 1;j.value = 12.5;printf("round int: %d\n", lilround(&i)); // пытаемся округлить целое числоprintf("round double: %d\n", lilround(&j)); // пытаемся округлить число двойной точностиreturn 0;}
Примечание: директивы компилятора #pragma
pack(push, 1)
и #pragma pack(pop)
необходимо
помещать до и после каждой специфической структуры, соответственно.
Данная директива используется для выравнивания структуры в памяти,
что обеспечит корректность метода. Однако не стоит также забывать о
порядке полей.
В теле функции аргумент приводится к структуре
iStruct
и проверяется значение поля type. Дальше уже
аргумент приводится к другому типу структуры, если нужно.
Перед тем, как перейти к последней части, стоить пояснить работу
с простыми void-указателями. Сложение, вычитание, инкремент,
декремент и т.д. не запрещены для типа void
, однако
могут вызывать предупреждения в C++ и не вполне понятное поведение.
Поэтому необходимо сперва привести аргумент к нужному типу, а уж
затем совершать операцию:
#include <stdio.h>int main(){int i = 22;void *var = &i; // объявляем void-указатель и инициализируем его адресом переменной i(*(int *)var)++; // приводим void-указатель к int-указателю, разыменовываем его и производим операцию инкрементаprintf("result: %d\n", i); // выводим измененное значение ireturn 0;}
Исходя из кода: для совершения операции необходимо записать
(*(int *)var)
и уже к данной записи применить
требуемый оператор.
Подобие интерфейсов в C
Вернемся к структурам. Если структура "засылается" далеко и
глубоко в код, возможно даже чужой, то имеет смысл передать вместе
с ней и методы, которые будут обрабатывать ее значения. Для этого
создадим дополнительную структуру, которая заменит поле
type
:
typedef struct {void (*printType)(); // указатель на функцию, выводящую типint (*round)(const void *); // указатель на функцию, округляющую значение} uMethods;
Опишем реализации указанных функций для разных сткрутур, а также функции инициализации разных типов структур. Результат ниже:
#include <stdio.h>typedef struct {void (*printType)(); // указатель на функцию, выводящую типint (*round)(const void *); // указатель на функцию, округляющую значение} uMethods;#pragma pack(push, 1)typedef struct {uMethods m; // структура с указателями на функцииint value; // целочисленное значение} iStruct;#pragma pack(pop)#pragma pack(push, 1)typedef struct {uMethods m; // структура с указателями на функцииdouble value; // значение двойной точности} dStruct;#pragma pack(pop)void intPrintType() // вывод типа для iStruct{printf("integer\n");}int intRound(const void *arg) // округление для iStruct{return ((iStruct *)arg)->value; // приводим аргумент к указателю на iStruct и возвращаем значение}void intInit(iStruct *s) // инициализация iStruct{s->m.printType = intPrintType; // задаем полю printType указатель на функцию вывода для iStructs->m.round = intRound; // задаем полю round указатель на функцию округления для iStructs->value = 0;}void doublePrintType() // вывод типа для dStruct{printf("double\n");}int doubleRound(const void *arg) // округление для dStruct{double a = ((dStruct *)arg)->value;int b = (int)a;return b == (int)(a - 0.5) // если дробная часть >= 0.5? b + 1 // округляем в плюс: b; // отбрасываем дробную часть}void doubleInit(dStruct *s){s->m.printType = doublePrintType; // задаем полю printType указатель на функцию вывода для dStructs->m.round = doubleRound; // задаем полю round указатель на функцию округления для dStructs->value = 0;}int lilround(const void *arg){((iStruct *)arg)->m.printType(); // приводим к любой структуре, в данном случае iStruct, и выводим типreturn ((iStruct *)arg)->m.round(arg); // возвращаем округленное значение}int main(){iStruct i;intInit(&i); // инициализируем целочисленную структуруi.value = 12;dStruct j;doubleInit(&j); // инициализируем структуру с данными двойной точностиj.value = 12.5;printf("round int: %d\n", lilround(&i)); // пытаемся округлить целое числоprintf("round double: %d\n", lilround(&j)); // пытаемся округлить число двойной точностиreturn 0;}
Вывод:
integerround int: 12doubleround double: 13
Примечание: директивами компилятора следует обрамлять только те структуры, которые необходимо использовать в качестве аргумента для void-указателя.
Заключение
В последнем примере можно заметить сходство с ОПП, что, в общем-то, правда. Здесь мы создаем структуру, инициализируем ее, задаем ее ключевым полям значения и вызываем функцию округления, которая, кстати говоря, крайне упростилась, хотя мы сюда же добавили вывод типа аргумента. На этом все. И помните, что применять подобные конструкции нужно размумно, ведь, в подавляющем большинстве задач их наличие не требуется.