Programmation orientée objet : utiliser l’OOP in C

Contrairement aux langages C++ et Objective-C, axés sur l’OOP, C ne propose aucune fonctionnalité orientée objet de manière native. Ce langage est très répandu et la programmation orientée objet est extrêmement populaire ; il existe donc des solutions pour utiliser l’OOP in C.

OOP in C : est-ce vraiment possible ?

Le langage de programmation C en tant que tel ne prend pas en charge la programmation orientée objet. Ce langage constitue un excellent exemple du style de programmation structuré propre à la programmation impérative Il reste toutefois possible d’intégrer à C des approches orientées objet; en effet, le langage dispose déjà de tous les éléments nécessaires. Le langage C a donc notamment servi de base à la programmation orientée objet avec Python.

Avec l’OOP, vous pouvez définir vos propres « types de données abstraits » (ADT). Un ADT peut prendre la forme d’un ensemble de valeurs possibles et de fonctions opérant sur ces dernières. Il est essentiel de dissocier l’interface visible en externe de la mise en œuvre (implémentation) interne. En tant qu’utilisateur, vous pouvez donc être sûr que le comportement des objets de ce type est conforme à la description.

Les langages orientés objet comme Python, Java et C++ font appel au concept de « classe » pour modéliser des types de données abstraits. Ces classes agissent comme des modèles permettant de créer des objets similaires ; cette solution porte également le nom d’instanciation. Aucune classe n’est intégrée par défaut au langage C, et il n’est pas possible de les y reproduire. Il existe toutefois différentes solutions pour implémenter des fonctionnalités d’OOP avec C.

Comment fonctionne l’OOP avec C ?

Pour comprendre le fonctionnement de l’OOP avec C, il convient tout d’abord de répondre à cette question : qu’est-ce précisément que l’OOP] ? La programmation orientée objet est un style de programmation courant qui correspond à une sorte de paradigme de programmation impératif. L’OOP va donc à l’encontre de la programmation déclarative et de sa forme spécialisée, la programmation fonctionnelle.

Le fait de modéliser des objets et de les faire interagir les uns avec les autres constitue le fondement de la programmation orientée objet. Le déroulement du programme dépend des interactions des objets ; il n’est donc connu qu’au moment de l’exécution du programme. En substance, l’OOP ne se caractérise que par trois éléments :

  1. Les objets encapsulent leur état interne.
  2. Les objets reçoivent des messages par l’intermédiaire de leurs méthodes.
  3. L’attribution de ces méthodes est effectuée de manière dynamique lors de l’exécution.

Dans les langages purement axés sur l’OOP comme Java, un objet correspond à une unité totalement autonome. Elle comprend une structure de données, quel que soit son niveau de complexité, ainsi que des méthodes (fonctions) opérant sur celle-ci. L’état interne de l’objet est représenté par les données qu’il contient. Il ne peut être lu et modifié que par l’intermédiaire des méthodes. Une fonction du langage portant le nom de « Garbage Collector » est généralement utilisée pour gérer la mémoire des objets.

Le langage C ne permet pas de relier facilement une structure et des fonctions à des objets. Il est toutefois possible d’élaborer un système clair avec des structures de données, des définitions de types, des pointeurs et des fonctions. Comme il est d’usage avec le langage C, le programmeur est responsable de l’attribution et de la libération de la mémoire.

Il en résulte un code C basé sur les objets qui ne ressemble en rien aux résultats habituellement obtenus avec les langages d’OOP, mais qui a le mérite de fonctionner. Découvrez avec nous une présentation rapide des principaux concepts de l’OOP et de leur équivalent avec le langage C :

Concept d’OOP Équivalent avec le langage C
Classe Type « Struct »
Instance de classe Instance « Struct »
Méthode d’instance Fonction qui accepte les pointeurs vers les variables « Struct »
Variable « this » / « self » Pointeur vers la variable « Struct »
Instanciation Attribution et référence par l’intermédiaire d’un pointeur
Mot-clé « new » Appel de la fonction « malloc »

Modéliser des objets en tant que structures de données

Commençons par étudier la manière dont la structure de données d’un objet peut être modélisée avec C, dans le style des langages d’OOP. C’est un langage compact qui ne requiert que peu de constructions linguistiques. Pour créer des structures de données, quel que soit leur niveau de complexité, il convient d’utiliser des « Structs » ; ce nom vient du terme « Data Structure » (littéralement « structure de données »).

Une « C Struct » définit donc une structure de données comprenant des champs appelés « Members » (littéralement « membres »). Dans d’autres langages, cette construction peut également être nommée « record ». Il est possible de se représenter une « Struct » comme une ligne dans une table de base de données : elle correspond à l’association de plusieurs champs, qui peuvent être de types différents.

La syntaxe d’une déclaration « Struct » avec C est extrêmement simple :

struct struct_name;
C

