Skip to content
LeRetardatN edited this page Oct 31, 2025 · 5 revisions

Quelques concepts sur Python

On a évidemment pas et on ne va pas tout apprendre sur Python en NSI. Du coup voici des explications pour expliquer les concepts et principes que j'utilise dans le projet.

0. Les bonnes pratiques

Il est facile de créer un code qui fait ce qu'on veut mais il est un peu plus compliqué d'écrire un code compréhensible facilement. Heureusement, des développeurs bien plus expérimenté que nous se sont penchés sur la question et nous ont apporté des conseils pour rendre le code plus lisible et instinctif. J'insiste sur le fait que ce sont des conseils et peuvent ce révéler dans certains cas plus contre-productifs qu'autre chose, c'est donc au développeur de réfléchir si il est pertinent d'écrire de cette façon pour une situation donnée.

Le ZEN de Python

La PEP 20 nous raconte que Tim Peters (qui à beaucoup contribué Python) à énoncé 20 directives qu'il faudrait suivre pour le design du langage Python, en voici la traduction des principales:

Explicite c'est meilleur qu'implicite.
Simple c'est meilleur que complexe.
Complexe c'est meilleur que compliqué.
Plat c'est meilleur qu'indenté (nesté en VO).
Clairsemé c'est meilleur que dense.
La lisibilité compte.
Les cas spéciaux ne sont pas assez spéciaux pour briser les règles.
Les erreurs ne devraient jamais passer inaperçues.
A moins que ce le soit explicitement.
Maintenant c'est mieux que jamais.

Le principe DRY

Le principe D.R.Y. est simple: Don't Repeat Yourself (ne te répète pas). Dans un code parfais, il ne devrait pas avoir deux lignes qui se ressemblent, tout doit être regroupé. Pourquoi?
Pour répondre à cette question, prenons un exemple qui était dans le code avant refactorisation:

pygame.quit()
sys.exit()

Elles ont une fonction bien précise: quitter le programme; en oublier une ou se tromper dans l'ordre peut potentiellement causer un comportement inatendu du programme. Même en copiant et en collant, c'est un problème; imaginons que pour déboguer l'on veuille ajouter un print() à chaque fois que le programme se ferme, doit-on faire Ctrl + F pour chaque fichier et y ajouter son print()? Puis quand on veut l'enlever, rebelote? non, il y a plus simple.

La solution est simplement de créer une fonction prenant ces deux lignes:

def quit():
	pygame.quit()
	sys.exit()

Maintenant quand on veut quitter le programme, on appelle quit(); s'il faut ajouter un print(), on l'ajoute dans la fonction et on est content car il n'y a qu'une ligne de changée.

Il est même pertinent de créer des fonctions même si le code n'est pas nécessairement répété car c'est souvent plus propre que de le voir au milieu d'un autre blob de code. En fait, rien que le nom de la fonction peut faire office de commentaire vu qu'il explique le but du bout de code.

Évitez de trop indenter

Dans le meilleur des projets, le niveau de l'indentation ne devrait pas dépasser 4 en tout point du code et ce niveau de 4 devrait être occasionnel. La raison est simple: plus des blocs sont nestés (ont de if, for, etc... imbriqués les uns dans les autres), plus ces blocs sont compliqués à lire par la simple raison qu'il faut se souvenir de chaque condition qu'il a fallu pour y arriver en plus de comprendre leurs buts.

Pour dénester le code, il existe 2 technique prédominantes: l'extraction et l'inversion. La première consiste juste à déplacer le code dans une fonction; la deuxième est un peu plus complexe, prenons:

def fonction():
	if condition:
		fait_qql_chose()

L'inverser donnerait

def fonction():
	if not condition:
		return	# Retourne None
	
	# On est sûrs que condition est vraie
	# car return finit l'exécution de la fonction
	fait_qql_chose()

Il est aussi possible de le faire dans des boucles avec continue ou break:

for nombre in (1, 2, 3, 4):
	if nombre == 1:
		le_un()

devient

for nombre in (1, 2, 3, 4):
	if nombre != 1:
		continue

	# On est sûrs que nombre == 1
	le_un()

Comme exercice, vous pouvez refactoriser ceci:

liste = [1, 2, 3, 4]

jusqu_au_pairs = []
for nombre in liste:
	if nombre % 2 == 0:		# pair
		jusqu_au_pairs.append([])
		for nb in range(1, nombre+1):	# [1, 2, ..., nombre]
			jusqu_au_pairs[-1].append(nb)

# jusqu_au_pairs = [
# 	[1, 2],
# 	[1, 2, 3, 4],
# ]

solution

def ajouter_jusqua(n):	# mise en fonction
	resultat = []
	for nb in range(1, n+1):	# [1, 2, ..., nombre]
		resultat.append(nb)
	# On aurait pu aussi faire
	# return list(range(1, n+1))
	# ou return [nb for nb in range(1, n+1)]
	
	return resultat

liste = [1, 2, 3, 4]
jusqu_au_pairs = []
for nombre in liste:
	if nombre % 2 != 0:	# inversion
		continue
	jusqu_au_pairs.append(ajouter_jusqua(nombre))

# jusqu_au_pairs = [
# 	[1, 2],
# 	[1, 2, 3, 4],
# ]

C'est, certes, plus long mais beaucoup plus compréhensible instinctivement.

Souvenez-vous que l'objectif est que le plus grand nombre de ligne soit au plus petit niveau d'indentation.

Les commentaires peuvent mentir

