Vous savez qu'en Python tout est un objet, les entiers, les listes, les chaînes de caractères, les fonctions, les modules, absolument tout. Et les caractéristiques de chaque objet sont définies par leur type. En Python, les types “built-in” sont très puissants mais ils ne peuvent pas couvrir tous vos besoins.
C'est pourquoi vous avez le concept de classe. Une classe, en Python, vous permet de définir vos propres types, c'est-à-dire que vous allez pouvoir créer un modèle pour des objets que vous pouvez produire qui auront leurs propres caractéristiques.
Comme en Python tout est un objet, les classes également sont des objets. La classe est la définition des caractéristiques que vous allez écrire dans votre module, et lorsque le module sera importé, l'objet classe sera créé. Vous aurez ainsi ce qu'on appelle une usine à instances (la classe permet de produire les instances); à chaque fois que vous appellerez votre classe, votre classe va créer de nouvelles instances. Et vous allez avoir une relation particulière entre l'instance et sa classe. En fait, on dit que l'on a une relation d'héritage entre l'instance et la classe. Ce qui veut dire que l'instance va pouvoir hériter, observer tous les attributs qui sont définis dans la classe.
Cette relation d'héritage est en fait liée à la notion d'espace de nommage. Une classe a son propre espace de nommage. Et une instance a son propre espace de nommage. Lorsque vous recherchez un attribut dans une instance, vous allez le chercher dans l'espace de nommage de l'instance. Si vous ne le trouvez pas dans l'instance, vous allez remonter l'arbre d'héritage et le chercher dans l'espace de nommage de sa classe. C'est cette recherche d'attribut dans les espaces de nommage de l'instance et de la classe que l'on appelle arbre d'héritage.
class Phrase: "La classe Phrase définit un nouveau type stockant une phrase." attr_phrase = 'Je fais un MOOC Python.' # variable de classe partagée par toutes les instances
Pour créer une classe, on utilise l'instruction “class” suivie du nom de la classe ici “Phrase”. Ensuite, au sein de la classe on définit un attribut qui s'appelle “attr_phrase” et qui va référencer une chaîne de caractères qui vaut 'Je fais un MOOC Python.'.
Maintenant, regardons cet objet `Phrase`.
>>> print(Phrase) <class '__main__.Phrase'>
L'interpréteur a bien créé un objet “Phrase”. À partir de la classe “Phrase”, on va pouvoir produire des instances. Pour définir une instance de la classe “Phrase” référencée par “p” ( produire l'instance), on utilise les parenthèses après le nom de la classe, on appelle la classe comme une fonction.
>>> p = Phrase() >>> print(p) <__main__.Phrase object at 0x7fbbd91043a0>
“p” est également un objet. La classe et l'instance sont toutes les deux des objets.
On peut accéder aux espaces de nommage de ces objets (classe et instance) avec un attribut particulier qui s'appelle __dict__
>>> Phrase.__dict__ mappingproxy({'__module__': '__main__', '__doc__': 'La classe Phrase définit un nouveau type stockant une phrase.', 'attr_phrase': 'Je fais un MOOC Python.', '__dict__': <attribute '__dict__' of 'Phrase' objects>, '__weakref__': <attribute '__weakref__' of 'Phrase' objects>})
On voit s'afficher l'espace de nommage mais en pratique nous n'utilisons pas ces méthodes spéciales directement à la main, nous avons toujours des fonctions built-in ou des opérateurs pour y accéder. Ici c'est la fonction built-in “vars()” qui permet d'accéder à ce dictionnaire.
>>> vars(Phrase) mappingproxy({'__module__': '__main__', '__doc__': 'La classe Phrase définit un nouveau type stockant une phrase.', 'attr_phrase': 'Je fais un MOOC Python.', '__dict__': <attribute '__dict__' of 'Phrase' objects>, '__weakref__': <attribute '__weakref__' of 'Phrase' objects>})
Observons cet espace de nommage. On y voit un certain nombre d'attributs, l'attribut “module” qui référence “main” indiquant que la classe est définie le module “main”. On voit également un attribut “attr_phrase”, que l'on a défini dans la classe et qui référence la chaîne de caractères “Je fais un MOOC Python.”.
Vous pouvez remarquer également que l'espace de nommage de la classe a un objet un peu particulier qui s'appelle un mappingproxy ; vous devez savoir que la manière dont sont implémentés les espaces de nommage en Python sont vraiment des détails d'implémentation. Donc nous n'avons pas vraiment à nous soucier des caractéristiques de cet objet. Sachez simplement que le mappingproxy pour une classe est une sorte de dictionnaire qui ne peut être qu'en lecture seule, on ne peut pas le modifier directement. Cependant, une classe est malgré tout un objet mutable ; ça veut simplement dire qu'on ne peut pas modifier le dictionnaire à la main et en fait, ce choix a été fait pour des raisons de stabilité et de performance.
Maintenant regardons l'espace de nommage de l'instance:
>>> vars(p) {}
On constate que l' espace de nommage est vide. Donc lorsque je crée une instance, l'instance est créée avec un espace de nommage vide mais je vous rappelle que l'on a une relation d'héritage entre l'instance et la classe; ça veut dire que si je cherche un attribut à partir de mon instance et que je ne le trouve pas dans l'espace de nommage de l'instance, je vais aller le chercher dans l'espace de nommage de la classe.
# afficher la valeur de l'attribut attr_phrase >>> print(p.attr_phrase) Je fais un MOOC Python.
On va chercher l'attribut “attr_phrase” dans l' instance. Or, on a vu que l'espace de nommage de l' instance était vide. Pourtant, on arrive à y accéder. En fait, on cherche l'attribut dans l'instance, on remonte l'arbre d'héritage, on le trouve dans la classe.
Les classes et les instances sont des objets mutables. Et la résolution des attributs le long de l'arbre d'héritage est faite de manière dynamique en fonction de l'état des espaces de nommage au moment où on fait la résolution de l'attribut.
# on ajoute un attribut à la classe Phrase pendant l'éxécution >>> Phrase.mots = Phrase.attr_phrase.split() # on affiche le contenu du nouvel attribut de classe >>> print(Phrase.mots) ['Je', 'fais', 'un', 'MOOC', 'Python.']
On a définit un nouvel attribut “mots” qui contient la liste des mots retournée par la fonction “split()” appliquée à l'attribut précédent “Phrase.attr_phrase”. On voit qu'il s'agit effectivement d'une liste.
Maintenant, essayons de chercher cet attribut `mots` dans l'instance. On se souvient que l'espace de nommage de l' instance est vide. Cependant, on peut quand même accéder à cet attribut “mots” qui a été rajouté dans la classe après la création de l'objet instance.
# espace de nommage de l'instance est toujours vide >>> vars(p) {} # on accède tout de même au nouvel attribut # de classe la classe Phrase depuis l'instance préexistante >>> print(p.mots) ['Je', 'fais', 'un', 'MOOC', 'Python.']
On voit effectivement que l' on peut accéder à cet attribut “mots” depuis l' instance p existant avant la modification sur la classe.
Si l'on vérifie les espaces de nommage dans la classe, et dans l' instance:
Cela confirme que la résolution d'attribut est faite de manière dynamique le long de l'arbre d'héritage.
Pour résumer ce que nous avons vu jusqu'à maintenant, les classes et les instances sont toutes les deux des objets mutables, qui ont leur propre espace de nommage et qui sont liés par une relation d'héritage. On recherche les attributs de l'instance, on remonte dans la classe.
Cependant, il nous manque un mécanisme majeur pour faire de vraies classes. Lorsque vous définissez une classe, vous définissez en particulier des comportements dont vont hériter vos instances. Or, pour l'instant, nous n'avons défini aucun comportement, nous avons simplement deux attributs de classe “attr_phrase” et “mots”.
On implémente les comportements par l'intermédiaire de méthodes. En fait, les méthodes, sont simplement des fonctions que l'on définit dans les classes. Et ces fonctions ont une caractéristique particulière, c'est qu'elles sont capables de travailler sur les attributs de l'instance.
class Phrase: "La classe Phrase définit un nouveau type stockant une phrase." def initialise(self, mess): self.attr_phrase = mess # définition de l'attribut attr_phrase dans l'instance lors # de l'appel de la methode 'initialise'
Le code ci-dessus définis une méthode “initialise” comme on le fait pour une fonction. On utilise l'instruction “def” suivi du nom de fonction. La méthode prend un argument “self” et un argument “mess”.
Détaillons le comportement de cette fonction, que l'on appelle une méthode quand elle est définie dans une classe. On remarque que cette méthode prend deux arguments, un argument “self”, un argument “mess”. Le corps de la méthode contient une seule instruction.
Lorsque vous appelez une méthode sur une instance, la référence de l'instance sur laquelle vous appelez cette méthode va être automatiquement passée comme premier argument à la variable “self” qui va référencer l' instance. Les arguments suivants peuvent être quelconques, ici on lui a passé “mess”.
Comme “self” est une référence sur l'instance courante, lorsque la méthode va s'exécuter, “self.attr_phrase” va créer un attribut “attr_phrase” dans mon instance courante et lui faire référencer l'objet qui est passé en deuxième argument “mess” donc, probablement, une chaîne de caractères dans ce cas-là.
# création d'une instance de type Phrase >>> p = Phrase() # l'instance n'a pas d'attribut pour l'instant >>> vars(p) {} # Appel de la méthode initialise sur l'instance >>> p.initialise('Ma jolie phrase.') # La méthode a créé un attribut dans l'instance p >>> vars(p) {'attr_phrase': 'Ma jolie phrase.'}
Dans le code ci-dessus on a définit une instance de la classe “Phrase” référencée par “p”; On a ensuite appelé la méthode “initialise()” sur l' instance “p”. La fonction built-in “vars()” appelée avant et après la méthode “initialise()” montre qu'un attribut a bien été créé dans l'instance.
Maintenant, on peut se demander comment est-ce que Python fait pour passer automatiquement l'instance? En fait, c'est un mécanisme défini par Python qui s'appelle un mécanisme de fonction bound. Regardons ce que cela veut dire.
La classe “Phrase”, possède un attribut qui s'appelle “initialise” qui se trouve être en fait une fonction tout à fait classique.
>>> print(Phrase.initialise) function Phrase.initialise at 0x7ff942d39700>
C'est une fonction qu'on a définie avec “def” qui est une fonction classique. Par contre, si j'appelle “initialise” sur l' instance référencée par “p”:
>>> print(p.initialise) <bound method Phrase.initialise of <__main__.Phrase object at 0x7ff9428a0310>>
nous observons qu'en fait c'est un autre type d'objet qui s'appelle un objet bound. C'est une fonction qui est liée à l'instance et lors de l'appel, Python va automatiquement passer l'instance comme premier argument comme évoqué précédemment.
C'est le fait qu'on appelle cette fonction sur l'instance qui crée cet objet particulier et qui indique à Python qu'on doit lui passer l'instance comme premier argument. Les appels ci-dessous sont donc parfaitement équivalents:
>>> p.initialise('Ma courte phrase.') >>> Phrase.initialise(p, 'Ma courte phrase')
C'est totalement équivalent. En fait, Python va faire automatiquement cette conversion pour vous.
En résumé nous avons vu les notions de classe et d'instance. Nous avons vu que les classes et les instances sont des objets mutables, qui ont leur propre espace de nommage mais qui ont surtout une relation d'héritage. Nous avons également vu la notion de méthode bound qui permet à Python de passer automatiquement l'instance lorsqu'on appelle une méthode sur une instance.
La méthode __init__, comme toutes celles qui ont un nom dela forme __nom__, est une méthode spéciale. En l'occurrence, il s'agit de ce qu'on appelle le constructeur de la classe, c'est-à-dire le code qui va être appelé lorsqu'on crée une instance.
class MaClasse: "Docstring de description de la classe. Affiché par help(MaClasse)" __init__(self, var): "Initialise les instances du type MaClasse" self._msg = var
Comme toutes les méthodes, le premier argument de ceux qui sont déclarés dans la méthode, correspond à la référence sur instance qui sera créée et automatiquement passée par l'interpréteur python à la méthode init. En ce sens, le terme constructeur est impropre puisque la méthode init ne crée pas l'instance, elle ne fait que l'initialiser, mais c'est un abus de langage très répandu. Nous reviendrons sur le processus de création des objets lorsque nous parlerons des métaclasses en dernière semaine.
Par convention on nomme le premier argument de ce constructeur self.
Le plus souvent, le constructeur se contente de mémoriser à l'intérieur de l'instance, les arguments qu'on lui passe, sous la forme d'attributs de l'instance self.
C'est un cas extrêmement fréquent et de manière générale, il est recommandé d'écrire des constructeurs passifs de ce genre. Autrement dit, on évite de faire trop de traitements dans le constructeur.
Quelques-uns des avantages qui sont généralement mis en avant concernant la POO1) :
L'idée de la notion d'encapsulation consiste à ce que :
La notion d'encapsulation peut paraître à première vue banale, il ne faut pas s'y fier. C'est de cette manière qu'on peut efficacement découper un gros logiciel en petits morceaux indépendants, et réellement découplés les uns des autres, et ainsi casser, réduire la complexité.
La programmation objet est une des techniques permettant d'atteindre cette bonne propriété d'encapsulation. Il faut reconnaître que certains langages comme Java et C++ ont des mécanismes plus sophistiqués, mais aussi plus complexes, pour garantir une bonne étanchéité entre l'interface publique et les détails d'implémentation. Les choix faits en la matière en Python reviennent, une fois encore, à privilégier la simplicité.
Aussi, il n'existe pas en Python l'équivalent des notions d'interface public
, private
et protected
qu'on trouve en C++ et en Java. Il existe tout au plus une convention, selon laquelle les attributs commençant par un underscore sont privés et ne devraient pas être utilisés par un code tiers, mais le langage ne fait rien pour garantir le bon usage de cette convention.
Malgré cette simplicité revendiquée, les classes de python permettent d'implémenter en pratique une encapsulation tout à fait acceptable, on peut en juger rien que par le nombre de bibliothèques tierces existantes dans l'écosystème python.
Le deuxième atout de la POO, c'est le fait que l'envoi de méthode est résolu lors de l'exécution (run-time) et non pas lors de la compilation (compile-time). Ceci signifie que l'on peut écrire du code générique, qui pourra fonctionner avec des objets non connus a priori.
L'héritage est le concept qui permet de :
Comme évoqué plus haut, le premier argument d'une méthode s'appelle self par convention. Cette pratique est particulièrement bien suivie, mais ce n'est qu'une convention, en ce sens qu'on aurait pu utiliser n'importe quel identificateur. Pour le langage self n'a aucun sens particulier, ce n'est pas un mot clé ni une variable built-in.
Ceci est à mettre en contraste avec le choix fait dans d'autres langages, comme par exemple en C++ où l'instance est référencée par le mot-clé this
, qui n'est pas mentionné dans la signature de la méthode. En Python, selon le manifeste, explicit is better than implicit, c'est pourquoi on mentionne l'instance dans la signature, sous le nom self.
Il est fréquent en Python qu'une classe expose dans sa documentation un ou plusieurs attributs. C'est une pratique qui, en apparence seulement, paraît casser l'idée d'une bonne encapsulation.
En réalité, grâce au mécanisme de property, il n'en est rien. Nous allons voir comment une classe peut en quelque sorte intercepter les accès à ses attributs, et par là fournir une encapsulation forte.
Pour être concret, on va parler d'une classe “Temperature”. Au lieu de proposer, comme ce serait l'usage dans d'autres langages, une interface avec des accesseurs de la forme get_kelvin()
et set_kelvin()
, on va se contenter d'exposer l'attribut kelvin, et malgré cela on va pouvoir associer divers comportements (introduire de la logique lors de l'accès en lecture ou écriture sur l'attribut).
Si vous avez été déjà exposés à des langages orientés objet comme C++, Java ou autre, vous avez peut-être l'habitude d'accéder aux données internes des instances par des méthodes de type getter ou setter, de façon à contrôler les accès et, dans une optique d'encapsulation, de préserver des invariants, comme ici le fait que la température doit être positive.
Ces appels systématiques aux accesseurs sont assez lourds syntaxiquement:
instance.get_propriete()
La façon proposée par python consiste à définir une property. Comme on va le voir ce mécanisme permet d'écrire du code qui fait référence à l'attribut kelvin de l'instance, mais qui passe tout de même par une couche de logique.
class Temperature3: def __init__(self, kelvin): self.kelvin = kelvin # On définit bien des accesseurs de type getter et setter # mais en les préfixant les noms des méthodes par un underscore # car ils ne sont pas censés être appelés par l'extérieur def _get_kelvin(self): return self._kelvin # idem def _set_kelvin(self, kelvin): self._kelvin = max(0, kelvin) # une fois les accesseurs définis on peut créer une property kelvin = property(_get_kelvin, _set_kelvin) # et toujours la façon d'imprimer def __repr__(self): return f"{self._kelvin}K"
Comme vous pouvez le voir, cette technique a plusieurs avantages :
C'est pour cette raison que vous ne rencontrerez presque jamais en Python une bibliothèque qui offre une interface à base de méthodes “get_something()” et “set_something()”, mais au contraire les API vous exposeront directement des attributs que vous devez utiliser directement.