Qu’est-ce que la programmation orientée objet (OOP) ?

La programmation orientée objet (OOP) est utilisée partout. En effet, les technologies orientées objet servent à créer des systèmes d’exploitation, des logiciels commerciaux et des logiciels libres. Les avantages de l’OOP n’apparaissent que lorsqu’un projet atteint une certaine complexité. Le style de programmation orienté objet est toujours l’un des paradigmes de programmation dominants.

Qu’est-ce que la programmation orientée objet et à quoi sert-elle ?

Le terme « programmation orientée objet » (« Object-Oriented Programming » ou OOP en anglais) a été inventé vers la fin des années 1960 par la légende de la programmation Alan Kay. Ce dernier a participé au développement du langage de programmation orienté objet Smalltalk, directement influencé par Simula, le premier langage à proposer des fonctionnalités d’OPP. Les idées fondamentales de Smalltalk continuent d’influencer les fonctionnalités d’OPP des langages de programmation modernes. Parmi les langages influencés par Smalltalk figurent notamment Ruby, Python, Go et Swift.

La programmation orientée objet fait partie des paradigmes de programmation dominants, au même titre que la programmation fonctionnelle (FP), très populaire. Les approches de programmation peuvent être classées en deux grands courants : « impératif » et « déclaratif ». Le OOP est une expression du style de programmation impératif et un développement spécifique de la programmation procédurale :

  1. Programmation impérative : décrire en différentes étapes comment résoudre un problème. Exemple : algorithme
  • Programmation structurée
    • Programmation procédurale
      • Programmation orientée objet
  1. Programmation déclarative : générer des résultats selon certaines règles. Exemple : requête SQL
  • Programmation fonctionnelle
  • Programmation spécifique au domaine
Note

Les termes « procédure » et « fonction » sont souvent utilisés comme synonymes. Dans les deux cas, il s’agit de blocs de code exécutables qui peuvent recevoir des arguments. La différence réside dans le fait que les fonctions renvoient une valeur, ce qui n’est pas le cas des procédures. Tous les langages ne prennent pas explicitement en charge les procédures.

En principe, il est possible de résoudre n’importe quel problème de programmation avec n’importe quel paradigme, car tous les paradigmes sont « Turing-complet ». L’élément limitant n’est donc pas la machine, mais l’homme. Les programmeurs ou les équipes de programmation ne peuvent appréhender qu’une quantité limitée de complexité. Ils utilisent donc des abstractions pour maîtriser la complexité. En fonction du domaine d’application et de la problématique, un style de programmation conviendra mieux qu’un autre.

La plupart des langages modernes sont des langages dits « multi-paradigmes », c’est-à-dire qu’ils permettent de programmer dans plusieurs styles de programmation. À l’inverse, certains langages ne supportent qu’un seul style de programmation. C’est notamment le cas des langages strictement fonctionnels comme Haskell :

Paradigme Caractéristiques Particulièrement adapté à Langues
Impératif OOP Objets, classes, méthodes, héritage, polymorphisme Modélisation, conception de systèmes Smalltalk, Java, Ruby, Python, Swift
Impératif Procédural Flux de contrôle, itération, procédures / fonctions Traitement séquentiel des données C, Pascal, Basic
Déclaratif Fonctionnel Immutabilité, fonctions pures, lambda-calcul, récursivité, système de type Traitement parallèle des données, applications mathématiques et scientifiques, analyseurs et compilateurs Lisp, Haskell, Clojure
Déclaratif Langage spécifique au domaine (DSL) Expressif, grande richesse de langage Applications spécifiques au domaine SQL, CSS
Note

Curieusement, même CSS est un langage Turing-complet. Cela signifie que tout calcul écrit dans un autre langage peut également être résolu en CSS.

La programmation orientée objet fait partie de la programmation impérative et est issue de la programmation procédurale. Cette dernière s’occupe en fait de données inertes qui sont traitées par du code exécutable :

  1. Données : valeurs, structures de données, variables
  2. Code : expressions, structures de contrôle, fonctions

C’est précisément là que réside la différence entre la programmation orientée objet et la programmation procédurale : l’OOP réunit les données et les fonctions en objets. Un objet est pour ainsi dire une structure de données vivante ; car les objets ne sont pas inertes, ils ont un comportement. Les objets sont donc comparables à des machines ou à des organismes unicellulaires. Si l’on se contente d’opérer sur des données, on interagit avec les objets ou les objets interagissent entre eux.