Certains développeurs avancent qu'il ne faudrait jamais utiliser de commentaire (à quelques exceptions près). Bien que je ne soit pas d'accord, j'approuve leur argument principal: il est facile d'oublier de modifier un commentaire lors d'une modification du code, ce qui provoque un décalage entre le commentaire et le code, ce qui mène à un une section pas comprise ou pire mal comprise.

Il n'existe pas vraiment de solution à ce problème mais on peut réfléchir au but des commentaires en premier lieu: un commentaire ne devrait pas expliquer ce que fait le code, mais pourquoi le code est ainsi pour pas que les autres développeurs ou vous du futur fassent une erreur en le modifiant.
Attention, cela ne veut pas dire qu'il faut nécessairement abandonner tout commentaire expliquant le code, il faut juste les limiter car les codeurs savent lire du Python.

La PEP 8

On sait tous à quoi sert la PEP 8, c'est un assemblage de règles qui permet d'écrire du Python plus élégamment, même si certaines règles sont contestables comme les lignes de 79 caractères maximum.

Il faudrait essayer d'y adhérer le plus possible.

Autres recommandations

Cette section est pour des suggestions autres.

Si vous parlez assez bien anglais, allez voir CodeAesthetics, il fait des vidéos de quelques minutes sur comment organiser le code.

1. Les annotations

Pourquoi utiliser les annotations ?

Python est ce qu'on appelle un langage typé dynamiquement, c'est-à-dire que le type des variables est deviné par l'interpréteur (la chose qui exécute le code) Python au moment où une valeur y est assigné; des fois, ça peut être utile mais la plupart du temps c'est plus énervant qu'autre chose.

Prenons cette signature de fonction:

def calcul_duree(date_debut, date_fin):

On n'a aucune idée de quoi passer en argument: des string sous forme "dd/mm"? des ints pour des dates EPOCH? des listes/tuples contenant des ints? et les dates doivent-elles inclure une année? On en a aucune idée sans documentation.

Heureusement, il existe des langages statiquement typés dans lesquels on connaît les types, par exemple en C++:

unsigned int calcul_duree(unsigned int[3] dateDebut, unsigned int[3] dateFin) {

On sait directement que la fonction accepte des tableaux (grossièrement la même chose que les listes de Python) contenant 3 entiers positifs chacun et retourne un entier positif.
On peut mimer ce comportement en Python, avec ce qu'on appelle les annotations:

def calcul_duree(date_debut : list[int, int, int], date_fin : list[int, int, int]) -> int:

Attention, j'ai bien écrit "mimer", en effet là où C++ vous lancera une erreur au visage, Python n'en a rien à faire: les annotations sont ignorées lors de l’exécution du programme.

Exemple:

def ma_fonction(param : int) -> None:
	...

ma_fonction("Pas un nombre entier du tout")	# OK pour Python

Pour éviter les comportements inattendus, on peut utiliser assert et l'opérateur is.

def ma_fonction(param : int) -> None:
	assert(type(param) is int)

ma_fonction("Pas un nombre entier du tout")		# AssertionError

J'en profite pour rappeler que == compare les valeurs (5 == 5) et que is compare les locations des variables. Quand on teste pour un type ou None, il faut utiliser is (bien que dans l'écrasante majorité des cas les deux sont équivalents); si vous voulez connaître la raison voici une explication (relativement) longue et une autre plus courte.

Même si dans la PEP 484 les auteurs indiquent en gras qu'ils "ne désirent pas rendre les annotations obligatoire, même par convention". Bien que dans les trois PEPs sur les annotations, ils ne précisent jamais pourquoi c'est le cas; je pense que c'est pour respecter la règle "simple est meilleur que complexe" (si c'est le cas ça contredit "Explicite est meilleur qu'implicite").
Dans tous les cas, mon point de vue est celui du standard C++: "Types are the simplest and best documentation" (Les types sont la meilleur documentation).

La syntaxe

La syntaxe est assez simple, il y a 3 règle:

  • Pour annoter une variable, on utilise les deux points :.
  • Pour indiquer le type de retour d'une fonction, on utilise la flèche -> avant les deux points commençant la fonction.
  • Si jamais il peut y avoir plusieurs types, on les sépare par l'opérateur de disjonction |.

Exemples:

variable_sans_valeur : bool	# On déclare le variable sans lui donner de valeur (annotation obligatoire dans ce cas là)
string_var : str = "string"

def fonction_basique() -> None:	# Si une fonction ne retourne rien, elle retourne None
	...	# alternative à pass

def conversion_en_string(nombre : float) -> str:
	"""Convertit `nombre` en string"""
	return str(nombre)

def parametre_par_defaut(p1 : None, p2 : int = 69) -> None:
		...


multi_var : int | None = None	# une int ou None
multi_var = 5		# Pas d'annotation

def ou_alors(p1 : int | float) -> str | None:
	...

Certaines annotations (comme list) peuvent prendre des types en paramètre, pour cela il faut ajouter des crochets [] avec un ou des types à l'intérieur.

ma_simple_liste : list # une liste avec un nombre indéterminé d'éléments de types inconnus

ma_liste : list[bool]		# une liste avec des booléens à l'intérieur
ma_liste : list[int | float]	# une liste avec des int et des float à l'intérieur
ma_tuple : tuple[()]		# une tuple vide
ma_tuple : tuple[float, str]	# une tuple avec exactement une float et une string à l'intérieur
ma_tuple : tuple[float, ...]	# une liste avec des float à l'intérieur
mon_dictionnaire : dict[str, int]	# une dictionnaire avec des strings pour clefs et des entiers pour valeur

