146. Object-oriented C: opaque pointers, function tables, inheritance, _Generic
C has no classes, and that does not stop you from writing object-oriented code: the Linux kernel, GTK,
SQLite and device drivers are built in an object-oriented way — from structures with
function pointers. The three ideas of OOP — encapsulation, polymorphism,
inheritance — are expressed in C by three techniques: an incomplete type, a function
table, and a base structure as the first field. This section shows each one in
working code (_verify/sys146/, stm-sys:1) and compares it with
what a C++ compiler does.
The sensor drivers from part XI and the kernel's file_operations in section 147
use the same technique: a function table, one per device class.
What it is called in the documentation
Term Meaning encapsulation hiding fields behind functions opaque pointer, incomplete type a pointer to a type whose fields are not visible, a type declared but not defined virtual function table (vtable) a table of function pointers, one per class polymorphism, dispatch choosing the implementation by the object inheritance by composition inheritance by embedding the base structure constructor, destructor functions that create and destroy an object linkage (external, internal) whether a name is visible in other files; internal means statictranslation unit one .cfile with all its includesgeneric selection ( _Generic)choosing by type at compile time
146.1 Encapsulation: an incomplete type
The header declares the structure but does not define it:
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);
The definition is only in counter.c:
struct counter { int value; int adds; }; /* определение — только здесь */
The user works with the pointer and the functions; an attempt to reach inside does not compile:
error: invalid use of incomplete typedef 'counter_t' {aka 'struct counter'}
invalid application of 'sizeof' to incomplete type 'counter_t'
The fields can be changed, added and reordered without rebuilding
the users — this is what a stable ABI is (section 143). The price: the object
cannot be placed on the stack or inside another structure by value, only
through counter_new/counter_free. This is how sqlite3, DIR
from <dirent.h> and FILE in musl are built; in glibc FILE is a complete type.
pthread_mutex_t is the opposite case: the type is complete so that a mutex can be
declared by value and initialized with PTHREAD_MUTEX_INITIALIZER, and the fields are hidden
only by convention. The second encapsulation tool is
static at file scope: a function with internal linkage is not visible
from other translation units, and nm marks it with a lowercase t
versus T for an external one.
Without a heap. In firmware malloc is often forbidden (MISRA rule 21.3,
section 129), and the incomplete type lives in a static pool inside counter.c:
static struct counter pool[COUNTER_MAX]; static size_t used; —
counter_new hands out &pool[used++], and when the pool runs out, NULL. The number of
objects is decided at build time, and in the memory map the pool appears as an ordinary
array. Another way is to declare in the header a "storage" type of the required size,
typedef struct { uint32_t opaque[2]; } counter_storage_t;, and in
counter.c check with _Static_assert that
sizeof(struct counter) <= sizeof(counter_storage_t) and that the alignment
of the storage is not smaller. The object can then be placed on the stack, and the fields
are still hidden, but the storage size becomes part of the ABI: the fields
can grow only within the spare room.
146.2 Polymorphism: a function table
A class is a structure of function pointers, one for all objects of the class; an object is a structure whose first field is a pointer to the table:
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;
};
A virtual call takes one line:
double shape_area(const struct shape *s) { return s->ops->area(s); }
One array, different classes, one loop:
#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) is 16 bytes: a pointer to the table and an int with
alignment. A C++ base class with one
virtual function and an int takes exactly the same (sizeof(S) C++ = 16): the C++ compiler
does the same thing — it puts a hidden vtable pointer as the first field.
The only difference is that in C you write the table and the pointer by hand, and
write ops->area(s) instead of s->area().
The tables circle_ops and rect_ops are static const, one per class;
nm shows them as data (d), not code. Not r, as you would expect for
a constant, because gcc here builds a position-independent
executable (PIE) by default: a table with function addresses goes into .data.rel.ro, and
the loader writes the addresses into it after loading. With -fno-pie the same
table sits in .rodata (r), and so it does in Cortex-M firmware: there
.rodata is Flash, not RAM.
146.3 Inheritance: the base structure as the first field
struct circle { struct shape base; double r; }; /* наследование: базовая структура — первое поле */
The standard guarantees that a pointer to a structure equals a pointer to its
first field. So a struct circle * can be cast to
struct shape * and back without offsets, and the constructor returns
the base type:
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; /* указатель на первое поле == указатель на объект */
}
A method of the derived class receives the base pointer and casts it to its own type:
static double circle_area(const struct shape *s) { const struct circle *c = (const struct circle *)s; return M_PI * c->r * c->r; }
This is how in the Linux kernel every device starts with struct device, and
every file with struct file, and common code works with the base part
without knowing the rest. Multi-level inheritance means putting the derived
class's base as the first field of the next one; multiple inheritance works only through
container_of, a macro that recovers the address of a structure from the address of a field:
#define container_of(p, T, m) ((T *)((char *)(p) - offsetof(T, m))).
The second base is not the first field, and that offset is exactly what is needed (offsetof —
section 126).
146.4 Constructor, destructor and ownership
A constructor is a function that allocates memory and fills in the table; a destructor is a table entry, because a derived object must be freed by whoever knows its size:
void shape_destroy(struct shape *s) { s->ops->destroy(s); }
The ownership rule is one per project: whoever created the object destroys it, or
the object keeps a reference count (get/put, like kref in the kernel). Without
such a rule leaks and double free calls are inevitable — valgrind from section 145
finds them but does not prevent them.
146.5 Overloading: _Generic
One call for different types — this is not polymorphism by object but a choice of function by the argument type at compile time:
#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) picks a branch by the type of the expression; this is how
<tgmath.h> and macros such as cbrt for float/double/long double are built.
A string literal has type char *, so a const char * branch
alone will not match — you need both. Section 127 covers _Generic in more detail.
146.6 When C and when C++
Object-oriented C is justified where C++ is unavailable or undesirable: the kernel,
firmware, a library with an ABI for all languages (sqlite3, libcurl,
zlib). Everything a C++ compiler does — the table, the cast, the call —
is visible here and costs the same: sizeof 16 and 16. What C does not give you:
type checking on casts ((struct circle *)s accepts anything),
automatic destructor calls when leaving a scope, and
exceptions. Large object systems in C — GObject (GTK, GStreamer) —
add these with macros: G_DECLARE_DERIVABLE_TYPE, a reference count,
signals, run-time type checking on casts.
What of this C++ gives for free and what firmware pays for is covered in section 146а.
146.7 Pitfalls
| Symptom | Cause |
|---|---|
invalid use of incomplete typedef |
accessing fields of an opaque type from another file — use the functions only |
invalid application of 'sizeof' to incomplete type |
an object by value is impossible — counter_new |
| a cast to a derived type "works" on a foreign object | C does not check; compare ops or keep a type tag |
free of a derived object through the base pointer leaks memory |
free through ops->destroy, not free(base) |
| every object has its own function table | the table is static const, one per class; the object holds a pointer |
the base field is not first — the cast breaks the object |
the base structure strictly as the first field, otherwise container_of |
_Generic does not catch a string literal |
its type is char *, not const char * |
two functions with the same name in different .c files conflict |
no static — external linkage |
146.8 Summary
- An incomplete type in the header gives encapsulation and a stable ABI; the fields live only in the
.c. - A class is a
static constfunction table, an object holds a pointer to it as the first field; the call iss->ops->area(s); 16 bytes, as in C++. - Inheritance is the base structure as the first field; casts need no offset.
- The destructor is in the table; the ownership rule is one per project.
_Genericchooses by type at compile time, bothchar *andconst char *.
Full sources
counter.h — an opaque type: only the name is known from outside.
/* Раздел 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 — the shape "class": the virtual function table and the common object header.
/* Раздел 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 — two "classes", a circle and a rectangle, with a common function table.
/* Раздел 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); }
Next: section 146а "C++ on a microcontroller: what is free and what is expensive".