OOP et Python : comment fonctionne la programmation orientée objet ?

Depuis la sortie de sa version 3, Python mise avant tout sur la programmation orientée objet (OOP, de l’anglais « object-oriented programming »). Ce langage s’appuie sur la philosophie de conception « everything is an object », selon laquelle « tout est un objet ».

Contrairement à Java, C++ et Python 2.x, il n’existe ici aucune distinction entre les valeurs primitives et les objets. Dans le langage Python, les nombres, les chaînes de caractères et les listes, voire les fonctions et les classes, sont tous considérés comme des objets.

Par rapport à d’autres langages, l’OOP basée sur les classes avec Python se distingue par sa grande flexibilité et son faible nombre de contraintes fixes. Ce langage est donc aux antipodes de Java, qui propose un système d’OOP décrit comme étant tout particulièrement rigide. Découvrez notre explication détaillée sur le fonctionnement de la programmation orientée objet dans Python.

Programmation orientée objet dans Python : à quoi sert-elle ?

La programmation orientée objet correspond à une forme de programmation impérative. Les objets servent à associer données et fonctionnalités. Un objet encapsule son état interne avec un accès par l’intermédiaire d’une interface publique, l’interface de l’objet. L’interface d’un objet est définie par les méthodes de ce dernier. Les objets interagissent entre eux par des messages, transmis grâce aux appels de ces méthodes.

Conseil

Pour mieux comprendre le contexte auquel nous faisons référence, intéressez-vous aux articles « Qu’est-ce que l’OOP ? », « Paradigmes de programmation » et « Tutoriel Python », rédigés par nos soins.

Encapsuler des objets grâce à l’OOP dans Python

Découvrez avec nous comment les objets peuvent être encapsulés avec l’OOP dans Python. Supposons que nous devons écrire du code pour une cuisine, un bar ou un laboratoire. Pour ce faire, nous allons modéliser des récipients tels que des bouteilles, des verres, des tasses, etc. ; tous représentent un volume et peuvent être remplis. Ces objets appartiennent à différentes catégories, appelées « classes ».

Les objets représentant ces récipients possèdent un état interne, qu’il nous est possible de modifier. Nous sommes en effet en mesure de remplir ces récipients, de les vider, etc. S’ils disposent d’un système de fermeture (d’un bouchon, par exemple), nous pouvons aussi les ouvrir et les fermer. En toute logique, il n’est toutefois pas possible de modifier le volume d’un récipient a posteriori. À l’évidence, différentes réflexions sont possibles en ce qui concerne l’état d’un récipient, notamment :

  • « Le verre est-il plein ? »
  • « Quel est le volume de la bouteille ? »
  • « Le récipient peut-il se fermer ? »

De la même manière, il est logique de créer des interactions entre les objets. À titre d’exemple, il est normalement possible de transvaser le contenu d’un verre dans une bouteille. Commençons par nous intéresser à la modification de l’état interne d’un objet avec la programmation orientée objet proposée par Python. Pour modifier les différents états ou poser des questions sur ces derniers, nous utilisons des appels de méthodes :


cup = Container(400)
assert cup.volume() == 400
assert not cup.is_full()
# add some water to the cup
cup.add('Water', 250)
assert cup.volume_filled() == 250
# add more water, filling the cup
cup.add('Water', 150)
assert cup.is_full()
Python

Définir des types avec l’OOP dans Python

Les types de données constituent l’un des concepts fondamentaux de la programmation. Différentes données se prêtent à divers usages ; les nombres sont traités à l’aide d’opérations arithmétiques, tandis que les chaînes de caractères (ou « strings ») peuvent être explorées :

# addition works for two numbers
39 + 3
# we can search for a letter inside a string
'y' in 'Python'
Python

Si nous essayons d’additionner un nombre et une chaîne de caractères ou d’effectuer une recherche au sein d’un nombre, nous nous retrouvons face à une erreur de ce type :

# addition doesn’t work for a number and a string
42 + 'a'
# cannot search for a letter inside a number
'y' in 42
Python

Les types intégrés à Python sont abstraits, ce qui signifie qu’un nombre peut représenter tout et n’importe quoi : une distance, du temps, de l’argent, etc. Seul le nom de la variable donne alors une indication sur la signification d’une telle valeur :

# are we talking about distance, time?
x = 51
Python

Mais alors, comment procéder pour modéliser des concepts spécialisés ? Pour atteindre cet objectif, il faut encore une fois recourir à la programmation orientée objet dans Python. Les objets sont des structures de données dont le type est identifiable. Pour l’afficher, il convient d’utiliser la fonction intégrée « type() » :

