Динамическая типизация C

от автора

Преамбула

Эта статья была написана и опубликована мной на своем сайте более десяти лет назад, сам сайт с тех пор канул в лету, а я так и не начал писать что-то более вразумительное в плане статей. Все ниже описанное является результатом исследования 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: 22 d_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) // если передан int 		return *((int *)arg); // просто возвращаем значение целого аргумента 	// если передан double  	double 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: 12 round 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) // если передан int 		return s->value; // просто возвращаем значение целого аргумента 	// если передан double 	double 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); // выводим измененное значение i  	return 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 указатель на функцию вывода для iStruct 	s->m.round = intRound; // задаем полю round указатель на функцию округления для iStruct 	s->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 указатель на функцию вывода для dStruct 	s->m.round = doubleRound; // задаем полю round указатель на функцию округления для dStruct 	s->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; }

Вывод:

integer round int: 12 double round double: 13

Примечание: директивами компилятора следует обрамлять только те структуры, которые необходимо использовать в качестве аргумента для void-указателя.

Заключение

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

ссылка на оригинал статьи https://habr.com/ru/post/560730/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *