Question

Object-Oriented Programming in C: Structs, Function Pointers, and Polymorphism

coopobject

Question

What are the common ways to write object-oriented style code in C, especially when you need polymorphism?

In particular, how can C programs model ideas such as:

  • objects with data and behavior
  • encapsulation
  • inheritance-like composition
  • polymorphic behavior

Although C is not an object-oriented language, many codebases still use object-oriented design patterns. What practical techniques are used to achieve this in C?

Short Answer

By the end of this page, you will understand how C can emulate object-oriented programming using structs, functions, opaque types, and function pointers. You will learn how to represent objects, group data with behavior, hide implementation details, and build polymorphic interfaces in a way that is common in real C codebases.

Concept

C does not have built-in classes, inheritance, or virtual methods. However, it gives you low-level building blocks that let you model many object-oriented ideas manually.

The main techniques are:

  • struct for object state: a struct holds the data for an object.
  • Functions for methods: regular functions operate on a pointer to that struct.
  • Opaque pointers for encapsulation: users see only a forward declaration, not the internal fields.
  • Function pointers for polymorphism: a table of function pointers can act like virtual methods.
  • Composition for reuse: instead of inheritance, C often nests one struct inside another.

For example, in C you might represent a Shape with function pointers like area and draw. A Circle and a Rectangle can each provide their own implementations of those functions. Then code can work with any Shape through a common interface.

This matters because many systems programs, embedded projects, graphics libraries, kernels, and game engines are written in C but still need clean modular design. Object-oriented style in C helps organize code, reduce duplication, and support extensibility without requiring a different language.

Mental Model

Think of a C struct as a device, and function pointers as the buttons attached to it.

  • The data fields inside the struct are the device's internal parts.
  • The functions are the actions the device can perform.
  • A pointer to the struct is the actual device you are holding.
  • A function pointer table is like a label that says, "When someone presses the draw button, use this specific implementation."

So instead of a class with methods built into the language, you build the same idea yourself:

  • data lives in a struct
  • behavior lives in functions
  • dynamic behavior is selected through function pointers

It is manual, but the design idea is very similar to object-oriented programming.

Take Quiz

Syntax and Examples

In C, the most common object-oriented pattern looks like this:

#include <stdio.h>

typedef struct {
    int x;
    int y;
} Point;

void Point_move(Point *p, int dx, int dy) {
    p->x += dx;
    p->y += dy;
}

void Point_print(const Point *p) {
    printf("Point(%d, %d)\n", p->x, p->y);
}

int main(void) {
    Point p = {2, 3};
    Point_print(&p);
    Point_move(&p, 5, -1);
    Point_print(&p);
    return 0;
}

This gives you an object-like design:

  • Point is the object data
  • Point_move and Point_print are methods
  • the first parameter, Point *p, plays the role of this in object-oriented languages

Adding polymorphism with function pointers

#include <stdio.h>
#include <stdlib.h>

typedef struct Shape Shape;

typedef struct {
    double (*area)(const Shape *self);
    void (*describe)(const Shape *self);
} ShapeVTable;

struct Shape {
    const ShapeVTable *vtable;
};

typedef struct {
    Shape base;
    double radius;
} Circle;

double Circle_area(const Shape *self) {
    const Circle *circle = (const Circle *)self;
    return 3.14159 * circle->radius * circle->radius;
}

void Circle_describe(const Shape *self) {
    const Circle *circle = (const Circle *)self;
    printf("Circle(radius=%.2f)\n", circle->radius);
}

const ShapeVTable circle_vtable = {
    Circle_area,
    Circle_describe
};

int main(void) {
    Circle c;
    c.base.vtable = &circle_vtable;
    c.radius = 4.0;

    Shape *shape = (Shape *)&c;
    shape->vtable->describe(shape);
    printf("Area: %.2f\n", shape->vtable->area(shape));

    return 0;
}

What this example shows

  • Shape is the common base interface
  • ShapeVTable stores function pointers for shared operations
  • Circle contains Shape as its first field
  • a Shape * can point to a Circle
  • calling through shape->vtable->area(shape) gives polymorphic behavior

This is one of the closest C equivalents to virtual methods in class-based languages.

Step by Step Execution

Consider this small example:

#include <stdio.h>

typedef struct Animal Animal;

typedef struct {
    void (*speak)(const Animal *self);
} AnimalVTable;

struct Animal {
    const AnimalVTable *vtable;
};

typedef struct {
    Animal base;
    const char *name;
} Dog;

