Magazine Focus Emploi

Les langages de programmation – Partie 2 : Le modèle objet

Publié le 10 janvier 2012 par Abouchard

Après vous avoir parlé des langages que je connais (petit moment narcissique inutile), je vais maintenant partager quelques réflexions concernant le modèle objet, et comment il est implémenté dans les langages de programmation.

Les objets, l’héritage et le polymorphisme

La notion la plus importante de la programmation orientée objet, c’est… l’objet (tadaa !), une structure qui encapsule en même temps des données et les traitements qui y sont associés. Très vite, on trouve derrière l’héritage et le polymorphisme.

L’héritage : Un objet peut dériver d’un autre. Il en reprend toutes les caractéristiques, en y ajoutant des attributs (données) et/ou des méthodes (traitements) supplémentaires. La plupart du temps, il est même possible de suppléer une méthode de l’objet parent, en redéfinissant une méthode du même nom.

Prenons un exemple. L’objet Véhicule possède les attributs poids et vitesse, et la méthode avance. Les objets Voiture et Camion héritent de Véhicule, en ajoutant des attributs supplémentaires. Voici un petit diagramme UML :

Les langages de programmation – Partie 2 : Le modèle objet

Si on crée une instance de l’objet Voiture, on aura accès aux attributs poids, vitesse et nombre_passager, et aux méthodes avance et allume_autoradio.

Le polymorphisme : Tout les objets qui dérivent d’un même objet (avec différent sous-types) peuvent être manipulés comme s’ils étaient du type de l’objet parent − tant qu’on n’utilise que leur partie commune.

Si on reprend l’exemple précédent, toutes les instances des objets Voiture et Camion peuvent être manipulées comme si elles étaient du type Véhicule. Si on veut calculer leur poids total, il suffit d’additionner la somme des poids de tous les véhicules, sans avoir besoin de savoir s’il s’agit de voitures ou de camion.

Par contre, seuls les camions peuvent charger une cargaison.

Après l’héritage et le polymorphisme, on peut ajouter l’encapsulation et la notion de visibilité, qui permettent de déterminer les règles d’accès aux attributs et aux méthodes dans les objets et depuis l’extérieur des objets. Bien que ces notions soient très répandues, il existe des langages orientés objet qui ne les supportent pas (Javascript, Python).

Implémentation des types d’héritages

Il existe deux types d’héritage : l’héritage simple et l’héritage multiple.

L’avantage de l’héritage multiple, c’est qu’il permet − comme son nom l’indique − à un objet d’hériter de plusieurs objets à la fois. Parmi les langages qui le supportent, je ne connais vraiment que le C++ et le Perl,  sans oublier le Python (que je connais mal) ; il en existe d’autres, qui sont plutôt exotiques (Eiffel, OCaml, …).

La plupart des langages ne supportent que l’héritage simple. Enfin, c’est l’impression que j’ai, je ne peux pas vraiment l’étayer de manière formelle. Mais si on prend l’exemple du Java, du PHP, du Ruby… ça semble être la mode.

Toujours sans la moindre preuve, j’ai tendance à penser que l’héritage multiple est mis de côté pour simplifier le développement des langages. Je m’explique. Dans les années 80, il n’existait pas de compilateur C++. Bjarne Soustrup, son créateur, avait mis à disposition un programme qui traduisait du code C++ en code C, qui pouvait alors être compilé. J’ai été amené, durant mes études, à m’intéresser à ce type de translateur, et c’est très intéressant.

Ce qu’il faut comprendre, c’est qu’il est possible de mettre en œuvre des techniques de programmation orientée objet en C. Cela est notamment utilisé dans les bibliothèques graphiques comme (Motif, LessTif ou GTK+), pour gérer leurs éléments : une fenêtre ou un bouton sera géré simplement comme un widget générique par moments.
Je vais expliquer le principe de ce genre de techniques, sachant que cela peut aller bien plus loin que ce que je vais vous montrer.

Imaginons un code C qui contiennent une structure Véhicule, et la fonction avance :

typedef struct {
   int poids;
   short vitesse;
} vehicule_t;

void avance(vehicule_t *vehicule, short vitesse) {
    vehicule->vitesse = vitesse;
    printf("Le véhicule avance à %d km/h", vitesse);
}

La fonction avance prend en paramètre un pointeur sur le type correspondant au véhicule, et un seconde paramètre qui indique la vitesse à laquelle le véhicule se déplace. Elle se contente d’affecter la vitesse dans le champ prévu à cet effet dans la structure.