Illustrons la différence par un exemple. Une variable entière en Java ou C++ ne contient qu’une valeur. Il ne s’agit pas d’une structure de données, mais d’une « primitive » :

int number = 42;
Java

Les opérations sur les primitives sont effectuées à l’aide d’opérateurs ou de fonctions définis à l’extérieur. Voici l’exemple de la fonction successeur qui renvoie un nombre entier au suivant :

int successor(int number) {
    return number + 1;
}
// returns `43`
successor(42)
Java

En revanche, dans des langages comme Python et Ruby, « everything is an object » (« tout est un objet »). Même un simple nombre comprend la valeur proprement dite ainsi qu’un ensemble de méthodes qui définissent des opérations sur la valeur. Ici, l’exemple de la fonction succ intégrée en Ruby :


42.succ
Ruby

C’est tout d’abord pratique, car la fonctionnalité est regroupée pour un seul et même type de données. Il n’est pas possible de faire appel à une méthode qui ne correspond pas au type. Mais les méthodes peuvent faire bien plus. En Ruby, même la boucle For est réalisée comme méthode d’un nombre. Nous donnons à titre d’exemple les nombres de 51 à 42 :

51.downto(42) { |n| print n, ".. " }
Ruby

D’où proviennent les méthodes ? Dans la plupart des langages, les objets sont définis par des classes. On dit que les objets sont instanciés à partir de classes, c’est pourquoi on appelle les objets des instances. Une classe est un modèle permettant de créer des objets similaires disposant des mêmes méthodes. Ainsi, les classes fonctionnent comme des types dans les langages d’OOP. Cela est évident dans la programmation orientée objet en Python; la fonction type renvoie une classe comme type d’une valeur :

type(42) # <class 'int'>
type('Walter White') # <class 'str'>
Python

Comment fonctionne la programmation orientée objet ?

Si l’on demande à une personne ayant un peu d’expérience en programmation en quoi consiste l’OOP, la réponse sera probablement que cela a à voir avec les classes. En réalité, les classes ne sont pas le cœur du sujet. Les idées de base de la programmation orientée objet d’Alan Kay sont plus simples et peuvent être résumées comme suit :

  1. Les objets encapsulent leur état interne.
  2. Les objets reçoivent des messages via leurs méthodes.
  3. L’allocation des méthodes se fait de manière dynamique au moment de l’exécution.

Examinons ces trois points de plus près.

Les objets encapsulent leur état interne

Pour comprendre ce que l’on entend par encapsulation, prenons l’exemple d’une voiture. Une voiture présente un certain état, par exemple la charge de la batterie, le niveau de remplissage du réservoir, le moteur allumé ou non. Si nous représentons une telle voiture en tant qu’objet, les propriétés internes doivent pouvoir être modifiées exclusivement via des interfaces définies.

Regardons quelques exemples. Nous avons un objetcar*qui représente une voiture. À l’intérieur de l’objet, l’état estenregistré dans des variables. L’objet gère les valeurs des variables ; on peut ainsi s’assurer que de l’énergie est dépensée pour démarrer le moteur, par exemple. Nous démarrons le moteur de la voiture en envoyant un message start :

car.start()
Python

À ce stade,c’est l’objet qui décide de ce qui va se passer ensuite : si le moteur tourne déjà, le message est ignoré ou un message correspondant est émis. S’il n’y a pas assez de charge de batterie ou si le réservoir est vide, le moteur reste arrêté. Si toutes les conditions sont remplies, le moteur est démarré et l’état interne est adapté. Par exemple, une variable booléenne motor_running est définie sur « True » et la charge de la batterie est diminuée de la charge nécessaire au démarrage. Nous montrons à quoi pourrait ressembler le code à l’intérieur de l’objet :

# starting car
motor_running = True
battery_charge -= start_charge
Python

Il est important que l’état interne ne puisse pas être modifié directement de l’extérieur. Sinon, nous pourrions définir motor_running sur « True », même si la batterie est vide. Cela relèverait de la magie et ne refléterait pas les conditions réelles.

Envoi de messages / appel de méthodes

