How do you implement a class in C?
Categories:
Implementing Object-Oriented Concepts in C: A Practical Guide
Explore how to simulate classes, objects, and polymorphism in C, a procedural language, using structs, function pointers, and careful memory management. This guide is especially useful for embedded systems development.
While C is fundamentally a procedural language, it's often necessary to adopt object-oriented programming (OOP) paradigms, especially in complex projects or embedded systems where C++ might be too resource-intensive. This article will guide you through the techniques to simulate classes, objects, encapsulation, and polymorphism in C, leveraging structs, function pointers, and conventions to achieve a modular and maintainable codebase.
The Foundation: Structs as Classes
In C, the struct
keyword is the closest equivalent to a class for defining custom data types. A struct
can encapsulate related data members, much like a class's attributes. To add behavior (methods), we embed function pointers within the struct. This allows each 'object' instance to have its own set of functions, or more commonly, to point to shared functions that operate on the instance's data.
typedef struct {
int x;
int y;
void (*print)(void*);
void (*move)(void*, int, int);
} Point;
// Constructor-like function
Point* Point_create(int x, int y);
// Destructor-like function
void Point_destroy(Point* p);
// Method implementations
void Point_print(void* self);
void Point_move(void* self, int dx, int dy);
Defining a 'Point' class using a struct and function pointers.
Encapsulation and Information Hiding
Encapsulation, the bundling of data with the methods that operate on the data, is achieved in C through careful header file design. By declaring the struct's members in the .c
file (or using an opaque pointer in the header), and only exposing public functions in the header, you can hide implementation details. This forces users of your 'class' to interact with it only through the defined interface, promoting modularity and reducing coupling.
// point.h
#ifndef POINT_H
#define POINT_H
typedef struct Point Point; // Opaque pointer declaration
Point* Point_create(int x, int y);
void Point_destroy(Point* p);
void Point_print(Point* p);
void Point_move(Point* p, int dx, int dy);
#endif // POINT_H
// point.c
#include "point.h"
#include <stdio.h>
#include <stdlib.h>
struct Point { // Full definition in .c file
int x;
int y;
};
// Private helper function (not exposed in header)
static void Point_internal_print(Point* p) {
printf("Point (internal): (%d, %d)\n", p->x, p->y);
}
Point* Point_create(int x, int y) {
Point* p = (Point*)malloc(sizeof(Point));
if (p) {
p->x = x;
p->y = y;
}
return p;
}
void Point_destroy(Point* p) {
free(p);
}
void Point_print(Point* p) {
if (p) {
printf("Point: (%d, %d)\n", p->x, p->y);
}
}
void Point_move(Point* p, int dx, int dy) {
if (p) {
p->x += dx;
p->y += dy;
}
}
Implementing encapsulation using opaque pointers and separate header/source files.
Simulating Polymorphism with Function Pointers
Polymorphism, the ability of objects of different types to respond to the same method call in a type-specific way, can be simulated using a common base struct and function pointers. A 'base class' struct can define a set of common function pointers (a 'virtual table' or vtable). Derived 'classes' then implement these functions and assign their addresses to the corresponding function pointers in their instances. This allows you to treat different 'objects' uniformly through a pointer to the base type.
Polymorphism in C using a base struct and function pointers.
// shape.h
#ifndef SHAPE_H
#define SHAPE_H
typedef struct Shape Shape;
struct Shape {
void (*draw)(Shape*);
double (*area)(Shape*);
};
// circle.h
#ifndef CIRCLE_H
#define CIRCLE_H
#include "shape.h"
typedef struct Circle Circle;
Circle* Circle_create(double radius);
void Circle_destroy(Circle* c);
#endif // CIRCLE_H
// circle.c
#include "circle.h"
#include <stdio.h>
#include <stdlib.h>
static void Circle_draw(Shape* s) {
Circle* c = (Circle*)s;
printf("Drawing Circle with radius %.2f\n", c->radius);
}
static double Circle_area(Shape* s) {
Circle* c = (Circle*)s;
return 3.14159 * c->radius * c->radius;
}
struct Circle {
Shape base; // Embed the base struct
double radius;
};
Circle* Circle_create(double radius) {
Circle* c = (Circle*)malloc(sizeof(Circle));
if (c) {
c->base.draw = Circle_draw;
c->base.area = Circle_area;
c->radius = radius;
}
return c;
}
void Circle_destroy(Circle* c) {
free(c);
}
// main.c (example usage)
#include "circle.h"
#include <stdio.h>
int main() {
Circle* myCircle = Circle_create(5.0);
// Treat Circle as a Shape
Shape* shapePtr = (Shape*)myCircle;
shapePtr->draw(shapePtr);
printf("Area: %.2f\n", shapePtr->area(shapePtr));
Circle_destroy(myCircle);
return 0;
}
Implementing a polymorphic 'Shape' hierarchy with 'Circle' as a derived type.
void* self
or Shape* s
to methods, remember to cast it back to the specific type (Point* p = (Point*)self;
or Circle* c = (Circle*)s;
) inside the method implementation to access type-specific members.Constructor and Destructor Patterns
C doesn't have built-in constructors or destructors, but you can implement functions that serve similar purposes. A 'constructor' function typically allocates memory for the object and initializes its members, including setting up function pointers. A 'destructor' function frees allocated memory and performs any necessary cleanup. These functions are crucial for managing object lifecycle and preventing memory leaks.
// Example of a constructor-like function
MyObject* MyObject_create(int initial_value) {
MyObject* obj = (MyObject*)malloc(sizeof(MyObject));
if (obj == NULL) {
// Handle allocation failure
return NULL;
}
obj->data = initial_value;
obj->method_a = &MyObject_method_a_impl;
obj->method_b = &MyObject_method_b_impl;
return obj;
}
// Example of a destructor-like function
void MyObject_destroy(MyObject* obj) {
if (obj != NULL) {
// Perform any necessary cleanup (e.g., free internal arrays)
free(obj);
}
}
Common patterns for constructor and destructor functions in C.
malloc
with free
to prevent memory leaks. For complex objects, ensure your destructor frees all dynamically allocated internal resources.