Licence CC BY-SA

Programmation orientée objet avancée

Dans ce chapitre, nous allons nous intéresser à des concepts plus avancés de programmation orientée objet disponibles en Python, tels que les attributs/méthodes de classe ou les propriétés.

Les attributs entrent en classe

Nous avons déjà rencontré un attribut de classe, quand nous nous intéressions aux parents d’une classe. Souvenez-vous de __bases__, nous ne l’utilisions pas sur des instances mais sur notre classe directement.

En Python, les classes sont des objets comme les autres, et peuvent donc posséder leurs propres attributs.

1
2
3
4
5
6
>>> class User:
...     pass
...
>>> User.type = 'simple_user'
>>> User.type
'simple_user'

Les attributs de classe peuvent aussi se définir dans le corps de la classe, de la même manière que les méthodes.

1
2
class User:
    type = 'simple_user'

On notera à l’inverse qu’il est aussi possible de définir une méthode de la classe depuis l’extérieur :

1
2
3
4
5
6
>>> def User_repr(self):
...     return '<User>'
...
>>> User.__repr__ = User_repr
>>> User()
<User>

L’avantage des attributs de classe, c’est qu’ils sont aussi disponibles pour les instances de cette classe. Ils sont partagés par toutes les instances.

1
2
3
4
5
6
>>> john = User()
>>> john.type
'simple_user'
>>> User.type = 'admin'
>>> john.type
'admin'

C’est le fonctionnement du MRO de Python, il cherche d’abord si l’attribut existe dans l’objet, puis si ce n’est pas le cas, le cherche dans les classes parentes.

Attention donc, quand l’attribut est redéfini dans l’objet, il sera trouvé en premier, et n’affectera pas la classe.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>>> john = User()
>>> john.type
'admin'
>>> john.type = 'superadmin'
>>> john.type
'superadmin'
>>> User.type
'admin'
>>> joe = User()
>>> joe.type
'admin'

Attention aussi, quand l’attribut de classe est un objet mutable1, il peut être modifié par n’importe quelle instance de la classe.

1
2
3
4
5
6
7
8
>>> class User:
...     users = []
...
>>> john, joe = User(), User()
>>> john.users.append(john)
>>> joe.users.append(joe)
>>> john.users
[<__main__.User object at 0x7f3b7acf8b70>, <__main__.User object at 0x7f3b7acf8ba8>]

L’attribut de classe est aussi conservé lors de l’héritage, et partagé avec les classes filles (sauf lorsque les classes filles redéfinissent l’attribut, de la même manière que pour les instances).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> class Guest(User):
...     pass
...
>>> Guest.users
[<__main__.User object at 0x7f3b7acf8b70>, <__main__.User object at 0x7f3b7acf8ba8>]
>>> class Admin(User):
...     users = []
...
>>> Admin.users
[]

  1. Un objet mutable est un objet que l’on peut modifier (liste, dictionnaire) par opposition à un objet immutable (nombre, chaîne de caractères, tuple). 

La méthode pour avoir la classe

Comme pour les attributs, des méthodes peuvent être définies au niveau de la classe. C’est par exemple le cas de la méthode mro.

1
int.mro()

Les méthodes de classe constituent des opérations relatives à la classe mais à aucune instance. Elles recevront la classe courante en premier paramètre (nommé cls, correspondant au self des méthodes d’instance), et auront donc accès aux autres attributs et méthodes de classe.

Reprenons notre classe User, à laquelle nous voudrions ajouter le stockage de tous les utilisateurs, et la génération automatique de l’id. Il nous suffirait d’une même méthode de classe pour stocker l’utilisateur dans un attribut de classe users, et qui lui attribuerait un id en fonction du nombre d’utilisateurs déjà enregistrés.

1
2
3
4
5
6
7
>>> root = Admin('root', 'toor')
>>> root
<User: 1, root>
>>> User('john', '12345')
<User: 2, john>
>>> guest = Guest('guest')
<User: 3, guest>

Les méthodes de classe se définissent comme les méthodes habituelles, à la différence près qu’elles sont précédées du décorateur classmethod.

 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
34
35
import crypt

class User:
    users = []

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

    @classmethod
    def register(cls, user):
        cls.users.append(user)
        user.id = len(cls.users)

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

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

    def __repr__(self):
        return '<User: {}, {}>'.format(self.id, self.name)

class Guest(User):
    def __init__(self, name):
        super().__init__(name, '')

    def check_pwd(self, password):
        return True

class Admin(User):
    def manage(self):
        print('I am an über administrator!')

Vous pouvez constater le résultat en réessayant le code donné plus haut.

