Logo OFPPT

ISMO - Institut Spécialisé dans les Métiers de l'Offshoring

OFPPT - Office de la Formation Profesionnelle et de la Promotion du Travail

La Programmation Orientée Objet (POO) avec Python

Dernière mise à jour : Novembre 2025

Partie 1 : Les Fondamentaux de la POO

Chapitre 1 : Pourquoi la POO ? De la procédure à l'objet

Jusqu'à présent, vous avez programmé de manière procédurale : une suite d'instructions et de fonctions qui manipulent des données. Cette approche fonctionne bien pour des scripts simples, mais devient difficile à maintenir lorsque les programmes grandissent. La Programmation Orientée Objet (POO) propose une nouvelle façon de penser : au lieu de séparer les données des fonctions qui les traitent, on les regroupe dans des entités logiques appelées objets.

Organisation du Code

La POO permet de structurer le code en "boîtes" logiques et autonomes. Un objet "Voiture" contient ses propres données (couleur, marque) et ses propres actions (démarrer, accélérer). C'est plus clair et plus facile à gérer.

Réutilisabilité

Une fois qu'un "moule" à objet (une classe) est créé, on peut l'utiliser pour fabriquer autant d'objets que l'on veut. On peut aussi créer de nouveaux moules basés sur les anciens, en héritant de leurs fonctionnalités sans avoir à tout réécrire.

Chapitre 2 : Les Classes et les Objets

Les deux concepts centraux de la POO sont la classe et l'objet.

Illustration 1

Illustration 2

2.1. Déclarer une classe et créer un objet

En Python, on utilise le mot-clé `class` pour définir une classe. Par convention, les noms de classes commencent par une majuscule. Pour créer un objet (instancier la classe), on appelle la classe comme une fonction.

# 1. Définition de la classe (le plan)
class Chien:
    pass  # 'pass' signifie que la classe est vide pour l'instant

# 2. Création des objets (les instances)
mon_chien = Chien()
autre_chien = Chien()

# On peut vérifier le type de nos objets
print(type(mon_chien))
print(type(autre_chien))

Chapitre 3 : Le Constructeur et les Attributs d'instance

Nos objets sont pour l'instant des coquilles vides. Pour qu'ils aient leurs propres données (un nom, une couleur, un âge...), nous devons définir des attributs d'instance. Cela se fait généralement dans une méthode spéciale appelée le constructeur.

Illustration 3

3.1. La méthode `__init__` et le paramètre `self`

Le constructeur en Python est une méthode qui s'appelle `__init__` (avec deux tirets bas avant et après). Elle est exécutée automatiquement chaque fois qu'un nouvel objet est créé. Son premier paramètre est toujours `self`.

Le mot-clé `self` représente l'objet lui-même. Il permet, à l'intérieur de la classe, de faire référence à l'instance en cours de création ou d'utilisation. On l'utilise pour attacher des données à l'objet. On parle alors d'attributs d'instance (ex: `self.nom`).

class Voiture:
    # Le constructeur
    def __init__(self, marque_voiture, couleur_voiture):
        # On crée les attributs d'instance en les attachant à 'self'
        self.marque = marque_voiture
        self.couleur = couleur_voiture
        print(f"Une voiture {self.marque} de couleur {self.couleur} a été créée !")

# On crée des objets en passant les arguments attendus par __init__ (sauf self)
voiture_1 = Voiture("Renault", "bleue")
voiture_2 = Voiture("Peugeot", "rouge")

# On peut accéder aux attributs de chaque objet avec la notation pointée
print(f"La première voiture est une {voiture_1.marque}.")
print(f"La seconde voiture est de couleur {voiture_2.couleur}.")

Chapitre 4 : Les Méthodes, le comportement des objets

Les méthodes sont des fonctions définies à l'intérieur d'une classe. Elles définissent les actions que les objets de cette classe peuvent effectuer. Tout comme le constructeur, leur premier paramètre est toujours `self`, ce qui leur donne accès aux attributs et aux autres méthodes de l'objet.

4.1. Définir et appeler une méthode

class Personne:
    def __init__(self, nom, age):
        self.nom = nom
        self.age = age

    # Une méthode d'instance simple
    def se_presenter(self):
        print(f"Bonjour, je m'appelle {self.nom} et j'ai {self.age} ans.")

    # Une méthode qui modifie un attribut de l'instance
    def vieillir(self):
        self.age += 1
        print(f"Joyeux anniversaire ! {self.nom} a maintenant {self.age} ans.")