La PEP 484 introduit d'autre syntaxes comme les types comments (commentaires de type) et les strings en guise d'annotation de type, mais les deux ont un usage occasionnel. Sachez que ces méthodes existent.

# Les string en annotation
# Le type doit être dans une string littérale, surtout utilisé en POO
facebook : 'str' = "méta string"

meta_str : str = 'str'
une_string : meta_str = ''	 # invalide => le type de la variable est inconnu
# Le type est dans un commentaire
# C'est surtout utilisé dans des cas plus complexe, comme des unpack de conteneurs ou des boucles for
# Allez voir la PEP 484 pour plus d'exemple d'utilisation
les_coms = 23	# type: int

# Empêche le vérificateur de type de lancer une erreur
mauvais_type1 : float = False	# type: ignore

mauvais_type2 : int = True # type: ignore # Commentaire après le commentaire de type
bon_type = 1 # type: int # Commentaire après le commentaire de type

Liste des annotations disponibles

Je ne vais pas faire la liste exhaustive (vous en trouverez une bonne partie ici) mais retenez que en général, c'est le même nom que le type en anglais.

  • aucune valeur (none): None
  • booléen (boolean): bool
  • entier (integer): int
  • flottant (floating point): float
  • chaîne de caractères (string): str
  • liste (list): list
  • tuple (tuple): tuple

Il n'y a presque aucune autre annotation de type en vanilla Python, mais il existe la librairie typing qui définie plein d'autre types.

Comme vous le savez peut-être les fonctions peuvent être stockées dans des variables, si on importe Callable de collections.abc, il est possible de d'annoter des fonctions:

from  collections.abc import Callable
def fun_to_be_a_function(param : str) -> bool:
	...

def fonction_seconde(p1 : bool, p2 : int, p3 : float) -> None:
	...

def fonction_troisieme(p1 : bool, p2 : bool) -> None:
	...

function : Callable[[str], bool] = fun_to_be_a_function		# pas de parenthèse car on n'appelle pas la fonction, on veut l'objet en lui-même

no_fun : Callable[[bool, int, float], None] = fonction_seconde

multi_fun : Callable[..., None] = fonction_seconde	# Peut importe les arguments
multi_fun = fonction_troisieme

Le module typing donne aussi accès aux annotations Any et NoReturn qui ne représentent pas de type en particulier.
Any représente une union de tous les types qui existent, il ne faut pas l'utiliser si le type d'une variable n'est pas connu (on ne met pas d'annotation dans ce cas), il faut l'utiliser seulement quand une variable peut être (et probablement sera) de n'importe quel type.
NoReturn ne s'utilise qu'en type de retour d'une fonction et indique que soit la fonction lancera une erreur, soit la fonction fermera le programme mais ne doit jamais atteindre de return explicite ou implicite.
Exemple:

from typing import Any, NoReturn
tous_type : Any
tous_type = 1	# int
tous_type = 1.0	# float
tous_type = "s"	# str
tous_type = {}	# dict
tous_type = max # fonction

def retourne_implicitement() -> None:
	print("Je retourne None")
	# Retourne implicitement None

from sys import exit
def ne_retourne_pas(lancer : bool) -> NoReturn:
	if lancer:
		raise Exception("Je suis une erreur")	# Quitte la fonction par une erreur
	
	exit(1)		# Quitte la fonction en fermant le programme
	# retour implicite jamais atteint

def OK_mais_moyen() -> NoReturn:
	if False:
		return	# retourne None mais n'est jamais atteint donc c'est bon
	exit()		# Même si une vraie fonction ne devrait pas faire ça

Créer de nouvelles annotations

Les types créés par l'utilisateur (nous) ont leurs annotations:

class LaClasse:
	...
classieux : LaClasse

v. la section sur la POO pour les classes.

On peut aussi créer des alias de type avec le mot-clef type, si l'on a importé TypeAlias de typing:

from typing import TypeAlias

# Pour Python 3.12
type entier = int
type charactere = str
type string = list[charactere, ...]

# Pour les anciens
meilleure_int : TypeAlias = tuple[int]	# avec annotation
meilleure_str = tuple[str]		# sans annotation

parler_francais		: entier = 9876
en_python		: string = ['h', 'e', 'y']
c_est_possible		: meilleure_int = (56,)
pour_les_francophones	: meilleure_str = ("Oi!",)

Pour plus de possibilités allez voir la documentation de typing.
Si jamais, vous voulez des définitions plus formelles allez voir la PEP 484.

2. La programmation orientée objet

La programmation orientée objet (POO ou OOP en anglais) est l'un des styles majeurs de design de code avec l'impératif et le fonctionnel. Grossièrement ce style repose sur la définition de plans (les classes) pour construire des objets qui représentent une composante du programme.
L'avantage de la POO c'est qu'elle nous permet de penser à notre programme comme des objets, plus ou moins tangibles; contrairement aux autres styles qui sont un emboîtement d'instructions.

Le fil rouge

Disons que nous sommes dans une usines de chaises et qu'il faut coder les machines pour fabriquer des chaises. Pour cela, il nous faut définir les caractéristiques que chaque chaise a, il nous faut donc:

  • le nombre de pieds
  • si la chaise à des roulettes
  • les dimensions de la chaise

Les constructeurs

Il nous faut définir une classe "chaise" qui définira ce qu'auront chaque chaises puis, à partir de cette classe nous allons construire des objets qui représenterons des chaises à fabriquer avec les machines.
Dans la classe, il doit y avoir une fonction qui "construit" les objets, on l'appelle le constructeur et en Python ce sera toujours la fonction __init__().

