theorie3_fonctions_et_objets

Base CINQ : Objets

Un objet permet de grouper plusieurs variables dans un... objet. Par exemple on peut grouper la "vie" et le "mana" dans un objet de type "Personnage".

D'abord on définit la classe de l'objet ci-dessous. Pour l'instant elle est vide mais utilisable. Une classe va définir le "modèle" d'une série d'objet, par exemple tous les personnages seront des... personnages.


class Personnage:
    pass  # vide

Pour créer un objet on écrit sa classe puis une paire de parenthèses. Vu que la classe est vide, il n'y a rien à mettre à l'intérieur de ces parenthèses.


bob = Personnage()
print(bob)  # "objet de la classe Personnage à l'adresse mémoire 0x..."

Vu que bob est un objet, on peut lui mettre des variables !

bob.vie = 60 
bob.max_vie = 100
bob.mana = 10

On peut créer d'autres personnages, comme alice, ou deux personnages sans noms :

alice = Personnage()
alice.vie = 40
alice.max_vie = 70
alice.mana = 50

# imaginez une liste de personnages !
les_personnages = [alice, bob]

alice.vie += 10
print(les_personnages[0].vie)  # 50
print(les_personnage[0] == alice)  # True

p = Personnage()
p.vie = 20
p.max_vie = 20
p.mana = 30

les_personnages.append(p)

p = Personnage()
p.vie = 25
p.max_vie = 25
p.mana = 20

les_personnages.append(p)

Lancez cet exemple sur pythontutor, et observez la dynamique des flèches lors du deuxième p = Personnage(). Plus d'infos sur ces flèches dans la section En savoir plus.

Essayez de créer une liste de 100 personnages différents en moins de 10 lignes de code. Remarquez qu'un personnage peut être différent d'un autre même s'il a les mêmes stats (vie, mana, ...).

Base SIX : Fonctions

On peut créer des fonctions, comme en math:


def f(x):  # f est une fonction d'un paramètre, appelé x
    return x + 1  # elle renvoie ce paramètre + 1

print(f(5))     # 6, les parenthèses APPELLENT la fonction
y = f(8)        # y = 9
a = 5
b = f(a+1) - 4  # b = (5+1) - 4 = 2
x = 2
z = f(x)        # z = 3
w = f(x+1)      # w = 4

À quoi ça sert ? Certaines structures de code reviennent souvent, par exemple dans un exercice précédent, on a calculé le maximum d'une liste:


ma_liste = [1,2,7,2]

m = ma_liste[0]
i = 0
while i < len(ma_liste):
    if ma_liste[i] > m:
        m = ma_liste[i]
    i = i + 1
print(m)

Ou encore, on aimerait donner 20 points de vie à un objet Personnage sans dépasser son maximum de vie:


bob.vie = bob.vie + 20
if bob.vie > bob.max_vie:
    bob.vie = bob.max_vie

Cependant, on aimerait bien mettre ça dans une boite, une boite à calculer le maximum d'une liste, une boite qui donne 20 points de vie sans dépasser le maximum et pouvoir la réutiliser autant de fois que l'on veut.

Nous allons donc faire une FONCTION:


def calculer_maximum(la_liste):
    m = la_liste[0]
    i = 0
    while i < len(la_liste):
        if la_liste[i] > m:
            m = la_liste[i]
        i = i + 1
    return m

On a fait une fonction, avec un seul paramètre (la liste), et une valeur de retour (le maximum).

Nous pouvons maintenant l'appeler:


une_belle_liste = [1,2,7,2]
a = calculer_maximum(une_belle_liste)

une_autre_liste = [8,0,1,6]
b = calculer_maximum(une_autre_liste)

Pour appeler une fonction on...


# remarquez que la valeur de retour peut être stockée dans une variable...
b = calculer_maximum(une_autre_liste)

# ou utilisée dans une expression
c = calculer_maximum(une_belle_liste) + calculer_maximum(une_autre_liste)

Lancez cet exemple dans pythontutor !