void Dog_speak(const Animal *self) {
    const Dog *dog = (const Dog *)self;
    printf("%s says woof!\n", dog->name);
}

const AnimalVTable dog_vtable = {
    Dog_speak
};

int main(void) {
    Dog d;
    d.base.vtable = &dog_vtable;
    d.name = "Rex";

    Animal *a = (Animal *)&d;
    a->vtable->speak(a);
    return 0;
}

Step by step

  1. AnimalVTable defines one operation: speak.
  2. Animal contains a pointer to its vtable.
  3. Dog starts with an Animal base field, then adds name.
  4. Dog_speak receives const Animal *self because it matches the common interface.
  5. Inside Dog_speak, the pointer is cast back to const Dog * so the function can access dog->name.
  6. In main, d.base.vtable = &dog_vtable; connects this object to the dog implementation.
  7. Animal *a = (Animal *)&d; treats the dog as a generic animal.
  8. a->vtable->speak(a); looks up the correct function in the vtable and calls Dog_speak.
  9. The output is:
Rex says woof!

The important part is that the calling code only knows about Animal, but the correct Dog behavior still runs.

Real World Use Cases

Object-oriented style in C is useful when code needs a clear interface but different implementations.

Common use cases

  • Device drivers
    • Different hardware devices expose the same operations such as init, read, and write.
  • GUI or graphics systems
    • Different widgets or shapes may all support draw, resize, or handle_event.
  • Parsers and compilers
    • Different node types in an abstract syntax tree can support operations like print, evaluate, or free.
  • Networking libraries
    • Different transport types may implement a common send/receive interface.
  • Embedded systems
    • Sensors, actuators, and communication modules often share a small interface but have different implementations.
  • Game engines
    • Entities may share operations such as update and render while storing different data.

Why C developers use this style

  • It keeps modules organized.
  • It allows code to depend on interfaces instead of concrete implementations.
  • It makes it easier to add new types without rewriting all calling code.
Take Quiz

Real Codebase Usage

In real C projects, object-oriented style is usually applied selectively, not everywhere.

Common patterns

1. Opaque structs for encapsulation

In a header:

typedef struct FileReader FileReader;

FileReader *FileReader_create(const char *path);
void FileReader_destroy(FileReader *reader);
int FileReader_read_line(FileReader *reader, char *buffer, int size);

In the .c file:

struct FileReader {
    FILE *fp;
    int line_count;
};

This hides implementation details from users.

2. Constructor/destructor-style functions

C has no constructors, so code often uses:

  • create / destroy
  • init / cleanup
  • open / close

This makes ownership explicit.

3. Guard clauses and validation

int Shape_draw(const Shape *shape) {
    if (shape == NULL || shape->vtable == NULL || shape->vtable->describe == NULL) {
        return -1;
    }

    shape->vtable->describe(shape);
    return 0;
}

This is important because C does not protect you from invalid pointers.

4. Interface structs with function pointers

Libraries often define a small API table:

typedef struct {
    int (*open)(void *ctx);
    int (*close)(void *ctx);
    int (*read)(void *ctx, char *buf, int size);
} StreamOps;

This lets different backends plug into the same API.

5. Composition over inheritance

Instead of deep inheritance trees, C code often stores reusable components as fields inside structs.

typedef struct {
    int x;
    int y;
} Position;

typedef struct {
    Position pos;
    int health;
} Player;

This is simple, explicit, and easy to maintain.

Common Mistakes

1. Forgetting to initialize function pointers

Broken code:

Shape s;
s.vtable->area(&s);  // undefined behavior

Why it fails:

  • s.vtable was never assigned.

Fix:

s.vtable = &some_vtable;

2. Casting to the wrong derived type

Broken code:

const Rectangle *r = (const Rectangle *)self;

Why it fails:

  • If self actually points to a Circle, the cast is invalid and leads to wrong memory access.

How to avoid it:

  • Ensure the object's vtable matches the implementation being called.
  • Keep object creation and vtable assignment tightly controlled.

3. Assuming C gives real inheritance

C does not understand inheritance rules. This pattern only works because you manually design memory layout and interfaces.

Avoid assumptions like:

  • automatic method dispatch
  • runtime type safety
  • base-class destructors

You must implement these yourself.

4. Exposing struct internals when you want encapsulation

If users can access all fields directly, they can break invariants.

Instead of this in the header:

typedef struct {
    int size;
    char *buffer;
} Buffer;