# class 'str'
type('Python')
# class 'tuple'
type(('Walter', 'White'))
Python

Créer des abstractions grâce à la programmation orientée objet dans Python

Dans le domaine de la programmation, les abstractions sont utilisées pour dissimuler toute complexité. Elles permettent aux programmeurs d’exercer leurs activités à un niveau supérieur. La question « Le verre est-il plein ? » est, par exemple, identique à la question « Le volume du contenu du verre est-il égal au volume du verre ? ». La première question, qui est plus abstraite, est aussi plus courte et plus concise ; c’est donc celle-ci qu’il convient de privilégier. Les abstractions permettent donc de créer des systèmes plus complexes, mais également d’en avoir une bonne vue d’ensemble :

# instantiate an empty glass
glass = Container(250)
# add water to the glass
glass.add('Water', 250)
# is the glass full?
assert glass.is_full()
# a longer way to ask the same question
assert glass.volume_filled() == glass.volume()
Python

L’OOP utilisée avec Python permet d’appliquer des concepts abstraits à de nouvelles idées. Intéressons-nous par exemple à l’opérateur d’addition de Python. Le signe « + » peut être utilisé pour relier deux nombres, mais nous pouvons aussi l’appliquer à des listes pour combiner leurs contenus :

assert 42 + 9 == 51
assert ['Jack', 'John'] + ['Jim'] == ['Jack', 'John', 'Jim']
Python

Il est ici logique d’appliquer ce concept d’addition à notre modèle. Pour ce faire, nous définissons un opérateur d’addition pour les récipients. Celui-ci nous permet d’écrire un code se lisant presque comme un langage naturel. Nous vous expliquerons un peu plus tard comment l’implémenter. Pour l’heure, intéressons-nous à cet exemple d’application :

# pitcher with 1000 ml capacity
pitcher = Container(1000)
# glass with 250 ml capacity
glass = Container(250)
# fill glass with water
glass.fill('Water')
# transfer the content from the glass to the pitcher
pitcher += glass
# pitcher now contains water from glass
assert pitcher.volume_filled() == 250
# glass is empty
assert glass.is_empty()
Python

Comment fonctionne la programmation orientée objet dans Python ?

Les objets combinent des données et des fonctionnalités, également connues sous le nom d’« attributs ». Contrairement à Java, PHP et C++, l’OOP dans Python ne propose pas de mots-clés semblables à « private » et « protected » afin de restreindre l’accès aux attributs. Il convient plutôt d’utiliser une convention : les attributs commençant par un tiret bas sont considérés comme privés. Il peut s’agir d’attributs de données répondant au schéma « _internal_attr », mais aussi de méthodes répondant au schéma « _internal_method() ».

Dans le langage Python, les méthodes sont définies en tant que premier paramètre par la variable « self ». Tout accès aux attributs d’un objet depuis l’intérieur de celui-ci s’effectue à l’aide d’une référence à « self ». Avec Python, « self » fonctionne comme un caractère générique pour une instance concrète ; ainsi, il joue un rôle identique à celui du mot-clé « this » pour Java, PHP, JavaScript et C++.

Grâce à une combinaison avec la convention mentionnée précédemment, un modèle simple d’encapsulation peut être créé : il est alors possible d’accéder à un attribut interne avec la référence « self._internal », car il s’effectue à l’intérieur de l’objet. Toute tentative d’accès externe, dans un style similaire à « obj._internal », est quant à elle contraire au principe d’encapsulation, et doit donc être évitée :

class ExampleObject:
    def public_method(self):
        self._internal = 'changed from inside method'
# instantiate object
obj = ExampleObject()
# this is fine
obj.public_method()
assert obj._internal == 'changed from inside method'
# works, but not a good idea
obj._internal = 'changed from outside'
Python

Classes

Une classe agit comme un modèle pour les objets. Un objet est instancié à partir de classes, ce qui veut dire qu’il est créé conformément au modèle en question. Par convention, les noms de classes personnalisés commencent toujours par une majuscule.

Au contraire de Java, C++, PHP et JavaScript, l’OOP dans Python ne fait pas appel au mot-clé « new ». C’est plutôt le nom de la classe qui est appelé en tant que fonction et qui sert alors de constructeur pour fournir une nouvelle instance. Le constructeur appelle, de façon implicite, la fonction « __init__() », qui permet d’initialiser les données de l’objet.

Intéressons-nous à nouveau aux modèles déjà étudiés avec un exemple de code. Nous modélisons le concept d’un récipient sous la forme d’une classe portant le nom « Container » et définissons des méthodes pour les interactions importantes :