L'exemple ci-dessous, définit le constructeur de la class Chaise puis l'appelle pour créer une objet Chaise.

class Chaise:
	# On définit le constructeur de Chaise
	def __init__(self, nb_pied : int, a_roulettes : bool, dimensions : tuple[float, float, float]):
		self.nb_pied	 : int = nb_pied
		self.a_des_roues : int = a_roulettes
		self.dim		 : tuple[float, float, float] = dimensions

ma_chaise : Chaise = Chaise(4, False, (.5, .5, 1))

A partir de la ligne 5, on définit des variables comme self.nb_pied, ce sont des variables qui appartiennent à l'objet. Notez que la fonction __init__() est la seule fonction à pouvoir déclarer des variables qui appartiennent à self.
A la dernière ligne, self est ignoré lors de l'appel du constructeur. C'est parce qu'il représente l'objet que l'on est en train de construire et est automatiquement assigné. C'est aussi pour cela que les variables que l'on déclare appartiennent à self, c'est des variables inhérentes à l'objet.

A la dernière ligne, la variable ma_chaise est définie, elle est de type Chaise car on lui assigne le résultat du constructeur constructeur qui est, pour l'instant, le seul moyen d'obtenir une chaise.
En regardant les arguments du constructeur, on conclue les égalités suivantes.

ma_chaise.nb_pied		== 4
ma_chaise.a_des_roues		== False
ma_chaise.dim			== (.5, .5, 1)

Pour résumer, les constructeurs sont les moyens principaux de créer des objets, définissent les variables des instances et peuvent être appelés avec le nom de la classe qu'ils construisent.

Parenthèse vocabulaire

Petit point lexique:
Les variables appartenant aux objets sont appelés variables membre ou alors attributs. Dans les sphères anglophones, on peut aussi trouver properties mais en français, c'est rare.
Les fonctions définies dans une classe sont des fonctions membre ou méthodes.
Si l'on ne précise pas la nature d'un membre, l'on parle à la fois d'un attribut et d'une méthode.

Une instance (de la classe X) est juste un objet (de la classe X).

Créer des méthodes

Créer une méthode, c'est comme créer une fonction normale mais on indente pour qu'elle appartienne à la classe. Toute méthode prend comme premier paramètre self, qui représente l'objet qui fait l'appel de la méthode. Pour les utiliser, c'est comme les méthodes des listes (.append(), .pop(), ...).

Dans notre scénario, nous pourrions avoir des méthodes qui gère la fabrication de la chaise:

class Chaise:
	# On définit le constructeur de Chaise
	def __init__(self, nb_pied : int, a_roulettes : bool, dimensions : tuple[float, float, float]):
		self.nb_pied	 : int	= nb_pied
		self.a_des_roues : int	= a_roulettes
		self.dim : tuple[float, float, float] = dimensions
	
	def placer_pieds(self) -> None:
		demander_pieds(self.nb_pieds)	# demande le bon nombre de pieds
		...	# fait autre chose
	
	def placer_dossier(self) -> None:
		pass
	
	def placer_assise(self) -> None:
		pass

	def fabriquer(self, combien : int) -> None:
		for _ in range(combien):	# fait `combien` itérations (aucune variable)
			self.placer_pieds()
			self.placer_assise()
			self.placer_dossier()
			...

ma_chaise : Chaise = Chaise(4, False, (.5, .5, 1))
ma_chaise.fabriquer(5)		# Fabrique 5 chaises avec les propriétés de ma_chaise

