The Developer’s Cry

Yet another blog by a hobbyist programmer

C with objects

In my quest of creating a generic Vec type in plain C (see the September posts of this blog) I ended up with a delete_Vec() that requires an item-deleter argument. The item-deleter is a handler function that deletes individual items from the Vec when the entire thing gets destroyed. However, matters get more complicated when nesting containers in containers. While it certainly is possible to make a Vec-of-Vec in this scheme, the item-deleter (at the highest level of nesting) now must have knowledge about dealing with the nested containers. The design is not fully “orthogonal”, and this leaves us with a nagging feeling that the generic Vec is still missing something. In the concluding remarks I already mentioned that it would be a small step to move the deleter into the “object”, at which point we start morphing into C with classes. Let’s see how that pans out.

Consider an example structure that represents a person:

typedef struct {
    OBJECT;
    char* name;
    int age;
} Person;

Here we have tagged the struct with a special macro OBJECT. What is that thing? The macro expands to a pointer member, which points at a class definition. A class is a description of the type; how is this struct initialized and deinitialized?

#define OBJECT  ObjectClass* objclass_

typedef struct {
    const char* name;
    size_t size;
    void (*init_func)(void*);
    void (*drop_func)(void*);
} ObjectClass;

The initializer function is named init. When we create an object, we do alloc and init.

#define create(T)       create_object(&objectclass_##T##_)
#define init(x,T)       init_object((x), &objectclass_##T##_)

void init_object(void* v, ObjectClass* c) {
    panic_if(v == NULL);
    panic_if(c == NULL);

    // zero out all struct members
    memset(v, 0, c->size);

    // the object instance knows what class it is
    // (it is the first struct member)
    *(ObjectClass**)v = c;

    // initialize the instance
    if (c->init_func != NULL) {
        c->init_func(v);
    }
}

void* create_object(ObjectClass* c) {
    panic_if(c == NULL);

    // create is alloc + init

    void* obj = malloc(c->size);
    panic_if(obj == NULL);

    init_object(v, c);
    return obj;
}

Deinitialization is named drop. Note that drop by itself only empties the object; it does not deallocate it. In order to deallocate (the counterpart to create), call destroy.

void drop(void* v) {
    panic_if(v == NULL);

    ObjectClass* c = *(ObjectClass**)v;
    panic_if(c == NULL);

    // deinitialize the instance
    if (c->drop_func != NULL) {
        c->drop_func(v);
    }
}

void destroy(void* v) {
    if (v != NULL) {
        drop(v);

        // for safety, reset the object's class
        *(ObjectClass**)v = NULL;

        free(v);
    }
}

The declaration and implementation of the objectclass for our Person are eased by macros:

#define decl_Object(T)  \
    void init_##T(void*);                   \
    void drop_##T(void*);                   \
    extern ObjectClass objectclass_##T##_

#define impl_Object(T)  \
    ObjectClass objectclass_##T##_ = {      \
        .name = OBJECTCLASS_AS_STRING(T),   \
        .size = sizeof(T),                  \
        .init_func = init_##T,              \
        .drop_func = drop_##T,              \
    }

decl_Object(Person);

impl_Object(Person);

What is left, is to write functions init_Person() and drop_Person(). These functions are straightforward. The only caveat is that they take void pointer arguments rather than Person pointers. The reason is that create/destroy can work with any type of object.

void init_Person(void* v) {
    panic_if(v == NULL);

    Person* p = v;
    p->name = NULL;
    p->age = -1;
}

void drop_Person(void *v) {
    panic_if(v == NULL);

    Person* p = v;
    if (p->name != NULL) {
        free(p->name);
        p->name = NULL;
    }
    p->age = -1;
}

With the plumbing in place, we can write some user code:

Person* p = create(Person);

p->name = string_from("Joe Jackson");
p->age = 42;

printf("Person: %s (%d)\n", p->name, p->age);

destroy(p);

Although maybe underwhelming, notice how we have just handcrafted a foundation for a hypothetical new framework.

Da heap versus da stack

The previous example allocated the object from the heap. We can also place objects on the stack. Mind that C does not automatically zero-initialize the variable, we must manually call the object’s init and drop.

Person p;
init(&p, Person);

p.name = string_from("Joe Jackson");
p.age = 42;

printf("Person: %s (%d)\n", p.name, p.age);

drop(&p);

Manually calling init and drop is tedious and prone to fail. For some reason it feels less natural than when pairing create/destroy.

You may possibly avoid stack-allocations altogether (as a form of code style), just be aware that there’s a performance penalty when doing everything on the heap.

Copy, Move

Besides init and drop, it is convenient to have a copy function that makes a deep copy of an instance of an object. Copying an object is relatively expensive in situations where you could discard the original (e.g. placing the object into an array). Then it would be better to move the object instead. Adding copy and move to our framework means that we now have to implement four functions per class. I call it “my rule of four”.

Evolution

Obviously we are mimicking how C++ works internally. Why are we doing this, again? A generic container requires that its items can be destroyed in a generic way. A way of realizing that, is to introduce the concept of a destructor. My initial aim never was to take it in this direction. It’s funny how object-oriented programming naturally emerges from the desire of having generic containers.

Interestingly, in languages like Python/Go/Rust you write the constructor, but never the destructor. The destructor is missing in action because these languages do automatic memory management.

There is no denying that mimicking C++ in plain C is convoluted. Strictly speaking, you don’t need object-oriented programming to implement generics.

Back to our Vec implementation for a moment; rather than passing an item-deleter, the Vec could have an item-deleter. This way a Vec always knows how to delete its items [at this particular level of nesting]. It is a simple solution that does not require reinventing C++.

Nevertheless it was neat figuring out C with objects.