Plus d'expressivité en Python

Nous allons maintenant voir quelques facilités fournies par le langage python

  • itérateurs
  • générateurs
  • décorateurs
  • gestion des classes et objets : classes abstraites, attributs/méthodes de classes

Itérateurs

On a vu que l'on pouvait itérer directement sur des contenants comme des listes

for x in [1,3,5]:
    print x

Supposons que l'on définisse un objet qui contient lui aussi un ensemble de valeurs quelconque

In [5]:
class Equipe:   
    def __init__(self,nom,membres):
        self.nom = nom
        self.membres = membres
        
team = Equipe("Preum",["Adam","Eve"]) 
# on peut itérer sur les membres
for x in team.membres:
    print(x)    
Adam
Eve

mais on pourrait vouloir directement itérer sur l'objet, qui est une collection de membres le seul champ sur lequel on veut pouvoir itérer

In [1]:
for x in team:
    print(x)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-1-cc668c1c1ae9> in <module>()
----> 1 for x in team:
      2     print(x)

NameError: name 'team' is not defined
In [9]:
class Equipe:
    
    def __init__(self,nom,membres):
        self.nom = nom
        self.membres = membres
    
    # on peut définir un itérateur sur la classe. 
    def __iter__(self):
        return iter(self.membres)

team = Equipe("Preum",["Adam","Eve"])
for x in team:
    print(x)      
Adam
Eve

Mais comment est défini l'itérateur sur la liste ?

Il suffit que l'objet ait une méthode __next()__, qui est déjà défini sur les listes.

Elle doit soulever une exception StopIteration quand il est censé s'arrêter (pas obligatoire...).

On peut le définir soi-même sur un objet:

Exemple: définir un itérateur qui parcours une liste à l'envers.

In [11]:
class Reverse:
    """itérateur pour parcourir un conteneur à l'envers"""
    def __init__(self, data):
        self.data = data
        self.index = len(data)
   
    def __iter__(self):
        return self
    
    def __next__(self):
        if self.index == 0:
            raise StopIteration
        self.index = self.index - 1
        return self.data[self.index]

    def reset(self):
        self.index = len(self.data)
    

for x in Reverse([1,2,3]):
    print(x,end=" ") 
3 2 1 

Générateurs

un générateur est un moyen plus simple de définir des itérateurs.

le mot clef "yield" dans une fonction suffit à la définir la fonction comme générateur

In [16]:
def natural(n):
    i = 0
    while i<=n:
        yield i
        i = i + 1

gen = natural(3)
for x in gen:
    print(x,end=" ")
0 1 2 3 

Un générateur permet de générer au fur et à mesure des besoins des valeurs à énumérer, sans tout stocker explicitement.

La syntaxe cache en fait les choses suivantes:

  • la définition de l'initialisation de l'itérateur
  • la définition de ce qu'il renvoie à chaque étape (next)
  • l'exception quand on sort de ce qu'il doit énumérer

La fin de l'itération est optionnelle !

In [20]:
def natural0():
    i = 1
    while True:
        yield i
        i = i + 1
    
gen = natural0()
gen.__next__()
Out[20]:
1

Un générateur définit implicitement un itérateur, à condition de s'arrêter.

In [22]:
for i in natural(100):
    print(i,end=" ") 
0 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 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 

En fait, natural fait la même chose que la fonction range

On peut aussi définir un parcours d'un conteneur "à l'avance"

Exemple :

