Embedded HandbookПриложения
Все разделы › XVII. Системное программирование на C
· уровень средний · 11 мин чтения
Открыть в интерактивном справочнике

146. Объектно-ориентированный C: непрозрачные указатели, таблицы функций, наследование, _Generic

В C нет классов, и это не мешает писать объектно: ядро Linux, GTK, SQLite и драйверы устройств устроены объектно — структурами с указателями на функции. Три идеи ООП — инкапсуляция, полиморфизм, наследование — в C выражаются тремя приёмами: неполный тип, таблица функций, базовая структура первым полем. Раздел показывает каждый на работающем коде (_verify/sys146/, stm-sys:1) и сравнивает с тем, что делает компилятор C++.

Драйверы датчиков из части XI и file_operations ядра в разделе 147 — это тот же приём: таблица функций, одна на класс устройств.

Как это называется в документации

Оригинал Перевод
encapsulation инкапсуляция: скрыть поля за функциями
opaque pointer, incomplete type непрозрачный указатель, неполный тип
virtual function table (vtable) таблица виртуальных функций
polymorphism, dispatch полиморфизм, выбор реализации по объекту
inheritance by composition наследование включением базовой структуры
constructor, destructor конструктор, деструктор
linkage (external, internal) связывание: внешнее, внутреннее (static)
translation unit единица трансляции: один .c со всеми включениями
generic selection (_Generic) выбор по типу на этапе компиляции

146.1 Инкапсуляция: неполный тип

Заголовок объявляет структуру, но не определяет её:

typedef struct counter counter_t;                   /* неполный тип: sizeof неизвестен, поля недоступны */
counter_t *counter_new(int start);
void counter_add(counter_t *c, int d);
int counter_get(const counter_t *c);
void counter_free(counter_t *c);

Определение — только в counter.c:

struct counter { int value; int adds; };            /* определение — только здесь */

Пользователь работает с указателем и функциями; попытка залезть внутрь не собирается:

error: invalid use of incomplete typedef 'counter_t' {aka 'struct counter'}
invalid application of 'sizeof' to incomplete type 'counter_t'

Поля можно менять, добавлять и переставлять, не пересобирая пользователей, — это и есть стабильный ABI (раздел 143). Цена: объект нельзя положить на стек или в другую структуру по значению, только через counter_new/counter_free. Так устроены sqlite3, DIR из <dirent.h> и FILE в musl; в glibc FILE — полный тип. pthread_mutex_t — обратный случай: тип полный, чтобы мьютекс можно было объявить по значению и задать PTHREAD_MUTEX_INITIALIZER, а поля закрыты лишь договорённостью. Второй инструмент инкапсуляции — static на уровне файла: функция с внутренним связыванием не видна из других единиц трансляции, в nm она помечена строчной t против T у внешней.

Без кучи. В прошивке malloc часто запрещён (правило MISRA 21.3, раздел 129), и неполный тип живёт в статическом пуле внутри counter.c: static struct counter pool[COUNTER_MAX]; static size_t used; — counter_new отдаёт &pool[used++], а когда пул кончился, NULL. Сколько объектов, решается при сборке, и в карте памяти пул виден обычным массивом. Другой путь — объявить в заголовке «хранилище» нужного размера, typedef struct { uint32_t opaque[2]; } counter_storage_t;, а в counter.c проверить через _Static_assert, что sizeof(struct counter) <= sizeof(counter_storage_t) и что выравнивание хранилища не меньше. Объект тогда можно положить на стек, поля по-прежнему закрыты, но размер хранилища становится частью ABI: поля растут только в пределах запаса.

Объект хранит указатель на таблицу функций своего класса; вызов идёт через таблицу


146.2 Полиморфизм: таблица функций

Класс — структура с указателями на функции, одна на все объекты класса; объект — структура, первым полем которой лежит указатель на таблицу:

struct shape_ops {                                   /* таблица виртуальных функций: одна на класс */
    const char *name;
    double (*area)(const struct shape *);
    void (*scale)(struct shape *, double);
    void (*destroy)(struct shape *);
};

struct shape {                                       /* базовый класс: первым полем в каждом наследнике */
    const struct shape_ops *ops;
    int id;
};

Виртуальный вызов — на одну строку:

double shape_area(const struct shape *s) { return s->ops->area(s); }

Один массив, разные классы, один цикл:

