Classes

Une classe est en fait la définition d’un type. int, str ou encore list sont des exemples de classes. User en est une autre.

Une classe décrit la structure des objets du type qu’elle définit : quelles méthodes vont être applicables sur ces objets.

La classe à Dallas

On définit une classe à l’aide du mot-clef class survolé plus tôt :

1
2
class User:
    pass

(l’instruction pass sert ici à indiquer à Python que le corps de notre classe est vide)

Il est conseillé en Python de nommer sa classe en CamelCase, c’est à dire qu’un nom est composé d’une suite de mots dont la première lettre est une capitale. On préférera par exemple une classe MonNomDeClasse que mon_nom_de_classe. Exception faite des types builtins qui sont couramment écrits en lettres minuscules.

On instancie une classe de la même manière qu’on appelle une fonction, en suffixant son nom d’une paire de parenthèses. Cela est valable pour notre classe User, mais aussi pour les autres classes évoquées plus haut.

1
2
3
4
5
6
7
8
>>> User()
<__main__.User object at 0x7fc28e538198>
>>> int()
0
>>> str()
''
>>> list()
[]

La classe User est identique à celle du chapitre précédent, elle ne comporte aucune méthode. Pour définir une méthode dans une classe, il suffit de procéder comme pour une définition de fonction, mais dans le corps de la classe en question.

1
2
3
class User:
    def check_pwd(self, password):
        return self.password == password

Notre nouvelle classe User possède maintenant une méthode check_pwd applicable sur tous ses objets.

1
2
3
4
5
6
7
8
>>> john = User()
>>> john.id = 1
>>> john.name = 'john'
>>> john.password = '12345'
>>> john.check_pwd('toto')
False
>>> john.check_pwd('12345')
True

Quel est ce self reçu en premier paramètre par check_pwd ? Il s’agit simplement de l’objet sur lequel on applique la méthode, comme expliqué dans le chapitre précédent. Les autres paramètres de la méthode arrivent après.

La méthode étant définie au niveau de la classe, elle n’a que ce moyen pour savoir quel objet est utilisé. C’est un comportement particulier de Python, mais retenez simplement qu’appeler john.check_pwd('12345') équivaut à l’appel User.check_pwd(john, '12345'). C’est pourquoi john correspondra ici au paramère self de notre méthode.

self n’est pas un mot-clef du langage Python, le paramètre pourrait donc prendre n’importe quel autre nom. Mais il conservera toujours ce nom par convention.

Notez aussi, dans le corps de la méthode check_pwd, que password et self.password sont bien deux valeurs distinctes : la première est le paramètre reçu par la méthode, tandis que la seconde est l’attribut de notre objet.

Argumentons pour construire

Nous avons vu qu’instancier une classe était semblable à un appel de fonction. Dans ce cas, comment passer des arguments à une classe, comme on le ferait pour une fonction ?

Il faut pour cela comprendre les bases du mécanisme d’instanciation de Python. Quand on appelle une classe, un nouvel objet de ce type est construit en mémoire, puis initialisé. Cette initialisation permet d’assigner des valeurs à ses attributs.

L’objet est initialisé à l’aide d’une méthode spéciale de sa classe, la méthode __init__. Cette dernière recevra les arguments passés lors de l’instanciation.

1
2
3
4
5
6
7
8
class User:
    def __init__(self, id, name, password):
        self.id = id
        self.name = name
        self.password = password

    def check_pwd(self, password):
        return self.password == password

Nous retrouvons dans cette méthode le paramètre self, qui est donc utilisé pour modifier les attributs de l’objet.

1
2
3
4
5
>>> john = User(1, 'john', '12345')
>>> john.check_pwd('toto')
False
>>> john.check_pwd('12345')
True

Comment veux-tu que je t'encapsule ?

Au commencement étaient les invariants

Les différents attributs de notre objet forment un état de cet objet, normalement stable. Ils sont en effet liés les uns aux autres, la modification d’un attribut pouvant avoir des conséquences sur un autre. Les invariants correspondent aux relations qui lient ces différents attributs.

Imaginons que nos objets User soient dotés d’un attribut contenant une évaluation du mot de passe (savoir si ce mot de passe est assez sécurisé ou non), il doit alors être mis à jour chaque fois que nous modifions l’attribut password d’un objet User.

Dans le cas contraire, le mot de passe et l’évaluation ne seraient plus corrélés, et notre objet User ne serait alors plus dans un état stable. Il est donc important de veiller à ces invariants pour assurer la stabilité de nos objets.

Protège-moi

Au sein d’un objet, les attributs peuvent avoir des sémantiques différentes. Certains attributs vont représenter des propriétés de l’objet et faire partie de son interface (tels que le prénom et le nom de nos objets User). Ils pourront alors être lus et modifiés depuis l’extérieur de l’objet, on parle dans ce cas d’attributs publics.

D’autres vont contenir des données internes à l’objet, n’ayant pas vocation à être accessibles depuis l’extérieur. Nous allons sécuriser notre stockage du mot de passe en ajoutant une méthode pour le hasher1 (à l’aide du module crypt), afin de ne pas stocker d’informations sensibles dans l’objet. Ce condensat du mot de passe ne devrait pas être accessible de l’extérieur, et encore moins modifié (ce qui en altérerait la sécurité).

De la même manière que pour les attributs, certaines méthodes vont avoir une portée publique et d’autres privée (on peut imaginer une méthode interne de la classe pour générer notre identifiant unique). On nomme encapsulation cette notion de protection des attributs et méthodes d’un objet, dans le respect de ses invariants.

