Object Oriented Programming in C and Zig
Introduction
One of the primary differences between C and modern programming languages is often cited as the lack of object-oriented programming (OOP) in C, which was later introduced in C++. However, it is possible to implement OOP concepts in C, as well as in other more modern languages like Zig.
Implementing OOP in C using Function Pointers
To illustrate this, let's examine a simple example of implementing OOP in C using function pointers within a struct:
Section 1: Defining a Struct to Represent a Game Object
This code defines a struct called GameObject that contains three function pointers: init, update, and draw. Each function pointer takes a void* parameter called self, which represents the game object itself.
#include <stdio.h>
#define UNUSED(x) (void)(x)
typedef struct {
// below are 3 function pointers
void (*init)(void* self);
void (*update)(void* self);
void (*draw)(void* self);
} GameObject;
Section 2: Implementing the Game Object's Functions
These three functions implement the game object's behavior. Each function takes a void* parameter self, which is not used in this example. The UNUSED macro is used to suppress compiler warnings about the unused self parameter. The functions simply print a message to the console.
void init_obj(void* self)
{
UNUSED(self);
printf("Initializing object\n");
}
void update_obj(void* self)
{
UNUSED(self);
printf("Updating object\n");
}
void draw_obj(void* self)
{
UNUSED(self);
printf("Drawing object\n");
}
Section 3: Using the Game Object
This function takes a GameObject* parameter obj and calls its init, update, and draw functions if they are not null. This allows the game object to perform its behavior.
void use_GameObject(GameObject* obj)
{
if (obj->init) obj->init(obj);
if (obj->update) obj->update(obj);
if (obj->draw) obj->draw(obj);
}
Section 4: Creating and Using a Game Object
In the main function, a GameObject is created and its function pointers are set to the init_obj, update_obj, and draw_obj functions. The use_GameObject function is then called with the game object as an argument, which causes the game object to perform its behavior.
int main(void)
{
GameObject obj = {
.init = init_obj,
.update = update_obj,
.draw = draw_obj
};
use_GameObject(&obj);
return 0;
}
Output and Explanation
if you compile and run the program, the output will be:
Initializing object
Updating object
Drawing object
This approach enables the implementation of fundamental OOP paradigms, including:
- Encapsulation: The concept of bundling data and its associated methods within a single unit, making it harder for other parts of the program to access or modify the data directly.
- Polymorphism: The ability of an object to take on multiple forms, depending on the context in which it is used. In this example, we can achieve polymorphism through the use of function pointers, which allow us to invoke different functions depending on the object's type.
- Abstraction: The practice of exposing only the necessary information to the outside world while hiding the internal implementation details. In this case, we use procedural abstraction to define a set of functions that operate on the struct's data.
- Inheritance: The mechanism by which one object can inherit the properties and behavior of another object. Although C does not support inheritance in the classical sense, we can simulate it using function pointers and struct composition.
Equivalent Code in Zig
We can further explore how to expand this implementation in future posts, particularly in the context of game development, where OOP concepts are frequently employed.
Now let's see the equivalent code written in Zig:
Section 1: Defining a Struct to Represent a Game Object
This code defines a struct called GameObject that contains three methods: initObj, updateObj, and drawObj. Each method takes a *GameObject parameter self, which represents the game object itself. The _ = self; line is used to suppress compiler warnings about the unused self parameter. The methods simply print a message to the console.
Note that in Zig, methods are defined directly on the struct, unlike in C where function pointers are used.
const std = @import("std");
const print = std.debug.print;
const GameObject = struct {
// methods on struct instead of function pointers
fn initObj(self: *GameObject) void {
_ = self;
print("Initializing object\n", .{});
}
fn updateObj(self: *GameObject) void {
_ = self;
print("Updating object\n", .{});
}
fn drawObj(self: *GameObject) void {
_ = self;
print("Drawing object\n", .{});
}
};
Section 2: Creating and Using a Game Object
In the main function, a GameObject is created and its methods are called directly on the object. This is a more object-oriented approach than the C example, where function pointers were used.
pub fn main() void {
var obj: GameObject = .{};
obj.initObj();
obj.updateObj();
obj.drawObj();
}
Compile and run the program, you'll get the same output:
Initializing object
Updating object
Drawing object
Conclusion
Comparing the C and Zig implementations, we can see that the Zig version is more concise and easier to read, with fewer lines of code. Both implementations are valid, but they differ in style: the C version follows a traditional C-style approach, whereas the Zig version utilizes a more modern implementation, known as "methods on struct." This approach allows us to define functions directly within a struct, making the code more readable and maintainable.
In C, it is not possible to define methods or functions directly within a struct. However, Zig's more modern design enables this feature, contributing to the cleaner and more readable code.
Note: I understand some programmers despise the use of function pointers and at this point I'm not going to debate. For those who prefer a more traditional OOP approach, C++ is a viable option.