Comme nous l’avons vu, les objets réagissent aux messages et modifient éventuellement leur état interne en réaction. Nous appelons ces messages des méthodes ; d’un point de vue technique, il s’agit de fonctions liées à un objet. Le message se compose du nom de la méthode et, le cas échéant, d’autres arguments. L’objet qui reçoit est appelé récepteur. Nous exprimons le schéma général de la réception de messages par des objets comme suit :

# call a method
receiver.method(args)
Python

Exemple : imaginons que nous programmons un smartphone. Différents objets représentent différentes fonctionnalités comme les paramètres du téléphone, la lampe de poche, un appel, un message texte, etc. Habituellement, les différents sous-composants sont à leur tour modélisés comme des objets. Ainsi, le carnet d’adresses est un objet, tout comme chaque contact qu’il contient et le numéro de téléphone d’un contact. Il est ainsi facile de modéliser des processus issus de la réalité :

# find a person in our address book
person = contacts.find('Walter White')
# let's call that person's work number
call = phone.call(person.phoneNumber('Work'))
...
# after some time, hang up the phone
call.hangUp()
Python

Allocation dynamique des méthodes

Le troisième critère essentiel de la définition initiale de l’OOP d’Alan Kay est l’allocation dynamique des méthodes au moment de l’exécution. Cela signifie que la décision concernant le code à exécuter lors de l’appel d’une méthode n’est prise qu’au moment de l’exécution du programme. En conséquence, le comportement d’un objet peut être modifié au moment de l’exécution.

L’allocation dynamique des méthodes a des conséquences importantes sur l’implémentation technique des fonctionnalités de l’OOP dans les langages de programmation. Dans la pratique, on y est généralement moins confronté. Penchons-nous néanmoins sur un exemple. Nous modélisons la lampe de poche du smartphone en tant qu’objet flashlight. Celle-ci réagit aux messages on, off et intensity:

// turn on flashlight
flashlight.on()
// set flashlight intensity to 50%
flashlight.intensity(50)
// turn off flashlight
flashlight.off()
JavaScript

Supposons que la lampe de poche se casse et que nous décidions d’émettre un avertissement à chaque fois que nous voulions l’utiliser. L’approche consiste à remplacer toutes les méthodes par une nouvelle méthode. C’est par exemple très simple en JavaScript. Nous définissons la nouvelle fonction out_of_order et remplaçons les méthodes existantes :

function out_of_order() {
    console.log('Flashlight out of order. Please service phone.')
    return false;
}
flashlight.on = out_of_order;
flashlight.off = out_of_order;
flashlight.intensity = out_of_order;
JavaScript

Si nous essayons par la suite d’interagir avec la lampe de poche, out_of_order sera toujours appelé :

// calls `out_of_order()`
flashlight.on()
// calls `out_of_order()`
flashlight.intensity(50)
// calls `out_of_order()`
flashlight.off()
JavaScript

D’où proviennent les objets ? Instanciation et initialisation

Jusqu’à présent, nous avons vu comment les objets reçoivent des messages et y réagissent. Mais d’où proviennent les objets ? Intéressons-nous maintenant à la notion centrale d’instanciation. L’instanciation est le processus par lequel un objet prend vie. Dans les différents langages de programmation orientée objet, il existe différents mécanismes d’instanciation. La plupart du temps, un ou plusieurs des mécanismes suivants sont utilisés :

  1. Définition par objet littéral
  2. Instanciation avec une fonction constructeur
  3. Instanciation à partir d’une classe

JavaScript excelle dans ce domaine, car il est possible de définir directement des objets tels que des nombres ou des chaînes de caractères en tant que littéraux. Prenons un exemple simple : nous instancions un objet vide person et lui attribuons ensuite la propriété name ainsi qu’une méthode greet. Notre objet est alors en mesure de saluer une autre personne et de donner son propre nom :

// instantiate empty object
let person = {};
// assign object property
person.name = "Jack";
// assign method
person.greet = function(other) {
    return `"Hi ${other}, I'm ${this.name}"`
};
// let's test
person.greet("Jim")
JavaScript