Certains langages2 implémentent dans leur syntaxe des outils pour gérer la visibilité des attributs et méthodes, mais il n’y a rien de tel en Python. Il existe à la place des conventions, qui indiquent aux développeurs quels attributs/méthodes sont publics ou privés. Quand vous voyez un nom d’attribut ou méthode débuter par un _ au sein d’un objet, il indique quelque chose d’interne à l’objet (privé), dont la modification peut avoir des conséquences graves sur la stabilité.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import crypt

class User:
    def __init__(self, id, name, password):
        self.id = id
        self.name = name
        self._salt = crypt.mksalt() # sel utilisé pour le hash du mot de passe
        self._password = self._crypt_pwd(password)

    def _crypt_pwd(self, password):
        return crypt.crypt(password, self._salt)

    def check_pwd(self, password):
        return self._password == self._crypt_pwd(password)
1
2
3
>>> john = User(1, 'john', '12345')
>>> john.check_pwd('12345')
True

On note toutefois qu’il ne s’agit que d’une convention, l’attribut _password étant parfaitement visible depuis l’extérieur.

1
2
>>> john._password
'$6$DwdvE5H8sT71Huf/$9a.H/VIK4fdwIFdLJYL34yml/QC3KZ7'

Il reste possible de masquer un peu plus l’attribut à l’aide du préfixe __. Ce préfixe a pour effet de renommer l’attribut en y insérant le nom de la classe courante.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class User:
    def __init__(self, id, name, password):
        self.id = id
        self.name = name
        self.__salt = crypt.mksalt()
        self.__password = self.__crypt_pwd(password)

    def __crypt_pwd(self, password):
        return crypt.crypt(password, self.__salt)

    def check_pwd(self, password):
        return self.__password == self.__crypt_pwd(password)
1
2
3
4
5
6
7
>>> john = User(1, 'john', '12345')
>>> john.__password
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'User' object has no attribute '__password'
>>> john._User__password
'$6$kjwoqPPHRQAamRHT$591frrNfNNb3.RdLXYiB/bgdCC4Z0p.B'

Ce comportement pourra surtout être utile pour éviter des conflits de noms entre attributs internes de plusieurs classes sur un même objet, que nous verrons lors de l’héritage.


  1. Le hashage d’un mot de passe correspond à une opération non-réversible qui permet de calculer un condensat (hash) du mot de passe. Ce condensat peut-être utilisé pour vérifier la validité d’un mot de passe, mais ne permet pas de retrouver le mot de passe d’origine. 

  2. C++, Java, Ruby, etc. 

Tu aimes les glaces, canard ?

Un objet en Python est défini par sa structure (les attributs qu’il contient et les méthodes qui lui sont applicables) plutôt que par son type.

Ainsi, pour faire simple, un fichier sera un objet possédant des méthodes read, write et close. Tout objet respectant cette définition sera considéré par Python comme un fichier.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class FakeFile:
    def read(self, size=0):
        return ''
    def write(self, s):
        return 0
    def close(self):
        pass

f = FakeFile()
print('foo', file=f)

Python est entièrement construit autour de cette idée, appelée duck-typing : « Si je vois un animal qui vole comme un canard, cancane comme un canard, et nage comme un canard, alors j’appelle cet oiseau un canard » (James Whitcomb Riley)

TP : Forum, utilisateurs et messages

Pour ce premier TP, nous allons nous intéresser aux classes d’un forum. Forts de notre type User pour représenter un utilisateur, nous souhaitons ajouter une classe Post, correspondant à un quelconque message.

Cette classe sera inititalisée avec un auteur (un objet User) et un contenu textuel (le corps du message). Une date sera de plus générée lors de la création.

Un Post possèdera une méthode format pour retourner le message formaté, correspondant au HTML suivant :

1
2
3
4
5
6
<div>
    <span>Par NOM_DE_L_AUTEUR le DATE_AU_FORMAT_JJ_MM_YYYY à HEURE_AU_FORMAT_HH_MM_SS</span>
    <p>
        CORPS_DU_MESSAGE
    </p>
</div>

De plus, nous ajouterons une méthode post à notre classe User, recevant un corps de message en paramètre et retournant un nouvel objet Post.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import crypt
import datetime

class User:
    def __init__(self, id, name, password):
        self.id = id
        self.name = name
        self._salt = crypt.mksalt()
        self._password = self._crypt_pwd(password)

    def _crypt_pwd(self, password):
        return crypt.crypt(password, self._salt)

    def check_pwd(self, password):
        return self._password == self._crypt_pwd(password)

    def post(self, message):
        return Post(self, message)

class Post:
    def __init__(self, author, message):
        self.author = author
        self.message = message
        self.date = datetime.datetime.now()

    def format(self):
        date = self.date.strftime('le %d/%m/%Y à %H:%M:%S')
        return '<div><span>Par {} {}</span><p>{}</p></div>'.format(self.author.name, date, self.message)

if __name__ == '__main__':
    user = User(1, 'john', '12345')
    p = user.post('Salut à tous')
    print(p.format())

Nous savons maintenant définir une classe et ses méthodes, initialiser nos objets, et protéger les noms d’attributs/méthodes.

Mais jusqu’ici, quand nous voulons étendre le comportement d’une classe, nous la redéfinissons entièrement en ajoutant de nouveaux attributs/méthodes. Le chapitre suivant présente l’héritage, un concept qui permet d’étendre une ou plusieurs classes sans toucher au code initial.