Абстракции и наследование в Си - стреляем по ногам красиво
Иногда нет-нет да и хочется что-нибудь абстрагировать и обобщить в коде на Си. К примеру, хочешь ты принтануть содержимое структуры несколько раз, пишешь везде, как дурак, printf("%s %d %f\n", foo->bar, foo->baz, foo->boom)
, и интуитивно кажется, что есть способ сделать foo->print(foo)
, и так вообще со всеми структурами, не только с foo
.
Возьмем пример: есть некий чувак с именем и фамилией, и есть птица, у которой есть имя и владелец.
typedef struct Person Person;
struct Person {
char *first_name;
char *last_name;
};
typedef struct Bird Bird;
struct Bird {
char *name;
Person *owner;
};
Чтобы вывести информацию про этих животных, кондовый сишник напишет просто две функции:
void Person_Print(Person *p) {
printf("%s %s\n", p->first_name, p->last_name);
}
void Bird_Print(Bird *b) {
printf("%s of %s %s\n", b->name, b->owner->first_name, b->owner->last_name);
}
И будет таки прав! Но что если подобных структур у нас много, а наш мозг испорчен веяниями ООП? Правильно, надо у каждой структуры определить общий метод, например void Repr(Person* person, char* buf)
, который сбросит в buf
строковое представление объекта (да, теперь у нас появляются объекты), и дальше мы бы могли использовать этот результат для вывода на экран:
/* Person */
struct Person {
void (*Repr)(Person*, char*);
/* ... */
};
void Person_Repr(Person *person, char *buf) {
sprintf(buf, "<Person: first_name='%s' last_name='%s'>",
person->first_name, person->last_name);
}
Person *New_Person(char *first_name, char *last_name) {
Person *person = malloc(sizeof(Person));
person->Repr = Person_Repr;
person->first_name = first_name;
person->last_name = last_name;
return person;
}
/* Bird */
struct Bird {
void (*Repr)(Bird*, char*);
/* ... */
};
void Bird_Repr(Bird *bird, char* buf) {
char owner_repr[80];
bird->owner->Repr(bird->owner, owner_repr);
sprintf(buf, "<Bird: name='%s' owner=%s>",
bird->name, owner_repr);
}
Bird *New_Bird(char *name, Person *owner) {
Bird *bird = malloc(sizeof(Bird));
bird->Repr = Bird_Repr;
bird->name = name;
bird->owner = owner;
return bird;
}
Окей, вроде унифицировали, да не очень. Как теперь эти методы вызывать? Не очень удобно получается, каждый раз вылезает свистопляска с буферами:
char buf[80];
bird->Repr(bird, buf);
printf("%s\n", buf);
Как вариант - сделать базовую структуру Object
, положить в нее функцию Print()
, "наследовать" остальные структуры от Object
и в Object::Print()
дергать дочерний метод Repr()
. Выглядит логично, только мы пишем на Си, а не на плюсах, где такое на раз-два решается виртуальными функциями.
Но в Си есть такая штука: можно привести одну структуру к другой, если у нее та другая структура идет первым полем.
Например:
typedef struct {
int i;
} Foo;
typedef struct {
Foo foo;
int j;
} Bar;
Bar *bar = malloc(sizeof(Bar));
bar->foo.i = 123;
printf("%d\n", ((Foo*)bar)->i);
То есть мы смотрим на структуру bar
, но с типом Foo
, потому что по сути указатель на структуру - это указатель на ее первый элемент, и тут мы имеем право так кастовать.
Попробуем сделать базовую структуру Object
с одной функцией Print_Repr()
, которая, по идее, должна будет вызвать "дочерний метод" Repr()
у наших людишек и птичек:
typedef struct Object Object;
struct Object {
void (*Print_Repr)(Object*);
};
/*
Самая интересная часть. Функция берет указатель
на следующее поле в структуре после Object,
которое в текущем варианте является указателем
на функцию Repr().
*/
void Object_Print_Repr(Object *object) {
void **p_repr_func = (void*) object + sizeof(Object);
void (*repr_func)(Object*, char*) = *p_repr_func;
char buf[80];
repr_func(object, buf);
printf("%s\n", buf);
}
/* Person */
typedef struct Person Person;
struct Person {
Object object;
void (*Repr)(Person*, char*);
/* ... */
};
Person *New_Person(char *first_name, char *last_name) {
Person *person = malloc(sizeof(Person));
person->object.Print_Repr = Object_Print_Repr;
person->Repr = Person_Repr;
/* ... */
return person;
}
/* Bird */
typedef struct Bird Bird;
struct Bird {
Object object;
void (*Repr)(Bird*, char*);
/* ... */
};
Bird *New_Bird(char *name, Person *owner) {
Bird *bird = malloc(sizeof(Bird));
bird->object.Print_Repr = Object_Print_Repr;
bird->Repr = Bird_Repr;
/* ... */
return bird;
}
Вот мы и реализовали паттерн "Шаблонный метод" на чистом Си. Не совсем честно, и не совсем надежно, но кое-как работает.
Тут два вопроса:
- Как быть, если функция
Repr()
не является вторым полем в структуре? - Как быть, если хочется поддержки более чем одной функции?
Ответ не самый приятный, потому что портит всю красоту и чистоту базовой структуры Object
, туда надо добавить адреса нужных нам функций.
Получить их несложно, в stddef.h
есть полезный макрос offsetof(<struct>, <field>)
. Работает он так:
struct A {
char c;
int i;
long l;
}
offsetof(struct A, c) == 0;
offsetof(struct A, i) == 4;
offsetof(struct A, l) == 8;
С помощью этого макроса мы можем получить оффсеты всех нужных generic-функций, сохранить их в Object
, и вызывать их оттуда из других методов. Красиво? А то!
Допустим, к функции Repr()
мы захотели добавить функцию Str()
, которая представит объект в виде строки, но без всякой дебажной шелухи, типа <Person first_name='Ivan' last_name='Ivanov'>
, а просто сформирует строку Ivan Ivanov
для вывода в каком-то интерфейсе. (Чувствуете веяние Python с его __repr__()
и __str__()
? Оно здесь не просто так, а сложно так.)
Соответственно, Object
должен иметь соответствующую функцию Print_Str()
для вывода результатов. А чтобы он цеплял правильную функцию, нужно внутри него прикопать все оффсеты.
Листинг будет больше остальных, с комментариями, но вы не бойтесь, мы все это скоро порефакторим.
#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>
typedef struct Object Object;
typedef struct Person Person;
typedef struct Bird Bird;
struct Object {
size_t offset_repr;
void (*Print_Repr)(Object*);
size_t offset_str;
void (*Print_Str)(Object*);
};
/*
Получить функцию по адресу object + offset_repr,
кастануть ее к void(*)(Object*, char*) и вызвать,
передав адрес текущего объекта.
*/
void Object_Print_Repr(Object *object) {
void **p_repr_func = (void*) object + object->offset_repr;
void (*repr_func)(Object*, char*) = *p_repr_func;
char buf[80];
repr_func(object, buf);
printf("%s\n", buf);
}
/*
То же самое, только теперь вместо offset_repr берем offset_str.
Сигнатура функции такая же, поэтому больше ничего интересного.
*/
void Object_Print_Str(Object *object) {
void **p_str_func = (void*) object + object->offset_str;
void (*str_func)(Object*, char*) = *p_str_func;
char buf[80];
str_func(object, buf);
printf("%s\n", buf);
}
/*
Обратите внимание на порядок полей в структуре,
теперь их можно группировать как угодно.
*/
struct Person {
/* "Наследуемся" от Object */
Object object;
/* Собственно данные */
char *first_name;
char *last_name;
/* "Методы" */
void (*Repr)(Person*, char*);
void (*Str)(Person*, char*);
};
/* Person->Repr(...) */
void Person_Repr(Person *person, char *buf) {
sprintf(buf, "<Person: first_name='%s' last_name='%s'>",
person->first_name, person->last_name);
}
/* Person->Str(...) */
void Person_Str(Person *person, char *buf) {
sprintf(buf, "%s %s", person->first_name, person->last_name);
}
/*
Инициализация Person и вложенной структуры Object
*/
Person *New_Person(char *first_name, char *last_name) {
/*
Собираем данные и функции самого Person.
*/
Person *person = malloc(sizeof(Person));
person->first_name = first_name;
person->last_name = last_name;
person->Repr = Person_Repr;
person->Str = Person_Str;
/*
Оповещаем вложенный Object об адресах "дочерних"
функций, которые мы собираемся вызывать из самого Object
*/
person->object.offset_repr = offsetof(Person, Repr);
person->object.offset_str = offsetof(Person, Str);
/* И наполняем его смыслом */
person->object.Print_Repr = Object_Print_Repr;
person->object.Print_Str = Object_Print_Str;
return person;
}
/* Не забываем подчищать за собой */
void Del_Person(Person *person) {
free(person);
}
/* Со структурой Bird все ровно так же, комментарии излишни. */
struct Bird {
Object object;
char *name;
Person *owner;
void (*Repr)(Bird*, char*);
void (*Str)(Bird*, char*);
};
void Bird_Repr(Bird *bird, char* buf) {
char owner_repr[80];
bird->owner->Repr(bird->owner, owner_repr);
sprintf(buf, "<Bird: name='%s' owner=%s>",
bird->name, owner_repr);
}
void Bird_Str(Bird *bird, char* buf) {
sprintf(buf, "%s", bird->name);
}
Bird *New_Bird(char *name, Person *owner) {
Bird *bird = malloc(sizeof(Bird));
bird->name = name;
bird->owner = owner;
bird->Repr = Bird_Repr;
bird->Str = Bird_Str;
bird->object.offset_repr = offsetof(Bird, Repr);
bird->object.offset_str = offsetof(Bird, Str);
bird->object.Print_Repr = Object_Print_Repr;
bird->object.Print_Str = Object_Print_Str;
return bird;
}
void Del_Bird(Bird *bird) {
free(bird);
}
int main(void) {
Person *person = New_Person("Oleg", "Olegov");
Bird *bird = New_Bird("Kukushka", person);
/*
"Смотрим" на объект person как на Object
и вызываем функции с этим же объектом.
В принципе, никто не запрещает передать person
в функцию без дополнительного приведения типа:
((Object*)person)->Print_Repr(person);
GCC это схавает, но выкинет warning.
*/
((Object*)person)->Print_Repr((Object*)person);
((Object*)person)->Print_Str((Object*)person);
((Object*)bird)->Print_Repr((Object*)bird);
((Object*)bird)->Print_Str((Object*)bird);
Del_Bird(bird);
Del_Person(person);
}
Выглядит прикольно, но бредовато. Во-первых, много boilerplate-кода в инициализаторах, а во-вторых, постоянный кастинг (Object*)
просто кричит о протекающих абстракциях.
В принципе, последнюю проблему решить не так сложно. Достаточно добавить все Print_*
функции в дочерние структуры и снабдить их указателями на те же самые функции из Object
:
struct Person {
/* ... */
/* Ссылки на соответствующие функции Object */
void (*Print_Repr)(Person*);
void (*Print_Str)(Person*);
};
Person *New_Person(char *first_name, char *last_name) {
/* ... */
person->object.Print_Repr = Object_Print_Repr;
person->object.Print_Str = Object_Print_Str;
/*
Вставляем те же самые функции в person,
приведя их к void (*)(Person *), чтобы компилятор
не ругался.
*/
person->Print_Repr = (void (*)(Person *))Object_Print_Repr;
person->Print_Str = (void (*)(Person *))Object_Print_Str;
return person;
}
/* Bird - то же самое */
int main(void) {
/* ... */
person->Print_Repr(person);
person->Print_Str(person);
bird->Print_Repr(bird);
bird->Print_Str(bird);
/* ... */
}
Теперь совсем красота, ООП во все щели! Дергаем метод person->Print_Repr()
, который на самом деле person->object.Print_Repr()
, который при вызове дергает person->Repr()
.
Но boilerplate-кода все еще неприлично много. Каждый раз всю нашу ООП-машинерию нужно описывать в инициализаторах, и не дай боже что-то пропустить - SEGFAULT не дремлет!
Представляем - object.h
:
#pragma once
#include <stddef.h>
/*
Макрос, который встраивает нужные поля объекта.
*/
#define OBJECT(T) \
Object object; \
void (*Repr)(T*, char*); \
void (*Str)(T*, char*); \
void (*Print_Repr)(T*); \
void (*Print_Str)(T*);
/*
Инициализатор объекта, подсовывающий все
нужные функции и оффсеты
*/
#define INIT_OBJECT(x, T) \
x->object.Print_Repr = Object_Print_Repr; \
x->object._offset_Repr = offsetof(T, Repr); \
x->object.Print_Str = Object_Print_Str; \
x->object._offset_Str = offsetof(T, Str); \
x->Print_Repr = (void (*) (T*)) Object_Print_Repr; \
x->Print_Str = (void (*) (T*)) Object_Print_Str; \
x->Repr = T ## _Repr; \
x->Str = T ## _Str
/* Макрос, возвращающий указатель на функцию по ее названию */
#define OBJECT_FUNC(x, F) *(void **)((void*) x + x->_offset_ ## F)
typedef struct Object Object;
typedef void *(Repr)(Object *, char*);
typedef void *(Str)(Object *, char*);
/* Наши старые знакомые */
struct Object {
size_t _offset_Repr;
void (*Print_Repr)(Object*);
size_t _offset_Str;
void (*Print_Str)(Object*);
};
void Object_Print_Repr(Object *object) {
Repr *repr_func = OBJECT_FUNC(object, Repr);
char buf[80];
repr_func(object, buf);
printf("%s\n", buf);
}
void Object_Print_Str(Object *object) {
Str *str_func = OBJECT_FUNC(object, Str);
char buf[80];
str_func(object, buf);
printf("%s\n", buf);
}
И вот как эти макросы сокращают объем финального кода:
typedef struct Person Person;
typedef struct Bird Bird;
struct Person {
/*
Это не обычная структура, а наследник
абстракции по имени Object
*/
OBJECT(Person)
char *first_name;
char *last_name;
};
void Person_Repr(Person *person, char *buf) {
sprintf(buf, "<Person: first_name='%s' last_name='%s'>",
person->first_name, person->last_name);
}
void Person_Str(Person *person, char *buf) {
sprintf(buf, "%s %s", person->first_name, person->last_name);
}
Person *New_Person(char *first_name, char *last_name) {
Person *person = malloc(sizeof(Person));
/*
INIT_OBJECT() цепляет все нужные функции,
включая Person_Repr и Person_Str, и подсовывает
их в соответствующие поля структуры
*/
INIT_OBJECT(person, Person);
person->first_name = first_name;
person->last_name = last_name;
return person;
}
/*
Извините, но реализация garbage collector на Си -
тема отдельного выпуска
*/
void Del_Person(Person *person) {
free(person);
}
/* Bird снова ничем не отличается от Person */
struct Bird {
OBJECT(Bird)
char *name;
Person *owner;
};
void Bird_Repr(Bird *bird, char* buf) {
char owner_repr[80];
bird->owner->Repr(bird->owner, owner_repr);
sprintf(buf, "<Bird: name='%s' owner=%s>",
bird->name, owner_repr);
}
void Bird_Str(Bird *bird, char* buf) {
sprintf(buf, "%s", bird->name);
}
Bird *New_Bird(char *name, Person *owner) {
Bird *bird = malloc(sizeof(Bird));
INIT_OBJECT(bird, Bird);
bird->name = name;
bird->owner = owner;
return bird;
}
void Del_Bird(Bird *bird) {
free(bird);
}
int main(void) {
Person *person = New_Person("Oleg", "Olegov");
Bird *bird = New_Bird("Kukushka", person);
/*
Вызываем разные экземпляры "родительских" функций
Print_Repr и Print_Str
*/
person->Print_Repr(person);
bird->Print_Repr(bird);
person->Print_Str(person);
bird->Print_Str(bird);
Del_Bird(bird);
Del_Person(person);
}
Самое прелестное в этих макросах - это обеспечение compile-time проверок. Допустим, мы решили добавить новую структуру, "наследовали" ее от Object
, но обязательных методов Repr
и Str
не объявили:
typedef struct Fruit Fruit;
struct Fruit {
OBJECT(Fruit)
char *name;
};
Fruit *New_Fruit(char *name) {
Fruit *fruit = malloc(sizeof(Fruit));
INIT_OBJECT(fruit, Fruit);
fruit->name = name;
return fruit;
}
void Del_Fruit(Fruit *fruit) {
free(fruit);
}
И тогда нам незамедлительно прилетает от компилятора:
c_inheritance.c: In function ‘New_Fruit’:
c_inheritance.c:77:24: error: ‘Fruit_Repr’ undeclared (first use in this function)
77 | INIT_OBJECT(fruit, Fruit);
| ^~~~~
<...>
c_inheritance.c:77:24: error: ‘Fruit_Str’ undeclared (first use in this function)
77 | INIT_OBJECT(fruit, Fruit);
| ^~~~~
Очень удобно!
А в чем же здесь стреляние по ногам? - спросите вы. Раз уж все так клево, почему бы не применить это в промышленной разработке?
Во-первых, в команде вас будут считать наркоманом.
Во-вторых, даже если не будут, то скорость программы снизится. А Си используют как раз для того, чтобы эту скорость приобрести, и часто за нее приходится платить дублированием кода и избеганием абстракций. И несмотря на то, что компиляторы нынче супер-оптимизирующие, ассемблерный выхлоп из "ООП"-кода и кода с парой простых функций Person_Print()
и Bird_Print()
даже с -O3
будет различаться в полтора-два раза (не в пользу первого).
Посему данная статья носит исключительно информационный характер, а никак не рекомендательный.
UPD Читатели справедливо заметили, что небезопасно использовать буфер фиксированного размера (char buf[80]
), который я взял для упрощения кода. В реальной жизни, конечно, стоит выделять буфер по размеру финальной строки:
size_t size = snprintf(NULL, 0, "%s ...", foo, ...);
char *buf = malloc(size + 1);
if (buf == NULL) {
return 1;
}
sprintf(buf, "%s ...", foo, ...);
/* ... */
free(buf);
Учим немецкий
Герц (Гц) - это единица измерения частоты, сколько раз в секунду происходит какое-то событие. Названа в честь Генриха Герца ( He...
AI Shitstorm
Тут недавно прогремело несколько новостей: Slack тренирует свой AI на данных из корпоративных чатов; StackOverflow делает то ...
The Slappable Jerk
Давно хотел порекомендовать ютуб-канал The Slappable Jerk (буквально "Придурок, которому можно дать по щам"). Там чувак очень ...
Airplane! (1980)
Посмотрел вчера фильм Airplane! (1980), это одна из тех старых комедий, где люди каламбурят на серьезных щах, создавая абсурдн...
Точки опоры
Как-то лет 15 назад ехал я в электричке Москва-Шатура в тамбуре. Все сидячие места были заняты, углы в тамбуре были тоже заняты,...
Чаевые
В США и некоторых других странах чаевые играют большую роль, официанты и прочие работники сферы обслуживания ожидают их по умолч...