Design orienté objet

Introduction

Le design dit orienté-objet est une approche de programmation autour des 'objects'. Nous avons déjà manipulé des objets comme les lists, les tuples et les dictionnaires ainsi que les tableaux de la librairie NumPy.

Le sujet de la programmation orientée-objet peut faire l'objet d'un cours entier. Dans ce notebook, nous allons traité :

  • Les classes
  • Les attributs d'un objet
  • Les méthodes d'une classe

Nous allons commencer par des exemples. Nous n'allons pas aborder les notions d'héritage et polymorphisme.

Python prend en charge le paradigme de programmation orienté objet; en fait, tout en Python est un objet. Vous avez utilisé des concepts de l'informatique orientée objet tout au long de ce cours.

Objectifs

  • Apprécier les objets comme instanciations de classes
  • Compréhension des attributs et méthodes des classes
  • Apprendre à créer des classes simples
  • Implémenter et utiliser des méthodes de classe

Commençons par une importation de la librairie NumPy:

In [ ]:
import numpy as np

Exemple: objet array (tableau) de NumPy

Considérons un tableau NumPy:

In [ ]:
A = np.array([[1, -4, 7], [2, 6, -1]])
print(A)

Nous savons déjà comment vérifier le type d'un objet

In [ ]:
print(type(A))

Cela signifie que A est une instanciation de la classe numpy.ndarray. On peut dire que A est un numpy.ndarray'.

Alors, qu'est-ce qu'un numpy.ndarray? C'est une classe qui a des attributs et des méthodes (member functions en anglais).

Attributs

Les attributs sont des données appartenant à un objet. Le tableau A a un certain nombre d'attributs. Un attribut que nous connaissons est shape:

In [ ]:
s = A.shape
print(s)

Chaque objet de type numpy.ndarray a l'attribut shape qui décrit le nombre d'entrées dans le tableau dans chaque direction. Les autres attributs sont size, qui est le nombre total d'entrées:

In [ ]:
s = A.size
print(s)

et ndim, qui est le nombre de dimensions du tableau (i.e. 1 pour une vecteur, 2 pour une matrice):

In [ ]:
d = A.ndim
print(d)

Notez qu'après un nom d'attribut, il n'y a pas de (). C'est une caractéristique des attributs - nous avons simplement accédé à des données appartenant à un objet. Nous n'appelons pas une fonction ou ne faisons aucun travail de calcul.

Méthodes

Les méthodes sont des fonctions associées à une classe et exécutant des opérations sur les données associées à une instanciation d'une classe. Un objet numpy.ndarray a une méthode min, qui renvoie l'entrée minimum dans le tableau:

In [ ]:
print(A.min())

Les méthodes sont des fonctions, et comme les fonctions peuvent prendre des arguments. Par exemple, nous pouvons utiliser la méthode sort pour trier les lignes d'un tableau:

In [ ]:
A.sort(kind='quicksort')
print(A)

où nous avons appelé la méthode sort qui appartient ànumpy.ndarray, et nous avons passé un argument qui spécifie qu'elle doit utiliser le tri rapide.

Les méthodes d'un objet peuvent prendre d'autres objets comme arguments. Étant donné un tableau à deux dimensions (matrice) $A$ et un tableau à une dimension (vecteur) $x$

In [ ]:
A = np.array([[1, -4, 7], [2, 6, -1]])

x = np.ones(A.shape[1])
print(x)

On peut calculer $b = Ax$ en utilisant la méthode dot:

In [ ]:
b = A.dot(x)
print(b)

Recherche d'attributs et de méthodes de classe

Les attributs et méthodes de classe sont généralement répertoriés dans la documentation. Pour numpy.ndarray, tous les attributs et méthodes sont listés et expliqués à l'adresse https://docs.scipy.org/doc/numpy/reference/generated/numpy.ndarray.html.

En utilisant Jupyter (ou IPython), on peut faire appel à 'tab-completion' pour voir les attributs et méthodes disponibles.

