Faire de l'orienté objet en C

Écrit le 03/05/2005 par *OgGiZ*
Dernière mise à jour : 02/02/2006

Introduction

L'idée peut sembler ridicule ; d'ailleurs aujourd'hui pourquoi apprendre le C quand on peut apprendre le C++ ?

Ceux qui me connaissent savent que je passe mon temps à réinventer la roue et je dois dire que jusqu'à présent personne ne comprends pourquoi je fais cela. La seule référence que j'ai trouvé était dans un livre sur la programmation de jeu en DirectX où l'auteur notait dans la préface que « cela peut être intéressant de réinventer la roue ».

Je ne tenterai pas de vous convaincre de l'utilité d'un tel procédé de façon générale mais je me contenterai de souligner quelques points quant à la programmation de l'orienté objet. Refaire ce qui a déjà été fait (le C++) peut vous apporter une meilleur connaissance de la programmation et du traitement des données, et surtout, cela vous aidera à démystifier les objets.

Avant de se lancer dans le code, demandons-nous tout d'abord « Qu'est-ce qu'un objet ? ».

Reste à voir comment nous allons organiser ce set de données. Il existe deux façons de le faire :

Examinons ensemble un exemple suivant le point 1 et le point 2 :

unsigned char variables[8];
long *var_addr1 = (long*)(&variables[0]);
long *var_addr2 = (long*)(&variables[4]);

ce qui est équivalent à :

unsigned char variables[8];
long *var_addr1 = (long*)(variables);
long *var_addr2 = (long*)(variables+4);

Comme nous l'avons dit, ce n'est pas très confortable à l'utilisation ! Voyons ce que cela donne avec des structures :

struct variables
{
    long var1;
    long var2;
};

Ces deux exemples sont rigoureusements identiques, dans les deux cas nous créons un espace « variables » contenant deux types longs (32 bits). Néanmoins, il est clair que la seconde méthode est plus commode que la première.

Nous utiliserons donc les structures.

Imaginons que nous ayons envie de rajouter une fonction à notre classe « variables », nous allons faire :

void fonction (struct variables *this)
{
}

Pour que cet exemple puisse compiler, il est impératif de compiler en C sous peine d'avoir une erreur car le « this » est réservé en C++. Si vous utilisez Visual Studio, créez un fichier .c plutôt que .cpp et le logiciel vous le compilera tout naturellement en C. Notez qu'on est absolument pas obligé d'utiliser le nom « this » mais que nous le prenons en analogie au C++.

Avant d'aller plus loin il est primordial de marquer un point d'exclamation sur une propriété du C qui nous posera un très grand problème : l'overloading. En C, il est impossible d'utiliser l'overloading.

Ainsi, le code suivant est correct en C++ :

void test (int i){}
void test (float f){}

mais devra être adapté en C de cette façon (par exemple) :

void test_int (int i){}
void test_float (float f){}

C'est dommage, nous verrons pourquoi en abordant l'héritage.

Leçon 1

Pour le reste du code nous allons utiliser une classe appelée CClass qui se présentera sous la forme d'une structure. Voici le header file de la classe CClass, ne faites pas attention aux définitions de fonctions :

struct CClass
{
    int integer;
};

struct CClass *CClass_new (int i);
void CClass_delete (struct CClass **this);
void CClass_print (struct CClass *this);

Notre classe se compose de 3 fonctions (new, delete et print) ainsi que d'une variable de type int.

La fonction CClass_new est notre constructeur. Elle alloue la mémoire nécessaire à la structure CClass et initialise la donnée « integer » en fonction du paramètre passé. C'est comme un constructeur de classe en C++ !

struct CClass *CClass_new (int i)
{
    struct CClass *p = (struct CClass *)malloc (sizeof (struct CClass));

    if (!p)
        return NULL;

    p->integer = i;

    return p;
}

Notez que le test vérifie que la mémoire a bien été allouée. Il est possible que l'allocation échoue bien que cela soit improbable. Si c'est le cas, il faut immédiatement arrêter la fonction avant de tenter d'accéder aux données !!!

Un autre élément très important de notre classe est le destructeur, l'équivalent de delete en C++. La petite différence ici est qu'on annulera immédiatement le pointeur en le mettant sur NULL, ceci explique l'utilisation du double pointeur. Si vous ne connaissez pas les doubles pointeurs, ne vous excitez pas car ce n'est pas primordial pour votre compréhension de cet article.

void CClass_delete (struct CClass **this)
{
    if (*this)
        free (*this);

    *this = NULL;
}

Abordons finalement notre unique véritable fonction de notre classe, print. Print affichera la valeur de l'entier, stockée dans la structure (la classe) :

void CClass_print (struct CClass *this)
{
    if (!this)
        return;

    printf ("%d\n", this->integer);
}

À nouveau vous avez noté que nous testons la validité du pointeur pour éviter de lire un pointeur qui serait NULL.

Pour pouvoir utiliser notre classe il nous suffit de créer une pointeur, d'allouer la mémoire, d'utiliser le pointeur et finalement de détruire la classe :

struct CClass *myclass;

myclass = CClass_new (10);

CClass_print (myclass);

myclass = CClass_delete (&myclass);

Pour finir cette première partie je commenterai le nom des fonctions. Le choix d'appelation CClass_new, CClass_delete et CClass_print est purement volontaire. En C++ on aurait fait void CClass :: print (void) ce qui explique le choix de void CClass_print (void). C'est aussi simple que ça.

Leçon 2

