Le langage de programmation Python est un langage très accessible pour commencer la programmation. Grâce à ses librairies Numpy, Pandas, Scikit-learn et Matplotlib, il est aussi un outil formidable pour les Data Scientists.
La facilité avec laquelle Python peut être abordée vient notamment de ses multiples paradigmes: Python peut être utilisé de manière impérative, en suivant un paradigme fonctionnel ou en implémentant les règles de la Programmation Objet.
Dans ce dossier, nous allons définir la programmation orientée objet, ses grands principes et particularismes et son utilisation en Python. Nous verrons en quoi cette notion est très importante pour les Data Scientists comme pour les Data Engineers sans être pour autant trop difficile d’accès.
Table des matières
1. Introduction à la Programmation Orientée Objet
Dans cette série, nous parlerons de classes, d’instances, de méthodes, d’attributs. Si ces termes peuvent sembler obscurs à des Data Scientists junior, ces concepts sont pourtant manipulé tout le temps en Python, en particulier avec les librairies mentionnées plus tôt.
Ce paradigme est aussi très présent dans d’autres langages de programmation, que ce soit de manière totale (Java ou C#) ou partielle (C++, Ruby, Scala,…).
Si on se réfère au sondage de Stack Overflow concernant les technologies utilisées, Java et C# sont utilisés par 41 et 31% des développeurs interrogés.
Python peut donc servir de porte d’entrée à ces langages très utilisés.
Il est donc essentiel de maîtriser ce paradigme car on le retrouvera dans des outils comme Spark ou Hadoop.
Commencer à maîtriser la programmation orientée objet avec Python est donc une atout précieux pour ensuite s’intéresser à des outils très utilisés, en Data Science comme en Big Data.
Enfin, la programmation orientée objet est un outil incroyable pour améliorer la qualité, la lisibilité et la modularité de votre code. Le concept d’héritage permet notamment de simplifier la personnalisation de contenu créé par d’autres.
Par exemple, dans le cadre de nos formations, nous avons pu créer des outils qui se comportaient comme des clients Python de base de données mais qui en fait lançaient et communiquaient avec des images Docker de ces bases de données.
La personnalisation des modèles de Machine Learning est également très facile à mettre en oeuvre grâce à la programmation orientée objet.
Nous vous souhaitons donc un bon voyage au pays des classes, des instances, des constructeurs, de l’héritage, des méthodes et autres concepts de la programmation orientée objet.
2. Le concept de classe
La notion la plus importante en programmation orientée objet est le concept de classe. Les classes sont des moules, des patrons qui permettent de créer des objets en série sur le même modèle. On peut se représenter une classe comme le schéma de construction ainsi que la liste des fonctionnalités d’un ensemble d’objets.
En programmation orientée objet, on n’a affaire qu’à des classes et des objets (ou instance de classe). Tous les éléments manipulés en programmation objet sont des objets (d’où le nom) dont la construction repose sur la définition d’une classe.
a) Comment créer une classe avec Python ?
En utilisant ce modèle, nous pourrons alors créer des objets, appelées instances. Pour créer des classes en Python, on utilise le mot-clef class:
Dans cet exemple, on crée une classe nommée Animal. On choisit ainsi le modèle qu’auront les différentes instances de la classe Animal. Pour l’instant cette classe est très simple mais nous la complexifierons plus tard.
Pour instancier cette classe, nous allons appeler la classe Animal comme si elle était une fonction:
Ici, nous avons créé deux objets, deux instances de la classe Animal: animal1 et animal2. Si on affiche le type de ces deux objets grâce à la fonction type, on voit que ces deux objets appartiennent bien à la classe Animal.
b) Les constructeurs
Les constructeurs sont des fonctions (pas exactement mais gardons ce mot pour l’instant) très importantes: ce sont les fonctions qui sont appelées lorsqu’un objet est créé. Dans le premier cas que nous avons montré, nous n’avions rien de particulier à construire donc nous n’avons pas défini le constructeur.
Mais dans la plupart des cas, il faut le définir. Si nous voulons ajouter un affichage qui nous prévient de la création d’un objet de la classe Animal, nous allons définir la fonction __init__ comme suit:
Cette fonction __init__ prend un argument très important: self. Ce mot-clef désigne l’objet lui-même. Pour l’instant cette utilisation est un peu confuse mais nous verrons pourquoi ce mot est si important par la suite.
c) Les attributs
Notre classe n’a pas un grand intérêt pour l’instant… Nous devons ajouter des caractéristiques à nos animaux pour qu’ils soient intéressant.
Ces caractéristiques sont ce qu’on appelle les attributs.
Par exemple, nous allons donner un âge à tous nos objets de la classe Animal:
Dans la fonction __init__, on a ajouté la définition d’une variable self.age qui vaut 0.
En fait, lorsqu’on définit self.<quelquechose>, on définit un attribut des objets de la classe.
Ainsi toutes les instances de la classe Animal auront un attribut age qui lors de la création de l’objet vaudra 0.
Pour accéder aux attributs d’un objet, on utilise un point. Ainsi dans cette exemple, on accède à l’attribut age de animal1 et de animal2.
Il faut donc voir les attributs comme les caractéristiques des objets d’une classe: tous nos objets qui seront créés à partir de cette classe (qui instancieront cette classe), posséderont ces mêmes caractéristiques.
En les définissant dans la fonction __init__, toutes les instances de cette classe auront ces caractéristiques.
On peut modifier ces valeurs, leur donner une toute autre valeur objet par objet sans problème mais elles seront initialisées de la même façon:
Différentes idées de la classe
Dans cette partie, nous avons vu que les classes sont des modèles pour des objets, que ces modèles peuvent avoir des caractéristiques représentées par des attributs et enfin que ces objets ou instances de classe, sont créés grâce à une “fonction” spéciale, le constructeur.
Il nous reste encore plusieurs sujets à définir pour faire de vous de vrais As de la POO…
3. Les méthodes
Dans la section précédente , nous avons défini les attributs, c’est-à-dire, les caractéristiques d’un objet d’une classe.
Petit rappel : Si la classe prise est Citoyen, les attributs des instances de cette classe peuvent être nom, prénom, sexe, date de naissance, lieu de naissance, l’identifiant, la signature et la taille.
a) Méthodes vs Fonctions
Si fonctions et méthodes peuvent prendre des entrées et renvoyer des sorties, les fonctions ne sont pas relatives à un objet. En fait, en programmation orientée objet pure, les fonctions n’existent pas puisque tout est objet, c’est-à-dire instance de classe.
Grâce à une méthode, on va pouvoir réaliser des opérations qui sont spécifiques à un objet: modifier ses attributs, les afficher, les retourner (ou les initialiser dans le cas de la méthode __init__).
b) Comment créer une méthode
Si nous revenons à notre exemple de la classe Animal pour laquelle on a défini un attribut age. La méthode __init__ permet d’initialiser l’attribut age à 0. Nous allons pouvoir créer une méthode vieillir qui va ajouter 1 à l’attribut age:
Si on s’intéresse à la définition de cette méthode plus en détail, on remarque l’utilisation du mot-clef def qui introduit normalement la définition d’une fonction. Mais cette déclaration de méthode est indentée de manière à se retrouver “à l’intérieur” de la définition de la classe.
De plus on remarque que l’argument self est encore utilisé. Nous verrons plus tard qu’il n’est pas toujours nécessaire d’utiliser cet argument.
Ainsi grâce à cette définition, tous les objets qui sont des instances de cette classe Animal auront la possibilité d’appeler cette méthode:
Pour appeler une méthode d’instance, on utilise un point et puisqu’il s’agit d’une sorte de fonction, on utilise des parenthèses. D’ailleurs la définition d’une méthode fonctionne de la même façon que celle d’une fonction pour les arguments, les sorties …
On pourra ainsi utiliser des arguments supplémentaires à self.
Par exemple, si on veut donner un nom à nos animaux, on peut introduire un attribut nom dans la méthode __init__ et construire une méthode nommer qui permettra de changer la valeur de l’attribut nom:
c) Point d’étape
Jusqu’à présent, nous avons vu le concept de classe et d’instance de classe. Nous avons également défini les attributs et les méthodes. Il s’agit du coeur de la programmation orientée objet. On pourrait presque s’arrêter là. Ce qu’on ferait par la suite en utilisant ces outils serait de la POO simple, sans tirer avantage de tous les concepts de POO (Ce serait quand même dommage …).
Il faut garder à l’esprit que la Programmation Orientée Objet repose sur le concept de modélisation qui est essentiel en ingénierie (que ce soit ingénierie logicielle, Data Science, statistiques, …).
On choisit d’approcher un objet réel par une modélisation, de toute façon imparfaite mais la plus fonctionnelle possible, de l’objet.
Faisons donc une pause dans les concepts pour essayer de comprendre pourquoi ce que nous faisons ici est important: lorsque vous avez commencé à faire du Machine Learning avec scikit-learn, on vous a parlé très vite de la régression logistique et de son utilisation. Mais regardons l’implémentation (simplifiée) de cette régression logistique dans la librairie scikit-learn.
On remarque que LogisticRegression est une classe. Elle possède une méthode __init__ qui peut prendre différents arguments. Elle a aussi d’autres méthodes fit, predict, predict_proba, … Elle possède les attributs random_state, verbose, n_jobs, …
Ainsi si on prend un code classique de Data Scientist, on peut parler en terme de programmation orientée objet:
La programmation orientée objet est donc essentielle pour le Data Scientist !
Nous avons vu dans cette partie que les méthodes sont des fonctions propres à des instances d’une classe et comment les définir et les appeler avec Python. Dans la suite, nous parlerons de certaines méthodes, les accesseurs et les mutateurs, ce qui nous permettra de parler d’un concept important de programmation orientée objet: l’encapsulation.
4. L'encapsulation
a) Accesseurs et Mutateurs
Les accesseurs sont des méthodes qui permettent de retourner la valeur d’un attribut.
Les mutateurs permettent de modifier la valeur d’un attribut.
Mais pourquoi utiliser ces méthodes alors que l’on peut facilement modifier ou lire ces attributs comme on l’a vu précédemment?
En fait, l’utilisation de ces méthodes fait partie du concept d’encapsulation: on veut contrôler les accès en écriture ou en lecture des attributs. Par exemple, je peux vouloir contrôler le type ou la valeur qu’on peut donner à un attribut. En utilisant un mutateur (setter), je cache donc la modification de la valeur à un utilisateur en lui faisant utiliser cette méthode qui pourra alors contenir mes conditions de modification.
b) Encapsulation des attributs
En programmation orientée objet, on distingue différents types d’attributs: les attributs publics, les attributs protégés et les attributs privés. Nous reviendrons sur les attributs protégés rapidement lorsque nous parlerons d’héritage mais la différence importante est entre les attributs privés et les attributs publics.
Un attribut privé n’est accessible qu’à l’intérieur de la définition de la classe: je ne pourrais y accéder (pour lecture ou écriture) que dans la définition des différentes méthodes. A contrario, les attributs publics sont accessibles partout et toujours.
Pour l’instant nous n’avons eu affaire qu’à des attributs publics.
Dans un langage comme Java, cette différence est très stricte. Lorsqu’on utilise Python, on ne rend jamais totalement un attribut privé: Python est un langage beaucoup plus ouvert pour “adultes consentants”, c’est-à-dire qu’on fait en quelque sorte confiance à l’utilisateur final pour qu’il ne cherche pas à détruire le code.
Mais il existe tout de même des façons de faire en sorte que les attributs soient moins accessibles.
c) Attributs privés en Python
Nous allons modifier la définition de notre classe pour permettre de créer un attribut privé age (après tout, pourquoi aurait-on le droit de demander son âge à un animal qu’on ne connaît pas ?).
Pour définir un attribut privé, on va nommer cet attribut en commençant par __ mais faîtes attention, car si vous terminez le nom de l’attribut par __ aussi, alors il n’est plus considéré comme privé: il a simplement un nom plus compliqué.
La dernière ligne devrait générer une exception AttributeError: l’attribut __age n’existe pas…Mais on remarque que la méthode vieillir a bien fonctionné: l’attribut __age existe bien à l’intérieur de la définition de la méthode.
On a donc bien crée un attribut privé… ou alors il est simplement caché. En fait, il est disponible mais sous un autre nom: _Animal__age.
Dans d’autres langages de programmation, c’est ici que les accesseurs et les mutateurs jouent un rôle très important puisque c’est grâce à eux qu’on va pouvoir lire ou modifier ces attributs.
Mais nous pouvons tout de même les définir nous-mêmes:
Dans cet exemple, les méthodes get_age et set_age servent d’accesseur et de mutateur. On voit l’intérêt d’un mutateur pour contrôler le type d’un attribut, ce qui pour un langagetypé dynamiquement comme Python peut présenter un avantage.
d) Définir proprement accesseurs et mutateurs
Dans cette partie, nous allons utiliser les décorateurs. Si vous ne savez pas ce que sont les décorateurs, je vous invite à lire cet article avant de continuer.
Pour forcer l’accès aux attributs via les getters et les setters, on peut utiliser la classe pré-construite property:
La première définition de la méthode age permet de définir le getter et la seconde le setter. On remarque alors dans les lignes suivantes, que ce sont bien ces méthodes qui sont appelées lorsqu’on modifie ou appelle ces attributs.
Attention: dans ce code, l’attribut age est toujours public. Ce code permet simplement de montrer comment définir proprement et simplement accesseur et mutateur.
Pour les plus courageux, vous pouvez explorer cette page pour voir que ces méthodes sont utilisés dans des librairies comme pandas ou scikit-learn.
e) Encapsulation des méthodes
Le même principe d’encapsulation s’applique aux méthodes: on peut définir des méthodes privées, protégées ou publiques. Les méthodes publiques sont toujours accessibles alors que les méthodes privées ne sont accessibles qu’à l’intérieur de la classe.
Le principe en Python est le même: on utilise __ au début du nom de la méthode (et pas à la fin) et la méthode est toujours retrouvable sur le modèle de _NomDeLaClasse__NomDeLaMethode.
Dans cette section , nous avons vu le principe d’encapsulation qui consiste à cacher des attributs et des méthodes de l’extérieur de la classe. On peut ainsi contrôler les accès aux attributs et aux méthodes notamment grâce aux accesseurs et aux mutateurs.
Il faut toutefois garder à l’esprit que ce sont des concepts de Programmation Orientée Objet “pure”. Python ne respecte pas vraiment ces principes puisqu’on peut toujours avoir accès à des attributs ou méthodes privés. Dans un langage vraiment orienté objet comme Java, on ne peut pas tricher.
5. L'héritage
a) Le concept d’héritage
L’héritage en programmation orientée objet permet de créer facilement des classes similaires à partir d’une autre classe. On parle alors de faire hériter une classe fille d’une classe mère. Dans notre exemple de la classe Animal, on va pouvoir faire hériter des classes Reptile, Mammifère ou Oiseau de la class Animal: on continue de construire des classes, et donc des modèles pour des objets, mais ces classes filles vont avoir les attributs et méthodes de la classe mère et des attributs et des méthodes spécifiques à ces classes filles.
Dans notre exemple, la classe Animal avait un attribut age et un attribut nom, une méthode nommer et un méthode vieillir. Les classes filles de la classe Animal auront ces mêmes méthodes et ces mêmes attributs mais elles pourront aussi en avoir d’autres.
Pour faire une analogie avec la biologie, on peut rapprocher la construction de lien d’héritage entre classes de la construction d’un arbre phylogénétique.
L’héritage permet ainsi de simplifier considérablement l’écriture du code: imaginez construire une application pour une école. Vous avez besoin de modéliser des utilisateurs avec des informations très similaires (noms, prénoms, date de naissance). Mais certains utilisateurs vont pouvoir faire des actions plus spécifiques. On peut donc construire une classe Utilisateur, puis faire hériter deux classes: Elèves et Personnel. On pourra même faire hériter des classes Professeur, Assistant, Documentaliste, … de la classe Personnel. En terme de développement on évite de refaire le travail complet à chaque nouvelle classe.
b) L’héritage avec Python
Essayons de construire une classe Reptile qui hérite de la classe Animal. Pour cela, on va déclarer une classe Reptile et indiquer, juste après le nom de la classe fille, le nom de la classe mère entre parenthèses.
c) Polymorphisme
On peut toutefois vouloir qu’une méthode d’une classe fille n’ait pas le même comportement que son équivalent dans la classe mère. On redéfinit alors simplement la méthode dans la classe fille. On parle de polymorphisme et cette technique relève de la surcharge de méthode, ou en anglais overriding.
Attention, le polymorphisme peut désigner deux choses différentes: le fait de définir des méthodes différentes pour des classes qui héritent l’une de l’autre (overriding) mais aussi le fait de définir plusieurs fois la même méthode (ou fonction) avec des arguments différents (overloading). Le second cas est plus naturel dans des langages de programmation typé statiquement (Java, C++, …) et est assez éloigné de la philosophie de Python. Nous n’en parlerons donc pas ici.
Pour en savoir plus sur le type des données en Python, découvrez cet article.
Tentons de surcharger la méthode nommer de l’exemple précédent:
Dans cet exemple, on a juste ajouter une impression dans la méthode nommer mais la méthode fait bien la même chose.
Essayons à présent de surcharger la méthode __init__, pour lui donner de nouveaux attributs. Le problème est que les instructions donnés dans la méthode __init__ de la classe Animal sont toujours bonnes. On voudrait bien ne pas avoir à tout recoder juste pour ajouter un attribut. En fait, on peut faire appel à la méthode de la classe mère en utilisant le mot-clef super.
On voit dans cet exemple que lors de l’instanciation, on fait bien appelle à la méthode __init__ de la classe Animal puis on ajoute quelques instructions qui sont spécifiques à la classe Reptile.
Notez qu’on peut enchaîner les appels à la méthode de la classe mère grâce au mot clef super.
d) Héritages multiples
Jusqu’à présent, nous avons vu l’héritage dans le cas d’une classe mère et de plusieurs classes filles. Mais on peut aussi avoir plusieurs classes mère pour une classe fille.
Mais pourquoi vouloir faire hériter une classe de plusieurs classes mère? encore une fois il s’agit d’une question de simplicité du code: si une classe contient certaines fonctionnalités (authentification par exemple) et une autre d’autres fonctionnalités (modèle de Machine Learning) alors on peut vouloir les propriétés de ces deux classes pour une classe fille: par exemple, une voiture de fonction est à la fois un véhicule avec des caractéristiques propres à un véhicule mais c’est aussi un bien de l’entreprise avec des caractéristiques comptables qui lui sont propres.
Attention:
Dans Java par exemple, on ne peut pas avoir une classe qui hérite de plusieurs classes mères (au même niveau). On utilise alors un autre outil, les interfaces, qui permettent ces héritages multiples. Cependant on dit que la classe fille implémente une interface. En Python, on s’en fiche.
On retrouve beaucoup de ces exemples dans le module sklearn. Puisqu’il y a plusieurs classes mère, il suffit simplement de les signaler toutes .
Pourtant si vous faîtes tourner cet exemple, vous allez voir qu’on n’imprime pas l’instanciation de B… La fonction super ne prend en compte que la première class mère. Heureusement, il existe une autre méthode pour faire appel à la méthode de la classe mère:
Cette autre méthode permet d’être sûr de la méthode mère à laquelle on fait référence. Ici on a ajouté des attributs différents pour chacune des classes pour vérifier que l’instanciation se faisait bien. Mais que se passe-t-il si des attributs ont le même nom ? En fait, tout dépend simplement de l’ordre dans lequel on fait appel aux méthodes des classes mère.
e) Attributs et méthodes protégés
On a déjà vu beaucoup de choses sur ce concept d’héritage. On va s’arrêter pour le moment en revenant très rapidement sur la section précédente: nous avions alors parlé d’attributs privés, c’est-à-dire accessibles uniquement à l’intérieur de la définition de la classe, d’attributs publics accessibles absolument partout. Nous avions aussi fait le silence sur le concept d’attributs protégés: en programmation orientée objet, les attributs sont accessibles à l’intérieur de la définition de la classe ainsi que dans celles de toutes les classes qui en hérite (directement ou indirectement). Ce concept n’a, par contre, pas vraiment de sens en Python.