# Création d'un objet
p1 = Personne("Ali", 30)

# Appel des méthodes sur l'objet
p1.se_presenter()  # Affiche: Bonjour, je m'appelle Ali et j'ai 30 ans.
p1.vieillir()      # Affiche: Joyeux anniversaire ! Ali a maintenant 31 ans.
p1.se_presenter()  # Affiche: Bonjour, je m'appelle Ali et j'ai 31 ans.

Ateliers Pratiques : Vos premières classes

Il est temps de mettre en pratique ces concepts fondamentaux en créant vos propres classes.

Exercice 1 : La classe `Point`

1. Créez une classe `Point` qui prend deux coordonnées `x` et `y` lors de sa création.
2. Ajoutez une méthode `afficher()` qui affiche les coordonnées sous la forme `(x, y)`.
3. Créez deux instances de `Point` avec des coordonnées différentes et appelez leur méthode `afficher()`.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def afficher(self):
        print(f"({self.x}, {self.y})")

# Création et utilisation des objets
p1 = Point(2, 3)
p2 = Point(-4, 10)

print("Coordonnées du point 1 :")
p1.afficher() # Affiche (2, 3)

print("Coordonnées du point 2 :")
p2.afficher() # Affiche (-4, 10)

Exercice 2 : La classe `Livre`

1. Créez une classe `Livre` avec les attributs `titre`, `auteur` et `nb_pages`.
2. Ajoutez une méthode `description()` qui retourne une chaîne de caractères décrivant le livre : `"Le livre '[titre]' par [auteur] contient [nb_pages] pages."`.
3. Créez un objet de type `Livre` et affichez sa description.

class Livre:
    def __init__(self, titre, auteur, nb_pages):
        self.titre = titre
        self.auteur = auteur
        self.nb_pages = nb_pages
    
    def description(self):
        return f"Le livre '{self.titre}' par {self.auteur} contient {self.nb_pages} pages."

# Création et utilisation
livre_python = Livre("Python pour les Nuls", "John Paul Mueller", 432)
description_livre = livre_python.description()
print(description_livre)

Partie 2 : Les Piliers de la POO

Chapitre 5 : L'Encapsulation et la visibilité des attributs

L'encapsulation est le premier pilier de la POO. C'est l'idée de regrouper les données (attributs) et les méthodes qui les manipulent au sein d'un même objet. Mais cela va plus loin : l'encapsulation vise aussi à protéger les données d'un accès ou d'une modification non contrôlée depuis l'extérieur de l'objet. On parle de "cacher les données" (data hiding).

Illustration 4

Illustration 5

5.1. Attributs publics, protégés et privés

En Python, la visibilité est une convention :

  • Public : `nom_attribut`. Accessible de partout. C'est le comportement par défaut.
  • Illustration 6

  • Protégé : `_nom_attribut` (un seul underscore). Indique que l'attribut ne devrait pas être modifié depuis l'extérieur, mais est destiné aux classes filles (voir héritage).
  • Illustration 7

  • Privé : `__nom_attribut` (deux underscores). Python "mutile" le nom de cet attribut pour le rendre très difficile d'accès depuis l'extérieur, le réservant à un usage strictement interne à la classe.
  • Illustration 8

class CompteBancaire:
    def __init__(self, titulaire, solde_initial):
        self.titulaire = titulaire  # Attribut public
        self.__solde = solde_initial  # Attribut privé

    # Méthode publique pour déposer de l'argent (contrôle l'accès à __solde)
    def deposer(self, montant):
        if montant > 0:
            self.__solde += montant
            print(f"{montant} DH déposés. Nouveau solde : {self.__solde} DH.")
        else:
            print("Le montant du dépôt doit être positif.")

    # Méthode publique pour consulter le solde (un "getter")
    def get_solde(self):
        return self.__solde

compte = CompteBancaire("Fatima", 1000)

# On peut accéder à l'attribut public
print(f"Titulaire : {compte.titulaire}")

# On NE PEUT PAS accéder directement à l'attribut privé
# print(compte.__solde)  # -> Ceci lèvera une AttributeError

# On utilise les méthodes publiques pour interagir avec le solde
print(f"Solde initial : {compte.get_solde()} DH.")
compte.deposer(500)