#1 круг      площадь 3.142
#2 прямоугольник площадь 6.000
#3 круг      площадь 0.785
#2 после scale ×2: 24.0 (было 6, стало ×4)
sizeof(struct shape) = 16, ops у круга и прямоугольника разные: да
в таблице круга 3 указателя на функции

sizeof(struct shape) — 16 байт: указатель на таблицу и int с выравниванием. Ровно столько же занимает базовый класс в C++ с одной виртуальной функцией и int (sizeof(S) C++ = 16): компилятор C++ делает то же самое — кладёт скрытый указатель на vtable первым полем. Разница только в том, что в C таблицу и указатель пишут руками, а ops->area(s) пишут вместо s->area().

Таблицы circle_ops и rect_ops — static const, по одной на класс; в nm они видны как данные (d), а не код. Не r, как ждут от константы, потому что gcc здесь по умолчанию собирает перемещаемую программу (PIE): таблица с адресами функций ложится в .data.rel.ro, и адреса в неё после загрузки дописывает загрузчик. С -fno-pie та же таблица лежит в .rodata (r), и в прошивке для Cortex-M тоже: там .rodata — это Flash, а не ОЗУ.


146.3 Наследование: базовая структура первым полем

struct circle { struct shape base; double r; };     /* наследование: базовая структура — первое поле */

Стандарт гарантирует: указатель на структуру равен указателю на её первое поле. Поэтому struct circle * можно приводить к struct shape * и обратно без смещений, и конструктор возвращает базовый тип:

struct shape *circle_new(double r)
{
    struct circle *c = malloc(sizeof *c);
    c->base.ops = &circle_ops; c->base.id = ++next_id; c->r = r;
    return &c->base;                                 /* указатель на первое поле == указатель на объект */
}

Метод наследника получает базовый указатель и приводит к своему типу:

static double circle_area(const struct shape *s) { const struct circle *c = (const struct circle *)s; return M_PI * c->r * c->r; }

Так в ядре Linux каждое устройство начинается с struct device, а каждый файл — с struct file, и общий код работает с базовой частью, не зная остального. Многоуровневое наследование — вложить базу наследника первым полем следующего; множественное — только через container_of, макрос, который по адресу поля восстанавливает адрес структуры: #define container_of(p, T, m) ((T *)((char *)(p) - offsetof(T, m))). Вторая база лежит не первым полем, и нужен как раз этот сдвиг (offsetof — раздел 126).

Раскладка struct circle: базовая структура из 16 байт первым полем, оба указателя смотрят на один адрес


146.4 Конструктор, деструктор и владение

Конструктор — функция, которая выделяет память и заполняет таблицу; деструктор — поле таблицы, потому что освобождать наследника должен тот, кто знает его размер:

void shape_destroy(struct shape *s) { s->ops->destroy(s); }

Правило владения — одно на проект: кто создал, тот и уничтожает, либо объект хранит счётчик ссылок (get/put, как kref в ядре). Без правила утечки и двойные free неизбежны — valgrind из раздела 145 их находит, но не предотвращает.


146.5 Перегрузка: _Generic

Один вызов для разных типов — не полиморфизм по объекту, а выбор функции по типу аргумента при компиляции:

#define print(x) _Generic((x), int: print_int, double: print_double, const char *: print_str, char *: print_str)(x)
int 42
double 2.5
строка привет

_Generic (C11) выбирает ветвь по типу выражения; так устроены <tgmath.h> и макросы вроде cbrt для float/double/long double. Строковый литерал имеет тип char *, поэтому ветвь const char * одна не сработает — нужны обе. Раздел 127 разбирает _Generic подробнее.


146.6 Когда C, а когда C++

Объектный C оправдан там, где C++ недоступен или нежелателен: ядро, прошивка, библиотека с ABI для всех языков (sqlite3, libcurl, zlib). Всё, что делает компилятор C++, — таблица, приведение, вызов — здесь видно и стоит столько же: sizeof 16 и 16. Чего C не даёт: проверки типов при приведении ((struct circle *)s примет что угодно), автоматического вызова деструктора при выходе из области видимости и исключений. Большие объектные системы на C — GObject (GTK, GStreamer) — добавляют это макросами: G_DECLARE_DERIVABLE_TYPE, счётчик ссылок, сигналы, проверка типов при приведении во время работы.

