Object-style design
C is not an object-oriented language. There is no class keyword, no method dispatch, no inheritance, no constructor invocation, no access modifiers. What C has is struct and function pointers, out of which a programmer can construct several of the patterns that object orientation packages — encapsulation through opaque types, polymorphism through function-pointer tables, composition through embedding — but with no language-level support for any of them. The patterns documented here are conventions, not features. They appear regularly in production C code (the Linux kernel, GTK, X11, libcurl, Postgres) and serve the same architectural purposes as classes do in object-oriented languages, with the trade-off that the boilerplate is the programmer’s responsibility and the compiler enforces less.
Encapsulation through opaque types
The conventional mechanism for hiding the representation of a type is the opaque type: a structure declared in the public header, defined only in the implementation file, accessed through pointers.
/* widget.h */
typedef struct widget widget; /* forward declaration; size unknown to users */
widget *widget_create(int initial);
void widget_destroy(widget *w);
int widget_value(const widget *w);
void widget_set_value(widget *w, int v);
/* widget.c */
#include "widget.h"
#include <stdlib.h>
struct widget {
int value;
int revision;
void *user_data;
};
widget *widget_create(int initial) {
widget *w = malloc(sizeof *w);
if (!w) return NULL;
w->value = initial;
w->revision = 0;
w->user_data = NULL;
return w;
}
void widget_destroy(widget *w) {
free(w);
}
int widget_value(const widget *w) { return w->value; }
void widget_set_value(widget *w, int v) { w->value = v; ++w->revision; }
A user of the library obtains a widget * from widget_create, passes it through the public functions, and disposes of it with widget_destroy. The internal layout — the fact that widget contains a revision and a user_data — is invisible. The compiler enforces the encapsulation: a user cannot allocate a widget directly (the type is incomplete in their translation unit), cannot read w->value (no member access on an incomplete type), and cannot accidentally rely on layout details.
The trade-offs:
- The opaque type cannot be allocated on the stack by the user; every instance is heap-allocated.
- The compiler cannot inline the accessor functions across translation units (without link-time optimisation).
- The interface is verbose: every operation needs a function.
Where these costs are unacceptable — small, performance-sensitive types like a 2D point — the conventional alternative is to expose the structure and rely on convention rather than the type system to maintain invariants.
Polymorphism through function-pointer tables
C’s mechanism for run-time polymorphism is the vtable: a structure of function pointers, attached to each instance of a type, dispatched through the pointer. The pattern is the same one C++ generates implicitly for virtual functions and the one that GObject (the GTK object system) and the Linux kernel’s driver model use explicitly.
typedef struct shape shape;
typedef struct {
double (*area)(const shape *);
void (*draw)(const shape *);
void (*destroy)(shape *);
} shape_ops;
struct shape {
const shape_ops *ops;
};
double shape_area(const shape *s) { return s->ops->area(s); }
void shape_draw(const shape *s) { s->ops->draw(s); }
void shape_destroy(shape *s) { s->ops->destroy(s); }
The function shape_area does not know what kind of shape it has; it dispatches through the ops table to the implementation registered when the shape was created.
A concrete shape:
typedef struct {
shape base; /* must be the first member */
double radius;
} circle;
static double circle_area(const shape *s) {
const circle *c = (const circle *)s;
return 3.14159 * c->radius * c->radius;
}
static void circle_draw(const shape *s) {
const circle *c = (const circle *)s;
printf("circle r=%g\n", c->radius);
}
static void circle_destroy(shape *s) {
free(s);
}
static const shape_ops circle_ops = {
.area = circle_area,
.draw = circle_draw,
.destroy = circle_destroy,
};
shape *circle_new(double radius) {
circle *c = malloc(sizeof *c);
if (!c) return NULL;
c->base.ops = &circle_ops;
c->radius = radius;
return &c->base;
}
The pattern relies on three conventions:
- The base structure is the first member. A pointer to
circleand a pointer to itsbasemember share the same address (the standard guarantees this). The cast fromshape *back tocircle *is therefore well-defined. - The vtable is
static constand shared. Every circle instance points to the samecircle_opstable; the table is not duplicated per instance. - The “constructor” returns a
shape *. Callers see only the abstract type; they invoke operations through the public dispatch functions.
For comparison with the object-oriented version: the construction is more verbose at every step, but the mechanism is identical (object-oriented compilers generate the same machine code). The trade-off is again that the compiler does not enforce the conventions: forgetting to set ops, putting the base member in the wrong position, or duplicating the vtable per instance are mistakes the compiler cannot diagnose.
Composition through embedding
The “base member at offset 0” pattern is also the conventional mechanism for composition. A structure that contains another structure as its first member behaves, in pointer-cast terms, as a subtype of the inner structure:
typedef struct {
int refcount;
void (*destroy)(void *);
} object;
typedef struct {
object base;
char *name;
} named;
A named * may be cast to object *; the reference count and destroy callback are accessible through either view. The pattern is the conventional way to share infrastructure (reference counting, observer registration, type identification) across many types.
The Linux kernel’s container_of macro is the inverse operation: given a pointer to an embedded member, recover the pointer to the enclosing structure:
#define container_of(ptr, type, member) \
((type *)((char *)(ptr) - offsetof(type, member)))
object *o = ...;
named *n = container_of(o, named, base);
The macro lets a generic infrastructure (a linked-list implementation, a reference-counted object pool) carry pointers to the embedded member and recover the enclosing instance when the type-specific operation is required. The kernel’s <linux/list.h> is built around this idiom.
Constructors and destructors
C has no constructor invocation. The conventions:
- A factory function allocates an instance and initialises its members. By convention
T *T_create(...)orT *T_new(...). - A destroy function releases any owned resources and frees the instance. By convention
void T_destroy(T *)orvoid T_free(T *).
widget *widget_create(int initial);
void widget_destroy(widget *w);
The discipline:
- Every
_create/_newcorresponds to exactly one_destroy/_free. - The destroy function accepts a null pointer as a no-op (matches
free). - The destroy function releases all owned resources (allocations, file descriptors, mutexes), in the reverse order of acquisition.
Stack-allocated structures conventionally use initialise and deinit pairs that operate on a caller-provided structure:
typedef struct { int *items; size_t count, capacity; } vector;
void vector_init(vector *v) { v->items = NULL; v->count = 0; v->capacity = 0; }
void vector_deinit(vector *v) { free(v->items); v->items = NULL; v->count = v->capacity = 0; }
The caller is responsible for storage; the operations only manage the contents. The pattern works well for short-lived, scope-bound objects.
Inheritance and abstract classes
C does not have inheritance. The two patterns above — vtables and base-member embedding — together approximate single inheritance. Every concrete type contains a base structure as its first member; every concrete type provides a vtable of operations the base declares. The hierarchy is shallow by convention: deeper hierarchies, while possible, are difficult to read and require multiple-level casts.
C has no language mechanism for abstract methods. The conventional pattern is to require every implementation to fill every entry in the vtable; a vtable entry that has no implementation is set to NULL, and the dispatch site checks before calling. The check is a discipline; the compiler does not enforce that the vtable is complete.
Access control
C has no private/protected/public. The conventions:
- Public functions are declared in the header.
- Private (file-internal) functions are declared
staticat file scope; they are invisible outside the translation unit. - Protected (visible to derived types) does not have a clean analogue; one approach is to declare the would-be-protected functions in a “private” header (
widget_internal.h) that derived types include but library users do not.
The typical project structure:
include/widget.h # public API
src/widget_internal.h # protected: shared with derived types
src/widget.c # implementation, includes both
src/widget_text.c # a derived type, includes widget_internal.h
The mechanism is convention; nothing prevents a user from including widget_internal.h, but doing so signals an unsupported use.
When the patterns are not worth it
The full vtable-and-base-member pattern is a large amount of code per type. It is the conventional choice when:
- Many concrete types share an interface, and the implementations are genuinely heterogeneous.
- Run-time selection of behaviour is necessary (the type is not known at the call site).
- The implementation lives in a separate library and the consumer cannot directly know its concrete types.
When these criteria are not met, simpler alternatives are usually clearer:
- A tagged union (an
enumplus aunion, dispatched withswitch) suffices when the set of variants is closed and fixed. - A function pointer in the structure, without a separate vtable, suffices when there is exactly one operation.
- A plain struct with public members suffices when no invariants need to be maintained.
C makes the cost of object orientation visible at every step. Code that needs object orientation gets it; code that does not gets simpler structures by default.