Chapitre 6 : L'Héritage, réutiliser et étendre

L'héritage est le deuxième pilier de la POO. Il permet de créer une nouvelle classe (la classe fille ou sous-classe) qui hérite des attributs et méthodes d'une classe existante (la classe mère ou super-classe). C'est le principe de la relation "est un" : un `Chien` est un `Animal`.

Illustration 9

Illustration 10

Illustration 11

6.1. Héritage simple et `super()`

Pour faire hériter une classe, on indique le nom de la classe mère entre parenthèses. La classe fille a alors accès à toutes les méthodes de sa mère. Pour appeler une méthode de la classe mère (notamment le constructeur) depuis la classe fille, on utilise la fonction `super()`.

# Classe Mère
class Animal:
    def __init__(self, nom):
        self.nom = nom
    
    def manger(self):
        print(f"{self.nom} est en train de manger.")

# Classe Fille qui hérite de Animal
class Chien(Animal):
    def __init__(self, nom, race):
        # On appelle le constructeur de la classe mère (Animal) pour initialiser 'nom'
        super().__init__(nom)
        self.race = race
    
    # Méthode spécifique à la classe Chien
    def aboyer(self):
        print("Wouaf ! Wouaf !")

mon_animal = Animal("Générique")
mon_chien = Chien("Rex", "Berger Allemand")

# Le chien peut manger, car il a hérité cette méthode de Animal
mon_chien.manger()

# Le chien peut aboyer, car c'est sa propre méthode
mon_chien.aboyer()

# L'animal générique ne peut pas aboyer
# mon_animal.aboyer() # -> AttributeError

Chapitre 7 : Le Polymorphisme, un nom pour plusieurs formes

Le polymorphisme (du grec "plusieurs formes") est la capacité pour des objets de classes différentes de répondre à un même message (un même appel de méthode) de manière spécifique. Concrètement, si les classes `Chien` et `Chat` héritent toutes deux de `Animal` et ont toutes deux une méthode `crier()`, l'appel à cette méthode produira un son différent pour chaque objet.

Illustration 12

Illustration 13

7.1. Surcharge de méthode (Overriding)

On implémente le polymorphisme en surchargeant les méthodes de la classe mère. Cela signifie qu'on redéfinit dans la classe fille une méthode qui existe déjà dans la classe mère.

class Animal:
    def crier(self):
        print("L'animal crie...")

class Chien(Animal):
    # On surcharge la méthode crier() de la classe Animal
    def crier(self):
        print("Wouaf !")

class Chat(Animal):
    # On surcharge aussi la méthode crier()
    def crier(self):
        print("Miaou !")

chien = Chien()
chat = Chat()

# Le même appel de méthode 'crier()' produit un résultat différent
chien.crier()  # Affiche "Wouaf !"
chat.crier()   # Affiche "Miaou !"

Ateliers Pratiques : Les Piliers de la POO

Appliquons ces trois piliers pour construire des hiérarchies de classes logiques et robustes.

Exercice : Véhicules et Héritage

1. Créez une classe mère `Vehicule` avec un constructeur qui initialise `marque` et `annee`. Elle doit aussi avoir une méthode `decrire()` qui affiche ces informations.
2. Créez une classe fille `Voiture` qui hérite de `Vehicule`. Son constructeur doit prendre en plus un `nombre_portes`. La méthode `decrire()` de la voiture doit aussi afficher le nombre de portes.
3. Créez une classe fille `Moto` qui hérite de `Vehicule`. Son constructeur doit prendre en plus un booléen `side_car`. La méthode `decrire()` de la moto doit indiquer si elle a un side-car ou non.

class Vehicule:
    def __init__(self, marque, annee):
        self.marque = marque
        self.annee = annee
    
    def decrire(self):
        print(f"Véhicule de marque {self.marque}, année {self.annee}.", end=" ")

class Voiture(Vehicule):
    def __init__(self, marque, annee, nombre_portes):
        super().__init__(marque, annee)
        self.nombre_portes = nombre_portes
        
    # Surcharge de la méthode decrire()
    def decrire(self):
        super().decrire()
        print(f"C'est une voiture avec {self.nombre_portes} portes.")