Certaines méthodes ont des noms qui commencent et terminent par deux underscores __, ce sont les méthodes magiques, il y en a pas mal mais elles ne sont pas utiles pour ce projet, du coup je ne les détaillerais pas mais vous pouvez allez les voir vous même dans la documentation (il y en a d'autre suivant les modules installés).

L'encapsulation

Le cas général

L'encapsulation est un concept similaire à celui de la boite noire. Le principe est de considérer un objet comme une capsule opaque, creuse et avec des boutons dessus; à l'intérieur il y a des données (attributs) qui sont inaccessibles à l'utilisateur de la classe qui ne peut qu'appuyer sur les boutons à la surface en appelant des méthodes.
En somme, la seule façon de modifier et lire les attributs d'un objet doit être par ses méthodes. Malheureusement, en Python, il n'existe pas de moyen de s'assurer que ce soit respecté mais il y a des conventions pour montrer que faire aux développeurs, nous les verrons plus tard.

Je me souviens que quand j'ai vu ce concept pour la première fois, je n'y croyais pas beaucoup non plus alors pourquoi devrait-on respecter l'encapsulation en premier lieu? La réponse que j'ai reçu, c'est qu'il ne faut pas submerger l'utilisateur de la classe de choix avec trop de façon de faire la même chose (notion qui est inscrit dans le ZEN de Python); cependant je ne suis pas convaincu par cette explication, surtout en Python où les variables ne sont pas protégées; ma réponse est qu'avec cette approche, on peut forcer l'utilisateur à utiliser la classe d'une certaine façon.
Par exemple, il est possible pour la fabrication de chaises de ne vouloir, que de l'extérieur, on ne puisse savoir seulement le type de chaise construite sans de caractéristiques précises.

Avec ce design on ne peut pas directement lire une variable, il faut définir des méthodes conçues uniquement pour récupérer la valeur d'un attribut, on les appelle les fonctions getters. De même pour une fonction conçue uniquement pour changer la valeur d'un attribut s'appelle une fonction setter.

Il ne nous manque plus qu'un élément crucial pour pouvoir mettre en place l'encapsulation: un moyen de dire à l'utilisateur de ne pas accéder à un membre d'une classe ou d'un objet.
Dans un langage de programmation orienté objet standard, on trouve deux types de membres: les membres privés et les membres publics; les premiers ne peuvent être accédés seulement par une méthode membre de la même classe, les seconds peuvent être accédés depuis n'importe où.
Comme je l'ai écrit plus haut, il est impossible en Python de s'assurer qu'un membre soit privé, il existe cependant une convention installée par la PEP 8: les attributs non publics commencent par un seul underscore _. Comme Python n'a pas d'attribut privés à proprement parler, la PEP 8 indique qu'il ne faudrait pas parler d'attribut "privés" mais "non publics".

Voici une version de Chaise respectant l'encapsulation:

class Chaise:
	# On définit le constructeur de Chaise
	def __init__(self, nb_pied : int, a_roulettes : bool, dimensions : tuple[float, float, float]):
		# Tous les membres sont privés
		self._nb_pied : int = nb_pied
		self._a_des_roues : int = a_roulettes
		self._dim : tuple[float, float, float] = dimensions

	# getter pour _nb_pied
	def get_nb_pied(self) -> int:
		return self._nb_pied
	
	# Un getter n'est pas obligé de retourner un attribut existant
	def get_nb_roulettes(self) -> int:
		return self._nb_pied if self._a_des_roues else 0
	
	# Il devrait y avoir le moins de getters possibles

	# setter pour _dim
	# on aurait pu l'appeler autrement mais
	# la convention veut indiquer les setters par le préfixe `set_`
	def set_nb_pied(self, nouvelle_valeur : tuple[float, float, float]) -> int:
		self._dim = nouvelle_valeur
	
	# Pas de setter pour les autres attributs

	# Les méthodes non publiques existent aussis
	def _est_chaise_gamer(self) -> bool:
		pass

	def est_comfortable(self, reponse_honnete : bool = False) -> bool:
		return True		# Toutes les chaises fabriquées sont comfortables

ma_chaise : Chaise = Chaise(4, False, (.5, .5, 1))
print(ma_chaise.est_comfortable())		# pas d'erreur et autorisé
print(ma_chaise._est_chaise_gamer())	# pas d'erreur mais va à l'encontre des développeurs de la classe

Le cas de Python

Dans un langage classique, nous nous serions arrêté ici mais nous codons en Python et, Python n'aime pas le concept de getter et de setter. Ce langage à une propriété que d'autre n'ont pas: créer des attributs virtuels.
Ce que j'appelle "attribut virtuel" (pas du tout un terme officiel), c'est des méthodes qui sont accédées comme des attributs. On les déclare avec le décorateur @property.

exemple:

class Chaise:
	def __init__(self, nb_pied : int, a_roulettes : bool, dimensions : tuple[float, float, float]):
		self._nb_pied : int = nb_pied
		self._a_des_roues : int = a_roulettes
		self._dim : tuple[float, float, float] = dimensions
	
	def set_a_des_roulettes(self, val : bool) -> None:
		self._a_des_roues = val

	@property
	def nb_roulettes(self) -> int:
		if self._a_des_roues:
			return self._nb_pied
		return 0

ma_chaise : Chaise = Chaise(4, False, (.5, .5, 1))
print(ma_chaise.nb_roulettes)		#-> 0

ma_chaise.set_a_des_roulettes(True)
print(ma_chaise.nb_roulettes)		#-> 4

Ces propriétés servent de getter, pour les setters on utilise un décorateur différent: @nom_proprietee.setter, toujours une fonction.

class Chaise:
	def __init__(self, nb_pied : int, a_roulettes : bool, dimensions : tuple[float, float, float]):
		self._nb_pied : int = nb_pied
		self._a_des_roues : int = a_roulettes
		self._dim : tuple[float, float, float] = dimensions
	
	@property		# Obligatoire pour définir le setter
	def nb_roulettes(self) -> int:
		if self._a_des_roues:
			return self._nb_pied
		return 0
	
	@nb_roulettes.setter
	def nb_roulettes(self, valeur) -> None:
		self._a_des_roues = True
		self._nb_pied = valeur


ma_chaise : Chaise = Chaise(4, False, (.5, .5, 1))
print(ma_chaise.nb_roulettes)		#-> 0

ma_chaise.nb_roulettes = 4
print(ma_chaise.nb_roulettes)		#-> 4

Si quelque chose est confus, voici le post StackOverflow que j'ai suivi ainsi que la documentation.

Les membres statiques

Un membre statique est un membre qui appartient à la classe et non à une instance de la classe. Les attributs statiques représentent une valeur lié à ce que représente la classe; les méthodes statiques sont en général, soit des actions souvent utilisées par la classe mais impropre aux objets individuels soit une action sur les attributs statiques.
Les attributs statiques doivent au maximum respecter l'encapsulation, bien que l'on soit moins strict dessus que pour les attributs non statiques.

Pour créer un attribut statique, il faut le déclarer dans la classe. Quant aux méthodes statiques, ont utilise le décorateur @staticmethod (on peut aussi utiliser @classmethod qui fait la même chose pour plus de caractères).
Pour accéder à un membre statique, on fait comme pour les membres classiques mais on met le nom de la classe à la place d'une variable.

class Chaise:
	nom_usine : str = "NSI factory"	# nom_usine est statique
	_emplacement_usine: tuple[float, float] = (48.639101, 1.821532)

	def __init__(self, nb_pied : int, a_roulettes : bool, dimensions : tuple[float, float, float]):
		self._nb_pied : int = nb_pied
		self._a_des_roues : int = a_roulettes
		self._dim : tuple[float, float, float] = dimensions
	
	@staticmethod
	def fabriquer_fauteuils(combien : int) -> None:	# pas de `self` car il n'y a pas d'objet
		pass
	
	@classmethod
	def fabriquer_fauteuil(cls) -> None:	# `cls` remplace `self` et désigne la classe (ici `	`)
		cls.fabriquer_fauteuils(1)

print(Chaise.nom_usine)		#-> NSI factory
Chaise.fabriquer_fauteuils(5)

Toutes les méthodes non-statiques peuvent accéder aux méthodes statiques mais l'inverse n'est pas possible.

class Chaise:
	# ...
	
	def fabriquer_fauteuil(self) -> None:
		Chaise.fabriquer_fauteuils(1)
	
	@staticmethod
	def erreur() -> None:
		self.fabriquer_fauteuil()	# self n'est pas défini

Détails sur les objets

Copie et passage par référence

A l'instar des tuples et des listes, les objets sont passés par références. Cela veut dire que les objets ne sont pas copiés quand passés d'une variable à une autre.
Pour remédier à ce problème, Python nous apporte la méthode copy() ou deepcopy() du module copy.

from copy import copy, deepcopy
class MaClasse:
	def __init__(self, leger, lourd):
		self.petite_donnee = leger
		self.grosse_donnee = lourd
	def afficher(self):
		print(f"petit: {self.petite_donnee}, gros: {self.grosse_donnee}")

objet = MaClasse(4, [1, 2, 3, 4])

reference = objet
copie = copy(objet)
copie_profonde = deepcopy(objet)

objet.petite_donnee += 5
objet.grosse_donnee.append(5)

objet.afficher()		#-> petit: 9, gros: [1, 2, 3, 4, 5]
reference.afficher()	#-> petit: 9, gros: [1, 2, 3, 4, 5]
copie.afficher()		#-> petit: 4, gros: [1, 2, 3, 4, 5]
copie_profonde.afficher()	# petit: 4, gros: [1, 2, 3, 4]

Dans l'exemple du dessus, nous voyons qu'après les modifications faites à objet, reference les reproduits toutes, copie ne reproduit que celles faites aux données légère et copie_profonde n'en subit aucune.

Pour expliquer ces comportements, penchons nous sur la mutabilité des types. Une donnée mutable (comme les dict, list) est une donnée que l'on peut modifier, une donnée immuable (comme les int, tuple et str) est à l'inverse une donnée constante. Attention, donnée $\not =$ variable, une variable n'est qu'un moyen d'accéder à une donnée.
Ces catégories existent pour classifier les données suivant leurs poids, les types mutables pouvant être les plus lourds; le type str fait exception (car il est souvent utilisé et c'est facile d'oublier qu'il faut les copier, car il doit être hashable (ce qui permet d'en mettre en clef de dictionnaire), ...). Voici un exemple sur pythontutor.

