Абстракции и наследование в Си - стреляем по ногам красиво
Иногда нет-нет да и хочется что-нибудь абстрагировать и обобщить в коде на Си. К примеру, хочешь ты принтануть содержимое структуры несколько раз, пишешь везде, как дурак, 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);
Точки опоры
Как-то лет 15 назад ехал я в электричке Москва-Шатура в тамбуре. Все сидячие места были заняты, углы в тамбуре были тоже заняты,...
Telegram Premium
Если кто-то раздумывает над покупкой Telegram Premium, рассчитывая получить что-нибудь полезное - ничего такого там нет. Вчера ...
Стейкхолдеры
Всю дорогу думал, что термин "стейкхолдер" как-то относится к мясу. Типа, существуют обычные додики на зарплате, а есть основные...
Colon
В английском языке слово colon используется и для обозначения знака "двоеточие" : , и для обозначения толстого кишечника. Ка...
AI Shitstorm
Тут недавно прогремело несколько новостей: Slack тренирует свой AI на данных из корпоративных чатов; StackOverflow делает то ...
Большая комната
Если много лет живешь в двушке, где люди спят в обеих комнатах, у тебя нет "спальни" и "гостиной". Есть "маленькая комната", "бо...