Что из этого C++ даёт бесплатно, а за что платит прошивка, — раздел 146а.


146.7 Грабли

Симптом Причина
invalid use of incomplete typedef доступ к полям непрозрачного типа из чужого файла — только через функции
invalid application of 'sizeof' to incomplete type объект по значению невозможен — counter_new
приведение к наследнику «работает» с чужим объектом C не проверяет; сверять ops или хранить тег типа
free наследника через базовый указатель теряет память освобождать через ops->destroy, а не free(base)
таблица функций у каждого объекта своя таблица — static const, одна на класс; в объекте — указатель
поле base не первое — приведение ломает объект базовая структура строго первым полем, иначе container_of
_Generic не ловит строковый литерал его тип char *, а не const char *
две функции с одним именем в разных .c конфликтуют нет static — внешнее связывание

146.8 Итоги

Полные исходники

counter.h — непрозрачный тип: снаружи известно только имя.

/* Раздел 146: непрозрачный тип — снаружи известно только имя, поля видит один counter.c. */
#ifndef COUNTER_H
#define COUNTER_H
typedef struct counter counter_t;                   /* неполный тип: sizeof неизвестен, поля недоступны */
counter_t *counter_new(int start);
void counter_add(counter_t *c, int d);
int counter_get(const counter_t *c);
void counter_free(counter_t *c);
#endif

shape.h — «класс» фигуры: таблица виртуальных функций и общий заголовок объекта.

/* Раздел 146: «класс» фигуры — таблица виртуальных функций и общий заголовок объекта. */
#ifndef SHAPE_H
#define SHAPE_H
#include <stddef.h>

struct shape;
struct shape_ops {                                   /* таблица виртуальных функций: одна на класс */
    const char *name;
    double (*area)(const struct shape *);
    void (*scale)(struct shape *, double);
    void (*destroy)(struct shape *);
};

struct shape {                                       /* базовый класс: первым полем в каждом наследнике */
    const struct shape_ops *ops;
    int id;
};

double shape_area(const struct shape *s);            /* виртуальный вызов через таблицу */
void shape_scale(struct shape *s, double k);
void shape_destroy(struct shape *s);

struct shape *circle_new(double r);                  /* конструкторы возвращают базовый указатель */
struct shape *rect_new(double w, double h);
#endif

shapes.c — два «класса» — круг и прямоугольник — с общей таблицей функций.

/* Раздел 146: реализация двух «классов» — круг и прямоугольник — с общей таблицей функций. */
#define _DEFAULT_SOURCE                              /* M_PI из math.h */
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include "shape.h"

static int next_id;

/* --- круг --- */
struct circle { struct shape base; double r; };     /* наследование: базовая структура — первое поле */

static double circle_area(const struct shape *s) { const struct circle *c = (const struct circle *)s; return M_PI * c->r * c->r; }
static void circle_scale(struct shape *s, double k) { ((struct circle *)s)->r *= k; }
static void generic_destroy(struct shape *s) { free(s); }

static const struct shape_ops circle_ops = { "круг", circle_area, circle_scale, generic_destroy };

struct shape *circle_new(double r)
{
    struct circle *c = malloc(sizeof *c);
    c->base.ops = &circle_ops; c->base.id = ++next_id; c->r = r;
    return &c->base;                                 /* указатель на первое поле == указатель на объект */
}

/* --- прямоугольник --- */
struct rect { struct shape base; double w, h; };

static double rect_area(const struct shape *s) { const struct rect *r = (const struct rect *)s; return r->w * r->h; }
static void rect_scale(struct shape *s, double k) { struct rect *r = (struct rect *)s; r->w *= k; r->h *= k; }

static const struct shape_ops rect_ops = { "прямоугольник", rect_area, rect_scale, generic_destroy };

struct shape *rect_new(double w, double h)
{
    struct rect *r = malloc(sizeof *r);
    r->base.ops = &rect_ops; r->base.id = ++next_id; r->w = w; r->h = h;
    return &r->base;
}

/* --- виртуальные вызовы --- */
double shape_area(const struct shape *s) { return s->ops->area(s); }
void shape_scale(struct shape *s, double k) { s->ops->scale(s, k); }
void shape_destroy(struct shape *s) { s->ops->destroy(s); }

Дальше: раздел 146а «C++ на микроконтроллере: что бесплатно, а что дорого».

Открыть в интерактивном справочнике