Dans certains cas, on peut vouloir customiser la copie d'un objet (car par défaut trop lente, car ça casse le code, ..), il alors faut créer une méthode __copy__() ou __deepcopy__().

Exemple:

from copy import copy, deepcopy
class UneClasse:
	def __init__(self, attr1, attr2):
		self.un = attr1
		self.deux = attr2
	
	def __copy__(self):
		return UneClasse(self.un, self.deux)
	
	def __deepcopy__(self, memo):
		# memo est un dictionnaire, je pense qu'il associe les ids avec None
		# Mes tests n'ont rien donnés et la doc ne dit que
		# c'est un dictionnaire et rien de plus

		# cette ligne évite qu'e la fonction ne copie un objet déjà copié
		if memo.get(id(copy)) is not None:	# je recopie stackOverflow
			return None
		
		return UneClasse(
			deepcopy(self.un),
			deepcopy(self.deux),
		)


objet = UneClasse(3, [4])
objet_ref = objet

objet.un = "référence"
print(objet_ref.un)	#-> référence


objet_copie = copy(objet)

objet.un = "copie"
objet.deux.append(5)
print(objet_copie.un)	#-> copie
print(objet_copie.deux)	#-> [4, 5], copy() n'a pas copié la liste

objet_copie_profonde = deepcopy(objet)

objet.un = "deep copy"
objet.deux.append(5)
print(objet_copie_profonde.un)	#-> deep copy
print(objet_copie_profonde.deux)	#-> [4]

lien pythontutor

Pour d'autres questions allez voir cette réponse stackOverflow.

3. Les énumérations

Une énumération est un type qui ne peut prendre que certaines valeurs. Par exemple, pour une application météo il ne peut faire que: soleil, pluie, nuages.

Pour créer une énumération, il faut créer une classe qui hérite (prend toutes les méthodes et attributs) de Enum du module enum, on peut ensuite créer des variables globales qui seront les cas de l'énumération.
Pour ma météo:

from enum import Enum
class Meteo(Enum):
	SOLEIL = "ensoleillé"
	NUAGES = "nuageux"
	PLUIE = "pluvieux"

On peut ensuite les comparer avec des variables:

from enum import Enum
class Meteo(Enum):
	SOLEIL = "ensoleillé"
	NUAGES = "nuageux"
	PLUIE = "pluvieux"

temps_hier : METEO = METEO.SOLEIL
temps_aujourdhui : METEO = METEO.PLUIE