In [5]:
from sys import getsizeof
# là on crée une liste à chaque fois
a1 = [x*(x+1) for x in range(10000)] 
print(getsizeof(a1))
a1 = [x//2 for x in a1]
print(getsizeof(a1))
87624
87624
In [7]:
# ou bien ...
a2 = (x*(x+1) for x  in range(100))
print(a2)
a2 = (x//2 for x in a2) 
print(a2)
print(getsizeof(a2))

# plus simple
a2 = (y//2 for y in (x*(x+1) for x in range(100)))

for x in a2:
    print(x,end=" ") 
<generator object <genexpr> at 0x7f6007596fc0>
<generator object <genexpr> at 0x7f6007596e58>
72
0 1 3 6 10 15 21 28 36 45 55 66 78 91 105 120 136 153 171 190 210 231 253 276 300 325 351 378 406 435 465 496 528 561 595 630 666 703 741 780 820 861 903 946 990 1035 1081 1128 1176 1225 1275 1326 1378 1431 1485 1540 1596 1653 1711 1770 1830 1891 1953 2016 2080 2145 2211 2278 2346 2415 2485 2556 2628 2701 2775 2850 2926 3003 3081 3160 3240 3321 3403 3486 3570 3655 3741 3828 3916 4005 4095 4186 4278 4371 4465 4560 4656 4753 4851 4950 
In [9]:
# attention !
print(list(a2)[:100])
[]

Revenons à notre classe ; on aurait pu écrire l'itérateur nous mêmes:

In [32]:
class Equipe:
    def __init__(self,nom,membres):
        self.nom = nom
        self.membres = membres
    
    def __iter__(self):
        for x in self.membres:
            yield x

team = Equipe("Preum",["Adam","Eve"])
for x in team:
    print(x)      
Adam
Eve
In [6]:
class Equipe:
    def __init__(self,nom,membres):
        self.nom = nom
        self.membres = membres
        self.bannis = set()
    
    def __iter__(self):
        for x in self.membres:
            if x not in self.bannis:
                yield x

team = Equipe("Preum",["Adam","Eve","Cain","Abel"])
team.bannis.add("Cain")

for x in team:
    print x     
Adam
Eve
Abel

Retour sur les définitions de classes

  • attributs de classes
  • classes abstraite

on peut avoir des attributs de classes, partagés par toutes les instances

In [2]:
class Bidon:
    # attribut de classe
    general = [1,2,3]
    
    def __init__(self,x):
        self.particulier = x
        

a = Bidon(5)
b = Bidon(6)

print a.general, b.general
a.general[0] = a.particulier
print b.general
a.general is b.general
    
[1, 2, 3] [1, 2, 3]
[5, 2, 3]
Out[2]:
True

Mais comment faire pour avoir une fonction partagée par toutes les instances sans l'instance elle-même ? Impossible comme les attributs car le premier argument est toujours l'objet créé (self)

-> On a besoin d'autre chose (plus tard)

Classes Abstraites

Il est facile d'implémenter une telle classe en utilisant l'exception "NotImplementError"

In [10]:
class AbstractMaClasse:
    
    def __init__(self,x):
        self.base = x
    
    def a_method(self,x):
        return x*2
        
    def another(self,x,y):
        raise NotImplementedError, "appel à une classe abstraite"
        
 
In [11]:
   
class MaClasse(AbstractMaClasse):
    def another(self,x,y):
        return self.base + x*y
    
a = AbstractMaClasse(1)
print "objet a créé"
b = MaClasse(2)
print "objet b créé"
print b.another(2,3)
print a.another(2,3)
objet a créé
objet b créé
8
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
<ipython-input-11-26d51247942a> in <module>()
     10 print "objet b créé"
     11 print b.another(2,3)
---> 12 print a.another(2,3)

<ipython-input-10-214ea144276b> in another(self, x, y)
      8 
      9     def another(self,x,y):
---> 10         raise NotImplementedError, "appel à une classe abstraite"
     11 
     12 

NotImplementedError: appel à une classe abstraite

Un inconvénient de cette méthode est qu'il n'y a aucun contrôle que les sous-classes définissent bien toutes les méthodes au moment de la création d'une instance; il faut attendre l'appel d'une fonction non implémentée pour s'en rendre compte (tard).

In [12]:
class Oops(AbstractMaClasse):
    pass

a = Oops(1) # ne devrait pas marcher !
print "----- instance créée"

a.another(3,4)
----- instance créée
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
<ipython-input-12-f7d8f00168fd> in <module>()
      5 print "----- instance créée"
      6 
----> 7 a.another(3,4)

<ipython-input-10-214ea144276b> in another(self, x, y)
      8 
      9     def another(self,x,y):
---> 10         raise NotImplementedError, "appel à une classe abstraite"
     11 
     12 

NotImplementedError: appel à une classe abstraite

La aussi, il y a besoin d'autre mécanisme pour rendre ça plus général, que l'on va voir maintenant: les décorateurs

Décorateurs

On a vu que l'on pouvait facilement définir des fonctions avec des fonctions en arguments ou même en résultat. Les fonctions sont des objets comme les autres (ou presque).

Python fournit de plus une syntaxe spéciale pour faciliter les transformations de fonctions: les décorateurs.

Ce mécanisme va permettre de traiter les problèmes mentionnés auparavant

Exemple: faisons une fonction qui aide à déclarer des fonctions "obsolètes" (en donnant un avertissement, mais en laissant le programme continuer)

In [18]:
def obsolete(func):
    def new_func(x):
        print "!! attention, appel à fonction obsolete"
        return func(x)
    return new_func

def mafonction(x):
    return x*2

mafonction = obsolete(mafonction)
mafonction(12)
!! attention, appel à fonction obsolete
Out[18]:
24

Inconvénient: ne marche que si la fonction a un seul argument.

Avec des arguments quelconques cette fois :

In [19]:
def obsolete(func):
    def new_func(*args,**kwargs):
        print "!! attention, appel à fonction obsolete"
        return func(*args,**kwargs)
    return new_func

def mafonction(x,y):
    return x**2+y**2

mafonction = obsolete(mafonction)
mafonction(12,12)
!! attention, appel à fonction obsolete
Out[19]:
288

Python fournit en fait une syntaxe plus pratique

In [20]:
def obsolete(func):
    def new_func(*args,**kwargs):
        print "!! attention, appel à fonction obsolete"
        return func(*args,**kwargs)
    return new_func

@obsolete
def mafonction(x,y):
    return x**2+y**2

mafonction(12,12)
!! attention, appel à fonction obsolete
Out[20]:
288

Avantages des décorateurs :

  • plus concis -> plus clair !
  • ne sépare pas la définition de la fonction de ses modifications
  • permet de les empiler de façon lisible
  • autorise les arguments

--> un outil d'abstraction très puissant

avec un argument : revient à empiler un autre "emballage" (wrapper) par dessus le premier

In [21]:
def obsolete(date):
    def obsolete_decorator(func):
        def new_func(*args,**kwargs):
            print "!! attention, appel à fonction obsolete depuis", date
            return func(*args,**kwargs)
        return new_func
    return obsolete_decorator
    
    
@obsolete(2005)
def mafonction(x,y):
    return x**2+y**2

@obsolete(1980)
def oldfonction(x):
    return x*2

mafonction(12,12)
!! attention, appel à fonction obsolete depuis 2005
Out[21]:
288

Deux décorateurs utiles sont prédéfinis:

  • @staticmethod: méthode d'une class partagée par toutes les instances
  • @classmethod méthode de classe -> utiles pour héritage, permet de définir des méthodes statiques sans mettre le nom de classe explicitement et donc peut être repris par les sous-classes telle quelle

Et un autre du module abc permet de définir des méthodes de classes abstraites

  • @abc.abstractmethod

Méthode statique:

  • methode d'une classe qui ne dépend pas d'une instance
  • si on déclare normalement, on a une copie de la fonction à chaque instance

Exemple : supposons qu'on redéfinisse une classe complexe, et qu'on veut une méthode qui donne les racines enièmes de 1, i.e.

$$\exp(i 2k\pi/n) = cos(2k\pi/n) + i\cdot sin(2k\pi/n) $$

In [32]:
from math import cos,sin, pi

class Complexe:
    def __init__(self,r,i):
        self.reel = r
        self.imaginaire = i
        
    def __repr__(self):
        return "%f + i*(%f)"%(self.reel,self.imaginaire)
    
    @staticmethod
    def racine_unite(n,k):
        return Complexe(cos(2.0*k*pi/n),sin(2.0*k*pi/n))

a = Complexe(1,1)
b = Complexe(-1,3)
print Complexe.racine_unite(3,1)
a.racine_unite is b.racine_unite
-0.500000 + i*(0.866025)
Out[32]:
True

Méthodes de classe

Imaginons maintenant qu'on veuille définir un nouveau conteneur, que l'on peut initialiser avec une liste, mais aussi à partir des éléments d'un dictionnaire.

On a deux solutions :

In [24]:
class Table:
    def __init__(self,items):
            self.contenu = items
    
    def from_dict1(self,dico):
            self.contenu = dico.values()
            
    def from_dict2(self,dico):
            return Table(dico.values())

Pas très élégant, car il faut soit initialiser deux fois une instance ou bien créer une instance pour créer une autre instance

In [25]:
a = Table([])
a.from_dict1({1:2,3:4})
# ou bien 
c = a.from_dict2({5:6,7:8})

Déjà mieux avec une méthode statique

In [26]:
class Table:
     def __init__(self,items):
            self.contenu = items
    
     @staticmethod
     def from_dict(dico):
            return Table(dico.values())
            
a = Table.from_dict({})
print a
<__main__.Table instance at 0x104907248>

Problème : si on veut maintenant faire une sous classe de Table, on doit tout réécrire si on veut garder la cohérence des constructeurs (from_dict faisant explicitement appel à la surclasse Table)

In [27]:
# une table qui contient plus d'information
class AutreTable(Table):
     def __init__(self,items):
            self.contenu = items
            self.nb = len(items)
    
     @staticmethod
     def from_dict(dico):
            return AutreTable(dico.values())
            

Ne serait-il pas mieux d'avoir une méthode de classe à la place ? Du coup rien à réécrire.

In [1]:
class Table:
     def __init__(self,items):
            self.contenu = items
    
     @classmethod
     def from_dict(cls,dico):
            return cls(dico.values())

class AutreTable(Table):
     def __init__(self,items):
            self.contenu = items
            self.nb = len(items)
    

a = AutreTable.from_dict({1:2})
a.__class__
Out[1]:
<class __main__.AutreTable at 0x10373cf58>

Exercices: définir des décorateurs pour

  • garder en cache des résultats de fonction ("memoisation")
  • compter les appels de certaines fonctions

Indice: définir le décorateur comme objet et redéfinir la méthode __call__

In [10]:
class memoise:
    
    def __init__(self,f):
        self.func = f
        self.cache = {}
        
    def __call__(self,*args):
        if args in self.cache:
            return self.cache[args]
        else:
            val = self.func(*args)
            self.cache[args] = val
            return val
        
@memoise
def fibo(n):
    if n<2:
        return 1
    else:
        return fibo(n-1)+fibo(n-2)
    
fibo(50)
Out[10]:
20365011074
In [23]:
class compte:
    
    comptage = {}
    
    def __init__(self,f):
        self.func = f
        self.comptage[f.__name__] = 0
    
    def __call__(self,*args):
        self.comptage[self.func.__name__] += 1
        return self.func(*args)

    @staticmethod
    def stats():
        return compte.comptage
        
    def __repr__(self):
        return self.func.__doc__
    
@compte
def add(x,y):
    """ addition """
    return x+y

@compte
def mult(x,y):
    """multiplication"""
    return x*y


add(1,2)
add(3,4)

print compte.stats()
print add
{'add': 2, 'mult': 0}
 addition 

Classes abstraites

On a vu que définir des classes abstraites seulement avec des exceptions NotImplemtend délaie les problèmes au moment des appels de méthodes, alors qu'il vaut mieux les intercepter à la création d'un objet d'une classe à problème.

Ceci est permit par le module abc, qui définit le décorateur de méthode abstraite

In [29]:
import  abc 

class AbstractMaClass(object):
    __metaclass__  = abc.ABCMeta

    def __init__(self):
        pass
    
    @abc.abstractmethod
    def another(self):
        pass
    

a = AbstractMaClass()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-29-f22f8eb19779> in <module>()
     12 
     13 
---> 14 a = AbstractMaClass()

TypeError: Can't instantiate abstract class AbstractMaClass with abstract methods another