Skip to content

Langages Orientés Objet

Dans cette dernière section du Module 1, nous abordons le paradigme objet de façon plus générale que lors de notre découverte dans les deux premières sections, en donnant quelques exemples tirés d'autres langages que Python.

En plus de la référence principale Yves Roggeman, LANGAGES DE PROGRAMMATION, VOL. II (4ème édition) 2020, cette partie reprend une partie de l'article Programmation orientée objet de Wikipedia.

Si selon certains les langages orientés objet sont une sous-classe des langages procéduraux, ils sont généralement présentés à part, parce qu’ils correspondent aux langages les plus utilisés aujourd’hui, et qu'ils permettent d'utiliser le paradigme de programmation par objets, qui change la façon de concevoir les programmes informatiques via l’assemblage de briques logicielles appelées objets.

Il existe actuellement deux grandes catégories de langages à objets (voir article Programmation orientée objet de Wikipedia) :

  • les langages à classes, que ceux-ci soient sous forme fonctionnelle (Common Lisp Object System), impérative (C++, Java) ou les deux (Python, OCaml) ;
  • les langages à prototypes (JavaScript, Lua).

Origines

En implantant les Record Class de Hoare, le langage Simula 67 pose les constructions qui seront celles des langages orientés objet à classes : classe, polymorphisme, héritage, etc. Mais c'est réellement par et avec Smalltalk 71 puis Smalltalk 80, inspiré en grande partie par Simula 67 et Lisp, que les principes de la programmation par objets, résultat des travaux d'Alan Kay, sont véhiculés : objet, encapsulation, messages, typage et polymorphisme (via la sous-classification) ; les autres principes, comme l'héritage, sont soit dérivés de ceux-ci ou une implantation. Dans Smalltalk, tout est objet, même les classes. Il est aussi plus qu'un langage à objets, c'est un environnement graphique interactif complet.

À partir des années 1980, commence l'effervescence des langages à objets. On peut alors classer les langages en trois catégories :

  1. Langage orienté objet pur

Smalltalk est ce qu’on appelle un langage orienté objet pur, parce que « tout y est objet », sans aucune exception, et tout objet est une instance d’une classe héritière d’une classe mère universelle.

Dans un langage orienté objet pur, toute la syntaxe et la sémantique sont construites autour de la notion d’objet, sans exception : tous les types, même les primitifs, sont des classes, toutes les opérations, même prédéfinies, sont des méthodes de classes, toutes les classes sont des objets, instances de métaclasses, et le code du programme lui-même est un objet ; il y a donc réflexivité complète.

Des exemples sont Smalltalk, évidemment, mais aussi Ruby, Raku, Self, Eiffel, etc.

  1. Langage orienté objet issu d’un langage procédural

Mais la plupart des langages orientés objet sont, de fait, des langages procéduraux « avec des objets » pour lesquels leurs concepteurs se sont directement et explicitement inspirés de langages existants.

Ainsi, C++ est directement issu de C et quasi compatible avec lui ; le prototype de ce langage s’est d’ailleurs appelé « C with classes ». De même, Java (initialement appelé « Oak ») s’est inspiré de C et C++ pour garder une syntaxe familière aux programmeurs de ces langages déjà répandus. JavaScript s’est ensuite inspiré de Java, etc. C# vient de C++. De son côté, Python s’est inspiré directement d’ABC, un langage procédural pur, i.e. sans objets ni classes. Enfin, CLU et Modula-3 (à travers Modula-2) proviennent eux de Pascal.

Ces langages présentent donc des caractéristiques plus hétérogènes : d’une part des aspects procéduraux inspirés de leur langage d’origine, d’autre part des constructions originales caractéristiques de la programmation orientée objet.

Notons que cette construction par évolution n’est pas spécifique des langages impératifs. Par exemple, parmi les langages fonctionnels, CLOS est un langage orienté objet directement inspiré de Common LISP (un dialecte de LISP) ou OCaml est une extension de Caml (un dialecte de ML).

  1. Évolution orientée objet d’un langage