Les objets et fonctions vont bien ensemble, quand une fonction a accès à un objet elle peut modifier ses attributs:


def donner_potion(perso, montant):
    """Donne "montant" pv à un Personnage "perso" sans dépasser son maximum de vie"""
    perso.vie = perso.vie + montant
    if perso.vie > perso.max_vie:
        perso.vie = perso.max_vie
        
# on appelle la fonction avec nos personnages de la base CINQ
donner_potion(bob, 25)  # 25 points de vie pour bob
donner_potion(alice, 100)  # 100 points de vie pour alice

Vous pouvez aussi voir cet exemple sur pythontutor

Quand une fonction est appelée, python...

Remarquez que la variable i et m sont locales à la fonction, elles sont crées dans la fonction et détruites à la fin.

La boite noire

EN UN MOT: une fonction, c'est une sorte de boite noire:

  1. Elle a des paramètres d'entrée... ou pas,
  2. fait un calcul,
  3. et sort des valeurs de retour ... ou pas.


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

Super dessin de la boite noire:


# x,y -> [f] -> z

Les fonctions peuvent avoir:

Exemples

Voici plusieurs exemples de fonctions:


# 0 paramètre d'entrée, 0 de retour
def afficher_description():
    print("+------------------+")
    print("| Hello !          |")
    print("| Je suis Robert ! |")
    print("+------------------+")
    
afficher_description()

# 1 paramètre d'entrée, 0 de retour
def afficher_etoiles(nombre):
    for i in range(nombre):
        print("*")
        
afficher_etoiles(5)

# 2 paramètres d'entrée, 2 de retour
def minute_suivante(h,m):
    if m < 59:
        nouveau_h = h
        nouveau_m = m + 1
    else:
        nouveau_h = h + 1
        nouveau_m = 0
    return nouveau_h, nouveau_m

a,b = minute_suivante(13,30)
c,d = minute_suivante(12,59)

# pas d'entrée, un retour
def generate():
    liste = []
    for i in range(5):
        liste.append(i * i)
    return liste

l = generate()

return

Remarquez qu'on peut mettre autant de return que l'on veut, quand python lit un return, il arrête tout de suite la fonction et renvoie (ou non) une valeur.


def manger(x):
    if x < 0: # si x est négatif
        return -1  # la fonction s'arrête et renvoie -1
    
    i = 0
    s = 0
    while i < x:
        s += i
        i = i + 1
    return s

Le mot clé return rappelle que le code retourne dans la fonction appelante.

Pour en savoir plus (fonctions)

Paramètres par défaut


def f(x, y=5):
    return x + y

print(f(1))    # x=1, y=5
print(f(1,2))  # x=1, y=2

Attention, ne mettre comme valeur par défaut que des objets non modifiables (immutable en anglais) comme des int, str, tuple mais pas des list ni des objets classiques (la raison est expliquée plus bas).

Un bon défaut est None qui est une valeur spéciale en Python :


def f(x, y=None):
    if y is None:
        y = x + 1
    return x + y

Appel utilisant les noms des paramètres


def f(x, y):
    return x - y

print(f(5, 2))      # 3    
print(f(5, y=2))    # 3
print(f(x=5, y=2))  # 3
print(f(y=2, x=5))  # 3

Docstring et interfaces

Un des grand intérêt des fonctions est de les voir comme une boîte noire. Quand quelqu'un voit la première ligne (la signature) de la fonction, def calculer_maximum(la_liste), il peut comprendre qu'il peut utiliser cette fonction pour calculer le maximum d'une liste sans en lire son code.