Méthode Explication
__init__ Initialisation d’un nouveau récipient avec des valeurs de départ.
__repr__ Indication de l’état du conteneur sous forme de texte.
volume Indication du volume du récipient.
volume_filled Indication de l’état de remplissage du récipient.
volume_available Indication du volume encore disponible dans le récipient.
is_empty Indication donnée si le récipient est vide.
is_full Indication donnée si le récipient est plein.
empty Vidange du récipient et restitution du contenu.
_add Méthode interne ajoutant une substance sans procéder à des contrôles.
add Méthode publique ajoutant le volume de substance indiqué s’il reste suffisamment d’espace disponible.
fill Remplissage du volume disponible restant dans le récipient à l’aide d’une substance.
pour_into Transfert complet du contenu du récipient dans un autre récipient.
__add__ Implémentation de l’opérateur d’addition pour les récipients (utilisation de la méthode « pour_into »).

Voici le code réel de la classe « Container ». Une fois celui-ci exécuté dans votre REPL Python local, n’hésitez pas à tester les autres exemples de code présents dans l’article :

class Container:
    def __init__(self, volume):
        # volume in ml
        self._volume = volume
        # start out with empty container
        self._contents = {}
    
    def __repr__(self):
        """
        Textual representation of container
        """
        repr = f"{self._volume} ml Container with contents {self._contents}"
        return repr
    
    def volume(self):
        """
        Volume getter
        """
        return self._volume
    
    def is_empty(self):
        """
        Container is empty if it has no contents
        """
        return self._contents == {}
    
    def is_full(self):
        """
        Container is full if volume of contents equals capacity
        """
        return self.volume_filled() == self.volume()
    
    def volume_filled(self):
        """
        Calculate sum of volumes of contents
        """
        return sum(self._contents.values())
    
    def volume_available(self):
        """
        Calculate available volume
        """
        return self.volume() - self.volume_filled()
    
    def empty(self):
        """
        Empty the container, returning its contents
        """
        contents = self._contents.copy()
        self._contents.clear()
        return contents
    
    def _add(self, substance, volume):
        """
        Internal method to add a new substance / add more of an existing substance
        """
        # update volume of existing substance
        if substance in self._contents:
            self._contents[substance] += volume
        # or add new substance
        else:
            self._contents[substance] = volume
    
    def add(self, substance, volume):
        """
        Public method to add a substance, possibly returning left over
        """
        if self.is_full():
            raise Exception("Cannot add to full container")
        # we can fit all of the substance
        if self.volume_filled() + volume <= self.volume():
            self._add(substance, volume)
            return self
        # we can fit part of the substance, returning the left over
        else:
            leftover = volume - self.volume_available()
            self._add(substance, volume - leftover)
            return {substance: leftover}
    
    def fill(self, substance):
        """
        Fill the container with a substance
        """
        if self.is_full():
            raise Exception("Cannot fill full container")
        self._add(substance, self.volume_available())
        return self
    
    def pour_into(self, other_container):
        """
        Transfer contents of container to another container
        """
        if other_container.volume_available() < self.volume_filled():
            raise Exception("Not enough space")
        # get the contents by emptying container
        contents = self.empty()
        # add contents to other container
        for substance, volume in contents.items():
            other_container.add(substance, volume)
        return other_container
    
    def __add__(self, other_container):
        """
        Implement addition for containers:
        `container_a + container_b` <=> `container_b.pour_into(container_a)`
        """
        other_container.pour_into(self)
        return self
Python

Amusons-nous un peu avec certains exemples dans le cadre de l’implémentation des récipients. Nous allons ici instancier un verre et le remplir d’eau. Ce verre est alors plein :

glass = Container(300)
glass.fill(’Water’)
assert glass.is_full()
Python

L’étape suivante consiste à vider le verre en récupérant le volume d’eau qu’il contenait jusqu’ici. Si l’implémentation fonctionne, le verre est alors vide :

contents = glass.empty()
assert contents == {'Water': 300}
assert glass.is_empty()
Python

Intéressons-nous maintenant à un exemple plus complexe. Pour ce faire, nous mélangeons du vin et du jus d’orange dans un pichet. Nous commençons donc par créer les récipients nécessaires, avant de remplir deux d’entre eux avec chacun des liquides :

pitcher = Container(1500)
bottle = Container(700)
carton = Container(500)
# fill ingredients
bottle.fill('Red wine')
carton.fill('Orange juice')
Python

Pour finir, nous utilisons l’opérateur d’addition et d’assignation « += » pour transvaser le contenu des deux conteneurs ainsi remplis dans le pichet.