Le statique c'est fantastique

Les méthodes statiques sont très proches des méthodes de classe, mais sont plus à considérer comme des fonctions au sein d’une classe.

Contrairement aux méthodes de classe, elles ne recevront pas le paramètre cls, et n’auront donc pas accès aux attributs de classe, méthodes de classe ou méthodes statiques.

Les méthodes statiques sont plutôt dédiées à des comportements annexes en rapport avec la classe, par exemple on pourrait remplacer notre attribut id par un uuid aléatoire, dont la génération ne dépendrait de rien d’autre dans la classe.

Elles se définissent avec le décorateur staticmethod.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import uuid

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

    @staticmethod
    def _gen_uuid():
        return str(uuid.uuid4())

    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('john', '12345')
>>> john.id
'69ef1327-3d96-42a9-94e6-622619fbf666'

Attribut es-tu là ?

Nous savons récupérer et assigner un attribut dont le nom est fixé, cela se fait facilement à l’aide des instructions obj.foo et obj.foo = value.

Mais nous est-il possible d’accéder à des attributs dont le nom est variable ?

Prenons une instance john de notre classe User, et le nom d’un attribut que nous voudrions extraire :

1
2
>>> john = User('john', '12345')
>>> attr = 'name'

La fonction getattr nous permet alors de récupérer cet attribut.

1
2
>>> getattr(john, attr)
'john'

Ainsi, getattr(obj, 'foo') est équivalent à obj.foo.

On trouve aussi une fonction hasattr pour tester la présence d’un attribut dans un objet. Elle est construite comme getattr mais retourne un booléen pour savoir si l’attribut est présent ou non.

1
2
3
4
5
6
>>> hasattr(john, 'name')
True
>>> hasattr(john, 'last_name')
False
>>> hasattr(john, 'id')
True

De la même manière, les fonctions setattr et delattr servent respectivement à modifier et supprimer un attribut.

1
2
3
4
5
6
7
8
>>> setattr(john, 'name', 'peter') # équivalent à `john.name = 'peter'`
>>> john.name
'peter'
>>> delattr(john, 'name') # équivalent à `del john.name`
>>> john.name
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'User' object has no attribute 'name'

La dynamique des propriétés

Les propriétés sont une manière en Python de « dynamiser » les attributs d’un objet. Ils permettent de générer des attributs à la volée à partir de méthodes de l’objet.

Un exemple valant mieux qu’un long discours :

1
2
3
4
5
6
7
class ProfilePicture:
    @property
    def picture(self):
        return '{}-{}.png'.format(self.id, self.name)

class UserPicture(ProfilePicture, User):
    pass

On définit donc une propriété picture, qui s’utilise comme un attribut. Chaque fois qu’on appelle picture, la méthode correspondante est appelée et le résultat est calculé.

1
2
3
4
5
6
>>> john = UserPicture('john', '12345')
>>> john.picture
'1-john.png'
>>> john.name = 'John'
>>> john.picture
'1-John.png'

Il s’agit là d’une propriété en lecture seule, il nous est en effet impossible de modifier la valeur de l’attribut picture.

1
2
3
4
>>> john.picture = 'toto.png'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute

Pour le rendre modifiable, il faut ajouter à notre classe la méthode permettant de gérer la modification, à l’aide du décorateur @picture.setter (le décorateur setter de notre propriété picture, donc).

On utilisera ici un attribut _picture, qui pourra contenir l’adresse de l’image si elle a été définie, et None le cas échéant.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class ProfilePicture:
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._picture = None

    @property
    def picture(self):
        if self._picture is not None:
            return self._picture
        return '{}-{}.png'.format(self.id, self.name)

    @picture.setter
    def picture(self, value):
        self._picture = value

class UserPicture(ProfilePicture, User):
    pass
1
2
3
4
5
6
>>> john = UserPicture('john', '12345')
>>> john.picture
'1-john.png'
>>> john.picture = 'toto.png'
>>> john.picture
'toto.png'

Enfin, on peut aussi coder la suppression de l’attribut à l’aide de @picture.deleter, ce qui revient à réaffecter None à l’attribut _picture.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class ProfilePicture:
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._picture = None

    @property
    def picture(self):
        if self._picture is not None:
            return self._picture
        return '{}-{}.png'.format(self.id, self.name)

    @picture.setter
    def picture(self, value):
        self._picture = value

    @picture.deleter
    def picture(self):
        self._picture = None

class UserPicture(ProfilePicture, User):
    pass