Si maintenant nous voulons créer l’objet Voiture, qui hérite de l’objet Véhicule, c’est très simple. La structure Voiture doit contenir la structure Véhicule :

typedef struct {
   vehicule_t parent;
   short nombre_passagers;
} voiture_t;

void allume_autoradio(voiture_t *voiture) {
    printf("Il y a %d mélomanes", voiture->nombre_passagers);
}

Pourquoi avoir placé la structure « parente » ou début de la structure « fille » ? Tout simplement parce qu’ainsi, nous pouvons retrouver l’un ou l’autre à la même adresse en mémoire. En faisant un transtypage, on peut facilement se retrouver à manipuler l’un aussi bien que l’autre.
Voici un exemple de code, dans lequel on alloue la mémoire pour créer une voiture, mais on va ensuite la faire avancer en l’utilisant comme n’importe quel véhicule :

// on alloue la structure en mémoire, en l'initialisant à zéro
voiture_t *titine = memset(malloc(sizeof(voiture_t)),
                           0, sizeof(voiture_t));
// on allume l'auto-radio de la voiture
allume_autoradio(titine);
// on fait avancer la voiture comme n'importe quel véhicule
avance((vehicule_t*)titine, 60);

Voilà. C’est super simple, hein ? La seule chose, c’est qu’il faut faire attention à bien faire la conversion de type à chaque fois qu’on appelle une fonction qui attend un véhicule, ou lorsqu’on veut utiliser les champs qui sont dans la structure du véhicule. Mais quand on programme en C, cela semble naturel.

Par exemple, pour récupérer la vitesse de notre voiture, il faut faire :

((vehicule_t*)titine)->vitesse

Et notre objet Camion, qu’est-ce qu’il devient ?

typedef struct {
    vehicule_t parent;
   void* cargaison;
} camion_t;

void charge_cargaison(camion_t *camion, void *cargaison) {
   camion->cargaison = cargaison;
}

// on alloue la structure en mémoire, en l'initialisant à zéro
camion_t *mack = memset(malloc(sizeof(camion_t)),
                        0, sizeof(camion_t));
// on charge une cargaison dans le camion
charge_cargaison(mack, "Flash McQueen");
// on fait avancer le camion comme n'importe quel véhicule
avance((vehicule_t*)mack, 60);

Pas de problème, on voit bien que les camions et les voitures peuvent être manipulés comme des véhicules.

Surcharge de méthodes

Imaginons maintenant que l’on souhaite pouvoir faire de la surcharge de méthodes. Par exemple, on veut laisser la possibilité aux voitures et aux camions de redéfinir le code qui fait avancer le véhicule. Comment faire ?

En C, la réponse passe par les pointeurs de fonction. Le code devient plus complexe, mais rien qui ne devrait vous effrayer.

Pour le véhicule, on aurait quelque chose comme ça :

// définition de la structure
typedef struct vehicule_s {
   int poids;
   short vitesse;
   void (*avance)(struct vehicule_s*, short);
} vehicule_t;

// fonction avance()
void vehicule_avance(vehicule_t *vehicule, short vitesse) {
   vehicule->vitesse = vitesse;
    printf("Le véhicule avance à %d km/h", vitesse);
}
// fonction servant de "constructeur"
vehicule_t* vehicule_create() {
   // Allocation mémoire
   vehicule_t *vroum = memset(malloc(sizeof(vehicule_t)),
   0, sizeof(vehicule_t));
   // initialisation du pointeur de fonction
   vehicule->avance = vehicule_avance;
    // retour
    return (vroum);
}

Si on veut créer un véhicule et le faire avancer, c’est simple :

vehicule_t *vehicule = vehicule_create();
vehicule->avance(vehicule, 40);

Maintenant, faisons un camion ; lorsqu’on lui dit d’avancer, il va vérifier qu’on ne lui demande pas d’aller trop vite :

// définition de la structure
typedef struct {
   vehicule_t parent;
   void *cargaison;
} camion_t;

// fonction de chargement de la cargaison
void charge_cargaison(camion_t *camion, void *cargaison) {
   camion->cargaison = cargaison;
}
// fonction avance() spécifique aux camions
void camion_avance(vehicule_t *vehicule, short vitesse) {
   if (vitesse > 90)
   printf("Ce camion ne dépasse pas 90 km/h");
   else if (vitesse > 80 &
             ((camion_t)*vehicule)->cargaison != NULL)
   printf("Ce camion chargé ne dépasse pas 80 km/h");
   else {
   vehicule->vitesse = vitesse;
   printf("Le camion avance à %d km/h", vitesse);
   }
}
// fonction servant de "constructeur"
camion_t *camion_create() {
   camion_t *camion = memset(malloc(sizeof(camion_t)),
  0, sizeof(camion_t));
   ((vehicule_t)camion)->avance = camion_avance;
    return (camion);
}