Parfois, le nom à lui tout seul n'est pas suffisant pour bien utiliser la fonction, on y ajoute alors une docstring qui est un petit texte expliquant le rôle de la fonction. C'est généralement fait via """ et sur plusieurs lignes :


def calculer_maximum(la_liste):
    """
    Calcule le maximum d'une liste
    en utilisant une recherche linéaire.
    """
    m = la_liste[0]
    i = 0
    while i < len(la_liste):
        if la_liste[i] > m:
            m = la_liste[i]
        i = i + 1
    return m

Une docstring doit donner des informations sur son rôle et non sur comment elle remplit son rôle. Sur ce qu'elle fait et non sur comment elle le fait. L'idée est de donner uniquement les informations pertinentes à la personne appelant la fonction, le reste étant des détails d'implémentation.

Quand on a beaucoup de paramètres d'entrée et de retour dont les noms mérite explications, il est souvent utile de les lister dans la docstring :


def effectuer_un_combat(attaquant, defenseur, butin, lieu):
    """
    Effectue un combat entre une équipe attaquante et 
    une équipe en défense et renvoie la durée du combat.
    
    @param attaquant: l'équipe attaquante (de type Equipe)
    @param defenseur: l'équipe attaquante (de type Equipe)
    @param lieu: la position de la bataille (un tuple (latitude, longitude))
    @param butin: le nombre de pièce d'or volées à l'adversaire en cas de victoire (un int)
    
    @returns la durée du combat (un timedelta)
    """
    ...  # le code effectuant le combat!

Indiquer les types d'arguments étant une tâche très récurrente et utile, python 3 introduit les annotations en parallèle de la docstring:


def effectuer_un_combat(attaquant:Equipe, defenseur:Equipe, butin:int, lieu:tuple) -> timedelta:
    """Effectue un combat entre une équipe attaquante et 
    une équipe en défense en une (latitude, longitude) donnée et renvoie la durée du combat."""
    ...  # le code effectuant le combat!

Dans cet exemple, la docstring a pu être épurée les annotations ne doivent pas nécessairement être des types, ce code est possible:


def effectuer_un_combat(attaquant:Equipe, defenseur:Equipe, butin:int, lieu:(float, float)) -> timedelta:
    ...  # le code effectuant le combat!

En python 3.7, à condition d'écrire from __future__ import annotations au début du fichier, les variables utilisées commme Equipe ou timedelta ne doivent même pas être déclarées.

Simplifications automatiques

Voici un aperçu de mon article obtenues grâce aux fonctions.

(1) Les fonctions renvoyant un bool :


# avant
def f():
    ...
    if CONDITION:
        return True
    else:
        return False
    
# après (bool)
def f():
    ...
    return CONDITION

Le return se lira alors si et seulement si (ssi).

Par exemple:


# x est une voyelle ssi x == 'a' or x == 'e' or x == 'i' or x = 'o' or x = 'u'
def voyelle(x):
    return x == 'a' or x == 'e' or x == 'i' or x = 'o' or x = 'u'

plus d'infos ici et .

(2) Le if fonctionnel


# avant:
def f():
    ...
    if CONDITION:
        return X
    else:
        return Y
    
# après (if fonctionnel)
def f():
    ...
    return X if CONDITION else Y

Cette transformation n'est pas obligatoire c'est juste un autre style de programmation.

Plus d'infos ici et

(2) Le return dans un if :


# avant
def f():
    if CONDITION:
        A
        return X
    else:
        B
        return Y
    
# après (return)
def f():
    if CONDITION:
        A
        return X
    B
    return Y

A et B sont une suite de 0 ou plus instructions, je conseille de faire ceci si il y a peut d'instructions dans "A" et beaucoup dans "B".

Plus d'infos ici.

(3) ∃ et ∀ :


# avant
def f():
    ...
    for X in L:
        if CONDITION:
            return True
    return False

# après : (voir fichier progra_functionals)
def f():
    ...
    return any(CONDITION for X in L)

Lire ça comme il existe un X dans L qui vérifie CONDITION ou il existe un X dans L tel que CONDITION.

Par exemple:

if any(x == 2 for x in L):
    ...

Se lit si :

Un conseil: passer à la ligne en fonction de la longueur dans le any!

∀ est le cas dual de ∃ :


# avant
def f():
    ...
    for X in L:
        if not CONDITION:
            return False
    return True

# après: (voir fichier progra_functionals)
def f():
    ...
    return all(CONDITION for X in L)

On peut lire ça de plusieurs manières:

Par exemple:

if all(x > 0 for x in L):
    ...
    

Se lit si :

Plus d'infos ici et

(4+) Plus d'exemples dans mon article sur les équivalences.

Récursivité

Méditez là dessus avec pythontutor...


def f(x):
    if x > 0:
        f(x-1)
        print("Hello", x)
        f(x-1)

f(4)

Variables locales

Méditez là dessus sur pythontutor:


g = 5
p = 2
def f(x):
    print(g)
    y = x + 1
    p = 5
    print(p)

LECTURE: Quand on lit une variable (print(x) par exemple), python fait ceci :

ÉCRITURE: Quand on écrit dans une variable (variable = ), python fait ceci :

Quand une fonction se termine (return ou arrivée à la fin du bloc), les variables sont détruites

Même si c'est une mauvaise pratique, il est possible de dire qu'on parle d'une variable globale cela modifie donc la dynamique lors de l'écriture


g = 5
def f():
    global g
    g = 2

f()
print(g)

comment éviter de marquer une variable comme "globale" ? En RENVOYANT quelque chose.


def f():
    return 2

g = f()

Ou en groupant des variables dans des... OBJETS !


class Groupy:
    pass

def f(group):
    group.n = 2

group = Group()
group.n = 5

f(group)

print(group.n)

Pour en savoir plus (objets)

Méthodes


# Rappelons nous de notre fonction qui prend un objet en paramètre
def donner_potion(perso, montant):
    """Donne "montant" pv à un Personnage "perso" sans dépasser son maximum de vie"""
    perso.vie = perso.vie + montant
    if perso.vie > perso.max_vie:
        perso.vie = perso.max_vie
        
# on appelle la fonction avec nos personnages de la base CINQ
donner_potion(bob, 25)  # 25 points de vie pour bob
donner_potion(alice, 100)  # 100 points de vie pour alice

On peut mettre des fonctions dans les classes, on appelle ça des méthodes.

À la place de le faire dans l'espace global, on le fait dans la classe Ces fonctions reçoivent TOUJOURS un premier paramètre "self" qui représente l'objet manipulé.

Dans notre fonction donner_potion(perso, montant) le self, c'est perso


class Personnage:
    def boire_potion(self, montant):
        """Donne montant pv au Personnage sans dépasser son maximum de vie"""
        self.vie = self.vie + montant
        if self.vie > self.max_vie:
            self.vie = self.max_vie
            
    def crier(self):
        """Affiche un cri de guerre à l'écran"""
        print("Bouh!")