Prefer an opaque type when internal details should be hidden.

5. Mixing ownership rules

Broken design:

  • One function allocates memory
  • Another function sometimes frees it
  • The caller is not sure who owns the object

How to avoid it:

  • Define clear ownership rules
  • Use consistent naming such as create/destroy or init/cleanup

6. Overengineering small programs

Not every C program needs vtables and object hierarchies.

For simple tasks, plain structs and functions are usually better.

Use object-oriented patterns when they genuinely improve structure or extensibility.

Comparisons

ConceptIn CIn class-based OOP languages
Object datastructclass instance fields
Methodregular function taking self pointermember function / method
Encapsulationopaque struct + header/source splitprivate / public
Polymorphismfunction pointers / vtablevirtual methods / interfaces
Inheritancemanual composition or embedded base structbuilt-in inheritance
Type safetymanual and limitedlanguage enforced

Function pointers vs direct function calls

ApproachBest forTrade-off
Direct function callsimple code with fixed behaviornot polymorphic
Function pointerinterchangeable implementationsmore complexity

Composition vs inheritance-like embedding

ApproachExampleWhen to use
CompositionPlayer has a Position fieldreuse data cleanly
Embedded base structCircle starts with Shape baseshared interface with polymorphism

C-style OOP vs just procedural C

StyleGood forLimitation
Plain procedural Cstraightforward programsless flexible abstraction
OOP-style Cmodular libraries and interchangeable implementationsmore boilerplate
Take Quiz

Cheat Sheet

Quick reference

Object-like design in C

typedef struct {
    int value;
} Counter;

void Counter_increment(Counter *c) {
    c->value++;
}
  • Use a struct for data
  • Use functions for behavior
  • Pass StructName *self as the first argument

Encapsulation

In header:

typedef struct MyType MyType;
MyType *MyType_create(void);
void MyType_destroy(MyType *obj);

In source:

struct MyType {
    int hidden;
};

Polymorphism pattern

typedef struct Base Base;

typedef struct {
    void (*do_work)(Base *self);
} BaseVTable;

struct Base {
    const BaseVTable *vtable;
};

Derived type:

typedef struct {
    Base base;
    int extra;
} Derived;

Call polymorphically:

base_ptr->vtable->do_work(base_ptr);

Rules to remember

  • Initialize every vtable pointer before use
  • Keep the embedded base struct in a predictable place, usually first
  • Use opaque types when hiding implementation details
  • Use create/destroy or init/cleanup consistently
  • Validate pointers before calling through function pointers

Edge cases

  • A wrong cast can cause undefined behavior
  • Uninitialized function pointers are dangerous
  • C does not provide automatic destructors or runtime type checks

FAQ

How can C support object-oriented programming if it has no classes?

C does not support OOP directly, but you can build similar patterns using structs for data and functions for behavior.

How do you implement polymorphism in C?

The usual technique is to store function pointers in a struct, often called a vtable, and call behavior through that table.

Is inheritance possible in C?

Not as a language feature. You can simulate parts of it by embedding one struct inside another and designing a shared interface.

What is the C equivalent of a method?

A regular function that takes a pointer to the object as its first parameter.

How do you hide private fields in C?

Use an opaque struct: forward-declare the type in the header and define the full struct only in the source file.

Should all C code be written in an object-oriented style?

No. Many programs are clearer with simple procedural code. Use OOP-style patterns when you need modularity, encapsulation, or interchangeable implementations.

Are function pointers in C the same as virtual methods in C++?

They serve a similar purpose for dynamic dispatch, but in C you must build and manage the mechanism manually.

What is the biggest risk when emulating OOP in C?

Manual memory management and manual type handling. Mistakes can easily lead to undefined behavior.

Take Quiz

Mini Project

Description

Build a small shape system in C that demonstrates object-oriented style design. You will create a common Shape interface and two implementations: Circle and Rectangle. Each shape will support the same operations, but each type will calculate its area differently. This mirrors how real C libraries use structs and function pointers to support interchangeable behavior.

Goal

Create a C program where different shape types can be stored and used through one shared interface, then print each shape and its area polymorphically.

Requirements

  • Define a base Shape type with a vtable containing area and describe function pointers.
  • Create Circle and Rectangle structs that include Shape as a base field.
  • Implement type-specific area and describe functions for each shape.
  • Store both shape types in an array of Shape * and process them through the shared interface.
  • Print each shape description and computed area.
Take Quiz

Keep learning

Related questions

View more