Codementor Events

Object-Oriented Programming (OOP) in C

Published Mar 25, 2016Last updated Jan 18, 2017
Object-Oriented Programming (OOP) in C

Overview

Programming languages like C++ and Java have built-in support for OOP concepts. However, did you know that you don't need to use an OOP language in order to use OOP style and get some of the benefits of object-oriented programming? In this tutorial, I will explain how we can bring some of the style of object-oriented programming to C, a language without built-in OOP support.

Simple, non-polymorphic types

Let's consider a simple class that cannot be overriden (has no virtual methods):

 // Header
class Point {
 public:
    Point(int x, int y);
    ~Point();
    int x() const;
    int y() const;
 private:
    const int x_;
    const int y_;
 };

 // Source file
 Point::Point(int x, int y) : x_(x), y_(y) {}
 Point::~Point() {}
 int Point::x() const { return x_; }
 int Point::y() const { return y_; }

The header for this might be translated to C as follows:

// Header
struct Point;  // forward declared for encapsulation
Point* Point__create(int x, int y);  // equivalent to "new Point(x, y)"
void Point__destroy(Point* self);  // equivalent to "delete point"
int Point__x(Point* self);  // equivalent to "point->x()"
int Point__y(Point* self);  // equivalent to "point->y()"

There are a number of important points to note about this translation. Firstly, we don't specify the full definition of "Point" in order to achieve encapsulation; we keep "x" and "y" effectively "private" by defining "Point" fully only in the source file. Secondly, we create functions that correspond to the constructor/destructor plus allocation/deallocation which replace "new" and "delete". Thirdly, all member functions get an explicit "self" parameter of the type being operated on (which replaces the implicit "this" parameter).

The source file for the declarations above might look like the following:

// Source file
struct Point {
   int x;
   int y;
};

// Constructor (without allocation)
void Point__init(Point* self, int x, int y) {
  self->x = x;
  self->y = y;
 }

// Allocation + initialization (equivalent to "new Point(x, y)")
Point* Point__create(int x, int y) {
   Point* result = (Point*) malloc(sizeof(Point));
   Point__init(result, x, y);
   return result;
}

// Destructor (without deallocation)
void Point__reset(Point* self) {
}

// Destructor + deallocation (equivalent to "delete point")
void Point__destroy(Point* point) {
  if (point) {
     Point__reset(point);
     free(point);
  }
}

// Equivalent to "Point::x()" in C++ version
int Point__x(Point* self) {
   return self->x;
}

// Equivalent to "Point::y()" in C++ version
int Point__y(Point* point) {
   return self->y;
}

The patterns that apply to simple objects are also applied to other types of objects, but we add some enhancement to address concepts like polymorphism and inheritance.

Polymorphic types

To create polymorphic types in C, we need to include additional type information in our objects, and we need some way of mapping from that type information to the customization that the type entails. To illustrate this, let's consider:

// shape.h
class Shape {
public:
   virtual ~Shape() {}
   virtual const char* name() const = 0;
   virtual int sides() const  = 0;
};

// square.h
class Square : public Shape {
 public:
    Square(int x, int y, int width, int height)
        : x_(x), y_(y), width_(width), height_(height) {}
    virtual ~Square() {}

    const char* name() const override { return "Square"; }
    int sides()  const override { return 4; }

    int x() const { return x_; }
    int y() const { return y_; }
    int width() const { return width_; }
    int height() const { return height_; }

private;
    int x_;
    int y_;
    int width_;
    int height_;
};

For the Shape base class, we might declare the following C code:

// shape.h
struct Shape;
struct ShapeType; 

ShapeType* ShapeType__create(
   int buffer_size,
   const char* (*name)(Shape*),
   int (*sides)(Shape*),
   void (*destroy)(Shape*));

Shape* Shape__create(ShapeType* type);
ShapeType* Shape__type(Shape* self);
void* Shape__buffer(Shape* self);
int Shape__sides(Shape* self);
void Shape__destroy(Shape* shape);

In the above code, note that we created an extra object representing the type of the shape. This type information is how we perform dynamic dispatch (i.e. how we resolve virtual functions). You'll also note this funky "size" thing, which we use to allow a Shape to be allocated with additional space for a buffer, which we will use to store the data for a shape subclass.

The basic idea is that for each type of shape (Square, Pentagon, Hexagon, etc.), there will be exactly one instance of the ShapeType struct. That is, every Square that we create will reference the exact same instance of ShapeType representing squares.

The corresponding implementation code for the base class might look like the following:

 // shape.c
struct Shape {
   ShapeType* type;
   char buffer_start;
};

struct ShapeType {
   int buffer_size;
   const char* (*name)(Shape*);
   int (*sides)(Shape*);
   void (*destroy)(Shape*);      
};

ShapeType* ShapeType__create(
     int buffer_size,
     const char* (*name)(Shape*),
     int (*sides)(Shape*),
     void (*destroy)(Shape*)) {
   ShapeType* result = (ShapeType*) malloc(sizeof(ShapeType));
   result->buffer_size = buffer_size;
   result->name = name;
   result->sides = sides;
   result->destroy = destroy;
   return result;
}