bob = Personnage()
bob.vie = 60 
bob.max_vie = 100
bob.mana = 10

alice = Personnage()
alice.vie = 40
alice.max_vie = 70
alice.mana = 50

bob.boire_potion(25)  # on appelle les méthodes comme ceci :)
alice.boire_potion(100)
alice.crier()

Contructeur

On voit des parties du code précédent qui sont répétées. Ce serait pratique de faire une fonction qui initialise les attributs.


def init_personnage(perso, a, b, c):
    perso.vie = a
    perso.max_vie = b
    perso.mana = c
    
bob = Personnage()
init_personnage(bob, 60, 100, 10)
alice = Personnage()
init_personnage(alice, 60, 100, 10)

Cette fonctionnalité existe : Le constructeur. C'est une fonction qui est appelée à la création de tout objet. Le constructeur d'une classe s'appelle toujours __init__.


class Personnage:
    def __init__(self, a, b, c):
        self.vie = a
        self.max_vie = b
        self.mana = c
        
# On donne les paramètres à la création
bob = Personnage(60, 100, 10)
alice = Personnage(40, 70, 50)

Héritage


# Méditez là dessus (pythontutor):

class Personnage:
    def __init__(self, vie, max_vie):
        self.vie = vie
        self.max_vie = max_vie
        
    def crier(self):
        """Affiche un cri de guerre à l'écran"""
        print("Bouh!")
        
    def boire_potion(self, x):
        self.vie = min(self.vie + x, self.max_vie)

