Exercices

Durée de la session2h30

Consignes globales

Important

L’objectif de cette session est de vous aider à maîtriser des notions importantes en informatique. Un assistant de programmation intelligent tel que GitHub Copilot, que vous avez peut-être déjà installé, sera capable de vous fournir une solution à ces exercices basée uniquement sur un nom de fichier judicieusement choisi.

Dans un souci de formation, nous vous conseillons de désactiver d’abord ces outils.

À la fin de l’activité pratique, nous vous suggérons de retravailler l’exercice avec ces outils activés. Suivre ces deux étapes améliorera vos compétences à la fois fondamentalement et pratiquement.

De plus, nous vous fournissons les solutions aux exercices. Assurez-vous de ne les consulter qu’après avoir une solution aux exercices, à des fins de comparaison ! Même si vous êtes sûr que votre solution est correcte, veuillez y jeter un coup d’œil, car elles fournissent parfois des éléments supplémentaires que vous avez peut-être manqués.

Contenu de l’activité

Préparation de l’environnement

Avant de commencer, créez un projet uv dédié à cette séance. Dans un terminal, placez-vous dans le dossier de la séance (par exemple imt/s5/info/prog/session1), puis lancez la commande suivante :

uv init --no-package
uv init --no-package
uv init --no-package
uv init --no-package

Vous écrirez tous les fichiers de cette séance dans ce projet, en ajoutant les bibliothèques dont vous avez besoin avec uv add et en exécutant vos programmes dans l’environnement virtuel associé. Au besoin, revoyez la fiche Gestion moderne de projet Python avec uv.

Un tableau noir

Dans cet exercice, vous allez créer une classe représentant un tableau noir. Votre tableau noir :

  • a une surface (un attribut) sous forme de chaîne de caractères
  • fournit trois méthodes :
    • write(text) : ajoute text (une chaîne) au contenu actuel du tableau comme une nouvelle ligne
    • read() : retourne le contenu actuel du tableau sous forme de chaîne
    • show() : affiche le contenu actuel du tableau dans la console
    • erase() : efface le contenu du tableau.

Question. Dans un fichier blackboard.py, créez la classe Blackboard comme décrit ci-dessus.

Question. Créez un tableau noir, et utilisez ses méthodes pour écrire Hello, World! dessus, lire le message, puis l’effacer et, si le contenu du tableau n’est pas vide après l’avoir effacé, écrire un message d’erreur Error: The blackboard should be empty.

Un compte bancaire

Information importante

Dans cet exercice et les suivants, nous avons fait le choix de typer les arguments et les valeurs de retour des méthodes. Typer une variable signifie indiquer le type de données qu’elle est censée contenir (par exemple, une chaîne de caractères, un entier, un flottant, une liste, etc.). Cela se fait en suivant le nom de la variable par deux points (:) et le type de données attendu : p-ex, ma_variable: int.

Par exemple, la fonction suivante est typée : elle indique prendre une chaîne de caractères et un entier en entrée, et retourne une chaîne de caractères.

def repeat(chaine: str, times: int) -> str:
  """Repeats the input string a given number of times.
  """
  result: str = chaine * times
  return result

Dans la pratique, typer les arguments et les valeurs de retour des méthodes est une bonne pratique, car cela rend le code plus lisible et aide à détecter les erreurs plus tôt. Typer les variables locales est aussi une bonne pratique, mais moins courante.

Le typage sera vu plus en détail dans la session 2 de Programmation, mais vous pouvez déjà consulter la documentation officielle pour plus d’informations.