class Moto(Vehicule):
    def __init__(self, marque, annee, side_car):
        super().__init__(marque, annee)
        self.side_car = side_car

    def decrire(self):
        super().decrire()
        if self.side_car:
            print("C'est une moto avec un side-car.")
        else:
            print("C'est une moto sans side-car.")

ma_voiture = Voiture("Dacia", 2021, 5)
ma_moto = Moto("Yamaha", 2019, False)

ma_voiture.decrire()
ma_moto.decrire()

Partie 3 : Concepts Avancés et Magie Python

Chapitre 8 : Les Méthodes Spéciales (Dunder)

Vous avez déjà rencontré une méthode spéciale : `__init__`. Python est rempli de ces méthodes, appelées "dunder methods" (pour Double UNDERscore). Elles permettent à nos objets de s'intégrer de manière transparente avec les fonctionnalités natives du langage, comme l'affichage avec `print()`, la comparaison avec `==`, ou l'addition avec `+`. Elles rendent notre code plus "pythonique".

8.1. `__str__` et `__repr__` : Représentations de l'objet

Ces deux méthodes permettent de définir comment un objet doit être représenté sous forme de chaîne de caractères :

  • __str__ : Fournit une représentation lisible pour l'utilisateur final. C'est elle qui est appelée par `print()` et `str()`.
  • __repr__ : Fournit une représentation non ambiguë pour le développeur. L'idée est que cette chaîne puisse, si possible, recréer l'objet.
class Livre:
    def __init__(self, titre, auteur):
        self.titre = titre
        self.auteur = auteur
    
    def __str__(self):
        return f"'{self.titre}' par {self.auteur}"

    def __repr__(self):
        return f"Livre(titre='{self.titre}', auteur='{self.auteur}')"

mon_livre = Livre("1984", "George Orwell")

# __str__ est utilisée par print()
print(mon_livre)

# __repr__ est utilisée quand on affiche l'objet directement
mon_livre

Chapitre 9 : Attributs et Méthodes de Classe/Statiques

Jusqu'ici, nous n'avons manipulé que des attributs et méthodes d'instance, qui sont propres à chaque objet. Mais il existe aussi des attributs et méthodes qui sont liés à la classe elle-même.

9.1. Attributs de classe

Un attribut de classe est une variable partagée par toutes les instances de cette classe. Il est défini directement dans la classe, en dehors de toute méthode. C'est idéal pour des constantes ou des compteurs.

class Voiture:
    # Attribut de classe : partagé par tous les objets Voiture
    nombre_de_roues = 4

    def __init__(self, marque):
        self.marque = marque # Attribut d'instance

v1 = Voiture("Renault")
v2 = Voiture("Peugeot")

# On peut y accéder via la classe ou une instance
print(f"Une voiture a {Voiture.nombre_de_roues} roues.")
print(f"La voiture v1 a {v1.nombre_de_roues} roues.")

9.2. Méthodes de classe (`@classmethod`) et statiques (`@staticmethod`)

- Une méthode de classe est décorée avec `@classmethod`. Son premier paramètre n'est pas `self` mais `cls` (qui représente la classe elle-même). Elle est souvent utilisée pour créer des "usines à objets" (factory methods).
- Une méthode statique est décorée avec `@staticmethod`. Elle ne prend ni `self` ni `cls` en premier paramètre. C'est une simple fonction utilitaire qui a un lien logique avec la classe, mais qui ne dépend ni de la classe, ni d'une instance.

class Calculateur:
    def __init__(self, valeur):
        self.valeur = valeur

    # Méthode d'instance : dépend de l'objet 'self'
    def ajouter(self, x):
        return self.valeur + x
    
    @classmethod
    def creer_depuis_chaine(cls, chaine_de_valeur):
        # Cette méthode "usine" crée un objet de la classe 'cls'
        valeur = int(chaine_de_valeur)
        return cls(valeur)
        
    @staticmethod
    def info():
        # Fonction utilitaire qui n'a pas besoin de 'self' ou 'cls'
        print("Ceci est une classe de calcul simple.")

# Utilisation de la méthode de classe comme factory
calc = Calculateur.creer_depuis_chaine("10")
print(calc.ajouter(5)) # Affiche 15

# Utilisation de la méthode statique
Calculateur.info()

Ateliers Pratiques : Concepts Avancés

Exercice : Améliorer la classe `CompteBancaire`