class Guerrier(Personnage):  # un Guerrier est un Personnage
    def crier(self):  # mais qui fait "crier()" différemment
        print("Arrrrrrrrgggg")
    
class Magicien(Personnage):
    def crier(self):
        print("Wololo")

alice = Magicien(50, 100)
bob = Guerrier(75, 125)
guy = Personnage(20, 80)
personnages = [alice, bob, guy]  # différentes classes, mais tous des Personnages
for perso in personnages:
    perso.crier()                # va appeler une version différente en fonction de la classe
    perso.boire_potion(10)       # la fonction boire_potion existe toujours

Pour réutiliser une fonction de la classe héritée, on fait comme ceci :


class Chou(Personnage):
    def crier(self):
        Personnage.crier(self)  # on appelle la version de "crier" qui se trouve dans "Personnage"
        print("Wouloulou")
      
class Marchand(Personnage):
    def __init__(self):  # on crée un Marchand sans paramètre
        Personnage.__init__(self, 50, 100)  # on appelle la version de __init__ dans Personnage
        self.argent = 10  # une nouvelle variable
    
    def crier(self):
        if self.argent > 0:
            print("Je suis un Marchand riche !")
        else:
            print("Je suis un Marchand fauché...")
        
denis = Chou(20, 40)
denis.crier()  # Affiche "Bouh!", puis "Wouloulou"

marc = Marchand()   # pas de paramètres vu que Marchand.__init__ n'a pas de paramètres
print(marc.vie)     # 50
print(marc.argent)  # 10
marc.crier()

Comment ça se passe ? Python regarde si la classe de l'objet a la fonction appelée, si elle l'a, il l'appele, sinon, il regarde dans sa classe parente, et puis la parente de la parente. Et finalement si aucune des classes parentes n'a la fonction, il lance une exception.

On peut savoir si un objet est d'une classe

print(isinstance(alice, Magicien))  # True
print(isinstance(bob, Magicien))    # False
print(isinstance(guy, Magicien))    # False
print(isinstance(guy, int))         # False

print(isinstance(alice, Personnage))  # True car héritage

# ou donner plusieurs classes pour éviter de faire un "or"
print(isinstance(alice, Magicien) or isinstance(alice, Guerrier))  # long
print(isinstance(alice, (Magicien, Guerrier)))  # raccourci

# bien que peu utile en pratique, on peut accéder à la classe d'un objet
cls = alice.__class__
print(cls == Magicien)    # True
print(cls == Personnage)  # False
print(type(alice) == alice.__class__)  # True 

Veuillez toujours utiliser isinstance quand vous voulez tester l'appartenance, si votre code fait quelque chose pour un Personnage, ça devrait également marcher avec un Magicien ou un Guerrier.

Références


# Méditez là dessus avec pythontutor:

a = 5
b = a
a = 6
print(a)
print(b)

a = [1,2,3]
b = a
a[0] = 2
print(a)
print(b)

class Personnage:
    pass

a = Personnage()
b = a
a.vie = 1
print(a.vie)
print(b.vie)

Plot twist : les listes sont des objets, de la classe list. Quand on crée un objet (et donc une liste), on reçoit une "référence" (symbolisée par une "flèche").

Contrairement aux objets, les int, float, string n'ont pas de flèche, leur valeur est écrite directement dans la case.

Les objets et références sont dans deux zones de la mémoire différentes, symbolisées par deux colonnes dans pythontutor.

Quand on COPIE une variable, on copie la CASE, donc quand on copie un int, on copie le nombre.

Quand on copie une "flèche", on fait une nouvelle flèche avec la même cible une COPIE se passe uniquement avec x =, for x in ou un appel de fonction :)

Ainsi, dans le code précédent, on a plusieurs exemples de copie avec =


def modifier_premier(liste):
    liste[0] = 0

ma_liste = [1,2,3]
modifier_premier(ma_liste)

def modifier_local(x):
    x = 2

p = 2
modifier_local(p)