if temps_hier == temps_aujourdhui:
	print(f"Il à fait {temps_hier.name} hier et aujourd'hui.")
else:
	print(f"Il à fait {temps_hier.name} hier alors qu'aujourd'hui il a fait {temps_aujourdhui.name}.")

On peut aussi utiliser la fonction auto() du même module pour ne pas se préoccuper des valeurs de chaque cas.

Il existe deux sous classes de Enum définie dans le même module: StrEnum et IntEnum qui ne peuvent contenir que des membres de types str et int.

4. Autres concepts non spécifiques à Python

Les bit mask

Un bit mask c'est comme une liste de booléens qui décrivent chacun des états indépendants les uns des autres, par exemple les livres peuvent à la fois être grands et contenir la couleur jaune mais le livre peut aussi être petit et quand même contenir la couleur jaune.
En Python on traduis cette situation avec un type dérivé de Flag:

from enum import Flag, auto

class EtatsLivre(Flag):
	GRAND = auto()
	JAUNE = auto()

un_livre = EtatLivre.GRAND			# Le livre est grand mais pas jaune
un_autre_livre = EtatLivre.JAUNE	# l'opposé
# on peut se représenter ces variables comme:
# un_livre = [True, False]
# un_autre_livre = [False, True]

# Attention: même si c'est comme une liste de booléens,
# on ne peut pas accéder aux éléments avec les crochets []

Quand on manipule un bit mask, on le fait avec des opérateurs dits "bitwise":

  • | (OR/OU): Associe les booléens de manière à ce que tous les True présents dans les deux bit mask soient présents dans le résultat.
  • & (AND/ET): Associe les booléens de manière à ce qu'il ne reste que les True présents dans les deux opérandes à la fois.
  • ~ (NOT/NON): Inverse tous les booléens du bit mask.
  • ^ (XOR): Teste si chaque booléen est différent de celui de l'autre opérande.

ex:

from enum import Flag, auto

class EtatLivre(Flag):
	GRAND = auto()
	JAUNE = auto()

un_livre = EtatLivre.GRAND
un_autre_livre = EtatLivre.JAUNE
# un_livre = [True, False]
# un_autre_livre = [False, True]

grand_et_jaune = EtatLivre.GRAND | EtatLivre.JAUNE
# grand_et_jaune = [True or False, False or True] -> [True, True]

seulement_grand = grand_et_jaune & EtatLivre.GRAND
# seulement_grand = [True and True, True and False] -> [True, False]

rien_du_tout = ~grand_et_jaune
# rien_du_tout = [not True, not True] -> [False, False]

soit_grand_soit_jaune = EtatLivre.JAUNE ^ seulement_grand
# soit_grand_soit_jaune = [False != True, False != False] -> [True, False]

Dans la pratique, on utilise l'opérateur | pour ajouter des bit masks ensemble, l'opérateur & pour filtrer des valeurs.
Voici la syntaxe pour vérifier si un masque en contient un autre vérification:

# ...

masque = EtatLivre.GRAND
if masque & EtatLivre.GRAND:	# on isole la valeur EtatLivre.GRAND
	print("masque contient GRAND")

if masque & EtatLivre.JAUNE:
	print("masque contient JAUNE")	# n'est pas affiché

# masque & EtatLivre.GRAND = [True and True, False and False] -> [True, False]
# masque & EtatLivre.JAUNE = [True and False, False and True] -> [False, False]

Et cette méthode marche même avec plusieurs valeurs

grand_et_jaune = EtatLivre.GRAND | EtatLivre.JAUNE
masque = EtatLivre.GRAND

if grand_et_jaune & masque :
	print("grand_et_jaune contient masque")

# grand_et_jaune & masque = [True, False]

Seulement vu que l'on est en Python, il est possible d'utiliser le mot-clef in qui à l'avantage de ne pas être commutatif.

grand_et_jaune = EtatLivre.GRAND | EtatLivre.JAUNE
masque = EtatLivre.GRAND

if grand_et_jaune in masque:		# Faux
	print("masque contient grand_et_jaune")

if masque in grand_et_jaune:		# Vrai
	print("grand_et_jaune contient masque")

La documentation de Flag


Pour notre petit projet, il n'est pas très utile d'en connaître plus mais savoir comment les choses fonctionnent est toujours intéressant.
Si on cherche à revenir aux bases du concept, un bit mask n'est rien d'autres qu'un cas particulier d'int . Alors pourquoi et comment, le "pourquoi" est simple: dans les premières versions de C (et dans les autres languages de l’époque) il n'y avait qu'un seul type: les int et le "type" était déterminé par la façon dont les valeurs étaient utilisées; et c'est resté de cette façon dans la majorité des languages modernes car il n'existe pas de raisons de changer.
Le "comment" est aussi simple une fois intégré. Vous savez que les ordinateurs représentent les nombres en binaires avec chaque chiffre représenté par un bit, les bit masks vont juste associer arbitrairement chaque bit avec une signification.

Voici une représentation plus rudimentaire de la classe d'avant:

ETAT_LIVRE_GRAND = 0b1		# binaire
ETAT_LIVRE_JAUNE = 0b10
# Si on peut ajouter d'autres états:
# ETAT_LIVRE_DECHIRE = 0b100
# ETAT_LIVRE_EN_FEU =  0b1000
# ...

Les opérations bitwise prennent plus de sens vu que c'est juste les portes logiques appliquées à chaque bit:

0b01 | 0b10 == 0b11
0b11 & 0b00 == 0b00
0b11 ^ 0b01 == 0b10
~0b10       == 0b01