# pour ingredients into pitcher
pitcher += bottle
pitcher += carton
# check that everything worked
assert pitcher.volume_filled() == 1200
assert bottle.is_empty() and carton.is_empty()
Python

Si cela fonctionne, c’est parce que notre classe « Container » implémente la méthode « __add__() ». En arrière-plan, l’assignation « pitcher += bottle » se transforme en « pitcher = pitcher + bottle ». De plus, le langage Python traduit « pitcher + bottle » en un appel de méthode « pitcher.__add__(bottle) ». Notre méthode « __add__() » renvoie alors le récipient destinataire (dans ce cas, il s’agit du pichet), de sorte que l’assignation fonctionne comme il se doit.

Attributs statiques

Nous avons vu ensemble comment accéder aux attributs des objets : de manière externe avec les méthodes publiques, ou de manière interne en faisant référence à « self ». L’état interne des objets se réalise à l’aide d’attributs de données appartenant à l’objet en question. Les méthodes d’un objet sont elles aussi reliées à une instance spécifique. Il existe néanmoins des attributs pouvant appartenir à des classes. Cela semble logique, car les classes sont également considérées comme des objets dans Python.

Les attributs de classe sont également qualifiés d’attributs « statiques », car ils sont déjà présents avant l’instanciation d’un objet. Indifféremment, il peut s’agir d’attributs de données ou de méthodes. Cela peut s’avérer utile pour les constantes, qui sont identiques pour toutes les instances d’une même classe, mais aussi pour les méthodes ne fonctionnant pas sur « self ». Les routines de conversion sont souvent implémentées en tant que méthodes statiques.

Contrairement à des langages comme Java et C++, Python ne propose pas le mot-clé « static » pour effectuer une distinction explicite entre les attributs d’objet et les attributs de classe. Il convient plutôt d’utiliser un décorateur, nommé « @staticmethod ». À titre d’exemple, imaginons l’apparence que pourrait prendre une méthode statique pour notre classe « Container ». Commençons par implémenter une routine de conversion afin de convertir des millilitres en onces liquides :

    # inside of class `Container`
    ...
    @staticmethod
    def floz_from_ml(ml):
        return ml * 0.0338140227
Python

Pour accéder aux attributs statiques, il faut comme toujours utiliser une référence d’attribut avec une notation par point, en suivant le schéma « obj.attr ». Ici, la différence tient au fait que le nom de la classe est placé avant le point : « ClassName.static_method() ». Dans la logique, tout ceci est cohérent, car dans le cadre de la programmation orientée objet avec Python, les classes sont elles aussi des objets. Pour appeler la routine de conversion de notre classe « Container », il convient donc d’écrire ce qui suit :

floz = Container.floz_from_ml(1000)
assert floz == 33.8140227
Python

Interfaces

Une interface correspond à la collection de toutes les méthodes publiques d’un objet. En plus de définir et de documenter le comportement d’un objet, cette interface sert également d’API. Contrairement au langage C++, Python ne propose pas de niveaux séparés pour l’interface (fichiers d’en-tête) et l’implémentation. De plus, il n’existe aucun mot-clé explicite pour l’interface ; il se distingue en cela de Java et de PHP. Dans ces langages, les interfaces sont équipées de signatures de méthodes et permettent de décrire une fonctionnalité cohérente.

En langage Python, les informations sur les méthodes mises à la disposition d’un objet et sur la classe à partir de laquelle il a été instancié sont déterminées de façon dynamique au moment de l’exécution, de sorte qu’aucune interface explicite n’est nécessaire pour ce langage. En revanche, l’OOP avec Python est basée sur le principe du « duck typing » :

Citation

« If it walks like a duck and it quacks like a duck, then it must be a duck » — Source : https://docs.python.org/3/glossary.html#term-duck-typing. Traduction : « Si ça marche comme un canard et si ça cancane comme un canard, c’est qu’il s’agit sans doute d’un canard » (traduction de IONOS).

Mais alors, qu’est-ce que le « duck typing » ? Pour faire simple, un objet Python d’une classe donnée peut être utilisé en tant qu’objet d’une autre classe, à condition qu’il contienne les méthodes nécessaires. Prenons le cas d’un faux canard : s’il émet les mêmes bruits et qu’il nage comme un canard, alors il est perçu en tant que tel par les véritables canards.

Héritage

Comme avec la plupart des langages orientés objet, l’OOP avec Python s’appuie sur le concept d’héritage : une classe peut donc être définie comme la spécialisation d’une autre classe parente. En répétant ce processus, il est possible d’obtenir une arborescence hiérarchique des classes, avec la classe prédéfinie « Object » en tant que racine. Comme c’est le cas avec C++ (mais pas avec les langages Java et PHP), Python prend en charge l’héritage multiple, ce qui signifie qu’une classe peut venir de plusieurs classes parentes.