Enfin, il faut constater que la programmation orientée objet étant à la mode, de nombreux langages procéduraux existants ont évolué eux-mêmes en intégrant des concepts et syntaxes présents dans les « vrais » langages orientés objet. Par rapport à la catégorie précédente, il ne s’agit pas de nouveaux langages autonomes, mais d’une simple nouvelle version d’un langage.

Le résultat est évidemment horrible, parce qu’il faut conserver presque intégralement la définition originelle (pour des raisons de rétrocompatibilité). L’ajout des outils orientés objet est donc mal intégré (c’est presque un autre langage dans le langage de base) et leur syntaxe est plutôt lourde, car elle doit être distincte et compatible avec celle qui existait dans le noyau procédural. Il est évident que si l’on veut de la programmation orientée objet, il faut un langage conçu directement pour cela, pas un « patch », une nouvelle couche ad hoc.

Des exemples de ce type se retrouvent malheureusement pour presque tous les vieux langages. Il y a des objets en FORTRAN depuis sa version FORTRAN 2003 ; COBOL en a quelques aspects en COBOL 2002 (qui s’est appelé un temps « object-oriented COBOL »), et vraiment à partir de COBOL 2014. Même Pascal a des versions orientées objet officieuses (des extensions liées à un compilateur) : Turbo Pascal (de Borland), devenu Delphi ou Object Pascal (chez Apple), FreePascal (open source)...

Plusieurs langages de script ont connu également ce phénomène d’enrichissement par des constructions issues de la programmation orientée objet : Lua (partiellement dès sa version 2.1 de 1995 avec la délégation de prototypes, mais complété par l’usage indispensable de « métatables » introduites avec la version 5.0 de 2003), PHP (depuis sa version 5.0 de 2005), Tcl (seulement depuis sa version 8.6 de 2019), etc.

Dans tous les cas, la façon d'aborder les objets est loin d'être unique et chaque langage adopte ses propres choix. Difficile par conséquent de présenter en détail tout ce qui peut se faire et nous ne verrons dans ce module que les principales caractéristiques de la programmation orientée objet. Nous laissons le soin au lecteur de se référer aux ouvrages que nous citons, d'une part, et aux manuels de référence des langages dont il voudra explorer plus précisément les méandres.

Principes et définitions

La programmation orientée objet a été introduite par Alan Kay avec Smalltalk. Mais la formaliser et en donner une définition précise n'est pas aisée.

Un première définition (source : la page wikipédia) donne à la programmation orientée objet un vrai niveau de paradigme, qui au-delà d'une extension des langages impératifs procéduraux, invite à un modèle de développement à part entière :

La programmation orientée objet consiste en la définition et l'interaction de briques logicielles appelées objets ; un objet pouvant représenter un concept, une idée ou toute entité du monde physique [...]