Dans cet exercice, vous allez créer une classe pour représenter une version simple d’un compte bancaire (BankAccount) dans un fichier bank.py. Votre compte bancaire :

  • est identifié par un numéro (account_number)
  • a le solde actuel du compte (balance) et l’historique des transactions effectuées sur le compte (history)
  • fournit plusieurs méthodes :
    • un constructeur qui initialise le numéro de compte, le solde et l’historique des transactions
    • deposit(amount: float) : effectue des dépôts sur le compte. Un dépôt ne peut être possible que si amount est un nombre positif. Si ce n’est pas le cas, le dépôt n’est pas effectué et le message d’erreur Error: The amount cannot be < 0. s’affiche sur la console
    • withdraw(amount: float) : effectue des retraits. Un retrait ne peut être possible que si amount est un nombre positif et que le compte a suffisamment d’argent pour couvrir le retrait. Si ce n’est pas le cas, le retrait n’est pas effectué
    • get_account_number() -> int : retourne le numéro de compte
    • get_transaction_history() -> list[float] : retourne l’ensemble des transactions effectuées sur le compte
    • get_balance()-> float : retourne le solde actuel.

Question. Créez la classe BankAccount comme décrit ci-dessus.

Question. Créez un compte bancaire, effectuez quelques dépôts et retraits avec des montants positifs et négatifs, puis affichez l’historique des transactions et le solde. Vérifiez que les messages d’erreur sont affichés comme attendu.

Un compte bancaire et son propriétaire

Supposons que nous voulions savoir pour un compte bancaire qui est son propriétaire. Pour cela, vous devez :

  • créer une classe Person qui caractérise une personne avec son firstname, lastname, age.
  • modifier votre classe BankAccount pour ajouter un attribut owner comme une Person qui détient le compte. Le titulaire du compte doit être spécifié lors de la création du compte (sinon une exception (ValueError) est levée). De plus, une méthode get_owner() doit être fournie par la classe.
  1. Création de la classe Person.

Dans le fichier bank.py ajoutez la nouvelle classe avant BankAccount.

  • La classe aura les attributs suivants : _firstname, _lastname, et _age.
  • Le constructeur doit être de la forme __init__(self, name: str, firstname: str, age: int). Il initialise tous les attributs de la classe, avec l’attribut _lastname en majuscules.
  • Une méthode fullname(self) -> str retourne une chaîne correspondant à la concaténation des attributs _firstname et _lastname.
  • Une méthode __str__(self) -> str retourne une représentation sous forme de chaîne de la personne, incluant son nom, prénom et son âge.
Placement de la définition de la classe dans le fichier

Si vous placez la définition de la classe Person après la classe BankAccount, les références à la classe Person dans BankAccount doivent être placées entre guillemets simples ('Person') pour différer leur évaluation. Cela permet de faire une référence en avant à ce type.

  1. Modifications de la classe BankAccount.
  • Le constructeur de la classe doit être de la forme __init__(self, account_number: int, owner: Person). Il initialise l’attribut _owner de la classe avec la valeur du nouveau paramètre si celui-ci n’est pas None (sinon, un message d’erreur est affiché sur la console).
  • La méthode get_owner(self) -> Person retourne la valeur de l’attribut _owner.
  • Ajoutez les méthodes __str__(self) -> str et __repr__(self) -> str. Les deux retournent une représentation sous forme de chaîne du compte bancaire et doivent inclure le numéro de compte, le nom complet du propriétaire, le solde et le nombre de transactions effectuées. Comme mentionné dans le cours sur la syntaxe OOP, ce sont deux méthodes spéciales en Python. Pour comprendre la différence entre __str__() et __repr__(), vous pouvez consulter la documentation ou cet article.
  1. Utilisation des classes. Créez une personne, puis deux comptes bancaires dont elle est propriétaire. Effectuez un dépôt sur un des comptes, puis affichez le compte : vérifiez que les informations affichées sont correctes.

Représentants élus

Dans cet exercice, vous allez considérer le cas particulier des personnes qui sont des représentants élus. Les représentants élus sont des personnes avec un ensemble d’assistants (qui sont eux-mêmes des personnes). Un élu peut embaucher ou licencier un assistant.