L’héritage multiple s’utilise de manière flexible. Il permet entre autres d’obtenir les classes « mixins » proposées par Ruby ou les « traits » propres au langage PHP. Si vous souhaitez décomposer la fonctionnalité en interfaces et en classes abstraites, possibilité offerte par Java, vous pouvez pour cela utiliser l’héritage multiple dans le langage Python.

Reprenons l’exemple de nos récipients pour voir comment fonctionne l’héritage multiple avec le langage Python. Certains récipients peuvent se fermer, et nous souhaitons spécialiser notre classe « Container » à cet égard. Nous devons définir une nouvelle classe « SealableContainer », qui hérite de la classe « Container ». À cela, nous ajoutons également une nouvelle classe « Sealable », contenant des méthodes permettant d’apposer et de retirer un bouchon. Comme la classe « Sealable » a pour but de fournir des implémentations de méthodes supplémentaires à une autre classe, il s’agit en réalité d’une classe « mixin » :

class Sealable:
    """
    Implementation needs to:
    - initialize `self._seal`
    """
    def is_sealed(self):
        return self._seal is not None
    
    def is_open(self):
        return not self.is_sealed()
    
    def is_closed(self):
        return not self.is_open()
    
    def open(self):
        """
        Opening removes and returns the seal
        """
        seal = self._seal
        self._seal = None
        return seal
    
    def seal_with(self, seal):
        """
        Closing attaches the seal and returns the Sealable
        """
        self._seal = seal
        return self
Python

Notre classe « SealableContainer » hérite donc de la classe « Container » et de la classe « mixin » « Sealable ». Il est donc temps d’écraser la méthode « __init__() » et de définir deux nouveaux paramètres, lesquels vont permettre de définir le contenu et le type de fermeture du « SealableContainer » concerné lors de son instanciation. Cette opération est nécessaire pour créer des récipients avec des contenus, et qui ferment. Au sein même de la méthode « __init__() », nous utilisons « super() » pour appeler l’initialisation de la classe parente :

class SealableContainer(Container, Sealable):
    """
    Start out with empty, open container
    """
    def __init__(self, volume, contents = {}, seal = None):
        # initialize `Container`
        super().__init__(volume)
        # initialize contents
        self._contents = contents
        # initialize `self._seal`
        self._seal = seal
    
    def __repr__(self):
        """
        Append ’open’ / ’closed’ to textual container representation
        """
        state = "Open" if self.is_open() else "Closed"
        repr = f"{state} {super().__repr__()}"
        return repr
    
    def empty(self):
        """
        Only open container can be emptied
        """
        if self.is_open():
            return super().empty()
        else:
            raise Exception("Cannot empty sealed container")
    
    def _add(self, substance, volume):
        """
        Only open container can have its contents modified
        """
        if self.is_open():
            super()._add(substance, volume)
        else:
            raise Exception("Cannot add to sealed container")
Python

Comme pour la méthode « __init__() », il convient d’écraser d’autres méthodes de manière ciblée, afin de pouvoir différencier notre « SealableContainer » du récipient qui lui ne ferme pas. Nous écrasons ensuite « __repr__() », de manière à ce que l’état du récipient (ouvert ou fermé) soit lui aussi affiché. N’oublions pas non plus d’écraser les méthodes « empty() » et « _add() », qui ne sont pertinentes que si le récipient est ouvert. En procédant ainsi, nous pouvons forcer l’ouverture d’un récipient fermé avant de le vider ou de le remplir. Une fois encore, nous utilisons « super() » pour accéder à la fonctionnalité de la classe parente déjà existante. Imaginons par exemple que nous souhaitions réaliser un Cuba Libre. Il nous faudrait pour cela un verre, une petite bouteille de Coca-Cola et un verre à shot contenant 20 cl de rhum :

glass = Container(330)
cola_bottle = SealableContainer(250, contents = {’Cola’: 250}, seal = 'Bottlecap')
shot_glass = Container(40)
shot_glass.add('Rum', 20)
Python

Commençons par mettre un peu de glace dans le verre, et ajoutons-y le rhum. La bouteille de Coca-Cola étant fermée, nous devons l’ouvrir avant de verser son contenu dans le verre :

glass.add('Ice', 50)
# add rum
glass += shot_glass
# open cola bottle
if cola_bottle.is_closed():
    cola_bottle.open()
# pour cola into glass
glass += cola_bottle
Python