Il est éventuellement possible de définir la « Struct » en précisant le nom et le type des « Members ». Prenons un exemple standard : un point dans un espace en deux dimensions, avec des coordonnées « x » et « y ». Vous trouverez ci-dessous la définition de cette « Struct » :

struct point {
    /*X-coordinate*/
    int x;
    /*Y-coordinate*/
    int y;
};
C

En code C traditionnel, il convient ensuite de procéder à l’instanciation d’une variable « Struct ». Nous créons donc cette variable et nous initialisons les deux champs avec la valeur « 0 » :

struct point origin = {0, 0};
C

Les valeurs de ces champs peuvent alors être extraites et redéfinies. Pour accéder aux « Members », il faut passer par la syntaxe « origin.x » et « origin.y », qui vous est peut-être déjà familière si vous travaillez avec d’autres langages de programmation :

/*Read struct member*/
origin.x == 0
/*Assign struct member*/
origin.y = 42
C

Ici, nous ne respectons toutefois pas l’exigence relative à l’encapsulation : pour accéder à l’état interne d’un objet, il faut avoir recours à des méthodes définies à cet effet. Il semblerait donc qu’un élément fasse encore défaut à notre exemple.

Définir des types pour la création d’objets

Comme nous l’avons déjà expliqué, le concept de classe n’existe pas dans le langage C. Il est cependant possible de définir des types à l’aide de l’instruction « typedef ». Celle-ci nous permet de donner un nouveau nom à un type de données :

typedef <old-type-name> <new-type-name>
C

Il est possible de définir un type « Point » correspondant à notre « Struct “point” » en procédant comme suit :

typedef struct point Point;
C

La combinaison de « typedef » avec une définition « Struct » correspond à peu près à une définition de classe avec Java :

typedef struct point {
    /*X-coordinate*/
    int x;
    /*Y-coordinate*/
    int y;
} Point;
C
Note

Dans notre exemple, « point » correspond au nom de la « Struct », tandis que « Point » correspond au nom du type défini.

La définition de classe correspondante avec Java est la suivante :

class Point {
    private int x;
    private int y;
}; 
Java

Le fait d’utiliser « typedef » nous permet de créer une variable « Point » sans avoir à utiliser le mot-clé « Struct » :

Point origin = {0, 0}
/*Instead of*/
struct point origin = {0, 0}
C

Il nous manque encore l’encapsulation de l’état interne.

Encapsulation de l’état interne

Les objets représentent leur état interne dans leur structure de données. Dans les langages d’OOP, comme Java, les mots-clés « private », « protected », etc., sont utilisés pour limiter l’accès aux données des objets. Ainsi, aucun accès direct n’est possible depuis l’extérieur, et l’interface et l’implémentation sont bien séparées l’une de l’autre.

Pour l’OOP in C, il convient d’utiliser un autre mécanisme. Nous allons utiliser une déclaration « forward » en tant qu’interface dans le fichier d’en-tête, de manière à créer un « Incomplete type » :

/*In C header file*/
struct point;
/*Incomplete type*/
typedef struct point Point;
C

L’implémentation de la « Struct “point” » est ensuite effectuée dans un fichier distinct de code source C, qui intègre l’en-tête par la macro « include ». Cette stratégie empêche le type « Point » de créer des variables statiques, mais il est encore possible d’utiliser des pointeurs pour le type. Les objets étant des structures de données créées de manière dynamique, ils sont de toute façon référencés à l’aide de pointeurs. Sur les instances « Struct », les pointeurs correspondent plus ou moins aux références d’objets qui ont cours avec Java.

Remplacer des méthodes par des fonctions

Dans les langages d’OOP comme Java et Python, les objets englobent, en plus de leurs données, les fonctions qui opèrent sur ces dernières. Ces fonctions sont plus connues sous le nom de « méthodes » ou de « méthodes d’instance ». Pour écrire du code avec C dans le cadre de l’OOP, nous n’allons pas utiliser de méthodes, mais plutôt des fonctions. Celles-ci acceptent un pointeur vers une instance « Struct » :

/*Pointer to `Point` struct*/
Point * point;
C

Comme les classes n’existent pas dans le langage C, il n’est pas possible de regrouper toutes les fonctions d’un même type sous un seul et même nom. Nous allons plutôt ajouter au nom des fonctions un préfixe précisant le nom du type. Les signatures des fonctions correspondantes sont d’abord déclarées dans le fichier d’en-tête du langage C :

/*In C header file*/
/*Function to move update a point's coordinates*/
void Point_move(Point * point, int new_x, int new_y);
C

Nous pouvons maintenant implémenter la fonction dans le fichier de code source C :

/*In C source file*/
void Point_move(Point * point, int new_x, int new_y) {
    point->x = new_x;
    point->y = new_y;
};
C