Pour cela, vous allez créer une nouvelle classe ElectedOfficial qui représente les élus. Puisqu’un élu est une personne (Person), plutôt que de réécrire dans ElectedOfficial tout ce que fait dans Person, elle va hériter de Person et ajouter uniquement ce qui lui est propre.

Ajoutez la classe ElectedOfficial à la suite de Person et BankAccount, dans le même fichier bank.py. Un représentant élu :

  • est une personne avec un nouvel attribut _assistants pour stocker ses assistants. A la création d’un élu, il n’a pas d’assistants

  • a 3 nouvelles méthodes

    • hire_assistant(self, assistant: Person) -> None qui ajoute assistant à la liste des assistants s’il n’y est pas déjà
    • fire_assistant(self, assistant: Person) -> None qui supprime assistant de la liste des assistants s’il existe
    • get_assistants(self)-> list[Person] qui retourne la liste des assistants

Question. Créez la classe ElectedOfficial comme décrit ci-dessus.

Question. Ajoutez une méthode __str__() pour décrire votre nouvelle classe. Pour cela, redéfinissez la méthode __str__() de la classe parente.

Question. Créez un représentant élu et deux personnes. L’élu embauche la première comme assistante, affiche l’élu (vérifiez le nombre d’assistants), puis licencie cette assistante et affiche à nouveau l’élu.

Optimisez vos solutions

Ce que vous pouvez faire maintenant, c’est utiliser des outils IA tels que GitHub Copilot ou ChatGPT, soit pour générer la solution, soit pour améliorer la première solution que vous avez trouvée ! Essayez de faire cela pour tous les exercices ci-dessus, pour voir les différences avec vos solutions.

Pour aller plus loin

Retrouver les comptes d’une personne

Pour l’instant, un compte connaît son propriétaire, mais une personne ne connaît pas ses comptes. Nous allons ajouter ce second sens à la relation, dans la classe Person.

  • Ajoutez à Person un attribut _accounts, une liste de BankAccount, vide à la création.
  • Ajoutez add_account(self, account: 'BankAccount') -> None : ajoute account s’il n’est pas déjà dans la liste.
  • Ajoutez remove_account(self, account: 'BankAccount') -> None : retire account s’il y est.
  • Ajoutez get_accounts(self) -> list['BankAccount'] : retourne la liste des comptes.
  • Mettez à jour le __str__ de Person pour afficher le nombre de comptes.
  • Enfin, modifiez le constructeur de BankAccount pour qu’à la création d’un compte, celui-ci s’ajoute automatiquement à la liste des comptes de son propriétaire.
Pourquoi des guillemets autour de 'BankAccount' ?

Dans Person, on annote le type des comptes par list['BankAccount'], avec BankAccount entre guillemets. En effet, Person est écrite avant BankAccount dans le fichier : au moment où Python lit la classe Person, le nom BankAccount n’existe pas encore. Les guillemets transforment l’annotation en simple texte, dont l’évaluation est différée : c’est ce qu’on appelle une référence en avant (forward reference). Le code n’utilisant BankAccount que comme annotation dans Person, cette astuce suffit — pas besoin d’import.

Question. Créez une personne et deux comptes à son nom. Affichez la personne : elle doit indiquer 2 comptes. Retirez un des comptes puis affichez à nouveau.

Distribuer un budget (spend_allocation)

Un élu peut distribuer une somme équitablement entre ses assistants. Ajoutez à ElectedOfficial la méthode :

spend_allocation(self, amount: float) -> dict[str, float]

  • Si amount est négatif, aucune distribution n’est faite et un message d’erreur est affiché.
  • Sinon, la somme est divisée équitablement entre les assistants. Pour chaque assistant qui possède au moins un compte, la part est déposée sur celui dont le solde est le plus faible.
  • La méthode retourne un dictionnaire {nom complet: part} pour les assistants qui n’ont aucun compte (leur argent devra leur être remis autrement).
Trouver le minimum d’une liste d’objets

