Embedded HandbookApps
All sections › XVII. Systems programming in C
· level intermediate · 11 min read
Open in the interactive handbook

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 static
translation unit one .c file with all its includes
generic 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.

An object holds a pointer to its class's function table; the call goes through the table


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).

Layout of struct circle: the 16-byte base structure as the first field, both pointers point to the same address


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

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".

Open in the interactive handbook