Créaction de classes

Parfois, nous ne pouvons pas trouver une classe (type d'objet) qui convient à notre problème. Dans ce cas, nous pouvons créer le nôtre. À titre d'exemple simple, considérons une classe qui contient le nom et le prénom d'une personne:

In [ ]:
class PersonName:
    def __init__(self, surname, forename):
        self.surname = surname  # Attribute
        self.forename = forename  # Attribute
        
    # This is a method
    def full_name(self):
        "Return full name (forename surname)"
        return self.forename + " " + self.surname

    # This is a method
    def surname_forename(self, sep=","):
        "Return 'surname, forename', with option to specify separator"
        return self.surname + sep + " " + self.forename

Avant de détailler la syntaxe de cette classe, nous allons l'utiliser. Nous créons d'abord un objet (une instanciation) de type PersonName:

In [ ]:
name_entry = PersonName("Bloggs", "Joanna")
print(type(name_entry))

Nous testons les attributs :

In [ ]:
print(name_entry.surname)
print(name_entry.forename)

Ensuite, nous testons les méthodes de la classe :

In [ ]:
name = name_entry.full_name()
print(name)

name = name_entry.surname_forename()
print(name)

name = name_entry.surname_forename(";")
print(name)

Détaillons la classe, est déclarée par

class PersonName:

On a alors ce qu'on appelle le créateur (intialiser en anglais):

def __init__(self, surname, forename):
        self.surname = surname
        self.forename = forename

C'est la fonction qui est appelée à la création d'un objet, c'est-à-dire lorsque nous utilisons name_entry = PersonName (" Bloggs "," Joanna "). Le mot-clé self fait référence à l'objet lui-même - cela peut prendre du temps pour développer une compréhension de self. L'initialiseur dans ce cas stocke le nom et le prénom de la personne (attributs).

def full_name(self):
        "Return full name (forname surname)"
        return self.forename + " " + self.surname

    def surname_forename(self, sep=","):
        "Return 'surname, forname', with option to specify separator"
        return self.surname + sep + " " + self.forename

Ces méthodes sont des fonctions qui font quelque chose avec les données de classe. Dans ce cas, à partir du prénom et du nom, ils renvoient le nom complet de la personne, formaté de différentes manières.

Opérateurs

Les opérateurs comme +, -,* et / sont en fait des fonctions - en Python, ils sont des raccourcis pour les fonctions avec les noms __add__, __sub__, __mul__ et __truediv__, respectivement. En ajoutant ces méthodes à une classe, nous pouvons définir ce que les opérateurs mathématiques doivent faire.

Déclaration d'opérateurs arithmétiques

Disons que nous voulons créer nos propres numéros avec leurs propres opérations. À titre d'exemple simple (complètement inutile), nous décidons que nous voulons changer la notation de telle sorte que * signifie division et / signifie multiplication.

Pour changer * et / pour nos nombres spéciaux, nous créons une classe pour représenter nos nombres spéciaux, et lui fournir ses propres fonctions __mul__ et __truediv__. Nous fournirons également la méthode __str __ (self) - elle est appelée lorsque nous utilisons la fonction print.

In [ ]:
class crazynumber:
    "A crazy number class that switches the mutliplcation and division operations"
    
    # Initialiser
    def __init__(self, x):
        self.x = x  # This is an attribute

    # Define multiplication (*) (this is a method)
    def __mul__(self, y):
        return crazynumber(self.x/y.x)

    # Define the division (/) (this is a method)
    def __truediv__(self, y):
        return crazynumber(self.x*y.x)
    
    # This is called when we use 'print' (this is a method)
    def __str__(self):
        return str(self.x)  # Convert type to a string and return

Remarque: les noms de méthode __mul__,__truediv__, __str__, etc., ne doivent pas être appelés directement/ Ils sont mappés par Python aux opérateurs (* et / dans les deux premiers cas). La méthode __str__ est appelée dans les coulisses lors de l'utilisation de print.

Nous allons créer maintenant deux objets crazynumber:

In [ ]:
u = crazynumber(10)
v = crazynumber(2)

Puisque nous avons défini * comme une division, nous nous attendons à ce que u * v soit égal à 5:

In [ ]:
a = u*v  # This will call '__mul__(self, y)'
print(a)  # This will call '__str__(self)'

Test de l'opérateur /:

In [ ]:
b = u/v
print(b)

En fournissant des méthodes, nous avons défini comment les opérateurs mathématiques doivent être interprétés.

Test d'égalité

Nous avons précédemment utilisé des versions implémentées dans des librairies des fonctions de tri, et nous avons vu qu'elles sont beaucoup plus rapides que nos propres implémentations. Et si nous avons une liste de nos propres objets que nous voulons trier ? Par exemple, nous pourrions avoir une classe StudentEntry, puis une liste avec un objet StudentEntry pour chaque étudiant. Les fonctions de tri intégrées ne peuvent pas savoir comment nous voulons trier notre liste.

Un autre cas est si nous avons une liste de nombres, et nous voulons les trier selon une règle personnalisée ?

Les fonctions de tri intégrées ne se soucient pas des détails de nos données. Tout ce sur quoi ils comptent sont des comparaisons, par exemple les opérateurs <, > et ==. Si nous équipons notre classe d'opérateurs de comparaison, nous pouvons utiliser des fonctions de tri intégrées.

Tri personnalisé

Disons que nous voulons trier une liste de nombres de telle sorte que tous les nombres pairs apparaissent avant les nombres impairs, mais sinon la règle de classement habituelle s'applique. Nous ne voulons pas écrire notre propre fonction de tri. Nous pouvons faire ce tri personnalisé en créant notre propre classe pour contenir un nombre et en l'équipant des opérateurs <, > et ==.

Les fonctions associées à ces opérateurs sont :

  • __lt__(self, other) (less than other, <)
  • __gt__(self, other) (greater than other, >)
  • __eq__(self, other) (equal to other, ==)

Ces fonctions renvoient True ou False.

Nous avons ici, une classe pour stocker un numéro qui obéit à nos règles de tri personnalisées:

In [ ]:
class MyNumber:

    def __init__(self, x):
        self.x = x  # Stocker la valeur (attribut)
        
    # opérateur '<' personnalisé  (method)
    def __lt__(self, other):
        if self.x % 2 == 0 and other.x % 2 != 0:  # Je suis pair et l'autre est impair, donc je suis plus petit                   
            return True
        elif self.x % 2 != 0 and other.x % 2 == 0:  # Je suis impair, l'autre est pair, donc je ne suis pas plus petit 
            return False
        else:
            return self.x < other.x  # utiliser les règles classiques de tri

    # opérateur '==' personnalisé (method)
    def __eq__(self, other):
        return self.x == other.x

    # opérateur '>' personnalisé (method)
    def __gt__(self, other):
        if self.x % 2 == 0 and other.x % 2 != 0:  # Je suis pair et l'autre est impair, je ne suis pas plus grand                     
            return False
        elif self.x % 2 != 0 and other.x % 2 == 0:  # Je suis impair, l'autre est pair,je suis plus grand                     
            return True
        else:
            return self.x > other.x  # règles de tri classique 

    # Cette fonction est appelée par Python quand on veut afficher un objet   
    def __str__(self):
        return str(self.x)

Nous pouvons effectuer quelques tests simples sur les opérateurs (insérer des instructions print dans les méthodes pour vérifier la fonction utilisée)

In [ ]:
x = MyNumber(4)
y = MyNumber(3)
print(x < y)  # Attendu True (x est pair et y impair)
print(y < x)  # Attendu False

Nous essayons maintenant d'appliquer la fonction de tri de liste intégrée pour vérifier que la liste triée respecte notre règle de tri personnalisée:

In [ ]:
# Create an array of random integers
x = np.random.randint(0, 200, 10)

# Create a list of 'MyNumber' from x (using list comprehension)
y = [MyNumber(v) for v in x]

# This is the long-hand for building y
#y = []
#for v in x:
#    y.append(MyNumber(v))

# Use the built-in list sort method to sort the list of 'MyNumber' objects
y.sort()
print(y)

Sans modifier l'algorithme de tri, nous avons appliqué notre propre classement. Des approches comme celle-ci sont une caractéristique de l'informatique orientée objet. Les algorithmes de tri trient les objets et les objets ont simplement besoin des opérateurs de comparaison. Les algorithmes de tri n'ont pas besoin de connaître les détails des objets.

Utiliser les méthodes dites magiques (magic)

Les méthodes spéciales Python qui commencent et se terminent par un double trait de soulignement (__) sont des méthodes magic. Elles correspondent à des opérateurs spéciaux, généralement des opérateurs mathématiques tels que *, /, <, ==, etc. C'est des méthodes standard car elles peuvent être appelées directement sur un objet, mais ce n'est pas leur utilisation prévue. Utilisez plutôt des opérateurs. Voici un exemple.

In [ ]:
class SomePair:
    def __init__(self, x, y):
        self.x = x  # Store value (attribute)
        self.y = y  # Store value (attribute)

    # '==' operator (note that it has a return value)
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y
    
a = SomePair(23, 2)
b = SomePair(23, 4)

# Check for equality using ==
print(a == b)

# Check for equality using __eq__ (not recommended)
print(a.__eq__(b))

Un objet n'a pas besoin d'avoir toutes les fonctions magiques définies - seulement celles que vous avez l'intention d'utiliser. Si vous essayez d'utiliser et l'opérateur qui n'est pas défini, vous obtiendrez une erreur.

Deux exercices d'application

Exercice 1.

Créer une classe pour représenter des vecteurs de longueur arbitraire et qui est initialisée avec une liste de valeurs, par exemple:

x = MonVecteur([0, 2, 4])

Équiper la classe de méthodes qui:

  1. Renvoie la longueur du vecteur (utiliser le nom size)
  2. Calculer la norme du vecteur $\sqrt{x\cdot x}$ (utiliser le nom norm)
  3. Calculer le produit scalaire du vecteur avec un autre vecteur (utiliser le nom dot)

Tester l'implémentation en utilisant deux vecteurs de longueur $3$. Pour vous aider à démarrer, un squelette de la classe est fourni ci-dessous.

In [ ]:
class MonVecteur:
    """ Un objet vectoriel qui peut renvoyer sa taille et sa norme, et peut calculer 
       le produit scalaire avec un autre vecteur  """
    
    def __init__(self, x):
        self.x = x
        
    # Return length of vector
    def size(self):
        # Add your code here
        pass  # This can be removed once the body is added
    
    # This allows access by index, e.g. y[2]
    def __getitem__(self, index):
        return self.x[index]

    # Return norm of vector
    def norm(self):
        # Add your code here
        pass  # This can be removed once the body is added
    
    # Return dot product of vector with another vector
    def dot(self, other):
        # Add your code here
        pass  # This can be removed once the body is added

Vérifications à effectuer

In [ ]:
# création de deux vecteurs 
u = MonVecteur([1, 1, 2])
v = MonVecteur([2, 1, 1])

assert u.size() == 3
assert round(u.norm() - 2.449489742783178) == 0.0
assert round(u.dot(v) - 5.0, 10) == 0.0     

## on peut afficher les valeurs 
print(u.size())
print(u.norm())
print(u.dot(v))

Exercice 2.

Créer une classe RandExpo qui représente une loi exponentielle de paramètre rate (un double)

Équiper la classe de méthodes qui:

  1. Renvoie n réalisations d'une loi exponentielle de paramètre rate
  2. Renvoie la valeur de la densité de probabilité en un point x
  3. Renvoie la valeur de la fonction de répartition en un point x
  4. Renvoie la valeur de la fonction quantile en une valeur p

Tester l'implémentation sur un exemple.