Skip to content

Compléments

Principe d'encapsulation

L'encapsulation consiste à ne fournir aux utilisateurs d'une classe qu'un accès limité à la classe. En général, on masque tout ce qui est attribut pour ne laisser la possibilité d'utiliser que quelques méthodes.

  1. En masquant les attributs à l'utilisateur, on interdit à ce dernier d'effectuer directement des modifications de valeurs ; on peut ainsi contrôler l'intégrité et la validité des valeurs.
  2. On offre à l'utilisateur une interface : la signature et le rôle d'un ensemble de méthodes ; cette interface détermine les services possibles d'une classe en rendant ces services indépendants de leur implémentation.

En reprenant notre classe Personnage, illustrons le principe d'encapsulation :

  1. Les attributs devraient ne pas être accessibles en modification :
    • le nom est choisi une fois pour toute, aucune raison de pouvoir le changer ;
    • les points de vie max pourraient évoluer par exemple si le personnage change de niveau mais là encore, l'utilisateur ne devrait pas pouvoir les modifier à sa guise ;
    • les points de vie à l'instant t eux sont susceptibles de changer mais là encore pas n'importe comment (par exemple ils ne devraient jamais sortir de l'intervalle 0.. pv_max).
  2. Les méthodes doivent permettre une certaine intégrité des données.

Malheureusement de base Python n'offre pas vraiment les moyens de mettre en oeuvre l'encapsulation contrairement à des langages comme Java ou C++ où le concept d'attributs et méthodes privés existent.

En Python, on peut utiliser des conventions de nommage pour inciter l'utilisateur à respecter l'encapsulation. Voici une nouvelle définition de notre classe Personnage. Tous les noms d'attributs commencent par un souligné ce qui signifie à l'utilisateur qu'il ne devrait pas les utiliser directement. Noter que l'implémentation de la méthode soigner a été modifiée pour contrôler la validité (les points de vie doivent rester dans l'intervalle 0... point de vie max)

class Personnage:
    """Modélise un personnage du jeu Combat Pour de Faux"""

    def __init__(self, nom, pv_max):
        self._nom = nom
        self._pv_max = pv_max
        self._pv = pv_max

    def ko(self):
        return self._pv == 0

    def soigner(self, qte_soin):
        self._pv = min(self._pv_max, self._pv + qte_soin)

Ainsi, l'utilisateur peut faire :

>>> merlin = Personnage('Merlin', 80)
>>> merlin._nom
'Merlin'
>>> merlin.ko()
False
>>> merlin.soigne(10)

Mais ne devrait pas faire :

>>> merlin._pv = 20
>>> merlin._pv += 10
>>> merlin._pv_max = 1000

Même si en l'état actuel de la définition de notre classe rien ne l'en empêche.

Assesseurs et Mutateurs

Ce sont des méthodes qui vont permettre à l'utilisateur d'obtenir la valeur des attributs et de les modifier (lorsque c'est pertinent), sans pour autant accéder directement aux variables d'instance, qui, on le répète, doivent autant que possible rester cachées.

Ainsi pour chaque attribut _attr on devrait avoir :

  1. un assesseur :

    def get_attr(self): return self._attr
    
    qui permet d'obtenir la valeur de l'attribut ;

  2. éventuellement un mutateur pour les modifications :

    def set_attr(self, new_value): self._attr = new_value
    

Mais nous avons plusieurs inconvénients et cette façon de faire n'est que partiellement satisfaisante :

  1. le simple souligné devant le nom d'une variable d'instance n'est qu'une convention : l'utilisateur peut passer outre et accéder à l'attribut ;
  2. devoir passer par merlin.get_nom() juste pour obtenir le nom de Merlin est assez pénible et on aimerait continuer à écrire tout simplement merlin.nom.

Ceci est possible grâce aux noms spéciaux commençant par un double souligné et aux décorateurs, un mécanisme de Python permettant d'ajouter aux fonctions certaines fonctionnalités.

Ainsi, une instance de Personnage aura un attribut caché : __nom mais une interface nom laissant accéder en lecture à cet attribut. De même pour les points de vie maximum. Concernant les points de vie __pv la classe expose également une méthode permettant de les modifier. Voici la définition plus réaliste d'une classe Personnage mettant en place cette interface par variables d'instances cachées et décorateurs :

class Personnage:

    def __init__(self, nom, pv_max):
        self.__nom = nom
        self.__pv_max = pv_max
        self.__pv = pv_max

    @property
    def nom(self):
        return self.__nom

    @property
    def pv_max(self):
        return self.__pv_max

    @property
    def pv(self):
        return self.__pv

    @pv.setter
    def pv(self, pv):
        if 0 <= pv <= self.__pv_max:
            self.__pv = pv

    def soigner(self, qte):
        self.pv = min(self.__pv_max, self.pv + qte)

L'utilisateur retrouve un comportement simple et sain :

>>> merlin = Personnage('Merlin', 80)
>>> merlin.nom
'Merlin'
>>> merlin.nom = 'Arthur'
--------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-37-240e232355b3> in <module>
----> 1 merlin.nom = 'Arthur'

AttributeError: can t set attribute

>>> merlin.pv -= 30
>>> merlin.pv
50
>>> merlin.pv += 100 # valeur excessive
>>> merlin.pv        # l'interface a empêché la modification
50 
>>> merlin.soigne(20)
>>> merlin.pv
70

Attention, même avec la syntaxe du double souligné, la privatisation des variables d'instance en Python est une façade. On parle souvent de sucre syntaxique. Un appel à __dict__ met en lumière les vrais noms des attributs de l'instance, qui restent donc accessibles :

>>> merlin.__dict__
{'_Personnage__nom': 'Merlin',
 '_Personnage__pv_max': 80,
 '_Personnage__pv': 80}

Principes d'héritage et de polymorphisme

Ces deux concepts permettent d'organiser nos objets de façon hiérarchique et de définir des méthodes partageant les mêmes noms mais ne faisant pas tout à fait la même chose.

L'héritage est le principe qui autorise la définition d'une classe à partir d'une autre. La nouvelle classe appelée classe dérivée ou classe fille possède les attributs et les méthodes de la classe dont elle dérive (classe mère).

Par exemple un Magicien est un Personnage particulier. Il possède donc tous les attributs d'un Personnage ainsi que de la mana, pour lancer des sorts :

class Magicien(Personnage):

    def __init__(self, nom, pv_max, mana_max):
        Personnage.__init__(self, nom, pv_max)
        self.__mana_max = mana_max
        self.__mana = mana_max

Notons la deuxième ligne du constructeur qui appelle le constructeur de la classe mère avec comme premier argument l'instance de Magicien en train de se créer, c'est-à-dire self.

Dès lors :

>>> merlin = Magicien('Merlin', pv_max=80, mana_max=120)
>>> merlin.pv
80
>>> merlin.mana
120

Python autorise même l'héritage multiple, c'est-à-dire qu'une classe peut dériver de plusieurs classes.

Le polymorphisme n'est pas propre à la programmation orientée objet. Ainsi, le polymorphisme ad hoc désigne la possibilité d'avoir le même nom de fonction ou d'opérateur pour des classes qui n'ont rien à voir l'une avec l'autre. C'est par exemple le cas de l'opérateur + qui définit l'addition mathématique sur les entiers ou la concaténation sur les chaînes de caractères.

Nous ne parlerons pas du polymorphisme paramétrique car non disponible en Python.

Le polymorphisme d'héritage consiste à redéfinir au niveau de la classe fille une méthode présente dans la classe mère afin de la spécialiser.

Par exemple, redéfinition de la méthode spéciale __str__ :

class Magicien:

    def __str__(self):
        return Personnage.__str__(self) + f', {self.mana}/{self.mana_max} mana'

Méthodes spéciales

Leurs noms commencent et se terminent par un double souligné. Nous en avons déjà vu une : l'initialiseur __init__

__repr__ est la chaîne de caractères que retourne l'interprète Python lorsqu'il évalue l'objet. Lorsque l'on définit cette méthode, on essaie de retourner une chaîne de caractères se rapprochant de ce qu'on écrirait pour créer l'objet.

>>> L = [1, 2]
>>> repr(L)
'[1, 2]'
>>> f = open('test', 'w')
>>> repr(f)
"<_io.TextIOWrapper name='test' mode='w' encoding='cp1252'>"

Concernant notre classe Personnage :

def __repr__(self):
    return f'Personnage(nom={self.nom}, pv_max={self.pv_max})'

>>> repr(merlin)
'Personnage(nom=Merlin, pv_max=80)'

__str__ est aussi une chaîne de caractères, mais servant principalement à la fonction print pour afficher notre objet. Sur les objets prédéfinis, souvent les deux méthodes coïncident. Mais pour notre Personnage l'affichage peut être plus sympa :

def __str__(self):
    return f'{self.nom} : {self.pv}/{self.pv_max} pv'
>>> print(merlin)
Merlin : 80/80 pv

__iter__ pour parcourir... supposons que notre Personnage évolue dans un Monde :

class Monde:

    def __init__(self, ...):
        # tout plein d'attributs
        # ...
        self.__protagonistes = [] # la liste des Personnages du Monde

Le Monde a donc une liste de protagonistes qui sont des Personnages. On voudrait alors pouvoir parcourir l'ensemble des Personnages simplement. Supposons donc que terre soit une instance de Monde avec plusieurs Personnages s'y cotoyant. On voudrait pouvoir écrire ceci :

for personne in terre:
    # traitement

Ce qui est possible en définissant une méthode __iter__ de la classe Monde :

def __iter__(self):
    return self.__protagonistes.__iter__()

Les opérateurs : à chaque opérateur correspond une méthode spéciale. Voici le tableau des principales :

opérateur méthode spéciale
+ __add__
- __sub__
* __mul__
/ __truediv__
// __floordiv__
< __lt__
> __gt__
<= __le__
>= __ge__
== __eq__
[ ] __getitem__

Variables et Méthodes de classe

Nous avons vu comment définir et utiliser les variables et les méthodes d'instances. Il est possible de créer des variables et des méthodes de classe, ie définies dans la classe et communes à toutes les instances. Un exemple classique est la variable de classe qui comptabilise le nombre d'instances créées :

class A:

    nb = 0

    def __init__(self):
        A.nb += 1
        print(f'{A.nb} objet(s) {A.__name__} créé(s)')
>>> a = A()
1 objet(s) A créé(s)
>>> b = A()
2 objet(s) A créé(s)
>>> A.nb
2
>>> a.nb
2
>>> b.nb
2

On utilisera aussi les variables de classe pour définir les constantes associées à une classe.

Une méthode de classe se définit par la syntaxe suivante :

class A:

    @classmethod
    def methode_de_classe(cls):
        # traitement

On utilise le décorateur @classmethod ainsi que le mot-clé cls à la place de self en premier paramètre. Une des utilisations des méthodes de classe concerne la possibilité d'offrir une méthode alternative de la construction d'un objet :

import Time

Class Date:
    # Constructeur par défaut
    #
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day

    # Constructeur alternatif sous forme de methode de class
    # 
    @classmethod
    def today(cls):
        t = time.localtime()
        return cls(t.tm_year, t.tm_mon, t.tm_mday)

Ce qui permet :

>>> date_1 = Date(2021, 12, 24)  # constructeur par défaut
>>> date_2 = Date.today() # Méthode de classe