Reprenons la classe `CompteBancaire` :
1. Ajoutez une méthode `__str__` qui retourne une chaîne comme `"Compte de [Titulaire] - Solde : [Solde] DH"`.
2. Ajoutez un attribut de classe `nombre_comptes` qui s'incrémente à chaque fois qu'un nouveau compte est créé.
3. Ajoutez une méthode de classe `afficher_nombre_comptes()` qui affiche le nombre total de comptes créés.

class CompteBancaire:
    nombre_comptes = 0

    def __init__(self, titulaire, solde_initial):
        self.titulaire = titulaire
        self.__solde = solde_initial
        CompteBancaire.nombre_comptes += 1

    def __str__(self):
        return f"Compte de {self.titulaire} - Solde : {self.__solde} DH"
    
    @classmethod
    def afficher_nombre_comptes(cls):
        print(f"Nombre total de comptes créés : {cls.nombre_comptes}")

print("Création des comptes...")
c1 = CompteBancaire("Ali", 2000)
c2 = CompteBancaire("Samira", 5000)

print(c1)
print(c2)

CompteBancaire.afficher_nombre_comptes() # Affiche: Nombre total de comptes créés : 2

Partie 4 : Ateliers de Synthèse

Chapitre 10 : Projet Pratique - Mini-système de gestion de bibliothèque

Il est temps de rassembler toutes les compétences que vous avez acquises. Nous allons créer un petit programme en POO pour gérer une bibliothèque. Ce projet utilisera des classes, des objets, des méthodes, des attributs privés et des interactions entre objets.

Objectifs du projet :

  • Créer une classe `Livre` avec un titre, un auteur et un statut (disponible/emprunté).
  • Créer une classe `Bibliotheque` qui contient une collection de livres.
  • La `Bibliotheque` doit permettre d'ajouter un livre, d'emprunter un livre (en vérifiant sa disponibilité), de retourner un livre et de lister tous les livres avec leur statut.
  • Utiliser la méthode spéciale `__str__` pour afficher joliment les informations d'un livre.
class Livre:
    """Représente un livre avec un titre, un auteur et un statut."""
    def __init__(self, titre, auteur):
        self.titre = titre
        self.auteur = auteur
        self.est_emprunte = False
        
    def __str__(self):
    
        # utilisation de condition normale 
        # if self.est_emprunte:
            # statut = "Emprunté"
        # else:
            # statut = "Disponible"
    
        # affectation conditionnelle au lieu de la condition if normale 
        # statut = "Emprunté" if self.est_emprunte else "Disponible"
        return f"'{self.titre}' par {self.auteur} - Statut : {statut}"

class Bibliotheque:
    """Gère une collection de livres."""
    def __init__(self, nom):
        self.nom = nom
        self.__catalogue = [] # La liste des livres est privée

    def ajouter_livre(self, livre):
        self.__catalogue.append(livre)
        print(f"'{livre.titre}' a été ajouté au catalogue.")
            
    def lister_livres(self):
        print(f"\n--- Livres dans la bibliothèque '{self.nom}' ---")
        for livre in self.__catalogue:
            print(livre)
    
    def emprunter_livre(self, titre):
        for livre in self.__catalogue:
            if livre.titre == titre:
                if not livre.est_emprunte:
                    livre.est_emprunte = True
                    print(f"Vous avez emprunté '{titre}'.")
                else:
                    print(f"Désolé, '{titre}' est déjà emprunté.")
                return
        print(f"Le livre '{titre}' n'a pas été trouvé dans notre catalogue.")

    def retourner_livre(self, titre):
        for livre in self.__catalogue:
            if livre.titre == titre:
                livre.est_emprunte = False
                print(f"Merci d'avoir retourné '{titre}'.")
                return

# --- Programme principal ---
ma_biblio = Bibliotheque("Bibliothèque Municipale")

ma_biblio.ajouter_livre(Livre("L'Étranger", "Albert Camus"))
ma_biblio.ajouter_livre(Livre("1984", "George Orwell"))

ma_biblio.lister_livres()

ma_biblio.emprunter_livre("1984")
ma_biblio.emprunter_livre("Livre Inconnu")
ma_biblio.emprunter_livre("1984") # Essayer d'emprunter un livre déjà pris

ma_biblio.lister_livres() # Montre le changement de statut

ma_biblio.retourner_livre("1984")
ma_biblio.lister_livres() # Le livre est de nouveau disponible
Évaluation Finale