1
2
3
4
5
6
7
8
9
>>> john = UserPicture('john', '12345')
>>> john.picture
'1-john.png'
>>> john.picture = 'toto.png'
>>> john.picture
'toto.png'
>>> del john.picture
>>> john.picture
'1-john.png'

L'art des classes abstraites

La notion de classes abstraites est utilisée lors de l’héritage pour forcer les classes filles à implémenter certaines méthodes (dites méthodes abstraites) et donc respecter une interface.

Les classes abstraites ne font pas partie du cœur même de Python, mais sont disponibles via un module de la bibliothèque standard, abc (Abstract Base Classes). Ce module contient notamment la classe ABC et le décorateur abstractmethod, pour définir respectivement une classe abstraite et une méthode abstraite de cette classe.

Une classe abstraite doit donc hériter d’ABC, et utiliser le décorateur cité pour définir ses méthodes abstraites.

1
2
3
4
5
6
import abc

class MyABC(abc.ABC):
    @abc.abstractmethod
    def foo(self):
        pass

Il nous est impossible d’instancier des objets de type MyABC, puisqu’une méthode abstraite n’est pas implémentée :

1
2
3
4
>>> MyABC()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class MyABC with abstract methods foo

Il en est de même pour une classe héritant de MyABC sans redéfinir la méthode.

1
2
3
4
5
6
7
>>> class A(MyABC):
...     pass
...
>>> A()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class A with abstract methods foo

Aucun problème par contre avec une autre classe qui redéfinit bien la méthode.

1
2
3
4
5
6
7
8
>>> class B(MyABC):
...     def foo(self):
...        return 7
...
>>> B()
<__main__.B object at 0x7f33065316a0>
>>> B().foo()
7

TP : Base de données

Pour ce dernier TP, nous aborderons les méthodes de classe et les propriétés.

Reprenons notre forum, auquel nous souhaiterions ajouter la gestion d’une base de données.

Notre base de données sera une classe avec deux méthodes, insert et select. Son implémentation est libre, elle doit juste respecter l’interface suivante :

 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
>>> class A: pass
...
>>> class B: pass
...
>>>
>>> db = Database()
>>> obj = A()
>>> obj.value = 42
>>> db.insert(obj)
>>> obj = A()
>>> obj.value = 5
>>> db.insert(obj)
>>> obj = B()
>>> obj.value = 42
>>> obj.name = 'foo'
>>> db.insert(obj)
>>>
>>> db.select(A)
<__main__.A object at 0x7f033697f358>
>>> db.select(A, value=5)
<__main__.A object at 0x7f033697f3c8>
>>> db.select(B, value=42)
<__main__.B object at 0x7f033697f438>
>>> db.select(B, value=42, name='foo')
<__main__.B object at 0x7f033697f438>
>>> db.select(B, value=5)
ValueError: item not found

Nous ajouterons ensuite une classe Model, qui se chargera de stocker dans la base toutes les instances créées. Model comprendra une méthode de classe get(**kwargs) chargée de réaliser une requête select sur la base de données et de retourner l’objet correspondant. Les objets de type Model disposeront aussi d’une propriété id, retournant un identifiant unique de l’objet.

On pourra alors faire hériter nos classes User et Post de Model, afin que les utilisateurs et messages soient stockés en base de données. Dans un second temps, on pourra faire de Model une classe abstraite, par exemple en rendant abstraite la méthode __init__.

 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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import abc
import datetime

class Database:
    data = []

    def insert(self, obj):
        self.data.append(obj)

    def select(self, cls, **kwargs):
        items = (item for item in self.data
                 if isinstance(item, cls)
                 and all(hasattr(item, k) and getattr(item, k) == v
                         for (k, v) in kwargs.items()))
        try:
            return next(items)
        except StopIteration:
            raise ValueError('item not found')

class Model(abc.ABC):
    db = Database()
    @abc.abstractmethod
    def __init__(self):
        self.db.insert(self)
    @classmethod
    def get(cls, **kwargs):
        return cls.db.select(cls, **kwargs)
    @property
    def id(self):
        return id(self)

class User(Model):
    def __init__(self, name):
        super().__init__()
        self.name = name

class Post(Model):
    def __init__(self, author, message):
        super().__init__()
        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__':
    john = User('john')
    peter = User('peter')
    Post(john, 'salut')
    Post(peter, 'coucou')

    print(Post.get(author=User.get(name='peter')).format())
    print(Post.get(author=User.get(id=john.id)).format())

Ces dernières notions ont dû compléter vos connaissances du modèle objet de Python, et vous devriez maintenant être prêts à vous lancer dans un projet exploitant ces concepts.