Nous avons instancié un objet unique. Cependant, nous souhaitons souvent répéter l’instanciation afin de créer une série d’objets similaires. Ce cas peut aussi être facilement résolu en JavaScript. Nous créons ce que l’on appelle une fonction constructeur qui, lorsqu’elle est appelée, assemble un objet. Notre fonction constructeur, appelée Person, prend en compte un nom et un âge et crée un nouvel objet lorsqu’elle est appelée :

function Person(name, age) {
    this.name = name;
    this.age = age;
    
    this.introduce_self = function() {
        return `"I'm ${this.name}, ${this.age} years old."`
    }
}
// instantiate person
person = new Person('Walter White', 42)
// let person introduce themselves
person.introduce_self()
JavaScript

Attention à l’utilisation du mot-clé this. Celui-ci se trouve également dans d’autres langages tels que Java, PHP et C++ et est souvent source de confusion pour les novices en OOP. Pour faire simple,thisest un caractère de remplacement pour un objet instancié. Lors de l’appel d’une méthode,this’ référence le récepteur, c’est-à-dire pointe vers une instance d’objet spécifique. D’autres langages, comme Python et Ruby, utilisent le mot-clé self à la place dethi*s, ce qui a le même effet.

De plus, en JavaScript, nous avons besoin du mot-clé newpour correctement créer l’instance d’objet. On le trouve notamment en Java et en C++, qui font la distinction entre « pile » (stack) et « tas » (heap) pour le stockage de valeurs en mémoire. Dans les deux langages,newsert à allouer de la mémoire sur le tas. JavaScript, comme Python, place toutes les valeurs dans le tas, de sorte quenew est en fait inutile. Cela montre donc qu’il est possible de s’en passer.

Le troisième mécanisme de création d’instances d’objets, le plus répandu, fait appel aux classes. Une classe remplit un rôle similaire à celui d’une fonction constructeur en JavaScript : les deux servent de modèle à partir duquel des objets similaires peuvent être instanciés si nécessaire. En même temps, dans des langages comme Python et Ruby, une classe sert de substitut aux types utilisés dans d’autres langages. Un exemple de classe est présenté ci-dessous.

Quels sont les avantages et les inconvénients de l’OOP ?

La programmation orientée objet est de plus en plus critiquée depuis le début du 21ème siècle. Les langages fonctionnels modernes, dotés d’une immutabilité et de systèmes de types forts, sont considérés comme plus stables, plus fiables et plus performants. Cependant, l’OOP est très répandue et présente de nombreux avantages. Il est important de choisir le bon outil pour chaque problème, plutôt que de miser sur une seule méthodologie.

Avantage : l’encapsulation

L’un des avantages immédiats de l’OOP est le regroupement des fonctionnalités. Au lieu de regrouper plusieurs variables et fonctions dans une collection aléatoire, il est possible de les relier en unités cohérentes. Nous illustrons la différence par un exemple : nous modélisons un bus et utilisons pour cela deux variables et une fonction. Les passagers peuvent monter dans le bus jusqu’à ce qu’il soit plein :

# list to hold the passengers
bus_passengers = []
# maximum number of passengers
bus_capacity = 12
# add another passenger
def take_bus(passenger)
    if len(bus_passengers) < bus_capacity:
        bus_passengers.append(passenger)
    else:
        raise Exception("Bus is full")
Python

Le code fonctionne, mais il est problématique. La fonction take_bus accède aux variables bus_passengers et bus_capacity sans que celles-ci soient transmises comme arguments. Cela pose des problèmes pour les codes volumineux, car les variables doivent être soit fournies globalement, soit passées à chaque appel. Il est également possible de « tricher ». Nous pouvons continuer à ajouter des passagers au bus alors qu’il est déjà plein :

# bus is full
assert len(bus_passengers) == bus_capacity
# will raise exception, won't add passenger
take_bus(passenger)
# we cheat, adding an additional passenger directly
bus_passengers.append(passenger)
# now bus is over capacity
assert len(bus_passengers) > bus_capacity
Python

De plus, rien ne nous empêche d’augmenter la capacité du bus. Cependant, cela transgresse la réalité physique, car un bus existant a une capacité limitée qui ne peut pas être modifiée à volonté par la suite :

# can't do this in reality
bus_capacity += 1
Python

