Object Orientation in C
2023-08-02
C and C++ are two system languages with support for dynamic memory allocation. It is possible to allocate, address and access regions of memory directly to build more complex types from scratch. Abstract Data Types are, however, more difficult to implement; information hiding, encapsulation and other design patterns typically associated with object orientation are not present in the C specification.
Namespaces
C has no built in support for user defined namespaces, while C++
does. This becomes problematic when two libraries, each with a separate
header file, contain functions with the same name and signature, causing
a multiple function definition error. For a library implemented in the
file lib.c
, along with the lib.h
header file
containing function signatures, an extern struct
containing
function pointers can be used to provide the public interface to the
library. The structure definition is part of the same header file.
// lib.h
struct lib {
void (*F1) ();
void (*F2) ();
};
extern struct lib Lib;
This header does not contain any function signatures, they are
effectively private and could be made static. Any file including
lib.h
knows of the declaration of the variable
struct lib Lib
. This definition takes place in the library
source file, the function pointers are set to the appropriate internal
functions.
// lib.c
#include "lib.h"
#include <stdio.h>
static void F1_ () {puts("F1");}
static void F2_ () {puts("F2");}
struct lib Lib = {.F1 = &F1_, .F2 = &F2_};
By including lib.h
, any other source file can access the
functions of the library namespace referred to by
Lib
with a syntax that resembles many other high level
languages.
// main.c
#include "lib.h"
int main() {Lib.F1(); Lib.F2();}
Encapsulation
If the purpose of a source file or library is to define a type, it is
preferable that as little information about the type implementation is
exposed to the wider program. This is easily achieved in C by moving the
structure definition from the header file back to the source file,
leaving only the typedef
alias in place. For example, a
simple type containing two integers and representing a two dimensional
coordinate system, would be defined outwardly with a type alias and a
set of public functions.
// coord.h
typedef struct coord_S_ coord;
coord * new_coord(int x, int y);
void print_coord(coord * self);
void del_coord(coord * self);
The type definition in the header file is the only information made available outside the source file implementing the structure itself. Operations on the structure contents are restricted to functions implemented in the same source file as the full structure definition, which is the desired behaviour.
// coord.c
#include "coord.h"
#include <stdio.h>
#include <stdlib.h>
struct coord_S_ {
int x;
int y;
};
coord * new_coord(int x, int y) {
coord * new = (coord *) malloc(sizeof(coord));
new->x = x;
new->y = y;
return new;
};
void print_coord(coord * self) {
printf("(%d, %d)\n", self->x, self->y);
}
void del_coord(coord * self) {
free(self);
}
Adopting this approach necessitates the dynamic allocation of the opaque type. It is impossible to create a new structure variable automatically outside the type implementation, as the type definition is incomplete and has no size.
// main.c
#include "coord.h"
int main() {
// coord a; // not valid, incomplete type definition
coord * a = new_coord(3,2);
print_coord(a);
del_coord(a);
}
Polymorphism
To create a polymorphic type, the behaviour of which depends on its
type, a structure must remember its type and act accordingly. A
union
/ enum
pair can be used to this end. The
union is an area of memory large enough to store the biggest
member and hence every smaller member, although not at the same time.
The enumeration is a convenient way to determine the type of
the data value and hence how many bytes to read.
union item_type_U_ {
int int_val;
float flt_val;
double dbl_val;
char char_val;
char * str_val;
void * ptr_val;
};
enum item_type_E_ {
Integer,
Double,
Float,
Character,
String,
Pointer
};
struct item_T_ {
item_val val;
item_type type;
char * repr;
int repr_len;
unsigned long hash;
bool dync_memb;
};
Type aliases make driver code and function signatures more concise. Additionally, type aliases can be forward declared to hide the structure definition if required. For the sake of practicality, the structure definition should remain visible, enabling an item to be stored in place, without the need to be allocated or dereferenced. A type designed to be allocated dynamically presents many difficulties, consistent with the common drawbacks of explicit memory management, namely: ownership, type safety, memory leaks, mutability etc.
typedef union item_type_U_ item_val;
typedef enum item_type_E_ item_type;
typedef struct item_T_ item;
typedef struct item_T_ * item_p;
There are good reasons to handle the type as a pointer and a
statically allocated structure. Using forward declaration of the
structure and type alias, its implementation can be hidden, though this
prevents an allocated object in place, for the sake of accelerating a
program. As it is possible to obtain a pointer for an object later, this
version of the constructor returns a struct
directly. The
constructor is variadic, only the first parameter, the type, is
made explicit.
item item_new(item_type type, ...) {
va_list args;
va_start(args, 1);
item self;
self.type = type;
switch ( self.type ) {
case Integer: self.val.int_val = va_arg(args, int); break;
case Double: self.val.dbl_val = va_arg(args, double); break;
case Float: self.val.flt_val = (float) va_arg(args, double); break;
case Character: self.val.char_val = (char) va_arg(args, int); break;
case String: self.val.str_val = va_arg(args, char *); break;
case Pointer: self.val.ptr_val = va_arg(args, void *); break;
default: break;
}
va_end(args);
self.repr = NULL;
item_dealloc_dynamic_members(&self);
return self;
}
With this approach, a new item can be instantiated with the very
elegant (type, value)
syntax, which is quite atypical in
C.
item b = item_new(Integer, 1234);
item_p c = item_clone_p(&b);
item_modify(c, Character, 'T');
char * string1 = item_repr(&b);
char * string2 = item_repr(c);
printf("%s %s\n", string1, string2); // 1234 'T'
free(string1); free(string2); free(c);
The complete source file can be downloaded here.
Object Methods
Object orientated languages typically use dot-qualifier syntax for accessing object methods. This is a helpful organisational feature and design pattern. This can be emulated in C with function pointers. Here is an example of a structure which has data fields and a list of function pointers. These act as the methods of the improvised object.
typedef struct matrix_T {
float * array;
int x, y;
// methods
struct matrix_T * (* scale_matrix) (struct matrix_T * matrix_p, float scale_factor, bool adjust);
struct matrix_T * (* select_region) (struct matrix_T * matrix_p, int x, int y, int w, int h);
struct matrix_T * (* horiz_density) (struct matrix_T * matrix_p);
struct matrix_T * (* vert_density) (struct matrix_T * matrix_p);
float (* average_darkness) (struct matrix_T * matrix_p);
struct matrix_T * (* paste) (struct matrix_T * fg, struct matrix_T * bg);
struct matrix_T * (* translation) (struct matrix_T * matrix_p, int x_offset, int y_offset);
} matrix;
A function corresponding to each of these methods are declared and implemented in the usual fashion. Their signatures align with those of the associated method, though their names need not necessarily match (in this case, for clarity, they do).
matrix * scale_matrix(matrix * matrix_p, float scale_factor, bool adjust);
matrix * select_region(matrix * matrix_p, int x, int y, int w, int h);
matrix * horiz_density(matrix * matrix_p);
matrix * vert_density(matrix * matrix_p);
float average_darkness(matrix * matrix_p);
matrix * paste(matrix* fg, matrix * bg);
matrix * translation(matrix * matrix_p, int x_offset, int y_offset);
In the constructor, each method is assigned the address of a function.
matrix *
create_matrix(int height, int width)
{
matrix *new_matrix_p = (matrix *) malloc(sizeof(matrix));
new_matrix_p->array = (float *) malloc(sizeof(float) * height * width);
new_matrix_p->x = width;
new_matrix_p->y = height;
new_matrix_p->scale_matrix = &scale_matrix;
new_matrix_p->select_region = &select_region;
new_matrix_p->average_darkness = &average_darkness;
new_matrix_p->horiz_density = &horiz_density;
new_matrix_p->vert_density = &vert_density;
new_matrix_p->paste = &paste;
new_matrix_p->translation = &translation;
return new_matrix_p;
}
The constructor is defined in the same source file as the method
implementations. The signatures of those functions can be withheld from
the calling program by omitting them in the header file. Only the
matrix
structure and its fields are visible to the call
site. Now the methods of the matrix
object are
only accessible via the structure itself.
matrix * m1 = create_matrix(100, 100);
// ...
matrix * m2 = m1->scale_matrix(m1);
// ...
The complete source file can be downloaded here.
See Also
- GnuPG Usage
- Object Orientation in C
- Functional Programming
- Haskell Programming
- Emacs Initialisation
- Working with Java Packages
- Asynchronous & Concurrent Programming
Or return to the index.