Cette stratégie peut être rapprochée des méthodes utilisées avec Python, qui correspondent à des fonctions normales avec « self » en tant que premier paramètre. De plus, le pointeur vers une instance « Struct » est presque similaire à la variable « this » exploitée par Java ou JavaScript. Ce qui est différent ici, c’est que le pointeur est transmis de façon explicite lors de l’appel de la fonction C :

/*Call function with pointer argument*/
Point_move(point, 42, 51);
C

Pour l’appel de fonction équivalent avec Java, l’objet « point » est quant à lui disponible dans la méthode sous la forme d’une variable « this » :

// Call instance method from outside of class
point.move(42, 51)
// Call instance method from within class
this.move(42, 51)
Java

En Python, il est possible d’appeler des méthodes en tant que fonctions avec un argument « self » explicite :

# Call instance method from outside or from within class
self.move(42, 51)
# Function call from within class
move(self, 42, 51)
Python

Instancier des objets

La gestion manuelle de la mémoire constitue l’une des particularités les plus caractéristiques du langage C : il revient aux programmeurs d’attribuer de la mémoire aux structures de données. Or, les langages dynamiques et orientés objet tels que Java ou Python effectuent cette tâche à leur place. Avec Java, le mot-clé « new » est utilisé pour instancier un objet. En coulisses, cela permet d’attribuer automatiquement de la mémoire :

// Create new Point instance
Point point = new Point();
Java

Lorsque nous écrivons du code lié à l’OOP avec C, nous définissons une fonction de constructeur spéciale aux fins de l’instanciation. Celle-ci permet d’attribuer de la mémoire à notre instance « Struct », de l’initialiser et de mettre en place un pointeur s’y référant :

Point * Point_new(int x, int y) {
    /*Allocate memory and cast to pointer type*/
    Point *point = (Point*) malloc(sizeof(Point));
    /*Initialize members*/
    Point_init(point, x, y);
    // return pointer
    return point;
};
C

Dans cet exemple, nous avons choisi de dissocier l’initialisation des « Members » de la « Struct » de l’instanciation. Nous faisons à nouveau appel à une fonction avec le préfixe « Point » :

void Point_init(Point * point, int x, int y) {
    point->x = x;
    point->y = y;
};
C

Comment réécrire un projet C pour qu’il soit orienté objet ?

Sauf circonstances exceptionnelles, nous ne vous conseillons pas de réécrire un projet existant avec C en utilisant les techniques d’OOP précédemment développées. Il nous semble en effet plus judicieux d’adopter l’une des approches suivantes :

  1. Réécrire le projet dans un langage de type C proposant des fonctionnalités d’OOP, en utilisant la base de code C déjà existante en tant que spécification.
  2. Réécrire certaines parties du projet avec un langage prenant en charge l’OOP et conserver des composants spécifiques en langage C.

Si la base de code C a été écrite correctement, cette deuxième stratégie devrait vous permettre d’obtenir des résultats satisfaisants. Il n’est pas rare d’implémenter en C des parties essentielles du programme, puis d’y accéder à partir d’autres langages. Aucun autre ne se prête d’ailleurs mieux que C à cet exercice. Mais quels langages peuvent vous permettre de recréer un projet C déjà existant en faisant appel aux principes de l’OOP ?

Langages orientés objet de type C

Nombreux sont les langages de type C qui proposent une programmation orientée objet intégrée. C++ est probablement le plus connu d’entre tous, mais ce langage est aussi réputé pour sa complexité ; beaucoup de programmeurs l’ont d’ailleurs abandonné au cours de ces dernières années. Comme leurs constructions linguistiques de base se ressemblent énormément, il est relativement aisé d’intégrer du code C à C++.

Objective-C est toutefois beaucoup plus facile à prendre en main. Ce « dialecte » C s’inspire du langage original d’OOP utilisé par Smalltalk et a d’abord été utilisé principalement pour programmer des applications sur les systèmes d’exploitation Mac et iOS. Il a ensuite servi de base à la mise au point du langage Swift, développé par Apple. Les fonctions écrites avec C peuvent donc être appelées à partir de ces deux langages.

Langages orientés objet basés sur C

Vous pouvez également utiliser d’autres langages d’OOP dont la syntaxe n’est pas similaire à celle du langage C pour réécrire un projet créé avec C. Python, Rust et Java proposent des solutions standard relatives à l’intégration de code C .

C’est notamment le cas des « Python Bindings ». Les types de données Python doivent alors être traduits en « ctypes » équivalents. En outre, la C Foreign Function Interface (CFFI, littéralement « interface de fonction étrangère de C ») automatise dans une certaine mesure la traduction des types.

Avec Rust, la prise en charge de l’appel de fonctions C ne demande que peu d’efforts. Pour ce faire, une Foreign Function Interface (FFI) peut également être définie à l’aide du mot-clé « extern ». Les fonctions Rust qui accèdent à des fonctions externes doivent être déclarées « unsafe » (non sécurisées) :

extern "C" {
    fn abs(input: i32) -> i32;
}
fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}
Rust