Examen Régional 2024/2025

Mettez en pratique vos connaissances en Programmation Orientée Objet avec ces sujets d'examen.

Variante 1 : Système de gestion de notes pour une école (15 pts)

Vous êtes chargé de développer un système pour aider les professeurs d'une école à suivre les notes de leurs étudiants. Le programme doit permettre d'ajouter des étudiants, d'enregistrer leurs notes dans différentes matières, et de calculer des statistiques.

Énoncé :

1. Créer une classe Etudiant : (7 pts)
  • Attributs : nom, prenom, niveau, notes (dictionnaire avec les matières comme clés et les notes comme valeurs). (2 pts)
  • Méthodes :
    • ajouter_note(matiere, note) : ajoute une note pour une matière donnée. (2 pts)
    • calculer_moyenne() : retourne la moyenne des notes de l'étudiant. (3 pts)
2. Créer une classe Classe : (8 pts)
  • Attributs : nom_classe, liste_etudiants. (1 pt)
  • Méthodes :
    • ajouter_etudiant(etudiant) : ajoute un étudiant à la classe. (2 pts)
    • moyenne_classe() : calcule la moyenne des moyennes de tous les étudiants. (3 pts)
    • meilleur_etudiant() : affiche l'étudiant avec la meilleure moyenne. (2 pts)
class Etudiant:
    """
    Représente un étudiant avec ses notes.
    """
    def __init__(self, nom, prenom, niveau):
        self.nom = nom
        self.prenom = prenom
        self.niveau = niveau
        self.notes = {} # {matiere: note}

    def ajouter_note(self, matiere, note):
        """
        Ajoute ou met à jour la note pour une matière donnée.
        """
        if isinstance(note, (int, float)) and 0 <= note <= 20:
            self.notes[matiere] = note
        else:
            print(f"Note invalide: {note} doit être entre 0 et 20.")

    def calculer_moyenne(self):
        """
        Retourne la moyenne des notes de l'étudiant.
        """
        if not self.notes:
            return 0.0

        # solution élégante avec les listes des compréhensions
        # return sum([note for note in self.notes.values()]) / len(self.notes)

        # Accumulation sans listes de compréhension
        somme_notes = 0
        nombre_notes = 0
        
        for note in self.notes.values():
            somme_notes += note
            nombre_notes += 1
            
        return somme_notes / nombre_notes

    def __str__(self):
        moyenne = self.calculer_moyenne()
        return f"Etudiant: {self.prenom} {self.nom}, Niveau: {self.niveau}, Moyenne: {moyenne:.2f}"
    
class Classe:
    """
    Gère un groupe d'étudiants.
    """
    def __init__(self, nom_classe):
        self.nom_classe = nom_classe
        self.liste_etudiants = [] # Liste d'objets Etudiant

    def ajouter_etudiant(self, etudiant):
        """
        Ajoute un objet Etudiant à la liste de la classe.
        """
        if isinstance(etudiant, Etudiant):
            self.liste_etudiants.append(etudiant)
        else:
            print("Erreur: L'objet ajouté n'est pas un Etudiant valide.")

    def moyenne_classe(self):
        """
        Calcule la moyenne des moyennes de tous les étudiants.
        """
        if not self.liste_etudiants:
            return 0.0
        # solution élégante avec les listes des compréhensions
        # return sum([e.calculer_moyenne() for e in self.liste_etudiants]) / len(self.liste_etudiants)
 



        # Accumulation sans listes de compréhension
        somme_moyennes = 0.0
        nombre_etudiants = 0
        
        for etudiant in self.liste_etudiants:
            somme_moyennes += etudiant.calculer_moyenne()
            nombre_etudiants += 1
            
        return somme_moyennes / nombre_etudiants

    def meilleur_etudiant(self):
        """
        Affiche l'étudiant avec la meilleure moyenne.
        """
        if not self.liste_etudiants:
            print(f"La classe {self.nom_classe} est vide.")
            return None

        # solution élégante avec les listes des compréhensions
        # meilleur_etudiant = max(self.liste_etudiants, key=lambda e: e.calculer_moyenne())
        meilleur_etudiant = None
        meilleure_moyenne = -1 # Initialisation à une valeur impossible

        # Itération et comparaison explicite
        for etudiant in self.liste_etudiants:
            moyenne_actuelle = etudiant.calculer_moyenne()
            
            if moyenne_actuelle > meilleure_moyenne:
                meilleure_moyenne = moyenne_actuelle
                meilleur_etudiant = etudiant
        
        print(f"\n--- Meilleur étudiant de la classe {self.nom_classe} ---")
        print(meilleur_etudiant)
        print("-----------------------------------------------------")
        return meilleur_etudiant
    