Maintenant, créons un camion, chargeons-lui une cargaison et faisons-le avancer :

camion_t *mack = camion_create();
charge_cargaison(mack, "Flash McQueen");
((vehicule_t*)mack)->avance((vehicule_t*)mack, 85);

Si vous avez bien suivi, ce code affichera le résultat suivant :

Ce camion chargé ne dépasse pas 80 km/h

Bref, tout cela fonctionne très bien. Je ne dis pas que c’est facile à lire, ni que la syntaxe est super évidente. Mais c’est efficace.

Pour aller plus loin

Si la programmation orientée objet en C vous intéresse, n’hésitez pas à me contacter. À une époque j’étais allé assez loin dans le concept, avec notamment un support des exceptions, et des macros qui simplifiaient énormément de choses.

Je peux aussi vous recommander les liens suivants :

C’est bien beau, et alors ?

Qu’est-ce qu’on peut retirer de tout ça ? Eh bien, l’héritage simple et le polymorphisme sont des notions franchement pas compliquées à implémenter dans un langage de bas niveau. Je pense que cela fait partie des raisons qui ont conduit à privilégier l’héritage simple au détriment de l’héritage multiple. On m’objectera qu’il s’agit d’un choix philosophique, que les concepteurs de langages ne veulent pas proposer l’héritage multiple ; mais j’en douterais toujours.

Ce qui m’énerve un peu, c’est que s’est rapidement rendu compte que l’héritage simple réduit tellement les possibilités de programmation, que des solutions de contournement ont été mises en place.

Ainsi sont arrivées les interfaces, qui servent à enrichir la définition d’un objet (et de partager ces définitions entre plusieurs objets) sans fournir d’implémentation.
Puis sont arrivés les mixins et les traits, qui fournissent pour leur part des implémentations qui peuvent être intégrées dans des objets.

N’importe quel développeur C++ vous dirait que ces notions sont inutiles à partir du moment où vous savez créer des classes abstraites.

Récemment, je donnais à ce propos deux exemples à un ami.

  1. En Pascal, il y a une différence explicite entre les fonctions et les procédures. La différence, c’est simplement que les procédures ne retournent rien. En C et dans les langages qui en ont découlé, on n’a toujours que des fonctions, sauf que parfois elles ne retournent rien. C’est simple à comprendre, pas besoin de se retourner le cerveau.
  2. Autre parallèle : Que je veuille ouvrir mon ordinateur ou démonter un meuble, j’utilise le même tournevis cruciforme. J’ai beau aimer les meubles Ikea (ils sont faciles et rapides à monter), ça m’énerve d’avoir à aller chercher un clé Allen pour les démonter. Le tournevis cruciforme, c’est l’héritage multiple, qui s’adapte à tous les usages. Les meubles Ikea, c’est le Java ou le Ruby ; ça peut accélérer le développement, mais il n’y a − à mon sens − aucune bonne raison d’imposer les clés Allen (les interfaces).

Pour ma part, c’est le cheminement que je trouve bancal : On essaye de justifier la simplification (l’héritage multiple, c’est mal), mais on se retrouve à refaire du pseudo-héritage multiple incomplet.
Mais parce que le nom est différent, ça devient acceptable ? Mixin, c’est plus hype ?

Il existe des techniques pour gérer le problème de l’héritage en diamant (la recherche profonde et la linéarisation C3), plusieurs langages les utilisent déjà, il n’y a donc pas de raison. En plus de ça, si vous avez un objet qui essaye d’implémenter deux interfaces qui possèdent une méthode dont le nom est identique, ça ne marchera pas mieux pour autant. Dans tous les cas, c’est au développeur de faire correctement sa modélisation.

Dans le prochain article, je vais tenter de rassembler mes idées sur ce que j’aimerais avoir comme langage. C’est pas gagné.


Retour à La Une de Logo Paperblog

A propos de l’auteur


Abouchard 392 partages Voir son profil
Voir son blog

l'auteur n'a pas encore renseigné son compte l'auteur n'a pas encore renseigné son compte