Proposée par Yves Roggeman, cette deuxième définition (trop générale du point de vue de l'auteur lui-même) concerne plus les langages :

Un langage orienté objet est un langage de programmation dont les structures de données sont définies comme des objets et qui permet de programmer la définition de nouvelles familles d'objets.

Chaque langage y va de sa propre définition d'un objet :

  • Pour C++, un objet est une donnée qui occupe une zone de mémoire ;
  • Pour Java, c'est semblable : un objet est une instance de classe ou un tableau.
  • Pour Python : les objets sont des abstractions pour les données. Toute donnée dans un programme Python est représentée par des objets ou des relations entre objets.

Nous retiendrons cette définition d'objet :

Un objet est une donnée manipulable par un programme : il s'agit d'un conteneur pour une valeur ou un état auquel est associé un ensemble d'opérations. Cet objet est associé à un type, défini comme l'ensemble des valeurs possibles, cette liste d'opérations, ainsi que leur codage (binaire).

Un objet est identifié dans un programme par un nom ou une notation littérale, mais peut parfois être anonyme (comme les variables temporaires ou les composantes d'un tableau).

Et pour une définition d'un langage orienté objet, l'idée première que l'on retrouve dans la définition de wikipédia offre un cadre intéressant : un langage objet doit permettre l'analyse et le développement logiciel fondés sur des relations entre objets.

Un langage orienté objet est un langage de programmation impératif qui comporte de manière native les éléments suivants : l'encapsulation, l'héritage, le polymorphisme et la programmation générique.

Les éléments constitutifs

L'Objet

Concrètement, un objet est une structure de données qui répond à un ensemble de messages. Cette structure de données définit son état tandis que l'ensemble des messages qu'il comprend décrit son comportement :

  • les données, ou champs, qui décrivent sa structure interne sont appelées ses attributs ;
  • l'ensemble des messages forme ce que l'on appelle l'interface de l'objet ; c'est seulement au travers de celle-ci que les objets interagissent entre eux. La réponse à la réception d'un message par un objet est appelée une méthode (méthode de mise en œuvre du message) ; elle décrit quelle réponse doit être donnée au message.

Les attributs et les méthodes constituent les membres d'un objet. Un objet possède un type. La création d'un objet peut se faire de deux façons différentes en fonction des langages :

  • par prototypage (famille des langages ECMACScript) : un objet est créé comme clone d'un autre. L'objet créé hérite des membres de son modèle. L'héritage qui, nous le verrons, dans un modèle classique à base de classe est une relation entre classes est ici une relation entre objets. Le mécanisme de construction à partir d'un objet servant de prototype est appelé délégation.

  • par classe (C++, Java, Python) : un objet est alors une instance de sa classe. La classe est un type, un ensemble d'objets partageant les mêmes propriétés concrétisées par une liste de membres.

Les principes clés de la POO

L'encapsulation

Certains membres (ou plus exactement leur représentation informatique) sont cachés : c'est le principe d'encapsulation. Ainsi, le programme peut modifier la structure interne des objets ou leurs méthodes associées sans avoir d'impact sur les utilisateurs de l'objet. C'est un des principes fondamentaux notamment pour la robustesse du code.

Nous avons déjà vu comment faire de l'encapsulation en Python, même si cela reste syntaxique puisque fondamentalement Python offre une totale liberté de modification sur les membres d'un objet. C'est au programmeur de rester vigilant.

En C++, l'encapsulation est réalisée via les classes et leur mécanisme d'accessibilité, définitions de types abstraits, et l'usage de fonctions, méthodes ou opérateurs surchargés agissant différemment selon le type des objets auxquels ils sont appliqués.

Ci-dessous un exemple en Java qui montre l'utilisation du mécanisme d'accessibilité pour protéger les attributs d'un objet fraction :

public class Fraction {

    private int numerator;
    private int denominator;
}

Avec une telle définition, si f est un objet Fraction, on ne peut pas accéder à f.numerator. Il convient donc d'ajouter les fameux assesseur et mutateur (getter et setter anglais) déjà rencontrés lors de notre présentation de la POO du point de vue de Python.

public class Fraction {

    private int numerator;
    private int denominator;

    public int getNumerator() {
        return numerator;
    }

    public void setNumerator( int numerator ) {
        this.numerator = numerator;
    }

    public int getDenominator() {
        return denominator;
    }

    public void setDenominator( int denominator ) {
        this.denominator = denominator;
    }
}

L'héritage

L'héritage est une relation asymétrique entre deux classes : l'une est la classe mère (aussi nommée classe parente, superclasse, classe de base), l'autre la classe-fille. L'héritage permet une économie d'écriture par la réutilisation automatique, lors de la définition de la classe-fille, de tous les membres et autres éléments définis dans la classe mère. Ainsi, les objets de la classe-fille héritent de toutes les propriétés de leur classe mère.

Lorsqu'une classe-fille possède une unique classe mère on parle d'héritage simple ; dans le cas de plusieurs il s'agit d'héritage multiple. Cette dernière forme offrant son lot de difficultés, beaucoup de langages n'admettent que l'héritage simple. C'est notamment le cas de Smalltalk pourtant considéré comme l'archétype des langages orientés objet par de nombreux auteurs. C++ mais aussi Python, Perl, Eiffel proposent l'héritage multiple. Java et C# simulent l'héritage multiple par diverses techniques propres.

Ci-dessous un exemple en C++ d'héritage simple :

class B: public A {
    int _b = 0;
};

B b;  // définition d'un objet, instance de B

Le même exemple en Python :

class B(A):
    def __init__(self):
        self.__b = 0

b = B()

La contrainte est qu'au moment de la définition de la classe-fille B, la classe mère A soit complètement définie.

Le polymorphisme et la redéfinition

Un objet peut appartenir à plus d'un type : c'est le polymorphisme ; cela permet d'utiliser des objets de types différents là où est attendu un objet d'un certain type.

Par exemple c'est grâce au polymorphisme qu'on peut écrire ce genre d'expression en Python sans que cela ne pose problème :

>>> 'hello' * 3
>>> 2.1 * 10
>>> 1.1 * 3.7

Une façon de réaliser le polymorphisme est le sous-typage (appelé aussi héritage de type) : on raffine un type-père en un autre type (le sous-type) par des restrictions sur les valeurs possibles des attributs.

Associée à ce mécanisme de sous-typage, la redéfinition des méthodes est un cas particulier de surcharge qui permet à un objet de raffiner une méthode définie avec la même en-tête dans le type-père.

Attention à ne pas confondre polymorphisme et héritage de type (ou typage de premier ordre) qui impose certaines contraintes d'intégrité connues sous le nom de principe de substitution de Liskov et ne permet donc pas toutes les subtilités. Notamment, le typage de premier ordre ne permet pas de gérer les types récursifs ie avec au moins une opération qui accepte un objet du même type.

En 1995 Cook définit le typage de second ordre : les relations entre type sont définies par la sous-classification. Dans cette approche, les concepts de classe et de type ne sont plus distincts mais imbriqués. Si le type définit toujours l'interface des objets, la classe définit la mise en oeuvre d'une famille polymorphique finie et liée de types.

Un exemple de redéfinition de méthodes en C++

Voici quelques exemples de redéfinitions de méthodes en langage C++ ((exemple extrait de Yves Roggeman, LANGAGES DE PROGRAMMATION, VOL. II (4ème édition) 2020))

class B {
public:
    virtual void f ();
    virtual int f (int) const;
    virtual void f (const B&);
    virtual bool f (bool);
};

class D: public B {
public:
    virtual void f (const D&); // Masque tous les B::f non redéfinis
    // int f (int) override;   // ERREUR : pas une redéfinition (manque const)
    int f (int) const override; // OK
    void f (const B&) override;
    // int f (bool) override; // ERREUR : pas le même type de retour
    bool f (bool) override; // OK
};

{
    B *b = new D; D *d = new D; 
    b->f(true);    // OK, version D::f(bool) redéfinie
    b->B::f(true); // Force le choix statique de B::f(bool) 
    b->f();        // OK, version B::f() initiale
    // d->f();     // ERREUR : f() masqué (vérification statique)
}

La programmation générique

Il existe deux mécanismes de typage :

  • le typage dynamique : le type des objets est déterminé à l'exécution lors de la création desdits objets (Smalltalk, Common Lisp, Python, PHP...)
  • le typage statique : le type des objets est vérifié à la compilation et est soit explicitement indiqué par le développeur (C++, Java, C#...), soit déterminé par le compilateur à partir du contexte (Scala, OCaml...)

Conçue pour les langages à typage statique, la programmation générique vise à concevoir des classes (fonctions, méthodes...) paramétrées (par d'autres types ou des constantes entières), puis à les spécialiser en fonction de leur usage au sein du programme (à la compilation).

En C++, ce mécanisme est réalisé par les template ; en Java par les types génériques.

Exemple de type générique en Java

Ci-dessous la définition d'une Pile générique en Java (exemple extrait de Yves Roggeman, LANGAGES DE PROGRAMMATION, VOL. II (4ème édition) 2020)

public class Stack<T> {

    private class _Item {
        private T _info;
        private _Item _next;
        private _Item (T x, _Item n) {_info = x; _next = n;}
    }
    private _Item _top = null;

    public boolean isEmpty () {return _top == null;}
    public T top () throws IllegalStateException {
        if (_top == null) throw new IllegalStateException("Empty stack!");
        return _top._info;
    }
    public void push (T x) {_top = new _Item(x, _top);}
    public T pop () throws IllegalStateException {
        if (_top == null) throw new IllegalStateException("Empty stack!");
        T res = _top._info; _top = _top._next; return res;
    }
}

Et un exemple d'utilisation de cette structure générique pour une Pile d'entiers :

Stack<Integer> St = new Stack<Integer>();