L'héritage. L'héritage est un point-clé dans la programmation orienté objet et il aurait été futile de se lancer dans une telle expérience sans être sûr de réussir ce point précis.

Basiquement, il faudra s'arranger pour que les variables d'une classe soient passées à une seconde classe tout en permetant d'utiliser les fonctions de la première classe.

Comment réaliser un tel miracle ? En jouant sur les données. Rappelons-nous que nos données ne sont finalement qu'un array de bytes, soit un array de unsigned char.

Si notre classe1 se compose de la façon suivante :

[LONG L][CHAR C]

et notre classe2 de la façon suivante :

[LONG L][CHAR C][FLOAT F]

On peut traduire cela comme :

unsigned char class1[5];
unsigned char class2[9];

ou :

long *addr_l = (long*)(class1);
char *addr_c = (char*)(class1+4);

mais aussi :

long *addr_l = (long*)(class2);
char *addr_c = (char*)(class2+4);
float *addr_f = (float*)(class2+5);

Dès lors, il est tout à fait possible de caster une class2 en class1. Ceci est valable tant que les zones variables se recouvrent et c'est IMPERATIF !

Bref, l'exemple suivant est correct :

struct class1
{
    int i;
};

struct class2
{
    int i;
    float v;
};

Mais pas l'exemple suivant :

struct class1
{
    int i;
};

struct class2
{
    float v;
    int i;
};

Si nous faisons (struct class1*)&class2 nous allons nous retrouver avec les deux premiers bytes de v qui seront sauvés dans i (dans le cas où int = short) ou alors le float sera stocké dans le int (dans le cas ou int = long). Evidemment, un float et un int ne sont pas sauvés de la même façon en mémoire et les données récupérées seront complètement bidon !

Il est primordial que vous compreniez que pour pouvoir faire de l'héritage (c'est à dire un casting conservatif) vous devez pouvoir transposer le code de la classe mère dans la classe enfant sans voir à réaménager l'ordre de celle-ci.

Analysons un exemple de classe CInherited qui hérite de CClass :

struct CInherited
{
    int integer;
    int valueplus;
};

struct CInherited *CInherited_new (int i, int j);
void CInherited_delete (struct CInherited **this);
void CInherited_print (struct CInherited *this);
void CInherited_swap (struct CInherited *this);

A nouveau nous créons un constructeur :

struct CInherited *CInherited_new (int i, int j)
{
    struct CInherited *p = (struct CInherited *)malloc (sizeof (struct CInherited));

    if (!p)
        return NULL;

    p->integer = i;
    p->valueplus = j;

    return p;
}

Ainsi qu'un destructeur :

void CInherited_delete (struct CInherited **this)
{
    if (*this)
        free (*this);

    *this = NULL;
}

Créons à présent une fonction swap qui va échanger les valeurs de la première variable et de la deuxième variable :

void CInherited_swap (struct CInherited *this)
{
    int tmp;

    if (!this)
        return;

    tmp = this->integer;
    this->integer = this->valueplus;
    this->valueplus = tmp;
}

Et pour finir, la fonction print qui hérite directement de la classe CClass :

void CInherited_print (struct CInherited *this)
{
    if (!this)
        return;

    CClass_print ((struct CClass*)this);
}

Nous utilisons directement ce que nous avions dit plus haut au sujet de la superposition. La formulation est très simple (struct CClass*)this mais cache en réalité un grand principe d'organisation des données auquel vous devez faire TRÈS attention.

Contrairement au C++ il faudra exprimer clairement chaque fonction héritée comme nous l'avons fait pour la fonction print.

Si nous désirons utiliser nos classes, il suffit de faire :

struct CClass *myclass;
struct CInherited *myclass2;

myclass = CClass_new (10);
myclass2 = CInherited_new (20, 30);

CClass_print (myclass);
    
CInherited_print (myclass2);
CInherited_swap (myclass2);
CInherited_print (myclass2);

myclass = CClass_delete (&myclass);
myclass2 = CInherited_delete (&myclass2);

Leçon 3

Pour plus de lisibilité nous pouvons utiliser quelques macros :

#define new(a)                a##_new
#define delete(a,b)            a##_delete (&##b##)

#define print(a)            a##_print
#define swap(a)                a##_swap

Ce qui simplifie notre code en :

struct CClass *myclass;
struct CInherited *myclass2;

myclass = new (CClass) (10);
myclass2 = new (CInherited) (20, 30);

print (CClass) (myclass);

print (CInherited) (myclass2);
swap (CInherited) (myclass2);
print (CInherited) (myclass2);

delete (CClass, myclass);
delete (CInherited, myclass2);

À nouveau, il est primordial de compiler avec un compilateur C car les mots « new » et « delete » sont réservés en C++.

Cette étape n'est pas obligatoire mais peut donner un certain style à votre code.

Conclusion

Le C++ a les avantages indéniables de simplification du code. D'abord, l'arrangement des données est immédiat et il ne faut pas se préocuper de l'ordre de déclaration des variables. De plus, les fonctions héritées inchangées de la classe mère sont automatiquement rajoutées.

Néanmoins, nous avons démontré qu'il était tout à faire possible de faire de l'orienté objet en C d'une façon tout aussi généraliste que ce que permet le C++.

Dans la réalité vous ne serez pas obligé d'expliciter vos noms de classes comme nous l'avons fait. A vous de voir ce dont vous avez besoin :)

Bref, pour conclure nous dirons que le C n'a rien à envier au C++ et, qu'en plus, en C, on peut se la jouer 31337-c0d3rz ^^