# Création des étudiants
e1 = Etudiant("Hassani", "youssef", "TS")
e2 = Etudiant("karimi", "Amal", "TS")

# Ajout des notes
e1.ajouter_note("Maths", 15.5)
e1.ajouter_note("Physique", 17.0)
e2.ajouter_note("Maths", 10.0)
e2.ajouter_note("Physique", 12.5)

# Test des moyennes individuelles
print(f"Moyenne de Youssef: {e1.calculer_moyenne():.2f}") 
print(f"Moyenne de Amal: {e2.calculer_moyenne():.2f}") 

# Création et gestion de la classe
classe_TS = Classe("Terminale S - A")
classe_TS.ajouter_etudiant(e1)
classe_TS.ajouter_etudiant(e2)

# Test des statistiques de classe
print(f"\nMoyenne générale de la classe : {classe_TS.moyenne_classe():.2f}")

classe_TS.meilleur_etudiant()

Variante 2 : Calculateur de gestion de stock pour un magasin (15 pts)

Un magasin souhaite automatiser la gestion de son inventaire. Le programme doit pouvoir ajouter de nouveaux produits, mettre à jour les quantités en stock, et générer des rapports sur l'état des stocks.

Énoncé :

1. Créer une classe Produit : (7 pts)
  • Attributs : nom, prix, quantite_en_stock. (2 pts)
  • Méthodes :
    • approvisionner(quantite) : ajoute une quantité au stock existant. (2 pts)
    • vendre(quantite) : réduit le stock en fonction de la quantité vendue, en vérifiant qu'il y a suffisamment de stock. (3 pts)
2. Créer une classe Magasin : (8 pts)
  • Attributs : nom, adresse, catalogue (liste des produits). (1 pt)
  • Méthodes :
    • ajouter_produit(produit) : ajoute un produit au catalogue. (2 pts)
    • rechercher_produit(nom) : recherche un produit dans le catalogue. (2 pts)
    • generer_rapport_stock() : affiche un rapport avec tous les produits en stock et leur quantité. (3 pts)
class Produit:
    """
    Représente un produit avec son nom, son prix unitaire et sa quantité en stock.
    """
    def __init__(self, nom, prix, quantite_en_stock):
        """Initialise les attributs du produit."""
        self.nom = nom
        self.prix = prix
        # Assure que le stock initial est non négatif
        self.quantite_en_stock = max(0, quantite_en_stock) 

    def approvisionner(self, quantite):
        """
        Ajoute la quantité spécifiée au stock.
        """
        if quantite > 0:
            self.quantite_en_stock += quantite
            print(f"Stock de {self.nom} mis à jour : +{quantite}. Nouveau stock : {self.quantite_en_stock}")
        else:
            print("Erreur : La quantité d'approvisionnement doit être positive.")

    def vendre(self, quantite):
        """
        Réduit le stock si la quantité est disponible.
        Retourne True si la vente est réussie, False sinon.
        """
        if quantite <= 0:
            print("Erreur : La quantité vendue doit être positive.")
            return False
            
        if self.quantite_en_stock >= quantite:
            self.quantite_en_stock -= quantite
            montant_vente = quantite * self.prix
            print(f"Vente de {quantite} unités de {self.nom} réussie. Montant : {montant_vente:.2f}€. Stock restant : {self.quantite_en_stock}")
            return True
        else:
            print(f"Vente échouée pour {self.nom} : stock insuffisant ({self.quantite_en_stock} disponibles, {quantite} demandés).")
            return False

    def __str__(self):
        """Permet un affichage lisible du produit."""
        return f"Produit: {self.nom} | Prix: {self.prix:.2f}€ | Stock: {self.quantite_en_stock}"
    