Pour trouver le compte au solde le plus faible, on réutilise le motif classique de recherche du minimum : on suppose que le premier élément est le minimum, puis on parcourt la liste en gardant à jour ce minimum. La seule différence avec une liste de nombres est qu’on ne compare pas les objets directement, mais la valeur renvoyée par leur méthode get_balance().

Question. Créez un élu et trois assistants : un sans compte, un avec un compte, un avec deux comptes de soldes différents. Embauchez-les, distribuez un budget, puis affichez le solde de chaque compte et le dictionnaire retourné.

Séparer ElectedOfficial dans son propre fichier

Jusqu’ici, tout tient dans bank.py. Dans un vrai projet, on peut préférer avoir un fichier par classe (ou par groupe cohérent de classes). Déplaçons ElectedOfficial dans un fichier elected.py.

Comme ElectedOfficial hérite de Person et manipule des Person, elected.py doit importer ce dont il a besoin depuis bank.py :

from bank import Person   # BankAccount n'est pas nécessaire ici : on ne l'utilise pas directement
Quand faut-il vraiment gérer les références croisées ?

Séparer les fichiers peut réintroduire un problème que le fichier unique nous évitait. Ici, elected.py dépend de bank.py, mais bank.py ne dépend pas de elected.py : la dépendance va dans un seul sens, tout se passe bien avec un simple from bank import Person.

Le problème n’apparaîtrait que si deux fichiers dépendaient l’un de l’autre (par exemple person.py important BankAccount et bankaccount.py important Person). On parle alors d’import circulaire, et Python lève une erreur au chargement. La solution consiste à différer l’import réservé au typage :

from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from bankaccount import BankAccount

TYPE_CHECKING vaut True uniquement pendant la vérification statique de type (avec un outil comme MyPy ou Pylance), et False à l’exécution : l’import n’a donc lieu que pour le typage, jamais au moment où le programme tourne, ce qui casse le cycle. Vous n’en avez pas besoin dans cette extension — c’est une astuce à connaître pour le jour où vous découperez deux classes mutuellement dépendantes.

Pour aller encore plus loin

Comparer deux personnes avec __eq__

Par défaut, == entre deux objets teste s’il s’agit du même objet en mémoire (la même identité), et non s’ils se « ressemblent ». Nous voulons plutôt considérer que deux personnes sont égales si elles ont le même prénom, le même nom et le même âge.

  • Ajoutez à Person une méthode __eq__(self, other: object) -> bool qui retourne True si other est une Person ayant les mêmes _firstname, _lastname et _age, et False sinon. Pensez à vérifier d’abord, avec isinstance, que other est bien une Person.
Un point de vigilance

Redéfinir __eq__ sur Person ne change que la comparaison entre Person. Vos méthodes add_account et remove_account continuent de fonctionner : le in / not in qu’elles utilisent compare des objets BankAccount, qui n’ont pas de __eq__ et sont donc toujours comparés par identité.

En revanche, une classe qui définit __eq__ sans définir __hash__ devient non hachable : on ne peut plus placer une Person dans un set ni l’utiliser comme clé de dictionnaire. Ce n’est pas gênant ici, mais c’est bon à savoir.

Question. Créez une personne alice et un compte à son nom. Créez ensuite une seconde personne new_alice avec exactement les mêmes prénom, nom et âge. Comparez alice et new_alice avec ==. Comparez aussi account.get_owner() avec new_alice. Enfin, affichez les comptes de new_alice. Que constatez-vous ? Sauriez-vous l’expliquer ?

Règles de bonnes pratiques

En structurant (bien) votre code sous forme de classes, vous disposez d’un code plus facilement compréhensible et extensible. Bien que nous allons revenir un peu plus tard sur les bonnes pratiques de programmation, vous pouvez déjà regarder dans les conventions de nommages les recommandations concernant les classes, les objets, propriétés et méthodes :