Shape* Shape__create(ShapeType* type) {
  int size = sizeof(Shape) + type->buffer_size;
  Shape* result = (Shape*) malloc(size);
  result->type = type;
  return result;
}

ShapeType* Shape__type(Shape* self) {
  return self->type;
}

void* Shape__buffer(Shape* self) {
   return (void*) &(self->buffer_start);
}

int Shape__sides(Shape* self) {
  return self->type->sides(self);
}

void Shape__destroy(Shape* shape) {
   if (shape) {
      shape->type->destroy(shape);
   }
}

The function of the base class may become clearer as we look at a derived case. The declaration for a square might look like:

// square.h
struct Square;
Shape* Square__to_shape(Square* square);
Square* Square__from_shape(Shape* shape);
Square* Square__create(int x, int y, int width, int height);
void Square__destroy(Square* square);

// Similar to the accessors in the prior case
int Square__x(Square* self);    
int Square__y(Square* self));    
int Square__width(Square* self);    
int Square__height(Square* self);    

And its corresponding definition:

// square.c
struct SquareData {
   int x;
   int y;
   int width;
   int height;
};

const char* Square__name_override(Shape* self) {
   return "Square";
}

int Square__sides_override(Shape* self) {
   return 4;
}

void* Square__destroy_override(Shape* square) {
   free(square);
}

static ShapeType* square_type = 
       ShapeType__create(
           sizeof(SquareData),
           &Square__name_override,
           &Square__sides_override,
           &Square__destroy_override);

Shape* Square__to_shape(Square* square) {
   return ((Shape*) square);
}

Square* Square__from_shape(Shape* shape) {
  if (!shape)  {
    return ((Square*) 0);
  }

  ShapeType* type = Shape__type(shape);
  if (type != square_type) {
    return ((Square*) 0);
  }

  return ((Square*) shape);
}

SquareData* Square__square_data(Square* self) {
   Shape* shape = (Shape*) self;
   return (SquareData*) Shape__buffer(shape);
}

Square* Square__create(int x, int y, int width, int height) {
   Square* result = (Square*) Shape__create(square_type);
   SquareData* square_data = Square__square_data(result);
   square_data->x = x;
   square_data->y = y;
   square_data->width = width;
   square_data->height = height;
   return result;
}

void Square__destroy(Square* square) {
   Shape__destroy(Square__to_shape(square));
}

int Square__x(Square* self) {
   return Square__square_data(self)->x;
}

int Square__y(Square* self) {
   return Square__square_data(self)->y;
}   

int Square__width(Square* self) {
   return Square__square_data(self)->width;
}

int Square__height(Square* self) {
   return Square__square_data(self)->height;
}

To summarize the code above, when inheritance/polymorphism is involved, it is necessary to encapsulate the polymorphic functions in a struct representing different derived types of the base type. Because the derived types may also add more data to the object, the allocation operation must allow the derived types to request additional space. It is also necessary to supply functions that can perform an up-cast and down-cast between the various data types. Additionally, virtual functions in C++ translate to functions that look up and dispatch to the type-supplied implementation of the given function.

Given the above, you might ask, why the extra layer of indirection of ShapeType? Why not simply store the function pointers representing the virtual function overrides directly on the Shape object (to be supplied in the create function of the various derived types).

This is done for efficiency... combining the various virtual function overrides in a separate long-lived type object allows each instance to pay for just a single pointer field to support polymorphism; if this data were directly on the instances, themselves, then each instance would need as many additional fields as there are virtual functions.

Conclusion

Combining data with behavior, encapsulation of data fields, inheritance/polymorphism, and other OOP concepts are achievable in programming languages that lack OOP support (like C), albeit with more boilerplate. This should make intuitive sense as OOP languages have to / had to be implemented in terms of non-OOP languages at some point. Although, in some cases, the boilerplate that this approach generates may not always be worthwhile, writing imperative C code in OOP style can illuminate how OOP works. The ability to write in this style is also valuable for creating APIs that are shared between OOP and non-OOP languages.

Discover and read more posts from Michael Safyan
get started
post comments8Replies
Francisco Ramos
5 years ago

This fails with an initializer element is not a compile-time constant error when declaring and defining the variable:
static ShapeType* square_type = ShapeType__create(....

For this to work as the author intended you need to declare it without initializing and defer initialization inside a function like Square__create().

static ShapeType* square_type;

Square* Square__create(int x, int y, int width, int height)
{
  square_type = ShapeType__create(
      sizeof(SquareData),
      &Square__name_override,
      &Square__sides_override,
      &Square__destroy_override);

  Square* square = (Square*) Shape__create(square_type);
  SquareData* squareData = Square__square_data(square);
  squareData->x = x;
  squareData->y = y;
  squareData->width = width;
  squareData->height = height;
  return square;
}
JT Ying
6 years ago

Looking forward to share detailed video tutorial about this topic

Mark Benningfield
6 years ago

I don’t think the initialization of the static square_type variable will compile in C

Show more replies