class Magasin:
    """
    Représente un magasin gérant un catalogue de produits.
    """
    def __init__(self, nom, adresse):
        """Initialise le magasin."""
        self.nom = nom
        self.adresse = adresse
        self.catalogue = [] # Liste d'objets Produit

    def ajouter_produit(self, produit):
        """
        Ajoute un objet Produit au catalogue du magasin.
        """
        if isinstance(produit, Produit):
            self.catalogue.append(produit)
            print(f"Produit '{produit.nom}' ajouté au catalogue de {self.nom}.")
        else:
            print("Erreur : L'objet à ajouter n'est pas un Produit valide.")

    def rechercher_produit(self, nom):
        """
        Recherche un produit par son nom dans le catalogue.
        Utilise une boucle 'for' simple.
        Retourne l'objet Produit s'il est trouvé, None sinon.
        """
        nom_recherche = nom.lower()
        #  Liste de compréhension + next()
        # produit = next(
        #     (p for p in self.catalogue if p.nom.lower() == nom_recherche),
        #     None
        # )


        # Parcours du catalogue avec une boucle ordinaire
        for produit in self.catalogue:
            if produit.nom.lower() == nom_recherche:
                print(f"Produit trouvé : {produit}")
                return produit
                
        print(f"Produit '{nom}' non trouvé dans le catalogue.")
        return None

    def generer_rapport_stock(self):
        """
        Affiche un rapport détaillé sur l'état des stocks.
        Utilise une boucle 'for' simple pour l'affichage.
        """
        print(f"\n======== RAPPORT DE STOCK POUR {self.nom.upper()} ========")
        print(f"Localisation : {self.adresse}")
        
        # Affichage direct avec une liste de compréhension (pour l’itération)
        #[
        #    print(f"* {p.nom.ljust(20)} | Quantité: {p.quantite_en_stock} | Prix: {p.prix:.2f}€")
        #    for p in self.catalogue
        #]



        # Variables pour le résumé (calculé avec une boucle)
        nombre_total_produits = 0
        valeur_totale_stock = 0.0

        # Boucle ordinaire pour itérer sur le catalogue et générer le rapport
        for produit in self.catalogue:
            # Affichage du détail
            print(f"* {produit.nom.ljust(20)} | Quantité: {produit.quantite_en_stock} | Prix: {produit.prix:.2f}€")
            
            # Calcul du résumé
            nombre_total_produits += 1
            valeur_totale_stock += produit.quantite_en_stock * produit.prix
            
        print("--------------------------------------------------")
        print(f"Total de produits différents : {nombre_total_produits}")
        print(f"Valeur totale estimée du stock : {valeur_totale_stock:.2f}€")
        print("==================================================\n")

    def __str__(self):
        return f"Magasin : {self.nom} situé à {self.adresse}. Catalogue contient {len(self.catalogue)} produits différents."


# 1. Création et gestion des produits
pomme = Produit("Pomme", 0.50, 100)
lait = Produit("Lait", 1.20, 50)
pain = Produit("Pain", 2.00, 20)

print("--- Tests de la classe Produit ---")
pomme.vendre(10)      # Vente réussie
lait.vendre(60)       # Vente échouée (stock insuffisant)
pomme.approvisionner(20)
print(pomme)          # Affichage du stock mis à jour

print("\n--- Tests de la classe Magasin ---")
# 2. Création et gestion du magasin
mon_magasin = Magasin("Epicerie du Coin", "12 Rue Principale")
mon_magasin.ajouter_produit(pomme)
mon_magasin.ajouter_produit(lait)
mon_magasin.ajouter_produit(pain)

# 3. Recherche de produit
produit_trouve = mon_magasin.rechercher_produit("Lait")
produit_introuvable = mon_magasin.rechercher_produit("Pâtes")

# 4. Génération du rapport
mon_magasin.generer_rapport_stock()

Conclusion & Perspectives

Félicitations ! Vous avez maîtrisé les concepts fondamentaux de la Programmation Orientée Objet en Python.

En comprenant les classes, les objets et les grands piliers de la POO, vous avez acquis un socle indispensable pour concevoir des applications robustes et évolutives. Mais votre parcours de développeur ne fait que commencer.

Cette maîtrise de la POO est la clé qui vous ouvre désormais les portes de l'écosystème de la Data Science. Vous êtes parfaitement préparés pour aborder les bibliothèques essentielles comme NumPy, Pandas et Matplotlib, et ainsi vous lancer dans les domaines passionnants du Machine Learning et du Deep Learning. Préparez-vous, la prochaine étape de votre aventure ne fait que commencer !