L’encapsulation de l’état interne des objets protège contre les modifications absurdes ou non souhaitées. Voici la même fonctionnalité en code orienté objet. Nous définissons une classe bus et instancions un bus à capacité limitée. L’ajout de passagers n’est possible que par la méthode correspondante :

class Bus():
    def __init__(self, capacity):
        self._passengers = []
        self._capacity = capacity
    
    def enter(self, passenger):
        if len(self._passengers) < self._capacity:
            self._passengers.append(passenger)
            print(f"{passenger} has entered the bus")
        else:
            raise Exception("Bus is full")
# instantiate bus with given capacity
bus = Bus(2)
bus.enter("Jack")
bus.enter("Jim")
# will fail, bus is full
bus.enter("John")
Python

Avantage : modéliser des systèmes

La programmation orientée objet se prête particulièrement bien à la modélisation de systèmes. L’intuition de l’OOP est humaine, car nous pensons également en termes d’objets, qui peuvent être classés par catégories. Les objets peuvent être aussi bien des choses physiques que des concepts abstraits.

L’héritage via des hiérarchies de classes, présent dans de nombreux langages d’OOP, reflète également des schémas de pensée humains. Illustrons ce dernier point par un exemple. Un animal est un concept abstrait. Les animaux qui apparaissent réellement sont toujours des expressions concrètes d’une espèce. Selon l’espèce, les animaux ont des caractéristiques différentes. Un chien ne peut ni grimper ni voler, il est donc limité à des mouvements dans un espace à deux dimensions :

# abstract base class
class Animal():
    def move_to(self, coords):
        pass
# derived class
class Dog(Animal):
    def move_to(self, coords):
        match coords:
            # dogs can't fly nor climb
            case (x, y):
                self._walk_to(coords)
# derived class
class Bird(Animal):
    def move_to(self, coords):
        match coords:
            # birds can walk
            case (x, y):
                self._walk_to(coords)
            # birds can fly
            case (x, z, y):
                self._fly_to(coords)
Python

Inconvénients de la programmation orientée objet

L’un des inconvénients immédiats de l’OPP est son jargon, difficile à comprendre au départ On se voit contraint d’apprendre des concepts nouveaux, dont le sens et le but ne sont souvent pas compréhensibles dans des exemples simples. Il est donc facile de commettre des erreurs ; la modélisation des hiérarchies d’héritage demande beaucoup d’habileté et d’expérience.

L’une des critiques les plus fréquentes à l’encontre de l’OOP est l’encapsulation de l’état interne, censée être un avantage. Cela entraîne des difficultés lors de la parallélisation du code OOP. En effet, si un objet est transmis à plusieurs fonctions parallèles, l’état interne pourrait changer entre les appels de fonction. De plus, il est parfois nécessaire d’accéder, au sein d’un programme, à des informations encapsulées ailleurs.

La nature dynamique de la programmation orientée objet entraîne généralement des pertes de performance. En effet, il est moins possible de procéder à des optimisations statiques. De même, les systèmes de types des langages OOP purs, qui ont tendance à être moins prononcés, rendent certaines vérifications statiques impossibles. Ainsi, les erreurs ne sont visibles qu’au moment de l’exécution. Les développements les plus récents, comme le langage JavaScript TypeScript, permettent d’y remédier.

Quels sont les langages de programmation qui sont adaptés à l’OOP ?

Presque tous les langages multi-paradigmes conviennent à la programmation orientée objet. Les langages de programmation Web PHP, Ruby, Python et JavaScript en font partie. En revanche, les principes de l’OOP sont en grande partie incompatibles avec l’algèbre relationnelle sur laquelle repose SQL. Pour combler l’« Impedance Mismatch », on utilise des couches de traduction spéciales, connues sous le nom de « Object Relational Mapper » (ORM).

Même les langages purement fonctionnels comme Haskell n’apportent généralement pas de support natif pour l’OOP. Pour appliquer l’OOP en C, il faut déployer des efforts considérables. Il est intéressant de noter qu’il existe un langage moderne, Rust, qui se passe de classes. À la place, on utilise struct et enum comme structures de données, dont le comportement est défini par un mot-clé impl. Les comportements peuvent être regroupés à l’aide de ce que l’on appelle des traits ; l’héritage et le polymorphisme sont également représentés de cette manière. La conception du langage reflète la meilleure pratique de l’OOP : « Composition over Inheritance ».