La version plus rigoureuse de mon explication.

Files et piles

On dit souvent qu'il faut penser aux files (ou queues) comme à des files (le sens qu'il a en français) et aux piles comme à des piles.
Une queue est une liste mais on ne peut qu'accéder au premier élément de celle-ci et les éléments s'ajoutent à la fin: premier dedans, premier dehors (principe FIFO: First In, First Out); pour les piles on ne peut qu'accéder à l'élément du dessus mais on ne peut ajouter des éléments qu'aux dessus: dernier dedans, premier dehors (principe LIFO: Last In First Out).
Ajouter un élément à une file/pile est un push, enlever un élément est un pop.

diagram stack-queue

En Python, Il existe 2 types (+ SimpleQueue) de files et 1 type de pile, elles sont dans le module queue (documentation):

  • Queue: Une file comme décrite précédemment.
  • PriorityQueue: Une file qui trie ses éléments, les plus petits (déterminés avec les opérateurs < et >) sont les premiers à sortir.
  • LifoQueue: Une pile.

Les méthodes les plus utilisées (disponibles sur les trois types précédents) sont:

  • .put(item, block=True, timeout=None): Insère item dans la file/pile; si elle est pleine et que block est True, attend au plus timeout secondes qu'il y ait de la place pour l'insérer sinon élève une exception.
  • .put_nowait(item): Même chose que .put(item, block=False).
  • .get(block=True, timeout=None): Pop un élément de la file ou pile, les paramètres sont les mêmes que pour .put().
  • .get_nowait(): Même chose que .get(item, block=False).
  • .empty() et .full(): Vérifient que la file/pile n'est pas vide ou pleine.

Les Générateurs

Les générateurs sont des fonctions qui peuvent s'arrêter et reprendre leurs exécutions là où elle s'étaient arrêté (exemple sur PythonTutor).

Pour créer un générateur, nous devons d'abord créer une fonction qui sera une sorte de calque pour le générateur. Pour indiquer que la fonction renvoie un générateur, il faut y placer au moins un yield. Pour obtenir un objet, il ne reste plus qu'a exécuter la fonction.
Pour pauser un générateur, on utilise le mot yield, il agit comme return et si une valeur est mise derrière, elle sera renvoyée par next().

On peut itérer à travers les générateurs comme on ferait avec une liste:

def creer_alphabet():
    yield 'a'
    yield 'b'
    yield 'c'
    yield 'd'
alphabet1 = creer_alphabet()
alphabet2 = creer_alphabet()

for lettre in alphabet1:
    print(lettre)

# équivalent à
print(next(alphabet2))
print(next(alphabet2))
print(next(alphabet2))
print(next(alphabet2))

Vu que la fonction créée n'est qu'un calque, les paramètres passés vont influencer la fonction:

def creer_compteur(debut, fin):
    for i in range(debut, fin):
        yield i

compteur_1_10  = creer_compteur(1, 10)
compteur_2_512 = creer_compteur(2, 129) # range exclue la fin

for nombre in compteur_1_10:
    print(nombre)

print("")    # Une ligne d'espacement
for nombre in compteur_2_128:
    print(nombre)

et si return est utilisé dans la fonction? Dans ce cas, le générateur élèvera une exception StopIteration. Il ne semble pourtant qu'aucun moyen simple pour récupérer la valeur du return existe (au minimum il faut attraper l'exception).

Les générateurs possèdent trois méthodes:

  • .send(): détaillé après.
  • .throw(): Lance une exception depuis le dernier yield.
  • .close(): Ferme le générateur en élevant une exception GeneratorExit depuis le dernier yield. Après méthode indique que le générateur ne peut plus être utilisé.

Une particularité des générateurs, c'est que l'on peut leur envoyer des données avec .send(). En effet, les instructions yield renvoient la valeur en argument de .send():

def creer_liste_de_souhait(liste):
    for jeu in liste:
        nouveau_jeu = (yield jeu)
        if nouveau_jeu is not None:
            liste.append(nouveau_jeu)

liste_de_souhait = creer_liste_de_souhait(["Mario", "FNF", "Pikmin"])

print(next(liste_de_souhait))

print(liste_de_souhait.send("Pokemon"), "\t(On envoie Pokemon)")
print(liste_de_souhait.send("ULTRAKILL"), "\t(On envoie ULTRAKILL)")
for jeu in liste_de_souhait:
    print(jeu)

Pour savoir quand et quoi envoyer aux générateurs, on utilise les annotations; la PEP 484 la décrit:

"Le type de retour des [fonctions créant les générateurs] peut être annoté avec le type générique Generator[type_yield, type_envoi, type_retour] apporté par le module typing.py"

Voici un exemple complet de la liste de souhait:

from typing import Generator

def creer_liste_de_souhait(liste : list[str]) -> Generator[str, str, None]:
    for jeu in liste:
        nouveau_jeu : str|None
        try:
            nouveau_jeu = (yield jeu)
        
            if nouveau_jeu is not None:
                liste.append(nouveau_jeu)
        except GeneratorExit:
            break
            

liste_de_souhait = creer_liste_de_souhait(["Mario", "FNF", "Pikmin"])

print(next(liste_de_souhait))

print(liste_de_souhait.send("Pokemon"), "\t(On envoie Pokemon)")
print(liste_de_souhait.send("ULTRAKILL"), "\t(On envoie ULTRAKILL)")
liste_de_souhait.close()

liste_de_souhait.send("Minecraft")        # élève StopIteration