- Transmission des paramètres de fonction en Python
En Python, la manière dont les arguments sont passés aux fonctions est souvent décrite comme un "passage par référence d'objet" ou un "passage par valeur d'objet". Essentiellement, lorsqu'un objet est transmis à une fonction, ce n'est pas une copie de l'objet lui-même, ni une référence directe à la variable d'origine (comme un pointeur en C). Au lieu de cela, c'est une copie de la référence de l'objet qui est transmise.
Considérons ces exemples pour illustrer ce concept :
valeur_initiale = 10
def modifier_entier(nombre):
# 'nombre' est une nouvelle référence vers l'objet '10'.
# Modifier 'nombre' ici ne change pas l'objet référencé par 'valeur_initiale'
# mais fait pointer 'nombre' vers un nouvel objet (ici, 20).
nombre = 20
print(f"À l'intérieur de la fonction (après modif): {nombre}") # Affiche 20
modifier_entier(valeur_initiale)
print(f"À l'extérieur de la fonction: {valeur_initiale}") # Affiche 10
ma_liste_initiale = [1, 2]
def modifier_liste(collection):
# 'collection' est une nouvelle référence vers le même objet liste.
# Les opérations mutables sur 'collection' affectent l'objet original.
collection.append(3)
print(f"À l'intérieur de la fonction (après modif): {collection}") # Affiche [1, 2, 3]
modifier_liste(ma_liste_initiale)
print(f"À l'extérieur de la fonction: {ma_liste_initiale}") # Affiche [1, 2, 3]
Pour mieux comprendre la gestion des références, l'identifiant mémoire d'un objet (obtenu avec id()) est utile :
nombre_global = 50
def traiter_nombre(valeur):
print(f"ID à l'entrée de la fonction (valeur): {id(valeur)}")
valeur = 75 # 'valeur' est réaffectée, elle pointe maintenant vers un nouvel objet.
print(f"ID à l'intérieur de la fonction (après réaffectation): {id(valeur)}, ID de 75: {id(75)}")
print(f"ID avant appel de la fonction (nombre_global): {id(nombre_global)}, ID de 50: {id(50)}")
traiter_nombre(nombre_global)
print(f"ID après appel de la fonction (nombre_global): {id(nombre_global)}")
print(f"Valeur finale de nombre_global: {nombre_global}") # Affiche 50
Dans l'exemple ci-dessus, lorsque valeur = 75 est exécuté, la référence locale valeur change pour pointer vers l'objet entier 75, tandis que la référence nombre_global continue de pointer vers l'objet 50.
Cependant, pour les objets mutables, la situation est différente :
collection_globale = ["élément1"]
def modifier_collection(ref_collection):
print(f"ID à l'entrée de la fonction (ref_collection): {id(ref_collection)}")
ref_collection.append("élément2") # Modification de l'objet référencé, pas de la référence elle-même.
print(f"ID à l'intérieur de la fonction (après modification): {id(ref_collection)}")
print(f"ID avant appel de la fonction (collection_globale): {id(collection_globale)}")
modifier_collection(collection_globale)
print(f"ID après appel de la fonction (collection_globale): {id(collection_globale)}")
print(f"Collection finale: {collection_globale}") # Affiche ['élément1', 'élément2']
Le point crucial est de se rappeler que le type est attaché à l'objet, et non à la variable. Les objets en Python peuvent être classés comme mutables (modifiables après leur création, comme les listes, dictionnaires, ensembles) ou immutables (non modifiables, comme les chaînes de caractères, tuples, nombres). Ce concept est fondamental pour comprendre le comportement de passage des arguments.
Lorsqu'une référence est passée à une fonction, une nouvelle référence locale est créée, pointant initialement vers le même objet que la référence d'origine. Si cet objet est immutable et que la fonction tente de le "modifier", en réalité, la référence locale est simplement réaffectée pour pointer vers un nouvel objet immutable. La référence externe reste inchangée. Si l'objet est mutable, toute opération qui modifie l'objet via la référence locale affectera l'objet original, car les deux références pointent vers la même entité en mémoire.
Pour une explication plus approfondie, consultez ce lien Stack Overflow.
- Les métaclasses en Python
Les métaclasses sont un concept avancé en Python, peu utilisé dans la programmation quotidienne mais essentiel pour des frameworks complexes comme les ORM (Object-Relational Mappers). Une métaclasse est la "classe de la classe" ; elle définit comment une classe est créée. En d'autres termes, les métaclasses sont ce qui crée les objets de type classe.
Pour une compréhension détaillée, vous pouvez lire cette excellente discussion sur Stack Overflow.
- Les décorateurs @staticmethod et @classmethod
Python propose trois types de méthodes au sein des classes : les méthodes d'instance, les méthodes de classe et les méthodes statiques. Voici un exemple pour les différencier :
def fonction_autonome(param_x):
print(f"Exécution de fonction_autonome({param_x})")
class MaClasse:
def methode_instance(self, param_x):
print(f"Exécution de methode_instance({self}, {param_x})")
@classmethod
def methode_classe(cls, param_x):
print(f"Exécution de methode_classe({cls}, {param_x})")
@staticmethod
def methode_statique(param_x):
print(f"Exécution de methode_statique({param_x})")
# Création d'une instance de MaClasse
instance_mc = MaClasse()
Il est important de comprendre les paramètres self et cls. Ces paramètres servent à lier la méthode à l'instance ou à la classe :
- Une méthode d'instance (
methode_instance(self, param_x)) reçoit toujours l'instance de la classe (self) comme premier argument implicite. Elle est appelée sur une instance (par exemple,instance_mc.methode_instance(10)). - Une méthode de classe (
methode_classe(cls, param_x)) reçoit la classe elle-même (cls) comme premier argument implicite. Elle peut être appelée sur la classe (MaClasse.methode_classe(20)) ou sur une instance (instance_mc.methode_classe(20), mais l'instance sera ignorée au profit de la classe). - Une méthode statique (
methode_statique(param_x)) ne reçoit aucun argument implicite lié à l'instance ou à la classe. Elle se comporte comme une fonction normale mais est encapsulée dans la classe. Elle peut être appelée via la classe (MaClasse.methode_statique(30)) ou une instance (instance_mc.methode_statique(30)).
Le tableau suivant résume comment appeler ces méthodes :
| Méthode d'instance | Méthode de classe | Méthode statique | |
|---|---|---|---|
instance_mc = MaClasse() |
instance_mc.methode_instance(valeur) |
instance_mc.methode_classe(valeur) |
instance_mc.methode_statique(valeur) |
MaClasse |
Non disponible directement | MaClasse.methode_classe(valeur) |
MaClasse.methode_statique(valeur) |
Pour plus d'informations :
-
Quelle est la différence entre staticmethod et classmethod en Python ?
-
Démystification des méthodes d'instance, de classe et statiques
-
Variables de classe et variables d'instance
Comprendre la distinction entre les variables de classe et les variables d'instance est fondamental en Python.
Variables de classe : Elles sont partagées par toutes les instances d'une classe. Une variable de classe est définie directement au niveau de la classe, en dehors de toute méthode. Toute modification de cette variable par le biais de la classe affecte toutes les instances. Si une instance modifie une variable de classe, elle crée en fait une nouvelle variable d'instance portant le même nom, qui masque la variable de classe pour cette instance spécifique.
Variables d'instance : Elles sont propres à chaque instance de la classe. Chaque instance possède sa propre copie de ces variables, définies généralement dans la méthode __init__ avec self.nom_variable.
class CompteurInstances:
# 'nombre_d_instances' est une variable de classe
compteur_total_instances = 0
def __init__(self, designation):
self.nom_objet = designation # 'nom_objet' est une variable d'instance
CompteurInstances.compteur_total_instances += 1 # Incrémente la variable de classe
if __name__ == '__main__':
print(f"Compteur avant création: {CompteurInstances.compteur_total_instances}") # Affiche 0
obj1 = CompteurInstances('PremierObjet')
print(f"Compteur après obj1: {CompteurInstances.compteur_total_instances}") # Affiche 1
obj2 = CompteurInstances('DeuxiemeObjet')
print(f"Compteur après obj2: {CompteurInstances.compteur_total_instances}") # Affiche 2
print(f"Obj1 - Nom: {obj1.nom_objet}, Total instances: {obj1.compteur_total_instances}") # Affiche PremierObjet 2
print(f"Obj2 - Nom: {obj2.nom_objet}, Total instances: {obj2.compteur_total_instances}") # Affiche DeuxiemeObjet 2
Un autre exemple illustrant le comportement des variables de classe lorsqu'elles sont "modifiées" par une instance :
class Personne:
attribut_commun = "Inconnu" # Variable de classe
p1 = Personne()
p2 = Personne()
p1.attribut_commun = "Alice" # Crée une variable d'instance 'attribut_commun' pour p1
print(f"p1.attribut_commun: {p1.attribut_commun}") # Affiche 'Alice'
print(f"p2.attribut_commun: {p2.attribut_commun}") # Affiche 'Inconnu' (toujours la variable de classe)
print(f"Personne.attribut_commun: {Personne.attribut_commun}") # Affiche 'Inconnu'
Dans ce cas, p1.attribut_commun = "Alice" ne modifie pas la variable de classe Personne.attribut_commun. Au lieu de cela, elle crée une nouvelle variable d'instance nommée attribut_commun attachée spécifiquement à l'objet p1. La variable de classe reste intacte pour les autres instances et pour la classe elle-même.
Cependant, si la variable de classe est un objet mutable (comme une liste ou un dictionnaire) et qu'une instance modifie cet objet mutable en place (par exemple, en ajoutant un élément à une liste), alors cette modification sera visible par toutes les autres instances et par la classe, car elles partagent toutes la référence au même objet mutable :
class Groupe:
membres = [] # Variable de classe (liste mutable)
membre_a = Groupe()
membre_b = Groupe()
membre_a.membres.append("Dupont") # Modification de la liste partagée
print(f"Membre A, liste: {membre_a.membres}") # Affiche ['Dupont']
print(f"Membre B, liste: {membre_b.membres}") # Affiche ['Dupont']
print(f"Groupe, liste: {Groupe.membres}") # Affiche ['Dupont']
Ceci est une source fréquente de confusion et d'erreurs, car la modification de la liste via une instance affecte l'objet partagé par toutes les instances. Il est généralement préférable d'initialiser les attributs mutables en tant que variables d'instance dans __init__.
Référence: Stack Overflow sur les variables de classe
- L'introspection en Python
L'introspection est l'une des caractéristiques puissantes de Python. Elle permet à un programme, pendant son exécution, d'examiner les types ou les propriétés de ses objets. En d'autres termes, on peut obtenir des informations sur les objets à l'exécution. Des fonctions et opérateurs courants pour l'introspection incluent type(), dir(), getattr(), hasattr(), isinstance(), et issubclass().
liste_elements = [10, 20, 30]
dict_parametres = {'clé1': 'valeur1', 'clé2': 'valeur2'}
indicateur_status = False
print(f"Type de liste_elements: {type(liste_elements)}") # <class 'list'>
print(f"Type de dict_parametres: {type(dict_parametres)}") # <class 'dict'>
print(f"Type de indicateur_status: {type(indicateur_status)}") # <class 'bool'>
print(f"Est-ce que liste_elements est une liste ? {isinstance(liste_elements, list)}") # True
print(f"Est-ce que dict_parametres est une chaîne ? {isinstance(dict_parametres, str)}") # False
- Compréhensions de dictionnaires
Introduites avec Python 2.7 (et présentes dans Python 3), les compréhensions de dictionnaires offrent une syntaxe concise pour créer des dictionnaires, de manière similaire aux compréhensions de listes.
# Exemple de compréhension de dictionnaire
nombres_carres = {nombre: nombre * nombre for nombre in range(5)}
print(nombres_carres) # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
# Un autre exemple avec une condition
couleurs = {'pomme': 'rouge', 'banane': 'jaune', 'ciel': 'bleu'}
fruits_rouges = {fruit: couleur for fruit, couleur in couleurs.items() if couleur == 'rouge'}
print(fruits_rouges) # {'pomme': 'rouge'}
- Les underscores simples et doubles en Python
L'utilisation des underscores (_) dans les noms d'identifiants en Python suit des conventions et a des significations spécifiques :
class MonService:
def __init__(self):
self.__attribut_prive_fort = "Donnée très sensible"
self._attribut_semi_prive = "Donnée protégée"
self.attribut_public = "Donnée ouverte"
svc = MonService()
# Accès aux attributs
print(f"Accès public: {svc.attribut_public}") # Affiche Donnée ouverte
print(f"Accès semi-privé: {svc._attribut_semi_prive}") # Affiche Donnée protégée (convention, mais accessible)
# Tentative d'accès direct à l'attribut "privé"
try:
print(svc.__attribut_prive_fort)
except AttributeError as e:
print(f"Erreur d'accès direct à __attribut_prive_fort: {e}") # AttributeError
# L'attribut double underscore est "renommé" par Python (name mangling)
print(f"Accès via name mangling: {svc._MonService__attribut_prive_fort}") # Affiche Donnée très sensible
print(f"Contenu de __dict__: {svc.__dict__}")
# {'_MonService__attribut_prive_fort': 'Donnée très sensible', '_attribut_semi_prive': 'Donnée protégée', 'attribut_public': 'Donnée ouverte'}
__nom_methode_ou_attribut__(doubles underscores au début et à la fin) : Ce sont des noms spéciaux réservés au système Python. Ils sont utilisés pour les méthodes "magiques" ou "dunder" comme__init__,__del__,__call__. Il faut éviter de créer ses propres identifiants de ce type pour éviter les conflits avec le comportement interne de Python._nom_methode_ou_attribut(un seul underscore au début) : C'est une convention pour indiquer qu'un attribut ou une méthode est "privé" ou "protégé". Cela signifie qu'il ne devrait pas être accédé directement depuis l'extérieur de la classe ou du module. Le comportement defrom module import *est également affecté, car les identifiants commençant par un underscore ne sont pas importés par cette syntaxe. Cependant, ils restent accessibles directement (par exemple,objet._mon_attribut).__nom_methode_ou_attribut(doubles underscores au début, pas à la fin) : Ceci déclenche le "name mangling" (brouillage de nom) par l'interpréteur Python. Le nom est automatiquement modifié pour inclure le nom de la classe (par exemple,__attributdevient_NomDeLaClasse__attribut). Cela rend l'accès direct depuis l'extérieur de la classe plus difficile et sert à éviter les conflits de noms dans les hiérarchies d'héritage. Cependant, il ne s'agit pas d'une véritable encapsulation comme en Java ou C++ ; l'attribut est toujours accessible via son nom manglé.
Pour plus de détails :
- Formatage de chaînes : opérateurs
%et.format()
Python offre plusieurs façons de formater des chaînes. Historiquement, l'opérateur de modulo (%) était utilisé, mais la méthode .format(), introduite en Python 2.6, est souvent préférée pour sa flexibilité et sa lisibilité.
L'un des inconvénients de l'opérateur % est sa gestion des tuples. Si vous tentez de formater une chaîne avec une seule variable qui est elle-même un tuple, vous pourriez rencontrer un TypeError :
nom_utilisateur = "Jean"
message_simple = "Bonjour %s" % nom_utilisateur
print(message_simple) # Affiche "Bonjour Jean"
# Si 'nom_utilisateur' était un tuple accidentellement
nom_utilisateur_tuple = ("Alice", "Bob")
try:
message_erreur = "Salutations %s" % nom_utilisateur_tuple
except TypeError as e:
print(f"Erreur avec tuple: {e}") # Affiche "not all arguments converted during string formatting"
# Pour éviter cela avec %, il faut s'assurer de passer un tuple même pour un seul élément
message_correct = "Salutations %s" % (nom_utilisateur_tuple,)
print(message_correct) # Affiche "Salutations ('Alice', 'Bob')"
La méthode .format() ne souffre pas de ces ambiguïtés et est généralement plus intuitive :
message_format = "Bonjour {}".format(nom_utilisateur)
print(message_format) # Affiche "Bonjour Jean"
message_format_tuple = "Salutations {}".format(nom_utilisateur_tuple)
print(message_format_tuple) # Affiche "Salutations ('Alice', 'Bob')"
# Formatage avec noms de champs
informations = {"nom": "Marie", "âge": 30}
message_infos = "Nom: {nom}, Âge: {âge}".format(**informations)
print(message_infos) # Affiche "Nom: Marie, Âge: 30"
Les raisons de ne pas utiliser .format() pourraient inclure :
- Compatibilité avec des versions de Python antérieures à 2.6.
- Préférence pour la simplicité de l'opérateur
%pour les cas très basiques, ou pour la cohérence avec du code existant (par exemple, la bibliothèquelogging).
Pour plus d'informations, voir Python string formatting vs. .format.
- Itérateurs et Générateurs
Ce sujet est l'une des questions les plus populaires sur Stack Overflow concernant Python, et il mérite une attention particulière.
Un itérateur est un objet qui implémente les méthodes __iter__() et __next__() (next() en Python 2). L'itérateur permet de parcourir des collections d'éléments, un par un, sans avoir à charger toute la collection en mémoire.
Un générateur est une manière simple et élégante de créer des itérateurs. Il s'agit d'une fonction Python qui utilise le mot-clé yield. Lorsqu'une fonction de générateur est appelée, elle ne renvoie pas une valeur unique mais un objet générateur. Cet objet peut être parcouru (itéré), et chaque fois que next() est appelé dessus (implicitement par une boucle for, par exemple), la fonction reprend son exécution là où elle s'était arrêtée, exécute le code jusqu'à la prochaine instruction yield, et renvoie la valeur spécifiée par yield.
Une question courante en entretien est la différence entre une compréhension de liste et une expression de générateur :
# Compréhension de liste : crée une liste entière en mémoire
liste_carres = [val * val for val in range(10)]
print(f"Liste des carrés: {liste_carres}")
# Affiche: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# Expression de générateur : crée un objet générateur, ne stocke pas tous les éléments en mémoire
generateur_carres = (val * val for val in range(10))
print(f"Objet générateur: {generateur_carres}")
# Affiche: <generator object <genexpr> at 0x...>
# On peut itérer sur l'objet générateur
for carre in generateur_carres:
print(carre, end=" ") # Affiche: 0 1 4 9 16 25 36 49 64 81
print()
La principale différence est que la compréhension de liste construit et stocke l'intégralité de la liste en mémoire, ce qui peut être problématique pour de très grandes collections. L'expression de générateur, en revanche, génère les valeurs à la demande ("on the fly"), ce qui économise de la mémoire et est plus efficace pour des séquences potentiellement infinies ou très grandes.
Pour une explication approfondie sur le mot-clé yield et les générateurs :
- Que fait le mot-clé yield en Python ?
- Version en chinois (pour référence) : Python Stack Overflow Réponses
*argset**kwargs
Les syntaxes *args et **kwargs en Python sont des conventions pratiques, et non des exigences syntaxiques, utilisées pour passer un nombre variable d'arguments à une fonction.
*args(pour "arguments") permet à une fonction d'accepter un nombre arbitraire d'arguments positionnels. Ces arguments sont regroupés dans un tuple.**kwargs(pour "keyword arguments") permet à une fonction d'accepter un nombre arbitraire d'arguments nommés. Ces arguments sont regroupés dans un dictionnaire.
Exemple avec *args :
def afficher_tout(*elements):
for index, element in enumerate(elements):
print(f"{index}. {element}")
afficher_tout('pomme', 'banane', 'chou', 'datte')
# Affiche:
# 0. pomme
# 1. banane
# 2. chou
# 3. datte
Exemple avec **kwargs :
def afficher_parametres_nommes(**options):
for cle, valeur in options.items():
print(f"{cle} = {valeur}")
afficher_parametres_nommes(fruit='pomme', legume='chou', couleur='rouge')
# Affiche: (l'ordre peut varier pour les dictionnaires)
# fruit = pomme
# legume = chou
# couleur = rouge
Vous pouvez les combiner. Les arguments nommés fixes doivent être déclarés en premier, suivis de *args, puis de **kwargs :
def ma_fonction(param_fixe, *args_supp, **kwargs_supp):
print(f"Paramètre fixe: {param_fixe}")
print(f"Arguments supplémentaires: {args_supp}")
print(f"Arguments nommés supplémentaires: {kwargs_supp}")
ma_fonction("Premier", 1, 2, 3, cle_a="val1", cle_b="val2")
# Affiche:
# Paramètre fixe: Premier
# Arguments supplémentaires: (1, 2, 3)
# Arguments nommés supplémentaires: {'cle_a': 'val1', 'cle_b': 'val2'}
La syntaxe * et ** peut aussi être utilisée lors de l'appel d'une fonction pour déballer des collections :
def imprimer_trois_elements(a, b, c):
print(f"a = {a}, b = {b}, c = {c}")
mes_items = ['aigle', 'buffle', 'chat']
imprimer_trois_elements(*mes_items)
# Affiche: a = aigle, b = buffle, c = chat
parametres_dict = {'b': 'balle', 'a': 'arc', 'c': 'ciel'}
imprimer_trois_elements(**parametres_dict)
# Affiche: a = arc, b = balle, c = ciel
Notez que le nombre d'éléments déballés doit correspondre aux paramètres de la fonction.
Pour plus d'informations : Stack Overflow sur *args et **kwargs.
- Programmation Orientée Aspect (AOP) et Décorateurs
La Programmation Orientée Aspect (AOP) est une approche de programmation qui vise à accroître la modularité en permettant la séparation des préoccupations transversales. Un concept clé en Python pour implémenter l'AOP est l'utilisation des décorateurs.
Un décorateur est un motif de conception bien connu qui permet d'ajouter des fonctionnalités supplémentaires à une fonction ou une méthode existante sans modifier sa structure. Ils sont idéaux pour des besoins transversaux tels que l'ajout de journaux, la mesure des performances, la gestion des transactions ou la validation d'accès. En encapsulant la logique commune dans un décorateur, on peut éviter la duplication de code et réutiliser facilement ces fonctionnalités.
En résumé, l'objectif principal d'un décorateur est d'étendre ou de modifier le comportement d'un objet existant.
Pour une discussion approfondie :
- Comment créer une chaîne de décorateurs de fonction en Python ?
- Version en chinois (pour référence) : Stack Overflow sur Python
- Typage dynamique (Duck Typing)
Le "duck typing" (typage canard) est un concept de typage dynamique très influent en Python. L'idée est la suivante :
"Si ça marche comme un canard, nage comme un canard, et cancane comme un canard, alors c'est un canard."
En Python, cela signifie que le type d'un objet est moins important que les méthodes ou attributs qu'il possède. On ne se soucie pas de la classe exacte d'un objet, mais de sa capacité à effectuer les opérations attendues. L'interface d'un objet est définie par ses méthodes et attributs, et non par une déclaration de type explicite.
Par exemple, en Python, de nombreux objets se comportent comme des "fichiers", même s'ils ne sont pas de la classe file (ou io.TextIOBase en Python 3). Des objets comme io.StringIO, gzip.GzipFile ou même un socket TCP peuvent être traités comme des fichiers car ils implémentent des méthodes comme read(), write(), close(), etc.
De même, la méthode list.extend() attend un argument itérable. Elle ne se soucie pas si l'argument est une liste, un tuple, un dictionnaire (ses clés), une chaîne ou un générateur, tant qu'il peut être parcouru pour obtenir des éléments.
Le duck typing est un aspect clé des langages dynamiques comme Python, offrant une grande flexibilité et réduisant le besoin de hiérarchies de classes complexes ou de nombreux motifs de conception que l'on trouve dans des langages fortement typés comme Java.
- Surcharge de fonctions en Python
En Python, le concept de surcharge de fonctions (où plusieurs fonctions avec le même nom mais des signatures différentes sont définies) n'existe pas de la même manière qu'en C++ ou Java. Cela est principalement dû à la nature dynamique du langage et à la manière dont il gère les arguments.
Selon une réponse populaire sur Zhihu, la surcharge de fonctions vise généralement à résoudre deux problèmes :
- Gérer des types de paramètres variables.
- Gérer un nombre de paramètres variable.
Python fournit des solutions élégantes pour ces deux cas, ce qui rend la surcharge explicite inutile :
- Types de paramètres variables : Python est dynamiquement typé, ce qui signifie qu'une fonction peut accepter des arguments de n'importe quel type sans déclaration préalable. Si la logique de la fonction est la même quel que soit le type d'entrée (par exemple, elle s'applique à la fois aux nombres et aux chaînes), une seule fonction peut souvent gérer tous les types. Si un comportement spécifique est requis pour certains types, des vérifications de type internes (avec
isinstance()) peuvent être utilisées, ou le polymorphisme du duck typing permet à différents objets d'être traités de manière uniforme s'ils partagent une interface commune. - Nombre de paramètres variable : Python gère cela grâce aux arguments par défaut, aux arguments positionnels variables (
*args) et aux arguments nommés variables (**kwargs). Ces mécanismes permettent de définir une fonction qui peut être appelée avec un nombre variable d'arguments, sans avoir besoin de créer des versions multiples de la fonction.
Étant donné que Python offre des solutions intrinsèques pour les problèmes que la surcharge de fonctions est censée résoudre, le langage n'a pas besoin de cette fonctionnalité.
- Classes de style ancien et de style nouveau
La distinction entre les classes de "style ancien" (old-style) et de "style nouveau" (new-style) était un concept important en Python 2.x, mais elle est obsolète en Python 3. Toutes les classes en Python 3 sont des classes de style nouveau.
Historiquement, en Python 2.x :
- Les classes de style ancien étaient créées sans hériter explicitement de
object. Elles avaient des comportements et des limitations spécifiques, notamment en ce qui concerne le système d'objets (méthodes spéciales, propriétés), et surtout, leur résolution de l'ordre de méthode (MRO - Method Resolution Order) était basée sur une recherche en profondeur d'abord (DFS). - Les classes de style nouveau héritaient explicitement de
object(ou d'une autre classe de style nouveau). Elles apportaient une unification du modèle objet, permettant l'utilisation de toutes les fonctionnalités modernes de Python (propriétés, descripteurs,super(), etc.). Leur MRO est basé sur l'algorithme C3, qui est une recherche en largeur d'abord (BFS) garantissant un ordre de résolution cohérent et prévisible, surtout en cas d'héritage multiple.
Un excellent article qui présente les caractéristiques des classes de style nouveau est disponible sur cnblogs.
Voici un exemple illustrant la différence de MRO pour les classes de style ancien (même si ce comportement n'existe plus en Python 3) :
# Cet exemple simule le comportement du MRO d'une classe de style ancien
# En Python 3, toutes les classes sont de style nouveau et utilisent l'algorithme C3 MRO.
# Pour simuler le style ancien en Python 2.x, on n'hériterait pas de 'object'.
class BaseA:
def operation(self):
print("Opération de BaseA")
class MilieuB(BaseA):
def specifique_b(self):
pass
class MilieuC(BaseA):
def operation(self): # Surcharge de la méthode operation
print("Opération de MilieuC")
class DeriveeD(MilieuB, MilieuC):
pass
instance_d = DeriveeD()
instance_d.operation()
# En Python 3 (style nouveau, MRO C3, largeur d'abord): Affiche "Opération de MilieuB" si MilieuB avait operation, sinon "Opération de MilieuC"
# Pour le MRO de DeriveeD: ([DeriveeD, MilieuB, MilieuC, BaseA, object])
# Si MilieuB ne redéfinit pas 'operation', la recherche se fait de MilieuB vers BaseA, puis MilieuC.
# L'ordre MRO de DeriveeD en Python 3 est: DeriveeD, MilieuB, MilieuC, BaseA, object.
# Donc, MilieuC.operation() est trouvé avant BaseA.operation().
# L'exemple original est trompeur ou fait référence à Python 2.x old-style MRO (profondeur d'abord),
# où B puis A serait recherché avant C.
# Pour illustrer le comportement exact du code original (qui semble supposer old-style/profondeur):
# Si la classe D est une classe de style ancien (en Python 2), le MRO (profondeur d'abord)
# chercherait dans D -> B -> A. Si 'operation' est trouvée dans A, elle serait utilisée
# avant même de regarder dans C, contournant ainsi la surcharge de C.
# Cela est corrigé dans les classes de style nouveau de Python 2 et toutes les classes de Python 3.
print(f"Ordre de résolution des méthodes (MRO) pour DeriveeD en Python 3: {DeriveeD.__mro__}")
# Le MRO en Python 3 serait généralement (DeriveeD, MilieuB, MilieuC, BaseA, object)
# Donc, si 'operation' n'est pas dans MilieuB, elle serait trouvée dans MilieuC.
# Le code initial montre une sortie "A", ce qui implique une recherche en profondeur d'abord typique des old-style classes
# en Python 2, où D -> B -> A serait privilégié avant de considérer C.
# En Python 3, le résultat serait "Opération de MilieuC" si MilieuB n'avait pas sa propre 'operation'.
- Distinction entre
__new__et__init__
Les méthodes __new__ et __init__ sont deux méthodes spéciales appelées lors de la création et de l'initialisation d'objets en Python, mais elles ont des rôles distincts :
__new__(cls, ...)est une méthode statique (elle reçoit la classeclscomme premier argument implicite). Son rôle est de créer et de renvoyer une nouvelle instance de la classe. Elle est appelée avant__init__.__init__(self, ...)est une méthode d'instance (elle reçoit l'instance nouvellement crééeselfcomme premier argument). Son rôle est d'initialiser l'instance créée par__new__. Elle ne doit rien retourner (ou retournerNone).
Points clés à retenir :
__new__est le constructeur de l'objet ;__init__est l'initialiseur.__new__doit impérativement retourner une instance de la classe (généralement en appelantsuper().__new__(cls, ...)). Si__new__ne renvoie pas une instance decls, la méthode__init__ne sera pas appelée.__new__est rarement surchargée, sauf dans des cas particuliers comme l'implémentation de motifs de conception (par exemple, le singleton) ou la création de sous-classes de types immutables.
En complément, la métaclasse (définie par __metaclass__ en Python 2 ou par l'argument metaclass dans la définition de classe en Python 3) est responsable de la création de la classe elle-même. Ainsi, nous avons une séquence : la métaclasse crée la classe, puis la classe (via __new__) crée l'instance, et enfin l'instance (via __init__) est initialisée.
- Le motif de conception Singleton
Le motif de conception Singleton garantit qu'une classe n'a qu'une seule instance et fournit un point d'accès global à cette instance. Il est particulièrement utile lorsque l'on a besoin d'une seule instance d'une ressource coûteuse ou d'un service centralisé dans un système.
Voici plusieurs façons d'implémenter le Singleton en Python, un sujet fréquemment abordé en entretien technique :
1. En utilisant la méthode __new__
Cette approche est courante car __new__ est responsable de la création de l'instance.
class GestionnaireUnique:
_instance_unique = None # Variable de classe pour stocker l'instance
def __new__(cls, *args, **kwargs):
if not cls._instance_unique:
# Si aucune instance n'existe, en créer une nouvelle
cls._instance_unique = super().__new__(cls)
return cls._instance_unique
class ConfigurationApp(GestionnaireUnique):
def __init__(self, parametre_initial="Défaut"):
# __init__ peut être appelé plusieurs fois, donc il faut le protéger
if not hasattr(self, '_initialise'):
self.valeur_config = parametre_initial
self._initialise = True
# Les deux références pointent vers la même instance
config1 = ConfigurationApp("Prod")
print(f"Config 1: {config1.valeur_config}, ID: {id(config1)}")
config2 = ConfigurationApp("Dev") # Cette initialisation n'aura pas d'effet sur la valeur_config si déjà initialisée
print(f"Config 2: {config2.valeur_config}, ID: {id(config2)}")
print(f"Les deux instances sont-elles identiques ? {config1 is config2}") # True
print(f"Valeur de config1 après config2: {config1.valeur_config}") # Affiche 'Prod' si _initialise est utilisé
2. En partageant l'état entre les instances (Borg/Monostate)
Ce motif, souvent appelé Monostate ou Borg, crée des instances distinctes mais fait en sorte qu'elles partagent toutes le même dictionnaire d'attributs (__dict__). Ainsi, bien qu'il y ait plusieurs instances, leur état est synchronisé.
class EtatPartage:
_partage_etat = {} # Dictionnaire d'état partagé
def __new__(cls, *args, **kwargs):
obj_instance = super().__new__(cls)
obj_instance.__dict__ = cls._partage_etat # Chaque instance utilise le même dict d'état
return obj_instance
class CachePartage(EtatPartage):
def __init__(self, taille_max=100):
# L'initialisation doit être protégée pour ne pas écraser l'état
if 'taille_cache' not in self._partage_etat:
self.taille_cache = taille_max
self.donnees = {}
cache_a = CachePartage(50)
print(f"Cache A taille: {cache_a.taille_cache}, ID: {id(cache_a)}")
cache_b = CachePartage(200) # Cette initialisation ne changera pas la taille_cache si déjà définie par cache_a
print(f"Cache B taille: {cache_b.taille_cache}, ID: {id(cache_b)}")
print(f"Les deux instances sont-elles identiques ? {cache_a is cache_b}") # False (objets différents)
print(f"Cache A taille après cache_b: {cache_a.taille_cache}") # Affiche 50 (si _partage_etat avait été initié par cache_a)
cache_a.donnees['clé'] = 'valeur'
print(f"Données de cache_b: {cache_b.donnees}") # Affiche {'clé': 'valeur'}
3. En utilisant un décorateur
Un décorateur peut encapsuler la logique de création de singleton pour toute classe.
def decorateur_singleton(classe_a_decorer):
instances = {} # Dictionnaire pour stocker les instances créées
def obtenir_instance(*args, **kwargs):
if classe_a_decorer not in instances:
instances[classe_a_decorer] = classe_a_decorer(*args, **kwargs)
return instances[classe_a_decorer]
return obtenir_instance
@decorateur_singleton
class BaseDeDonnees:
def __init__(self, connexion_string):
self.connexion = connexion_string
print(f"Connexion à la base de données établie avec: {self.connexion}")
db1 = BaseDeDonnees("sqlite://test.db")
db2 = BaseDeDonnees("mysql://user:pass@host/db") # Cet appel utilise l'instance existante
print(f"DB1 connexion: {db1.connexion}") # Affiche "sqlite://test.db"
print(f"DB2 connexion: {db2.connexion}") # Affiche "sqlite://test.db"
print(f"DB1 et DB2 sont-elles la même instance ? {db1 is db2}") # True
4. En utilisant le mécanisme d'importation de module de Python
Par nature, les modules Python sont des singletons : un module n'est importé qu'une seule fois dans un processus Python, et toute référence ultérieure à ce module renvoie au même objet module. On peut exploiter cela pour créer un singleton.
Fichier gestionnaire_logger.py:
# gestionnaire_logger.py
class LoggerGlobal:
def __init__(self):
self.log_file = "application.log"
print("Logger Global initialisé.")
def log_message(self, message):
with open(self.log_file, "a") as f:
f.write(message + "\n")
# Création de l'unique instance lors de l'importation du module
logger_instance = LoggerGlobal()
Fichier main_app.py:
# main_app.py
from gestionnaire_logger import logger_instance
logger_instance.log_message("Message depuis l'application principale.")
Fichier module_sub.py:
# module_sub.py
from gestionnaire_logger import logger_instance
logger_instance.log_message("Message depuis un sous-module.")
Lors de l'exécution de main_app.py, LoggerGlobal ne sera initialisé qu'une seule fois, et logger_instance sera la même instance partout où elle est importée.
- Portée des variables en Python
En Python, la portée (scope) d'une variable est déterminée par l'emplacement où elle est définie (assignée) dans le code. Python utilise la règle LEGB pour la résolution des noms :
- Local : Noms définis à l'intérieur d'une fonction (y compris les paramètres).
- Enclosing (Englobant) : Noms dans les fonctions englobantes non locales (pour les fonctions imbriquées).
- Global : Noms définis au niveau supérieur d'un module.
- Built-in (Intégré) : Noms prédéfinis dans Python (par exemple,
print,len,str).
Lorsqu'une variable est référencée, Python recherche son nom dans cet ordre. La première définition trouvée est celle qui est utilisée.
var_globale = "Je suis globale"
def fonction_externe():
var_englobante = "Je suis englobante"
def fonction_interne():
var_locale = "Je suis locale"
print(f"À l'intérieur de interne: {var_locale}")
print(f"Accès à englobante depuis interne: {var_englobante}")
print(f"Accès à globale depuis interne: {var_globale}")
fonction_interne()
print(f"À l'intérieur de externe: {var_englobante}")
# print(var_locale) # Erreur: var_locale n'est pas définie dans ce scope
fonction_externe()
print(f"À l'extérieur: {var_globale}")
# print(var_englobante) # Erreur: var_englobante n'est pas définie dans ce scope
- Le GIL (Global Interpreter Lock)
Le GIL, ou Global Interpreter Lock, est un mutex (mécanisme de verrouillage) qui protège l'accès aux objets Python, empêchant plusieurs threads natifs d'exécuter du bytecode Python simultanément dans le même processus. En d'autres termes, même sur des systèmes multi-cœurs, un seul thread Python peut exécuter du bytecode à un instant donné. Cela simplifie la gestion de la mémoire et la sécurité des threads pour les développeurs de l'interpréteur Python, mais cela a des implications sur les performances des applications multi-threadées.
- Pour les tâches gourmandes en E/S (I/O-bound) : Les threads Python peuvent être efficaces car le GIL est relâché pendant les opérations d'E/S (comme la lecture de fichiers ou les requêtes réseau). Pendant qu'un thread attend une E/S, d'autres threads peuvent acquérir le GIL et exécuter du bytecode.
- Pour les tâches gourmandes en CPU (CPU-bound) : Le GIL devient un goulot d'étranglement. Les threads se disputent le GIL, et la surcharge de commutation de contexte entre les threads peut même ralentir l'exécution par rapport à un programme mono-threadé. Pour ces tâches, les solutions sont le multi-processing (où chaque processus a son propre interpréteur Python et donc son propre GIL) ou l'utilisation de modules écrits en C/C++ qui peuvent libérer le GIL pendant leurs calculs intensifs.
Le GIL est souvent considéré comme l'un des problèmes les plus complexes de Python, car il affecte la façon dont la concurrence est gérée.
- Les coroutines
Les coroutines sont un concept de concurrence qui peut être vu comme une évolution des threads ou processus dans certains contextes. Contrairement aux threads et processus qui subissent des commutations de contexte gérées par le système d'exploitation (impliquant un passage en mode noyau, coûteux en ressources), les coroutines permettent aux développeurs de contrôler explicitement quand les tâches doivent passer la main.
En Python, les coroutines sont étroitement liées au mot-clé yield (voir la section sur les générateurs) et plus récemment aux mots-clés async et await (introduits en Python 3.5 avec asyncio). Elles permettent une programmation asynchrone et non bloquante, idéale pour les tâches intensives en E/S. Bien que les coroutines s'exécutent généralement sur un seul thread CPU (donc ne contournent pas le GIL pour les calculs CPU-bound), elles réduisent considérablement les frais de commutation de contexte, ce qui améliore les performances pour les applications E/S-bound.
La beauté des coroutines réside dans leur capacité à "pause" et "reprendre" l'exécution en des points spécifiques, sans le surcoût de la création et de la gestion de threads multiples par le système d'exploitation.
- Les closures (fermetures)
Une closure, ou fermeture, est une technique de programmation fonctionnelle qui permet à une fonction interne (imbriquée) de "capturer" et de se souvenir de l'état de son environnement de création, même après que la fonction externe dont elle dépend a terminé son exécution. Cela signifie que la fonction interne peut toujours accéder aux variables de la portée de la fonction externe.
Pour qu'une closure soit créée, les conditions suivantes doivent être remplies :
- Il doit y avoir une fonction interne (imbriquée) définie au sein d'une autre fonction.
- La fonction interne doit référencer des variables qui ne sont ni locales à elle-même, ni globales, mais qui appartiennent à la portée de la fonction externe (ou englobante).
- La fonction externe doit retourner la fonction interne.
def generateur_multiplicateur(facteur_externe):
# 'facteur_externe' est une variable non locale pour la fonction interne
def multiplier_par(nombre_interne):
return nombre_interne * facteur_externe
return multiplier_par # Retourne la fonction interne
# Crée une closure où 'facteur_deux' est lié à facteur_externe = 2
multiplier_par_deux = generateur_multiplicateur(2)
print(f"Multiplication par 2: {multiplier_par_deux(5)}") # Affiche 10
# Crée une autre closure où 'facteur_dix' est lié à facteur_externe = 10
multiplier_par_dix = generateur_multiplicateur(10)
print(f"Multiplication par 10: {multiplier_par_dix(5)}") # Affiche 50
L'aspect clé des closures est que l'environnement (les variables de la fonction externe) n'est pas détruit après l'exécution de la fonction externe. Il est "fermé" avec la fonction interne et persiste en mémoire tant que la fonction interne existe. Cela permet de créer des fonctions avec un état persistant, sans avoir recours à des objets de classe.
- Fonctions lambda
Une fonction lambda en Python est une petite fonction anonyme, c'est-à-dire une fonction sans nom. Elle peut prendre n'importe quel nombre d'arguments mais ne peut avoir qu'une seule expression. Le résultat de cette expression est la valeur de retour de la fonction lambda. Les fonctions lambda sont souvent utilisées pour des opérations simples qui ne nécessitent pas une définition complète de fonction (avec def).
# Fonction lambda simple pour additionner deux nombres
additionner = lambda a, b: a + b
print(f"Addition de 3 et 5: {additionner(3, 5)}") # Affiche 8
# Fonction lambda utilisée comme clé de tri
liste_de_tuples = [('pomme', 5), ('banane', 2), ('orange', 8)]
liste_triee = sorted(liste_de_tuples, key=lambda x: x[1])
print(f"Liste triée par le second élément: {liste_triee}") # Affiche [('banane', 2), ('pomme', 5), ('orange', 8)]
Les fonctions lambda sont particulièrement utiles en conjonction avec des fonctions d'ordre supérieur comme map(), filter(), sorted() ou comme arguments à des méthodes qui attendent une fonction.
Référence: Zhihu sur les lambdas
- Programmation fonctionnelle en Python
Python n'est pas un langage purement fonctionnel, mais il intègre des concepts et des outils qui facilitent la programmation fonctionnelle. La programmation fonctionnelle se concentre sur l'utilisation de fonctions pures (qui n'ont pas d'effets secondaires et retournent toujours le même résultat pour les mêmes entrées), l'immuabilité des données et des constructions comme les fonctions d'ordre supérieur (qui peuvent prendre des fonctions en argument ou en retourner).
Voici quelques fonctions Python qui incarnent les principes de la programmation fonctionnelle :
filter()
La fonction filter(fonction, iterable) construit un itérateur à partir des éléments de iterable pour lesquels fonction renvoie True.
numeros = [1, 2, 3, 4, 5, 6, 7, 8, 9]
pairs = filter(lambda x: x % 2 == 0, numeros)
print(f"Nombres pairs: {list(pairs)}") # Affiche [2, 4, 6, 8]
map()
La fonction map(fonction, iterable) applique fonction à chaque élément de iterable et renvoie un itérateur des résultats.
valeurs = [1, 2, 3, 4]
carres = map(lambda x: x * x, valeurs)
print(f"Carrés des valeurs: {list(carres)}") # Affiche [1, 4, 9, 16]
reduce()
La fonction reduce(fonction, iterable) applique une fonction cumulativement aux éléments de iterable, de gauche à droite, de manière à réduire l'itérable à une seule valeur. Elle est disponible dans le module functools.
from functools import reduce
nombres = [1, 2, 3, 4]
produit = reduce(lambda x, y: x * y, nombres)
print(f"Produit des nombres: {produit}") # Affiche 24 (1 * 2 * 3 * 4)
somme_premiers = reduce(lambda acc, val: acc + val, range(1, 6)) # (1+2+3+4+5)
print(f"Somme des 5 premiers entiers: {somme_premiers}") # Affiche 15
Pour une discussion plus approfondie sur la programmation fonctionnelle en Python, l'article de CoolShell est une excellente ressource.
- Gestion des copies en Python
En Python, l'affectation de variables crée des références vers des objets existants. Pour créer des copies distinctes d'objets, il faut utiliser les fonctions de copie explicites du module copy.
- Affectation (référence) : Une simple affectation (
b = a) fait en sorte quebpointe vers le même objet quea. Toute modification de l'objet viaasera visible viab, et vice versa. - Copie superficielle (Shallow Copy) :
copy.copy(objet)crée un nouvel objet, mais insère dans ce nouvel objet des références aux mêmes sous-objets que l'original. Si l'objet original contient des objets mutables (comme des listes imbriquées), une modification de ces sous-objets dans la copie superficielle affectera également les sous-objets de l'original (et vice versa). - Copie profonde (Deep Copy) :
copy.deepcopy(objet)crée un nouvel objet, puis récursivement crée des copies des sous-objets trouvés dans l'original. Ainsi, l'original et la copie profonde sont entièrement indépendants.
import copy
liste_originale = [10, 20, [30, 40]] # Objet initial avec une liste imbriquée
# Affectation (partage de référence)
liste_reference = liste_originale
# Copie superficielle
liste_superficielle = copy.copy(liste_originale)
# Copie profonde
liste_profonde = copy.deepcopy(liste_originale)
print(f"Original: {liste_originale}, ID: {id(liste_originale)}")
print(f"Référence: {liste_reference}, ID: {id(liste_reference)}")
print(f"Superficielle: {liste_superficielle}, ID: {id(liste_superficielle)}")
print(f"Profonde: {liste_profonde}, ID: {id(liste_profonde)}")
print("\n--- Modifications ---")
liste_originale.append(50) # Modification de l'objet de niveau supérieur
liste_originale[2].append(60) # Modification du sous-objet (liste imbriquée)
print(f"Original (après modifs): {liste_originale}")
print(f"Référence (après modifs): {liste_reference}") # Reflète toutes les modifs
print(f"Superficielle (après modifs): {liste_superficielle}") # Reflète les modifs du sous-objet, pas du niveau supérieur
print(f"Profonde (après modifs): {liste_profonde}") # Totalement indépendante
# Résultat attendu:
# Original (après modifs): [10, 20, [30, 40, 60], 50]
# Référence (après modifs): [10, 20, [30, 40, 60], 50]
# Superficielle (après modifs): [10, 20, [30, 40, 60]]
# Profonde (après modifs): [10, 20, [30, 40]]
- Mécanismes de collecte de garbage en Python
La gestion de la mémoire en Python est principalement automatique grâce à son collecteur de garbage. Les mécanismes principaux sont :
- Comptage de références : C'est la méthode primaire. Chaque objet Python maintient un compteur du nombre de références qui pointent vers lui. Lorsque ce compteur tombe à zéro, l'objet est considéré comme inutile et sa mémoire est libérée.
- Marquage-nettoyage (Mark-and-Sweep) : Le comptage de références ne peut pas gérer les références cycliques (où des objets se référencent mutuellement mais ne sont plus accessibles depuis le reste du programme). Le ramasse-miettes utilise un algorithme de marquage-nettoyage pour détecter et collecter ces cycles. Il marque d'abord tous les objets accessibles depuis les racines (objets globaux, pile d'exécution, etc.), puis nettoie (libère) tous les objets non marqués.
- Collecte générationnelle : Pour optimiser les performances, le ramasse-miettes de Python utilise aussi un système de générations. Les objets sont divisés en différentes "générations" en fonction de leur ancienneté (combien de cycles de garbage collection ils ont survécu). Les objets plus jeunes sont collectés plus fréquemment car ils sont plus susceptibles de devenir inutiles rapidement. Les objets plus anciens (qui ont survécu à plusieurs collectes) sont moins souvent vérifiés, ce qui réduit le temps total passé en garbage collection.
Avantages du comptage de références :
- Simplicité d'implémentation.
- Réactivité : la mémoire est libérée dès qu'un objet devient inaccessible.
Inconvénients du comptage de références :
- Surcoût de maintenance du compteur pour chaque référence.
- Incapacité à gérer les références cycliques, ce qui nécessite un mécanisme supplémentaire.
- Listes en Python
Les listes sont l'une des structures de données les plus polyvalentes et les plus utilisées en Python. Elles sont ordonnées, mutables et peuvent contenir des éléments de types de données différents. Elles sont dynamiques, ce qui signifie que leur taille peut changer.
Pour une discussion détaillée sur les listes Python, leur fonctionnement interne et leurs performances, vous pouvez consulter cet article.
- L'opérateur
isen Python
En Python, les opérateurs is et == sont utilisés pour la comparaison, mais ils testent des propriétés différentes des objets :
iscompare l'identité des objets. Il vérifie si deux variables référencent exactement le même objet en mémoire (même adresse mémoire).==compare la valeur des objets. Il vérifie si les objets ont la même valeur, tel que défini par la méthode__eq__()de l'objet.
liste_a = [1, 2, 3]
liste_b = [1, 2, 3]
liste_c = liste_a # liste_c référence le même objet que liste_a
print(f"liste_a == liste_b : {liste_a == liste_b}") # True (mêmes valeurs)
print(f"liste_a is liste_b : {liste_a is liste_b}") # False (objets différents en mémoire)
print(f"liste_a == liste_c : {liste_a == liste_c}") # True (mêmes valeurs)
print(f"liste_a is liste_c : {liste_a is liste_c}") # True (même objet en mémoire)
# Cas particulier avec les petits entiers et chaînes (interning)
num1 = 100
num2 = 100
num3 = 1000
num4 = 1000
print(f"num1 is num2 : {num1 is num2}") # True pour les petits entiers (interning)
print(f"num3 is num4 : {num3 is num4}") # False (généralement, pas d'interning pour les grands entiers)
chaine1 = "bonjour"
chaine2 = "bonjour"
chaine3 = "bon" + "jour"
print(f"chaine1 is chaine2 : {chaine1 is chaine2}") # True (interning pour les littéraux)
print(f"chaine1 is chaine3 : {chaine1 is chaine3}") # True (optimisation à la compilation)
Il est important de ne pas confondre les deux, car is est un test d'identité strict, tandis que == est un test d'égalité sémantique.
- Fonctions de lecture de fichiers :
read(),readline()etreadlines()
Lors de la manipulation de fichiers en Python, plusieurs méthodes sont disponibles pour lire leur contenu, chacune adaptée à des scénarios différents :
fichier.read(taille=-1): Lit l'intégralité du fichier et renvoie son contenu sous forme de chaîne (ou d'octets si le fichier est ouvert en mode binaire). Si un argumenttailleest fourni, il lit et renvoie au maximumtaillecaractères/octets. Utile pour les fichiers de petite et moyenne taille.fichier.readline(taille=-1): Lit une seule ligne du fichier, jusqu'au caractère de nouvelle ligne (inclus) ou jusqu'à la fin du fichier. Si un argumenttailleest fourni, elle lit et renvoie au maximumtaillecaractères/octets de la ligne. Cette méthode est souvent utilisée dans une boucle pour traiter les fichiers ligne par ligne, ce qui est efficace en mémoire pour les grands fichiers.fichier.readlines(): Lit toutes les lignes du fichier et les renvoie sous forme de liste de chaînes de caractères, où chaque élément de la liste est une ligne du fichier (incluant le caractère de nouvelle ligne). Cette méthode charge l'intégralité du fichier en mémoire sous forme de liste, ce qui peut être problématique pour des fichiers très volumineux.
# Créons un fichier temporaire pour les exemples
with open("exemple.txt", "w") as f:
f.write("Première ligne.\n")
f.write("Deuxième ligne.\n")
f.write("Troisième ligne.")
# Utilisation de read()
with open("exemple.txt", "r") as f:
contenu_entier = f.read()
print(f"Contenu entier:\n---\n{contenu_entier}---")
# Utilisation de readline()
with open("exemple.txt", "r") as f:
print("\nLecture ligne par ligne:")
ligne1 = f.readline()
print(f"Ligne 1: {ligne1.strip()}")
ligne2 = f.readline()
print(f"Ligne 2: {ligne2.strip()}")
# Utilisation de readlines()
with open("exemple.txt", "r") as f:
toutes_les_lignes = f.readlines()
print("\nToutes les lignes dans une liste:")
for ligne in toutes_les_lignes:
print(f"- {ligne.strip()}")
Pour les grands fichiers, l'itération directe sur l'objet fichier est souvent la meilleure approche car elle utilise un générateur interne pour lire ligne par ligne de manière efficace :
with open("exemple.txt", "r") as f:
print("\nItération directe sur le fichier (efficace en mémoire):")
for ligne in f:
print(f"-> {ligne.strip()}")
- Différences entre Python 2 et Python 3
Python 2 et Python 3 sont deux versions majeures du langage qui ont introduit des changements significatifs, rendant le code incompatible entre les deux dans de nombreux cas. Python 2 n'est plus maintenu depuis 2020.
Voici quelques-unes des différences clés :
- Instruction
printvs. fonctionprint(): En Python 2,printest une instruction. En Python 3, c'est une fonction (nécessitant des parenthèses). - Division d'entiers : En Python 2,
5 / 2renvoie2(division entière). En Python 3,5 / 2renvoie2.5(division flottante). Pour la division entière en Python 3, utilisez//(par exemple,5 // 2renvoie2). - Chaînes de caractères (Unicode) : Python 2 a des types
str(ASCII par défaut) etunicode. Python 3 n'a qu'un seul typestrqui est Unicode par défaut, et un typebytespour les données binaires. xrangevs.range: En Python 2,range()crée une liste en mémoire, tandis quexrange()renvoie un itérateur. En Python 3,range()se comporte commexrange()de Python 2 (elle renvoie un itérateur) etxrange()n'existe plus.- Exceptions : La syntaxe pour capturer les exceptions a changé. En Python 2 :
except Exception, e:. En Python 3 :except Exception as e:. next()et__next__(): Les itérateurs en Python 2 ont une méthodenext(). En Python 3, elle est renommée__next__().- Classes : Toutes les classes en Python 3 sont des classes de "nouveau style" (héritent implicitement de
object). En Python 2, il fallait explicitement hériter deobjectpour obtenir les fonctionnalités des classes de nouveau style.
Pour un aperçu exhaustif, consultez cet article détaillé sur les principales différences entre Python 2.7.x et Python 3.x.
- L'utilisation de
super()avec__init__
La fonction super() en Python est utilisée pour accéder aux méthodes de la classe parente (ou "super-classe") dans une hiérarchie d'héritage. Son utilisation la plus courante est d'appeler le constructeur __init__() de la classe parente lors de l'initialisation d'une classe enfant. Cela garantit que les parties de l'initialisation gérées par les classes parentes sont correctement exécutées.
class Vehicule:
def __init__(self, marque):
self.marque = marque
print(f"Véhicule {self.marque} créé.")
class Voiture(Vehicule):
def __init__(self, marque, modele):
super().__init__(marque) # Appelle le __init__ de la classe Vehicule
self.modele = modele
print(f"Voiture {self.marque} {self.modele} créée.")
# En Python 2, la syntaxe était super(Voiture, self).__init__(marque)
# En Python 3, super().__init__(marque) est suffisant et plus concis.
mon_auto = Voiture("Renault", "Clio")
# Affiche:
# Véhicule Renault créé.
# Voiture Renault Clio créée.
L'avantage principal de super() est qu'il permet d'éviter de référencer explicitement la classe de base, ce qui rend le code plus robuste aux changements dans la hiérarchie d'héritage (par exemple, si la classe parente est renommée). Il est particulièrement puissant et nécessaire dans les scénarios d'héritage multiple pour garantir que toutes les méthodes __init__ des classes parentes sont appelées dans le bon ordre (selon le MRO de la classe), évitant ainsi le "problème du diamant" où une méthode de base pourrait être appelée plusieurs fois.
Notez le changement de syntaxe en Python 3.0 : vous pouvez simplement écrire super().__init__() au lieu de super(ChildB, self).__init__(), ce qui est plus élégant.
Pour plus d'informations : Comprendre super() avec les méthodes __init__ et super en Python 2.7.
range()etxrange()(en Python 2.x)
Ces deux fonctions sont utilisées pour générer des séquences numériques, principalement dans les boucles for, mais elles diffèrent par leur gestion de la mémoire et leur comportement (cette distinction est spécifique à Python 2.x).
range(début, fin, pas)(Python 2.x) : Crée et renvoie une liste contenant tous les nombres de la séquence spécifiée. Si vous appelezrange(1, 10000000), une liste de 9 999 999 éléments est générée et stockée entièrement en mémoire. Cela peut consommer une quantité significative de RAM pour de grandes séquences.xrange(début, fin, pas)(Python 2.x) : Renvoie un objetxrange, qui est un itérateur. Il génère les nombres "à la volée" (lazy evaluation), c'est-à-dire un par un au fur et à mesure que la boucle itère. Ainsi, il ne stocke pas toute la séquence en mémoire. Il est donc plus efficace en termes de mémoire pour les grandes séquences, similaire au comportement derange()en Python 3.
En Python 3 :
range()a été réimplémentée pour se comporter commexrange()de Python 2. Elle renvoie un objet "range" qui est un itérateur.xrange()n'existe plus.
Donc, en Python 3, la fonction range() est toujours la meilleure option pour les boucles sur des séquences numériques, car elle est optimisée pour la mémoire.
Pour plus de détails : Différence entre range et xrange en Python 2.X.
Systèmes d'exploitation
1. select(), poll() et epoll()
Ces trois appels système sont des mécanismes d'E/S multiplexées (I/O multiplexing) utilisés dans la programmation réseau pour permettre à un seul thread de gérer de multiples connexions simultanément. Ils permettent de surveiller plusieurs descripteurs de fichiers (sockets) pour des événements (lecture, écriture, erreurs) sans bloquer l'application.
Bien que tous effectuent une forme de "sondage" (polling) des descripteurs de fichiers, ils diffèrent significativement par leurs performances et leurs limitations :
select():- Limitations : Limité par le nombre maximal de descripteurs de fichiers qu'il peut gérer (généralement 1024 par défaut), défini par
FD_SETSIZE. - Performance : Scanne l'ensemble de tous les descripteurs de fichiers à chaque appel. Les données sur les événements doivent être copiées du noyau vers l'espace utilisateur. Cela le rend inefficace pour un grand nombre de connexions.
- Mécanisme : Utilise des masques de bits (
fd_set) pour indiquer les descripteurs à surveiller et ceux qui sont prêts.
- Limitations : Limité par le nombre maximal de descripteurs de fichiers qu'il peut gérer (généralement 1024 par défaut), défini par
poll():- Limitations : Résout la limitation de
FD_SETSIZEdeselect(), car il utilise un tableau de structurespollfdqui peut croître dynamiquement. - Performance : Comme
select(), il doit scanner la liste entière des descripteurs de fichiers à chaque appel, ce qui reste inefficace pour un très grand nombre de connexions actives. Les données sont toujours copiées.
- Limitations : Résout la limitation de
epoll()(Linux-specific) :- Limitations : Aucune limite pratique sur le nombre de descripteurs de fichiers.
- Performance : Améliore considérablement les performances pour un grand nombre de connexions.
- Pas de scan complet :
epollutilise un mécanisme de rappel (callback) au niveau du noyau pour notifier uniquement les descripteurs de fichiers qui sont prêts. Il n'a pas besoin de scanner toute la liste à chaque appel. - Pas de copie inutile : Les données d'événement sont stockées dans une structure de données partagée entre le noyau et l'espace utilisateur, réduisant les copies.
- Modes : Supporte à la fois le mode "edge-triggered" (déclenché sur front, notifie une fois par événement) et "level-triggered" (déclenché sur niveau, notifie tant que le descripteur est prêt).
- Pas de scan complet :
En résumé, epoll est le choix le plus performant pour la gestion d'un grand nombre de connexions concurrentes sur Linux (le fameux "problème C10K"), et des frameworks comme Tornado l'utilisent.
Pour plus de détails sur epoll : Comprendre epoll.
2. Algorithmes d'ordonnancement
Les systèmes d'exploitation utilisent divers algorithmes d'ordonnancement pour décider quel processus (ou thread) doit être exécuté par le CPU à un moment donné. L'objectif est d'optimiser l'utilisation du CPU, le débit, le temps de réponse et l'équité.
Voici quelques-uns des algorithmes d'ordonnancement les plus courants :
- Premier arrivé, premier servi (FCFS - First-Come, First-Served) : Les processus sont exécutés dans l'ordre où ils arrivent dans la file d'attente prête. Simple à implémenter mais peut entraîner un temps d'attente élevé pour les tâches courtes si une longue tâche arrive en premier.
- Plus court travail d'abord (SJF - Shortest Job First) : Le processus avec le temps d'exécution estimé le plus court est exécuté en premier. Optimal pour minimiser le temps d'attente moyen, mais difficile à implémenter car le temps d'exécution futur est rarement connu à l'avance. Peut être préemptif ou non-préemptif.
- Ordonnancement par priorité (Priority Scheduling) : Chaque processus se voit attribuer une priorité, et le CPU est alloué au processus ayant la priorité la plus élevée. Peut entraîner la famine (starvation) si les processus de faible priorité n'obtiennent jamais le CPU.
- Tourniquet (RR - Round Robin) : Chaque processus reçoit un petit quantum de temps (tranche de temps) pendant lequel il est autorisé à s'exécuter. Une fois le quantum écoulé, le processus est préempté et placé à la fin de la file d'attente prête. Idéal pour les systèmes interactifs.
- Ordonnancement par file d'attente à rétroaction multi-niveaux (Multilevel Feedback Queue Scheduling) : Combine plusieurs files d'attente, chacune avec son propre algorithme d'ordonnancement et sa propre priorité. Les processus peuvent se déplacer entre les files d'attente en fonction de leur comportement (par exemple, un processus qui utilise trop de CPU peut être déplacé vers une file de priorité inférieure).
Pour les systèmes temps réel, d'autres algorithmes sont utilisés :
- Plus tôt l'échéance d'abord (EDF - Earliest Deadline First) : Les processus sont prioritaires en fonction de leur échéance la plus proche.
- Moins de latence d'abord (LLF - Least Laxity First) : Priorise les processus qui ont la plus petite "marge" de temps avant leur échéance.
Un bon résumé des algorithmes d'ordonnancement est disponible sur JianShu.
3. Interblocage (Deadlock)
Un interblocage est une situation dans un système multi-processus où deux ou plusieurs processus sont bloqués indéfiniment, chacun attendant une ressource qui est détenue par un autre processus bloqué dans le même cycle.
Causes principales :
- Concurrence pour des ressources limitées.
- Séquence inappropriée d'acquisition et de libération des ressources par les processus.
Conditions nécessaires (les quatre conditions de Coffman) : Pour qu'un interblocage se produise, les quatre conditions suivantes doivent être remplies simultanément :
- Exclusion mutuelle : Au moins une ressource doit être non partageable, c'est-à-dire qu'un seul processus à la fois peut l'utiliser.
- Maintien et attente (Hold and Wait) : Un processus doit détenir au moins une ressource et attendre d'acquérir des ressources supplémentaires détenues par d'autres processus.
- Non-préemption : Une ressource ne peut pas être préemptée (forcément retirée) d'un processus qui la détient ; elle doit être libérée volontairement par le processus.
- Attente circulaire : Il doit exister une chaîne circulaire de deux processus ou plus, où chaque processus de la chaîne attend une ressource détenue par le suivant dans la chaîne.
Méthodes de gestion de l'interblocage :
- Prévention de l'interblocage : Vise à éliminer l'une des quatre conditions nécessaires.
- Nier l'exclusion mutuelle (pas toujours possible).
- Nier le maintien et l'attente (forcer les processus à demander toutes leurs ressources au début ou à les libérer avant d'en demander de nouvelles).
- Nier la non-préemption (permettre au système de retirer des ressources).
- Nier l'attente circulaire (ordonner les ressources et exiger que les processus les demandent dans un ordre croissant).
- Évitement de l'interblocage : Permet les trois premières conditions mais utilise des informations supplémentaires sur les besoins futurs en ressources pour décider si une allocation de ressources est sûre. L'algorithme du banquier est un exemple classique.
- Détection d'interblocage : Permet que l'interblocage se produise, mais le système détecte son apparition (par exemple, via un graphe d'allocation de ressources).
- Récupération d'interblocage : Une fois l'interblocage détecté, le système prend des mesures pour le briser, comme la préemption de ressources ou l'arrêt de processus.
Pour une description détaillée des stratégies de gestion de l'interblocage : Concepts de l'interblocage.
4. Compilation et liaison des programmes
Le processus de construction d'un programme exécutable à partir de code source est composé de plusieurs étapes distinctes. Prenons l'exemple du langage C :
-
**Prétraitement (Preprocessing) :**Le préprocesseur gère les directives commençant par
#. Il effectue les opérations suivantes :- Remplace les macros (
#define). - Traite les directives de compilation conditionnelle (
#if,#ifdef,#else,#endif). - Inclut les fichiers d'en-tête (
#include) en insérant leur contenu dans le fichier source. - Supprime les commentaires.
- Ajoute des informations de ligne et de fichier pour le débogage.
Le résultat est un fichier source étendu (souvent avec une extension
.ipour C). - Remplace les macros (
-
**Compilation (Compilation) :**Le compilateur prend le fichier prétraité et le traduit en code assembleur. Cette étape implique :
- Analyse lexicale (tokenisation).
- Analyse syntaxique (construction d'un arbre de syntaxe abstrait).
- Analyse sémantique (vérification de la signification du code).
- Optimisation du code.
- Génération du code assembleur (souvent avec une extension
.s).
-
**Assemblage (Assembly) :**L'assembleur prend le code assembleur généré et le convertit en code machine binaire (instructions exécutables par le CPU). Ce code machine est stocké dans un fichier objet (souvent avec une extension
.osur Unix/Linux ou.objsur Windows). Les fichiers objet contiennent le code machine, les données, et des informations sur les symboles (fonctions et variables) qui sont définis dans le fichier ou qui sont référencés mais non définis (symboles non résolus). -
**Liaison (Linking) :**Le lieur (linker) est l'étape finale. Il combine un ou plusieurs fichiers objet, ainsi que les bibliothèques nécessaires, pour créer un fichier exécutable final ou une bibliothèque partagée. Ses tâches principales incluent :
- Allocation d'adresses et de stockage : Attribue des adresses mémoire finales aux symboles.
- Résolution de symboles : Résout toutes les références aux symboles non définis en les faisant correspondre à leurs définitions dans d'autres fichiers objet ou bibliothèques.
- Relocalisation : Met à jour toutes les adresses dans le code machine pour refléter leurs emplacements finaux en mémoire.
Pour une explication plus détaillée du processus de compilation : Comprendre les compilateurs.
5. Liaison statique et liaison dynamique
Le processus de liaison, qui combine les fichiers objet et les bibliothèques pour créer un exécutable, peut être effectué de deux manières principales :
-
**Liaison statique (Static Linking) :**Lors de la liaison statique, le code de toutes les bibliothèques dont le programme a besoin est copié directement dans l'exécutable final. Le lieur résout toutes les références externes au moment de la compilation. Le résultat est un exécutable autonome qui ne dépend d'aucune bibliothèque externe au moment de l'exécution.
- Avantages : Exécutables autonomes, pas de problèmes de dépendances (DLL Hell), potentiellement plus rapides car toutes les fonctions sont locales.
- Inconvénients : Exécutables plus volumineux (chaque programme embarque sa propre copie des bibliothèques), les mises à jour de bibliothèques nécessitent une recompilation et une reliaison de tous les programmes, pas de partage de code en mémoire entre différents programmes utilisant la même bibliothèque.
-
**Liaison dynamique (Dynamic Linking) :**Avec la liaison dynamique, le code des bibliothèques n'est pas copié dans l'exécutable. Au lieu de cela, l'exécutable contient uniquement des références aux bibliothèques partagées (appelées bibliothèques dynamiques sur Linux, DLL sur Windows) et des informations sur les fonctions dont il a besoin. Le lieur dynamique (souvent une partie du système d'exploitation) charge ces bibliothèques en mémoire au moment de l'exécution du programme.
- Avantages : Exécutables plus petits, économie de mémoire (une seule copie d'une bibliothèque partagée peut être utilisée par plusieurs programmes), mises à jour de bibliothèques faciles (sans recompiler les programmes), permet le chargement dynamique de modules.
- Inconvénients : Dépendance aux bibliothèques externes (si une bibliothèque manque ou est incompatible, le programme ne s'exécute pas), légère surcharge à l'exécution pour la résolution des symboles.
La liaison dynamique est la méthode la plus courante dans les systèmes d'exploitation modernes.
6. Technologie de la mémoire virtuelle
La mémoire virtuelle est une technique de gestion de la mémoire qui donne à l'utilisateur l'illusion d'une quantité de mémoire physique plus grande que celle réellement disponible. Elle permet aux programmes d'accéder à des adresses mémoire (adresses virtuelles) qui sont ensuite traduites en adresses physiques par le matériel (unité de gestion de mémoire, MMU) et le système d'exploitation.
Les objectifs principaux de la mémoire virtuelle sont :
- Extension logique de la mémoire : Permet aux programmes d'utiliser un espace d'adressage plus grand que la RAM physique disponible en utilisant le stockage secondaire (disque dur) comme une extension de la mémoire.
- Isolation des processus : Chaque processus a son propre espace d'adressage virtuel, ce qui isole les processus les uns des autres et empêche un processus d'accéder ou de corrompre la mémoire d'un autre.
- Protection de la mémoire : Le système peut contrôler l'accès aux différentes régions de la mémoire virtuelle (par exemple, en lecture seule, en lecture/écriture) pour protéger le code et les données.
- Partage de mémoire : Des régions de mémoire virtuelle peuvent être mappées à la même mémoire physique pour permettre le partage de code ou de données entre processus.
- Chargement à la demande : Le système ne charge en mémoire physique que les parties d'un programme qui sont réellement nécessaires (paginage à la demande), ce qui réduit la consommation de RAM et permet de démarrer les programmes plus rapidement.
La mémoire virtuelle repose sur des techniques de pagination et/ou de segmentation.
7. Pagination et Segmentation
La pagination et la segmentation sont deux techniques de gestion de la mémoire qui permettent de mettre en œuvre la mémoire virtuelle et l'allocation non contiguë de la mémoire.
- Pagination (Paging) :
- Unité : L'espace d'adressage logique d'un programme est divisé en blocs de taille fixe appelés "pages". La mémoire physique est divisée en blocs de même taille appelés "blocs (frames) de page".
- Allocation : Toute page logique peut être placée dans n'importe quel bloc physique disponible. Cela permet une allocation non contiguë et réduit la fragmentation externe.
- Point de vue : Orienté système. La division en pages est transparente pour le programmeur et est gérée par le matériel. L'espace d'adressage est unidimensionnel.
- Segmentation (Segmentation) :
- Unité : L'espace d'adressage logique d'un programme est divisé en blocs de taille variable appelés "segments". Chaque segment correspond à une unité logique du programme (par exemple, le code, la pile, le tas, une fonction, un tableau).
- Allocation : Chaque segment est alloué de manière contiguë en mémoire physique, mais les segments eux-mêmes peuvent être dispersés dans la mémoire physique.
- Point de vue : Orienté utilisateur/programmeur. La segmentation reflète la structure logique du programme et offre une meilleure protection et un partage plus facile des modules logiques. L'espace d'adressage est bidimensionnel (numéro de segment, offset).
Différences clés entre pagination et segmentation :
- Unité d'information : Les pages sont des unités physiques de taille fixe (pour l'efficacité matérielle), tandis que les segments sont des unités logiques de taille variable (pour la pertinence sémantique).
- Problème résolu : La pagination résout principalement le problème de la fragmentation externe et permet une allocation non contiguë. La segmentation vise à mieux organiser et protéger les composants logiques du programme et à faciliter le partage.
- Transparence : La pagination est généralement transparente pour l'utilisateur. La segmentation est visible pour l'utilisateur et reflète la structure du code.
- Espace d'adressage : La pagination utilise un espace d'adressage unidimensionnel (adresse virtuelle = numéro de page + offset). La segmentation utilise un espace d'adressage bidimensionnel (adresse virtuelle = numéro de segment + offset).
Les systèmes modernes utilisent souvent une combinaison de pagination et de segmentation, ou un système paginé qui offre également des fonctionnalités de protection et de partage similaires à la segmentation.
8. Algorithmes de remplacement de page
Lorsqu'un programme tente d'accéder à une page qui n'est pas en mémoire physique (faute de page), le système d'exploitation doit choisir une page présente en mémoire à remplacer pour faire de la place à la nouvelle page. Les algorithmes de remplacement de page visent à minimiser le nombre de fautes de page.
- **Algorithme de remplacement optimal (OPT - Optimal Page Replacement) :**Cet algorithme remplace la page qui ne sera pas utilisée pendant la plus longue période dans le futur. Il est théoriquement optimal et produit le moins de fautes de page. Cependant, il est impossible à implémenter dans la pratique car il nécessite la connaissance du futur.
- **Algorithme premier entré, premier sorti (FIFO - First-In, First-Out) :**Remplace la page qui est restée le plus longtemps en mémoire. Il est simple à implémenter mais ne tient pas compte de l'utilisation future ou de la fréquence d'accès des pages. Peut souffrir de l'anomalie de Belady (augmentation du nombre de fautes de page lorsque le nombre de blocs de page augmente).
- **Algorithme LRU (Least Recently Used) :**Remplace la page qui n'a pas été utilisée depuis le plus longtemps. Il est basé sur l'heuristique que les pages utilisées récemment sont susceptibles d'être utilisées à nouveau bientôt. C'est un bon estimateur de l'algorithme OPT. L'implémentation est coûteuse (nécessite de suivre l'heure d'accès ou l'ordre d'utilisation de chaque page).
- **Algorithme de l'horloge (Clock Algorithm) :**Une approximation plus efficace du LRU. Chaque page a un bit de référence. Lorsqu'une page est référencée, son bit est mis à 1. Lors d'une faute de page, l'algorithme parcourt les pages en cercle. Si le bit de référence est 0, la page est remplacée. Si le bit est 1, il est remis à 0, et l'algorithme passe à la page suivante.
D'autres algorithmes existent (comme LFU - Least Frequently Used, Second-Chance, etc.), chacun avec ses compromis entre complexité d'implémentation et efficacité.
9. Déclenchement sur front et déclenchement sur niveau
Ces termes décrivent deux modes de signalisation d'événements, souvent utilisés dans le contexte des E/S asynchrones (comme epoll) ou des interruptions matérielles :
- Déclenchement sur front (Edge-Triggered) : Un événement est signalé (notifié) uniquement lorsque l'état du descripteur de fichier change (par exemple, des données arrivent et le descripteur passe de "non prêt" à "prêt à lire"). Le système d'exploitation notifie l'application une seule fois pour cette transition. L'application doit alors lire toutes les données disponibles sur ce descripteur jusqu'à ce qu'il ne soit plus prêt, sinon elle ne sera pas notifiée à nouveau tant qu'un nouveau changement d'état (nouvelles données) ne se produit pas.
- Déclenchement sur niveau (Level-Triggered) : Un événement est signalé (notifié) tant que la condition est vraie (par exemple, le descripteur de fichier contient des données prêtes à être lues). Le système d'exploitation continue de notifier l'application à chaque cycle d'événement tant que des données sont disponibles, même si elles n'ont pas été lues. L'application peut lire les données progressivement.
Comparaison :
- Le mode "edge-triggered" est plus performant car il génère moins de notifications (moins de réveils du CPU). Cependant, il exige que l'application soit plus attentive à vider complètement le tampon du descripteur à chaque notification pour ne pas manquer de futurs événements.
- Le mode "level-triggered" est plus simple à programmer car l'application n'a pas besoin de vider tout le tampon immédiatement. Le système continuera de notifier jusqu'à ce que la condition ne soit plus remplie.
epoll sur Linux supporte les deux modes, offrant de la flexibilité aux développeurs.
Bases de données
1. Transactions de bases de données
Une transaction de base de données représente une séquence d'opérations exécutées comme une seule unité de travail logique. Son principe fondamental est qu'elle doit être exécutée soit entièrement, soit pas du tout. Les transactions garantissent la fiabilité des bases de données face aux pannes du système et aux accès concurrents. Elles sont caractérisées par les propriétés ACID :
- Atomicité (Atomicity) : Une transaction est une unité indivisible de travail. Soit toutes les opérations d'une transaction sont réussies et validées (commit), soit toutes sont annulées (rollback) en cas d'échec, ramenant la base de données à son état initial.
- Cohérence (Consistency) : Une transaction doit amener la base de données d'un état valide à un autre état valide. Elle doit respecter toutes les contraintes d'intégrité (clés primaires, étrangères, contraintes de validation, etc.).
- Isolation (Isolation) : L'exécution simultanée de plusieurs transactions doit produire le même résultat que si elles avaient été exécutées séquentiellement. Chaque transaction doit paraître s'exécuter de manière isolée des autres transactions concurrentes.
- Durabilité (Durability) : Une fois qu'une transaction a été validée (commit), ses modifications sont permanentes et survivent aux pannes du système (par exemple, une coupure de courant).
Pour une compréhension approfondie des transactions de bases de données : Comprendre les transactions de base de données.
2. Index de bases de données
Les index de bases de données sont des structures de données spéciales conçues pour accélérer la récupération des données à partir d'une table. Ils fonctionnent comme l'index d'un livre, permettant au SGBD de trouver rapidement les lignes pertinentes sans avoir à parcourir toute la table (scan de table complet).
Concepts clés :
- Index B-Tree / B+Tree : Ce sont les structures d'index les plus courantes dans les bases de données relationnelles. Un B+Tree est une variante du B-Tree optimisée pour les disques durs, où toutes les données (ou pointeurs vers les données) sont stockées dans les nœuds feuilles, et les nœuds internes ne contiennent que des clés pour guider la recherche. Cela garantit un nombre égal d'accès disque pour toutes les recherches et permet des parcours de plages efficaces.
- Index clusterisé (Clustered Index) : L'index clusterisé détermine l'ordre physique de stockage des données dans la table. Une table ne peut avoir qu'un seul index clusterisé. La table est triée physiquement sur les colonnes de l'index. Les recherches sont très rapides car les données sont déjà dans l'ordre de l'index. (Ex: Clé primaire en InnoDB).
- Index non clusterisé (Non-Clustered Index) : L'index non clusterisé ne détermine pas l'ordre physique des données. Il est stocké séparément de la table de données et contient des pointeurs vers l'emplacement réel des données dans la table (ou vers la clé primaire de l'index clusterisé). Une table peut avoir plusieurs index non clusterisés.
- Principe du préfixe le plus à gauche (Leftmost Prefix Principle) : Pour les index composés (sur plusieurs colonnes), l'index peut être utilisé pour les requêtes qui filtrent sur le préfixe le plus à gauche des colonnes de l'index. Par exemple, un index sur
(col1, col2, col3)peut être utilisé pour des requêtes surcol1,(col1, col2), ou(col1, col2, col3), mais pas seulement surcol2ou(col2, col3).
Pour plus d'informations :
3. Principes de Redis
Redis (Remote Dictionary Server) est un magasin de données en mémoire, open source, qui peut être utilisé comme base de données, cache et broker de messages. Il est largement considéré comme un "serveur de structures de données" en raison de sa prise en charge riche de divers types de données.
Qu'est-ce que Redis ?
- C'est une base de données clé-valeur entièrement open source et gratuite, rapide et polyvalente.
- Il prend en charge des structures de données avancées comme les chaînes de caractères (strings), les listes (lists), les ensembles (sets), les ensembles triés (sorted sets), les hachages (hashes) et les HyperLogLogs.
- Souvent utilisé comme système de mise en cache, système de messagerie (avec ses listes agissant comme files d'attente de messages), et pour des applications nécessitant un accès rapide aux données.
Avantages de Redis :
- Vitesse élevée : Écrit en C, Redis stocke les données en mémoire, offrant des performances de lecture et d'écriture extrêmement rapides (souvent des centaines de milliers d'opérations par seconde).
- Persistance : Bien que principalement en mémoire, Redis offre des options de persistance pour sauvegarder les données sur disque.
- RDB (Redis Database) : Sauvegarde ponctuelle (snapshots) de l'ensemble des données à intervalles réguliers ou après un certain nombre de modifications.
- AOF (Append-Only File) : Journalise toutes les opérations d'écriture sur disque. Cela offre une meilleure durabilité des données.
- Opérations atomiques : Les opérations sur les types de données sont atomiques, garantissant la sécurité des threads et la cohérence des données.
- Réplication maître-esclave rapide : Permet une réplication asynchrone pour la haute disponibilité et la scalabilité en lecture.
- Sharding (partitionnement) : Facilite la distribution des données sur plusieurs instances Redis, permettant une scalabilité horizontale pour gérer de grands ensembles de données.
Inconvénients de Redis :
- Limitations de mémoire physique : La capacité de la base de données est limitée par la quantité de RAM disponible sur le serveur. Ce n'est pas idéal pour des volumes de données massifs qui ne peuvent pas tenir en mémoire.
- Complexité de mise à l'échelle en ligne : Bien que le sharding soit possible, l'ajout dynamique de nœuds à un cluster Redis existant sans interruption peut être complexe.
4. Verrouillage optimiste et pessimiste
Ce sont deux stratégies courantes pour gérer l'accès concurrent aux données et garantir leur intégrité dans un environnement multi-utilisateurs.
-
**Verrouillage pessimiste (Pessimistic Locking) :**Suppose qu'il est probable que des conflits de concurrence se produiront. Pour éviter cela, un verrou est posé sur les données dès le début de l'opération, empêchant tout autre utilisateur d'accéder ou de modifier ces données tant que le verrou n'est pas libéré. C'est une approche "méfiance par défaut".
- Avantages : Garantit une forte intégrité des données en empêchant les conflits.
- Inconvénients : Peut réduire les performances en bloquant l'accès aux données, même lorsque les conflits sont rares. Peut entraîner des interblocages.
- Exemple : Utilisation de
SELECT ... FOR UPDATEen SQL.
-
**Verrouillage optimiste (Optimistic Locking) :**Suppose qu'il est peu probable que des conflits de concurrence se produiront. Les données ne sont pas verrouillées au début. Au lieu de cela, une vérification est effectuée au moment de la validation pour s'assurer que les données n'ont pas été modifiées par un autre utilisateur depuis leur lecture initiale. Si une modification est détectée, l'opération est annulée ou l'utilisateur est invité à réessayer. C'est une approche "confiance par défaut".
- Avantages : Meilleures performances en l'absence de conflits élevés, car les données ne sont pas bloquées.
- Inconvénients : Nécessite une logique de gestion des conflits (rejet, fusion) et des mécanismes pour détecter les modifications (par exemple, une colonne de version ou un timestamp).
- Exemple : Ajout d'une colonne
versionoulast_updatedà une table. Lors de la mise à jour, la clauseWHEREinclut la vérification de cette colonne.
Le choix entre les deux dépend de la probabilité de conflits dans l'application. Le verrouillage pessimiste est adapté aux environnements avec des conflits élevés, tandis que l'optimiste est préférable pour des conflits faibles ou modérés.
Pour une explication détaillée : Différence entre verrouillage optimiste et pessimiste.
5. MVCC (Multi-Version Concurrency Control)
MVCC, ou Multi-Version Concurrency Control, est une technique de gestion de la concurrence utilisée par de nombreux systèmes de gestion de bases de données (SGBD) pour fournir une isolation transactionnelle sans verrous coûteux pour les opérations de lecture. Sous MVCC, chaque transaction de lecture voit une "instantané" (snapshot) cohérent de la base de données au moment où elle a commencé, et les lectures ne bloquent pas les écritures, ni les écritures ne bloquent les lectures.
Le principe clé est que le SGBD maintient plusieurs versions d'une même ligne de données. Chaque version est associée à un numéro de version (généralement un timestamp ou un ID de transaction global et croissant).
Implémentation de MVCC dans InnoDB (MySQL) :
InnoDB ajoute deux champs cachés à chaque ligne de données pour le MVCC (en plus de l'ID de transaction et du pointeur de rollback) :
- ID de version de création (
TRX_ID) : L'ID de la transaction qui a créé cette version de la ligne. - ID de version de suppression (
DB_ROLL_PTR/ pointeur vers l'enregistrement de suppression) : L'ID de la transaction qui a marqué cette version de la ligne comme supprimée (ou un pointeur vers l'enregistrement d'annulation si la ligne a été mise à jour). Si la ligne n'est pas supprimée, ce champ est nul.
Lorsqu'une transaction lit des données avec un niveau d'isolation comme "Repeated Read" ou "Read Committed", InnoDB applique les règles suivantes pour déterminer quelle version d'une ligne doit être visible :
SELECT: Une ligne est visible si :- Son
TRX_ID(version de création) est inférieur ou égal à l'ID de la transaction de lecture. (La ligne existait avant ou a été créée par la transaction courante). - Son ID de version de suppression est soit nul, soit supérieur à l'ID de la transaction de lecture. (La ligne n'a pas été supprimée par une transaction déjà validée).
- Son
INSERT: LeTRX_IDde la nouvelle ligne est défini à l'ID de la transaction actuelle.DELETE: L'ID de version de suppression de la ligne est défini à l'ID de la transaction actuelle. (La ligne n'est pas physiquement supprimée immédiatement).UPDATE: Une mise à jour n'est pas une opération "in-place". Elle est traitée comme une combinaison d'unDELETElogique et d'unINSERT. La ligne originale est marquée comme supprimée (son ID de version de suppression est défini à l'ID de la transaction actuelle), et une nouvelle version de la ligne est insérée avec l'ID de la transaction actuelle commeTRX_ID.
Les anciennes versions des lignes qui ont été supprimées ou mises à jour sont conservées dans l'historique des rollbacks (segment d'annulation) et sont éventuellement purgées par un thread de nettoyage en arrière-plan (processus purge) lorsque plus aucune transaction active n'a besoin de les voir.
MVCC permet d'obtenir des niveaux d'isolation comme "Read Committed" et "Repeatable Read" sans avoir besoin de verrous de lecture bloquants, améliorant ainsi la concurrence et les performances.
Référence : Analyse MVCC.
6. Comparaison de MyISAM et InnoDB (Moteurs de stockage MySQL)
MyISAM et InnoDB sont deux moteurs de stockage (Storage Engines) largement utilisés dans MySQL, chacun ayant des caractéristiques et des cas d'utilisation optimaux différents.
MyISAM :
- Type de table : Basé sur des fichiers, stocke les données et les index séparément.
- Transactions : Ne supporte PAS les transactions (ni les propriétés ACID).
- Verrouillage : Verrouillage au niveau de la table (table-level locking). Cela signifie que toute opération d'écriture (INSERT, UPDATE, DELETE) sur une table verrouille la table entière, empêchant d'autres opérations, même les lectures, pendant la durée du verrou.
- Clés étrangères : Ne supporte PAS les contraintes de clés étrangères (donc pas d'intégrité référentielle imposée par le moteur).
- Compteur de lignes : Très rapide pour
SELECT COUNT(*) FROM table;car le nombre total de lignes est stocké en interne. - Cas d'utilisation : Adapté aux applications avec des lectures intensives et peu d'écritures, en particulier où la concurrence en écriture n'est pas une préoccupation majeure, ou pour des tables qui sont principalement des journaux ou des données statiques.
InnoDB :
- Type de table : Orienté transaction, stocke les données et les index ensemble dans un index clusterisé.
- Transactions : Supporte les transactions et les propriétés ACID (Atomicité, Cohérence, Isolation, Durabilité).
- Verrouillage : Verrouillage au niveau de la ligne (row-level locking). Cela permet une concurrence élevée, car différentes transactions peuvent modifier différentes lignes de la même table simultanément sans se bloquer mutuellement. Utilise le MVCC.
- Clés étrangères : Supporte les contraintes de clés étrangères, garantissant l'intégrité référentielle.
- Compteur de lignes :
SELECT COUNT(*) FROM table;peut être lent car il doit parcourir l'index pour compter les lignes (sauf pour des cas très spécifiques où l'optimiseur peut faire mieux). - Récupération : Offre des fonctionnalités de récupération après crash grâce à ses journaux de transactions (redo logs, undo logs).
- Cas d'utilisation : Idéal pour les applications nécessitant une haute concurrence, des transactions, une intégrité des données (clés étrangères), et une tolérance aux pannes. C'est le moteur par défaut pour MySQL depuis la version 5.5.
En général, InnoDB est le choix préféré pour la plupart des applications modernes en raison de sa fiabilité, de sa gestion de la concurrence et de ses fonctionnalités transactionnelles.
Plus d'informations :
Réseaux
1. Poignée de main en trois étapes (Three-way Handshake)
La poignée de main en trois étapes est le mécanisme utilisé par le protocole TCP (Transmission Control Protocol) pour établir une connexion fiable entre un client et un serveur avant que des données ne soient échangées. C'est un processus en trois messages :
- **SYN (Synchronize Sequence Number) - Client vers Serveur :**Le client initie la connexion en envoyant un segment TCP avec le bit SYN activé (SYN=1) au serveur. Il inclut un numéro de séquence initial (ISN) aléatoire, disons
X. Ce message indique au serveur que le client souhaite établir une connexion et quel sera son numéro de séquence initial. - **SYN-ACK (Synchronize-Acknowledge) - Serveur vers Client :**Si le serveur est disponible et accepte la connexion, il répond avec un segment TCP qui a les bits SYN et ACK activés (SYN=1, ACK=1). Le numéro d'acquittement (ACK number) est
X+1, confirmant la réception du SYN du client. Le serveur inclut également son propre numéro de séquence initial aléatoire, disonsY. Ce message confirme que le serveur a bien reçu la requête du client et qu'il est prêt à établir la connexion. - **ACK (Acknowledge) - Client vers Serveur :**Le client reçoit le SYN-ACK du serveur et répond avec un segment TCP qui a le bit ACK activé (ACK=1). Le numéro d'acquittement est
Y+1, confirmant la réception du SYN du serveur. Ce dernier message établit la connexion des deux côtés. Le serveur et le client sont maintenant prêts à échanger des données.
À la fin de ce processus, une connexion bidirectionnelle et full-duplex est établie.
2. Fermeture en quatre étapes (Four-way Handshake)
La fermeture d'une connexion TCP est un processus en quatre étapes, plus complexe que l'établissement car chaque côté doit indépendamment indiquer qu'il a terminé d'envoyer des données. N'importe quel côté (client ou serveur) peut initier la fermeture. Voici un exemple où le client initie la fermeture :
- **FIN (Finish) - Client vers Serveur :**Le client, ayant terminé d'envoyer ses données, envoie un segment TCP avec le bit FIN activé (FIN=1) au serveur. Cela indique que le client n'a plus de données à envoyer, mais qu'il est toujours prêt à en recevoir du serveur. Le client passe à l'état
FIN_WAIT_1. - **ACK (Acknowledge) - Serveur vers Client :**Le serveur reçoit le FIN du client et répond avec un segment ACK (ACK=1), acquittant le FIN du client. Le serveur passe à l'état
CLOSE_WAIT. À ce stade, le serveur sait que le client ne lui enverra plus de données, mais le serveur peut encore continuer à envoyer des données au client si nécessaire. - **FIN (Finish) - Serveur vers Client :**Une fois que le serveur a terminé d'envoyer toutes ses données restantes au client, il envoie à son tour un segment FIN (FIN=1) au client. Le serveur passe à l'état
LAST_ACK. - **ACK (Acknowledge) - Client vers Serveur :**Le client reçoit le FIN du serveur et répond avec un segment ACK (ACK=1), acquittant le FIN du serveur. Le client passe à l'état
TIME_WAIT. C'est un état d'attente crucial, généralement de 2 * MSL (Maximum Segment Lifetime), pour s'assurer que le serveur a bien reçu l'ACK final du client et que tous les paquets du réseau ont eu le temps d de disparaître. Après cet état, la connexion est complètement fermée du côté client. Le serveur reçoit l'ACK final et ferme également sa connexion.
Diagramme explicatif : Explication graphique.
3. Protocole ARP (Address Resolution Protocol)
Le protocole ARP (Address Resolution Protocol) est un protocole de la couche liaison de données (couche 2 du modèle OSI) qui est essentiel pour la communication sur les réseaux locaux basés sur IPv4. Sa fonction principale est de traduire une adresse IP logique (couche 3) en une adresse MAC physique (couche 2) correspondante.
Lorsque qu'un appareil souhaite communiquer avec un autre appareil sur le même réseau local et connaît son adresse IP, mais pas son adresse MAC, il utilise ARP :
- L'appareil émet une requête ARP (ARP Request) en diffusion (broadcast) sur le réseau. Cette requête contient l'adresse IP de la cible et demande son adresse MAC.
- Tous les appareils du réseau reçoivent la requête. Seul l'appareil dont l'adresse IP correspond à l'adresse IP cible dans la requête ARP répondra.
- L'appareil cible envoie une réponse ARP (ARP Reply) directement à l'expéditeur. Cette réponse contient son adresse MAC.
- L'expéditeur reçoit la réponse et met à jour sa table ARP (un cache d'adresses IP-MAC) avec l'entrée correspondante. Il peut alors envoyer des paquets de données directement à l'adresse MAC de la cible.
ARP est indispensable pour IPv4. En revanche, dans IPv6, il a été remplacé par le Neighbor Discovery Protocol (NDP), qui intègre des fonctionnalités similaires mais étendues.
4. Différences entre urllib et urllib2 (en Python 2.x)
En Python 2.x, urllib et urllib2 étaient deux modules distincts pour interagir avec des URLs. Ils avaient des capacités et des utilisations différentes :
urllib(Python 2.x) :- Plus simple et plus ancien.
- Fournit la fonction
urlencode(), utile pour générer des chaînes de requête GET à partir de dictionnaires de paramètres. - Ne peut pas facilement gérer des requêtes HTTP plus complexes, comme l'ajout d'en-têtes HTTP personnalisés (User-Agent, etc.) ou l'authentification. Il n'accepte qu'une URL simple comme argument pour l'ouverture.
- Gère mal les erreurs HTTP (comme les codes 4xx ou 5xx).
urllib2(Python 2.x) :- Plus puissant et flexible.
- Peut accepter un objet
Request(urllib2.Request) comme argument, ce qui permet de spécifier des en-têtes HTTP personnalisés, de choisir la méthode HTTP (GET, POST), de gérer l'authentification et les redirections. - Permet de définir des "handlers" pour personnaliser le comportement (par exemple, pour les proxies, les cookies, l'authentification).
- Gère mieux les erreurs HTTP en levant des exceptions
urllib2.HTTPError.
Il était courant d'utiliser urllib pour encoder les paramètres d'URL (avec urlencode) et ensuite de passer la chaîne encodée à urllib2 pour la requête.
En Python 3 :
Les fonctionnalités de urllib, urllib2, urlparse et robotparser ont été refactorisées et regroupées dans un seul package nommé urllib, divisé en sous-modules :
urllib.request: Pour l'ouverture d'URLs (remplaceurllib2).urllib.error: Contient les exceptions levées parurllib.request.urllib.parse: Pour l'analyse et l'encodage d'URLs (remplaceurllib.urlparseeturllib.quote/urllib.urlencode).urllib.robotparser: Pour l'analyse de fichiersrobots.txt.
Il n'y a donc plus de distinction urllib vs urllib2 en Python 3.
5. Méthodes HTTP : GET et POST
GET et POST sont deux des méthodes HTTP les plus courantes, utilisées pour interagir avec des ressources sur le web. Elles ont des sémantiques et des caractéristiques différentes :
Méthode GET :
- Sémantique : Conçue pour récupérer des données d'un serveur. Elle est censée être idempotente et sans effet secondaire (safe).
- Paramètres : Envoyés dans la chaîne de requête de l'URL (par exemple,
/page?param1=val1¶m2=val2). - Visibilité : Les paramètres sont visibles dans l'URL (journaux du serveur, historique du navigateur).
- Longueur : Limitée par la longueur maximale des URLs supportée par les navigateurs et les serveurs.
- Mise en cache : Les requêtes GET peuvent être mises en cache par les navigateurs et les serveurs proxy.
- Exemples : Accéder à une page web, rechercher sur un site, récupérer une image.
Méthode POST :
- Sémantique : Conçue pour soumettre des données au serveur afin qu'elles soient traitées ou stockées (par exemple, créer une nouvelle ressource, mettre à jour une ressource). N'est pas nécessairement idempotente ni sans effet secondaire (unsafe).
- Paramètres : Envoyés dans le corps de la requête HTTP, et non dans l'URL.
- Visibilité : Les paramètres ne sont pas directement visibles dans l'URL, ce qui les rend plus appropriés pour les données sensibles.
- Longueur : Généralement aucune limite pratique sur la quantité de données pouvant être envoyées.
- Mise en cache : Les requêtes POST ne sont pas mises en cache par défaut (bien qu'elles puissent l'être sous certaines conditions, ce n'est pas la règle).
- Exemples : Soumettre un formulaire de connexion, télécharger un fichier, ajouter un commentaire.
Une discussion souvent citée sur les différences entre GET et POST se trouve sur Zhihu et les RFC HTTP (RFC 2616 GET, RFC 2616 POST).
6. Cookies et Sessions
Les cookies et les sessions sont deux mécanismes principaux pour maintenir un état (stateful) dans le protocole HTTP, qui est par nature sans état (stateless).
| Caractéristique | Cookie | Session |
|---|---|---|
| Lieu de stockage | Côté client (navigateur web) | Côté serveur |
| But principal | Suivi de session, mémorisation des préférences utilisateur, connexion automatique, paniers d'achat | Suivi de session, stockage de données sensibles, gestion de l'authentification et de l'autorisation |
| Sécurité | Moins sécurisé (vulnérable aux attaques XSS, peut être intercepté si non chiffré) | Plus sécurisé (les données sont côté serveur, seul l'ID de session est transmis au client) |
| Taille | Limitée (généralement 4KB par cookie, nombre limité par domaine) | Potentiellement plus grande, limitée par les ressources du serveur |
| Expiration | Peut être configurée pour expirer après une durée spécifiée ou à la fermeture du navigateur | Expire généralement après une période d'inactivité ou à la fermeture du navigateur (peut être configurée) |
Relation entre les deux :
La technologie des sessions repose souvent sur les cookies. Lorsqu'une session est créée sur le serveur, un ID de session unique est généré. Cet ID est ensuite envoyé au client, généralement sous la forme d'un cookie. Chaque fois que le client fait une nouvelle requête, il envoie ce cookie ID de session au serveur, permettant au serveur de récupérer l'état de la session associé. Ainsi, les cookies agissent comme un moyen pour le client d'informer le serveur de l'ID de session qu'il utilise.
L'avantage principal de la session est la sécurité. Les données sensibles ne quittent jamais le serveur, minimisant les risques d'exposition côté client.
7. Différences entre Apache et Nginx
Apache HTTP Server (souvent appelé simplement Apache) et Nginx (prononcé "Engine-X") sont deux des serveurs web les plus populaires. Bien qu'ils remplissent la même fonction de base de servir du contenu web, ils ont des architectures et des performances différentes.
Avantages de Nginx par rapport à Apache :
- Légèreté et efficacité : Nginx est conçu pour être très léger et consomme moins de mémoire et de ressources CPU que Apache pour le même volume de trafic.
- Haute concurrence : Nginx utilise une architecture asynchrone, non bloquante et basée sur des événements (event-driven). Cela lui permet de gérer un très grand nombre de connexions concurrentes avec une faible consommation de ressources, ce qui le rend excellent pour les serveurs à forte charge.
- Configuraton simple : La configuration de Nginx est souvent perçue comme plus simple et plus concise.
- Conception modulaire : Sa conception est hautement modulaire, ce qui facilite l'écriture de nouveaux modules et l'extension de ses fonctionnalités.
- Proxy inverse et équilibreur de charge : Nginx excelle en tant que proxy inverse, équilibreur de charge et cache HTTP, ce qui en fait un composant central dans les architectures distribuées.
Avantages d'Apache par rapport à Nginx :
- Module
mod_rewrite: Le module de réécriture d'URL d'Apache est considéré comme plus puissant et flexible que celui de Nginx. - Richesse des modules : Apache dispose d'un écosystème de modules beaucoup plus vaste et mature, couvrant presque tous les cas d'utilisation imaginables.
- Moins de bugs historiques : Étant plus ancien et plus testé, Apache a historiquement une réputation d'être extrêmement stable avec moins de bugs.
- Fichiers
.htaccess: Permet la configuration par répertoire, ce qui est pratique pour les hébergements partagés où les utilisateurs n'ont pas accès à la configuration principale du serveur. - Stabilité : Réputé pour sa robustesse et sa stabilité à long terme.
Dans de nombreuses architectures modernes, Nginx est souvent utilisé comme proxy inverse devant Apache (ou un serveur d'application comme Gunicorn/uWSGI pour Python), où Nginx gère les connexions clientes et sert les fichiers statiques rapidement, tandis qu'Apache gère la logique d'application plus complexe.
8. Stockage sécurisé des mots de passe des utilisateurs sur les sites web
Stocker les mots de passe des utilisateurs de manière sécurisée est une exigence critique pour toute application web. Voici une évolution des pratiques, des moins sécurisées aux plus recommandées :
- **Stockage en texte clair :**À PROSCRIRE ABSOLUMENT. C'est la méthode la plus dangereuse. En cas de compromission de la base de données, tous les mots de passe sont directement exposés. Cela viole les principes fondamentaux de sécurité.
- **Hachage en texte clair (par exemple, MD5, SHA-1) :**Une légère amélioration. Le mot de passe n'est pas stocké directement, mais plutôt sa version hachée (par exemple,
md5("monmotdepasse")). Lorsque l'utilisateur se connecte, le mot de passe entré est haché et comparé au hachage stocké. Cependant, les algorithmes comme MD5 ou SHA-1 sont rapides et ne sont plus considérés comme sûrs pour les mots de passe car ils sont vulnérables aux attaques par dictionnaire et aux tables arc-en-ciel (rainbow tables). - **Hachage avec sel (Salt) (par exemple, MD5+Salt, SHA-256+Salt) :**C'est un standard minimal. Un "sel" (salt) est une chaîne de caractères aléatoire et unique qui est ajoutée au mot de passe avant le hachage. Le sel est stocké avec le hachage. Cela empêche l'utilisation de tables arc-en-ciel et rend chaque mot de passe haché unique, même si deux utilisateurs ont le même mot de passe. Le hachage avec sel rend les attaques par dictionnaire plus difficiles, car chaque mot de passe doit être haché individuellement avec son propre sel.
- **Algorithmes de hachage adaptatifs et résistants (par exemple, bcrypt, scrypt, Argon2, PBKDF2) :**Méthode fortement recommandée. Ces algorithmes sont conçus pour être lents et coûteux en calcul, ce qui augmente considérablement le temps nécessaire pour effectuer une attaque par force brute, même avec du matériel puissant. Ils intègrent également un sel et un "facteur de coût" (nombre d'itérations) qui peut être ajusté pour s'adapter à l'évolution de la puissance de calcul. Des sites comme Zhihu utilisent ou ont utilisé des algorithmes similaires à Bcrypt.
La pratique actuelle la plus sûre est d'utiliser un algorithme de hachage adaptatif (comme Argon2, qui est le plus récent recommandé) avec un sel unique et aléatoire pour chaque mot de passe, et d'ajuster le facteur de coût pour le rendre suffisamment lent.
9. HTTP et HTTPS
HTTP (Hypertext Transfer Protocol) est le protocole fondamental utilisé pour transmettre des informations sur le World Wide Web. HTTPS (Hypertext Transfer Protocol Secure) est l'extension sécurisée de HTTP.
Différences clés :
| Caractéristique | HTTP | HTTPS |
|---|---|---|
| Sécurité | Non sécurisé, les données sont transmises en texte clair. | Sécurisé, les données sont chiffrées (cryptées) avant la transmission. |
| Port par défaut | 80 | 443 |
| Certificat | Pas de certificat nécessaire. | Nécessite un certificat SSL/TLS (émis par une autorité de certification). |
| Couche OSI/TCP/IP | Couche application. | HTTP sur une couche de sécurité SSL/TLS (entre la couche application et transport). |
| Performance | Légèrement plus rapide car pas de surcoût de chiffrement/déchiffrement. | Légèrement plus lent au début (poignée de main SSL/TLS), mais négligeable ensuite. |
Codes d'état HTTP courants :
| Gamme | Définition |
|---|---|
1xx (Informations) |
Requête reçue, traitement en cours. |
2xx (Succès) |
Action reçue, comprise et acceptée avec succès. |
3xx (Redirection) |
Des actions supplémentaires doivent être prises pour compléter la requête. |
4xx (Erreur client) |
La requête contient une erreur ou ne peut pas être traitée. |
5xx (Erreur serveur) |
Le serveur n'a pas pu satisfaire une requête apparemment valide. |
Exemples d'erreurs 4xx : 403 Forbidden (accès non autorisé), 404 Not Found (ressource non trouvée).
Fonctionnement de HTTPS :
HTTPS utilise le protocole SSL (Secure Sockets Layer) ou son successeur TLS (Transport Layer Security) pour chiffrer la communication entre le client et le serveur. Le processus de sécurisation implique :
- Poignée de main SSL/TLS (TLS Handshake) : Le client et le serveur échangent des messages pour établir une connexion sécurisée, négocier les algorithmes de chiffrement à utiliser, et échanger des clés.
- Chiffrement asymétrique (RSA) : Utilisé pendant la poignée de main TLS pour échanger de manière sécurisée une clé de session symétrique. Le serveur envoie son certificat (contenant sa clé publique) au client. Le client utilise cette clé publique pour chiffrer une clé de session générée aléatoirement. Seul le serveur, avec sa clé privée correspondante, peut déchiffrer cette clé de session.
- Chiffrement symétrique : Une fois la clé de session établie (généralement AES), le chiffrement symétrique est utilisé pour toutes les communications de données restantes. C'est plus rapide que le chiffrement asymétrique et suffisant puisque la clé de session est partagée de manière sécurisée.
- Certificats numériques : Un certificat SSL/TLS est utilisé pour authentifier l'identité du serveur. Il est délivré par une autorité de certification (CA) de confiance et contient la clé publique du serveur. Le client vérifie la validité de ce certificat pour s'assurer qu'il communique bien avec le serveur qu'il prétend être.
HTTPS offre confidentialité (personne ne peut lire les données), intégrité (les données n'ont pas été altérées) et authentification (le client sait avec qui il parle).
10. XSRF (CSRF) et XSS
Ce sont deux types d'attaques de sécurité web courantes, mais elles ciblent des vulnérabilités différentes :
- CSRF (Cross-Site Request Forgery - Falsification de Requête Inter-Sites) / XSRF :
- Cible : Les requêtes du navigateur d'un utilisateur authentifié vers un site web sur lequel il est déjà connecté.
- Principe : L'attaquant trompe la victime (qui est connectée à un site web légitime) pour qu'elle envoie une requête HTTP à ce site à son insu. Le navigateur de la victime envoie automatiquement les cookies d'authentification associés au site légitime, ce qui fait que le site traite la requête comme valide.
- Impact : Peut forcer la victime à effectuer des actions non désirées (changer de mot de passe, effectuer un virement bancaire, etc.).
- Protection : Utilisation de tokens anti-CSRF (jetons de synchronisation), vérification de l'en-tête
Referer, attributSameSitepour les cookies.
- XSS (Cross-Site Scripting - Scripting Inter-Sites) :
- Cible : Les utilisateurs d'un site web vulnérable en injectant des scripts malveillants (souvent JavaScript) dans les pages web affichées par d'autres utilisateurs.
- Principe : L'attaquant injecte du code client-side (par exemple, via un champ de commentaire non validé) dans une page web légitime. Lorsque d'autres utilisateurs visitent cette page, le script malveillant s'exécute dans leur navigateur, sous le domaine du site légitime.
- Impact : Vol de cookies de session (permettant la prise de contrôle du compte), redirection vers des sites de phishing, défiguration de site, exécution de requêtes arbitraires.
- Protection : Validation et échappement des entrées utilisateur (HTML encoding), utilisation de Content Security Policy (CSP).
En résumé : le CSRF cible les requêtes faites par la victime à son insu, tandis que le XSS cible l'exécution de scripts dans le navigateur de la victime.
11. Idempotence
En informatique, l'idempotence d'une opération signifie que l'application de cette opération plusieurs fois produit le même résultat qu'une seule application. Dans le contexte des méthodes HTTP, l'idempotence fait référence au fait qu'une et plusieurs requêtes sur une ressource doivent avoir les mêmes effets secondaires sur l'état du serveur.
Il est important de noter que l'idempotence ne signifie pas que le serveur renverra toujours la même réponse pour chaque requête. Le critère est que l'état du serveur ne change pas après la première requête réussie, même si d'autres requêtes identiques sont envoyées.
- Méthode
GET: Est idempotente. Une requêteGET /ressource/123pour récupérer une ressource ne modifie pas l'état du serveur. Que vous l'appeliez une fois ou cent fois, l'état de la ressource sur le serveur reste inchangé. (La réponse peut varier si la ressource est mise à jour par une autre partie). - Méthode
HEAD: Est idempotente. Similaire à GET, mais ne renvoie que les en-têtes de la réponse. - Méthode
DELETE: Est idempotente. Une requêteDELETE /ressource/42pour supprimer une ressource. Après la première suppression réussie, les requêtes suivantes sur la même ressource n'auront plus d'effet secondaire sur l'état du serveur (la ressource est déjà supprimée). Le serveur peut renvoyer un code 200 (OK) ou 404 (Not Found) pour les requêtes subséquentes, mais l'état de suppression ne change plus. - Méthode
PUT: Est idempotente. Une requêtePUT /ressource/123avec un nouveau corps pour mettre à jour ou créer la ressource 123. Si la ressource existe, elle est mise à jour. Si elle n'existe pas, elle est créée. L'envoyer plusieurs fois aura le même effet final sur la ressource 123. - Méthode
POST: N'est PAS idempotente. Une requêtePOST /articlespour créer un nouvel article. Chaque requête POST identique soumettra potentiellement de nouvelles données et créera une nouvelle ressource sur le serveur (avec un nouvel ID unique). L'effet secondaire n'est pas le même après plusieurs requêtes.
Comprendre l'idempotence est crucial pour concevoir des API robustes et pour gérer les problèmes de réseau (par exemple, retransmissions de requêtes).
12. Architectures RESTful (avec SOAP, RPC)
REST (Representational State Transfer) est un style architectural pour les systèmes hypermédia distribués, souvent appliqué aux services web. Une API RESTful adhère aux principes de REST, offrant un moyen flexible et léger d'intégrer des applications.
Les principes clés de REST incluent :
- Client-Serveur : Séparation des préoccupations entre l'interface utilisateur et le stockage des données.
- Sans état (Stateless) : Aucune information de session client n'est stockée sur le serveur entre les requêtes. Chaque requête du client doit contenir toutes les informations nécessaires pour que le serveur puisse la comprendre et la traiter.
- Mise en cache (Cacheable) : Les réponses des requêtes peuvent être mises en cache pour améliorer les performances.
- Interface uniforme : Utilise des méthodes HTTP standard (GET, POST, PUT, DELETE) pour interagir avec les ressources, et les ressources sont identifiées par des URIs.
- Système en couches (Layered System) : Le système peut être composé de couches intermédiaires (proxies, équilibreurs de charge) qui peuvent améliorer la scalabilité et la sécurité, sans que le client ne s'en rende compte.
- Code à la demande (Code-On-Demand, optionnel) : Les serveurs peuvent étendre les fonctionnalités du client en lui envoyant du code exécutable (par exemple, JavaScript).
Les services RESTful communiquent généralement en utilisant HTTP et des formats de données légers comme JSON (JavaScript Object Notation) ou XML (Extensible Markup Language).
Contexte par rapport à SOAP et RPC :
- RPC (Remote Procedure Call) : Le plus ancien. C'est un protocole qui permet à un programme d'exécuter une procédure (fonction ou sous-routine) sur un autre espace d'adressage (généralement sur un ordinateur en réseau) comme s'il s'agissait d'une procédure locale. Il est axé sur les actions ou les fonctions. Les systèmes basés sur RPC sont souvent très couplés.
- SOAP (Simple Object Access Protocol) : Un protocole basé sur XML pour l'échange de messages structurés dans les services web. Plus complexe et formel que REST. Il est axé sur les messages et offre des fonctionnalités intégrées pour la sécurité, les transactions, la fiabilité (via des extensions WS-*). Souvent utilisé dans les architectures orientées services (SOA) d'entreprise.
- RESTful : Plus léger, plus simple et utilise les protocoles et les standards web existants (HTTP, URIs). Il est axé sur les ressources et leurs états. REST a gagné en popularité pour les APIs publiques et les applications mobiles en raison de sa simplicité et de sa flexibilité.
L'évolution peut être vue comme : RPC -> SOAP -> RESTful, où chaque étape a tenté de simplifier ou d'améliorer certains aspects des communications distribuées.
Pour plus de détails : Comprendre l'architecture RESTful.
13. SOAP (Simple Object Access Protocol)
SOAP est un protocole basé sur XML pour l'échange de messages structurés dans la mise en œuvre de services web sur des réseaux informatiques. C'était un pilier de l'architecture orientée services (SOA) avant l'essor de REST. SOAP est indépendant du transport et peut être utilisé avec HTTP, SMTP, TCP, ou JMS, mais est le plus souvent utilisé avec HTTP.
Caractéristiques clés de SOAP :
- Basé sur XML : Tous les messages SOAP sont formatés en XML, ce qui les rend facilement extensibles, mais aussi plus verbeux et lourds que d'autres formats comme JSON.
- Extensible : SOAP est très extensible et peut intégrer des spécifications WS-* (WS-Security, WS-ReliableMessaging, WS-Transactions) pour ajouter des fonctionnalités avancées comme la sécurité, la fiabilité et les transactions.
- Fortement typé : Les messages SOAP sont souvent définis par des descriptions formelles appelées WSDL (Web Services Description Language), qui décrivent les opérations, les types de données, les protocoles et les points de terminaison. Cela permet la validation et la génération de code client/serveur automatique.
- Standardisé : Conçu pour l'interopérabilité entre différentes plateformes et langages de programmation.
SOAP est généralement utilisé dans des environnements d'entreprise où la standardisation, la complexité et la richesse des fonctionnalités sont privilégiées par rapport à la simplicité et la légèreté de REST.
14. RPC (Remote Procedure Call)
RPC (Remote Procedure Call) est un protocole qui permet à un programme d'ordinateur d'exécuter une procédure (fonction ou sous-routine) sur un autre ordinateur du réseau sans que le programmeur ait à coder explicitement les détails de la communication réseau. Cela donne l'impression que la procédure est exécutée localement.
Caractéristiques clés de RPC :
- Abstraction réseau : Cache la complexité de la communication réseau. L'appelant n'a pas besoin de savoir que la procédure est exécutée à distance.
- Modèle client-serveur : Le client fait un appel à une procédure, qui est exécutée par le serveur.
- Découpage : Le protocole RPC s'appuie sur des protocoles de transport existants comme TCP ou UDP pour l'échange de messages.
- Hautement couplé : Les clients sont généralement fortement couplés aux implémentations spécifiques des procédures distantes.
- Formats de données : Peut utiliser divers formats pour la sérialisation des données (par exemple, XDR, Protocol Buffers, JSON).
Des exemples célèbres de systèmes basés sur RPC incluent XML-RPC, gRPC (Google RPC) et Apache Thrift.
Résumé de l'évolution des styles d'architecture des services :
L'évolution des méthodes de communication entre services peut être schématisée comme suit :
- RPC : Le pionnier, axé sur l'appel de fonctions distantes. Simple mais souvent propriétaire et peu interopérable.
- SOAP : Arrivé avec l'ère des services web d'entreprise, offrant une interopérabilité standardisée (basée sur XML et WSDL) avec des fonctionnalités avancées, mais au prix d'une complexité et d'une verbosité accrues.
- RESTful : Émergé avec l'ère de l'internet et des applications mobiles, simplifiant les communications en adoptant le paradigme web (ressources, URIs, méthodes HTTP, formats légers comme JSON). Il s'agit d'une approche plus légère et flexible, axée sur la manipulation des ressources (créer, lire, mettre à jour, supprimer).
15. CGI et WSGI
Ces deux interfaces définissent des standards pour la communication entre les serveurs web et les applications, mais à des niveaux différents et pour des environnements différents.
- CGI (Common Gateway Interface) :
- Qu'est-ce que c'est : CGI est un standard pour que les serveurs web (comme Apache ou Nginx) puissent exécuter des programmes externes (scripts) pour générer du contenu web dynamique.
- Fonctionnement : Chaque fois qu'une requête HTTP arrive pour un script CGI, le serveur web lance un nouveau processus pour ce script, lui transmet les données de la requête via des variables d'environnement et l'entrée standard (stdin), et reçoit la sortie générée par le script via la sortie standard (stdout).
- Langages : Les scripts CGI peuvent être écrits dans presque n'importe quel langage de programmation (Python, Perl, C, Shell, etc.).
- Inconvénients : Le lancement d'un nouveau processus pour chaque requête est coûteux en ressources et peut devenir un goulot d'étranglement pour les applications à fort trafic.
- WSGI (Web Server Gateway Interface) :
- Qu'est-ce que c'est : WSGI est une spécification pour une interface standard entre les serveurs web Python et les applications web Python ou frameworks. C'est un standard pour les applications Python.
- Fonctionnement : Un serveur WSGI (par exemple, Gunicorn, uWSGI, mod_wsgi) accepte les requêtes du serveur web et les transmet à une application WSGI (écrite selon la spécification WSGI). L'application WSGI traite la requête et renvoie une réponse au serveur WSGI, qui la transmet au client. Le serveur WSGI reste en vie et ne lance pas un nouveau processus pour chaque requête.
- Avantages : Offre un moyen standardisé de connecter différents serveurs web à différentes applications/frameworks Python, sans avoir à adapter le code. Améliore considérablement les performances par rapport à CGI en évitant le lancement répétitif de processus.
En résumé, CGI est une interface générale entre n'importe quel serveur web et n'importe quel programme exécutable, tandis que WSGI est une interface spécifique à Python, conçue pour l'efficacité des applications web Python.
Pour la spécification officielle : PEP-3333.
16. Attaque de l'homme du milieu (Man-in-the-Middle Attack, MITM)
Une attaque de l'homme du milieu (MITM) est un type de cyberattaque où un attaquant intercepte et relaye secrètement la communication entre deux parties qui croient communiquer directement l'une avec l'autre. L'attaquant se positionne entre le client et le serveur (ou entre deux parties communiquant), peut écouter, modifier, ou insérer des messages dans la communication sans être détecté par les parties légitimes.
Fonctionnement général :
- L'attaquant intercepte une communication entre la victime A et la victime B.
- L'attaquant se fait passer pour B auprès de A, et pour A auprès de B.
- A et B croient communiquer directement l'un avec l'autre, tandis que l'attaquant contrôle entièrement le flux d'informations, pouvant lire, modifier ou bloquer les messages.
Exemples de vecteurs d'attaque :
- Détournement SSL/TLS : L'attaquant peut présenter un faux certificat SSL/TLS au client, se faisant passer pour le serveur légitime. Si le client accepte le faux certificat (souvent en ignorant un avertissement de sécurité), l'attaquant peut déchiffrer la communication.
- Spoofing ARP : Sur un réseau local, l'attaquant peut envoyer de fausses réponses ARP pour faire croire aux machines qu'il est la passerelle par défaut ou le routeur, interceptant ainsi le trafic.
- DNS Spoofing : L'attaquant peut manipuler les réponses DNS pour rediriger la victime vers un serveur malveillant.
- Réseaux Wi-Fi non sécurisés : Sur un réseau Wi-Fi public non chiffré, l'attaquant peut facilement intercepter tout le trafic.
Protection :
- Utilisation de HTTPS (TLS/SSL) avec une vérification stricte des certificats.
- VPN (Virtual Private Networks).
- Utilisation de protocoles sécurisés comme SSH, IPsec.
- Éviter les réseaux Wi-Fi non fiables ou non chiffrés.
- Sensibilisation des utilisateurs aux avertissements de sécurité des navigateurs.
17. Le problème C10K
Le problème C10K (Concurrent 10 000 Connections) est un défi en matière de performances et de scalabilité pour les serveurs web et d'applications. Il fait référence à la difficulté pour un serveur de gérer simultanément un grand nombre (dizaines de milliers, voire plus) de connexions clientes concurrentes tout en maintenant des performances élevées.
Historiquement, les serveurs utilisaient un modèle de "un processus/thread par connexion". Avec un petit nombre de clients, cela fonctionnait bien. Cependant, à mesure que le nombre de clients augmentait vers 10 000 et au-delà, les serveurs rencontraient des problèmes de performances et de stabilité dus à :
- Consommation de ressources : Chaque thread ou processus consomme de la mémoire et des ressources CPU (pour la commutation de contexte).
- Gestion des threads/processus : Le système d'exploitation a des limites sur le nombre de threads/processus qu'il peut gérer efficacement.
- E/S bloquantes : Si chaque thread attendait une opération d'E/S (lecture/écriture sur un socket), les autres threads étaient bloqués inutilement.
Pour résoudre le problème C10K, des architectures de serveur plus efficaces ont été développées, notamment :
- Modèle basé sur les événements (Event-Driven) : Utilisation d'un seul thread (ou un petit nombre de threads) pour gérer de multiples connexions de manière asynchrone et non bloquante. Des mécanismes d'E/S multiplexées comme
select(),poll(),epoll()(Linux),kqueue()(BSD/macOS) ou IOCP (Windows) sont utilisés pour surveiller de nombreux descripteurs de fichiers pour des événements. - Programmation asynchrone : Des frameworks comme Node.js, Nginx, ou les bibliothèques
asyncioen Python sont conçus autour de ce modèle.
Le problème C10K a été largement résolu avec l'avènement de ces architectures modernes, permettant aux serveurs de gérer des centaines de milliers, voire des millions de connexions concurrentes.
Pour plus d'informations : Le problème C10K.
18. Sockets
Un socket est un point d'extrémité d'une communication bidirectionnelle entre deux programmes sur un réseau. Il fournit une interface programmatique pour l'accès aux protocoles réseau sous-jacents, tels que TCP/IP. Les sockets permettent aux applications de lire et d'écrire des données à travers le réseau.
Conceptuellement, un socket peut être identifié par la combinaison suivante :
Socket = Adresse IP + Protocole (TCP/UDP) + Numéro de Port
Types de sockets courants :
- Sockets de flux (Stream Sockets - TCP) : Fournissent une connexion fiable, orientée connexion, et ordonnée. Ils garantissent que les données sont livrées sans erreur et dans l'ordre d'envoi. Utilisés pour des applications comme le web (HTTP), le transfert de fichiers (FTP), l'e-mail (SMTP).
- Sockets de datagramme (Datagram Sockets - UDP) : Fournissent une communication sans connexion, non fiable. Les datagrammes peuvent arriver dans le désordre, être dupliqués ou perdus. Utilisés pour des applications où la vitesse est plus critique que la fiabilité (par exemple, streaming vidéo/audio, jeux en ligne, DNS).
Les étapes de base pour établir une communication socket (côté serveur pour TCP) sont :
- Création du socket (
socket()). - Liaison du socket à une adresse IP et un numéro de port local (
bind()). - Mise en écoute des connexions entrantes (
listen()). - Acceptation d'une connexion entrante, créant un nouveau socket pour la communication avec le client (
accept()). - Envoi et réception de données (
send(),recv()). - Fermeture du socket (
close()).
Côté client, le processus est plus simple : créer un socket, se connecter au serveur (connect()), envoyer/recevoir des données, puis fermer.
Pour plus d'informations : Comprendre les sockets.
19. Cache du navigateur
Le cache du navigateur est un mécanisme essentiel utilisé par les navigateurs web pour stocker temporairement des copies de fichiers de sites web (HTML, CSS, JavaScript, images, vidéos) sur le disque local de l'utilisateur. L'objectif principal est d'améliorer la vitesse de chargement des pages web et de réduire la consommation de bande passante.
Fonctionnement :
- Lors de la première visite d'une page, le navigateur télécharge toutes les ressources.
- Il stocke certaines de ces ressources en cache, en fonction des en-têtes de cache HTTP (
Cache-Control,Expires,Last-Modified,ETag) envoyés par le serveur. - Lors des visites suivantes à la même page ou à d'autres pages utilisant les mêmes ressources, le navigateur vérifie d'abord son cache.
- Si la ressource est dans le cache et est toujours valide (selon les règles de cache), le navigateur la charge depuis le cache local, sans faire de nouvelle requête au serveur.
- Si la ressource est dans le cache mais est potentiellement périmée, le navigateur envoie une requête conditionnelle au serveur (par exemple, avec les en-têtes
If-Modified-SinceouIf-None-Match).- Si la ressource n'a pas changé, le serveur répond avec un code d'état
304 Not Modified(économisant la bande passante en ne renvoyant pas le contenu), et le navigateur utilise la version mise en cache. - Si la ressource a changé, le serveur renvoie un code
200 OKavec la nouvelle ressource, et le cache est mis à jour.
- Si la ressource n'a pas changé, le serveur répond avec un code d'état
Le cache du navigateur réduit la latence, diminue la charge sur le serveur et améliore considérablement l'expérience utilisateur.
Pour plus d'informations : Cache du navigateur.
20. HTTP/1.0 et HTTP/1.1
HTTP/1.0 et HTTP/1.1 sont deux versions du protocole HTTP qui ont été largement utilisées. HTTP/1.1, introduit en 1997, a apporté des améliorations significatives par rapport à HTTP/1.0 pour mieux s'adapter à l'évolution du web.
Différences clés et améliorations de HTTP/1.1 :
- En-tête
Hostobligatoire : HTTP/1.1 rend l'en-têteHostobligatoire. Cela permet à un seul serveur IP d'héberger plusieurs noms de domaine (hébergement virtuel), car le serveur sait quelle ressource spécifique demander en fonction du nom d'hôte. - Connexions persistantes (Keep-Alive) : HTTP/1.1 introduit des connexions persistantes par défaut. Au lieu de fermer la connexion TCP après chaque requête/réponse (comme en HTTP/1.0), la connexion reste ouverte pour des requêtes ultérieures. Cela réduit la surcharge de l'établissement de multiples connexions TCP.
- Pipelining : Permet au client d'envoyer plusieurs requêtes sur une seule connexion persistante avant même d'avoir reçu les réponses aux requêtes précédentes. Cela peut améliorer les performances en réduisant le temps d'attente.
- Reprise de téléchargement (Range Requests) : HTTP/1.1 supporte l'en-tête
Range, permettant au client de demander des parties spécifiques d'un fichier. Utile pour la reprise de téléchargements interrompus ou pour le streaming de médias. - Gestion du cache améliorée : Introduit des en-têtes de cache plus sophistiqués (
Cache-Control,ETag) pour un contrôle plus fin de la mise en cache par les navigateurs et les serveurs proxy. - Authentification et état de session : Bien que HTTP soit sans état, HTTP/1.1 a des mécanismes améliorés pour l'authentification (par exemple,
Digest Access Authentication) et des en-têtes pour aider à la gestion des cookies pour l'état de session. - Nouveaux codes d'état : Ajout de plusieurs codes d'état (par exemple,
100 Continue,409 Conflict,410 Gone).
Pour une explication plus approfondie : HTTP1.0 vs HTTP1.1.
Méthodes de requête HTTP/1.1 :
HTTP/1.1 définit plusieurs méthodes de requête (verbes) pour indiquer l'action souhaitée à effectuer sur la ressource identifiée par l'URI. Voici les plus courantes :
GET: Demande la représentation de la ressource spécifiée. Les requêtes GET doivent uniquement récupérer des données et ne doivent pas avoir d'effets secondaires sur le serveur (idempotent et safe).HEAD: Similaire à GET, mais le serveur ne renvoie que les en-têtes de la réponse, sans le corps de la ressource. Utile pour vérifier l'existence d'une ressource ou obtenir ses métadonnées. (Idempotent et safe).POST: Soumet des données à une ressource spécifiée, souvent pour créer une nouvelle ressource ou effectuer une action. Les requêtes POST ne sont pas idempotentes et peuvent avoir des effets secondaires.PUT: Télécharge une représentation de la ressource spécifiée. Si la ressource existe déjà, elle est mise à jour ; sinon, une nouvelle ressource est créée à cet URI. Les requêtes PUT sont idempotentes.DELETE: Supprime la ressource spécifiée. Les requêtes DELETE sont idempotentes.CONNECT: Utilisé pour établir un tunnel à travers un serveur proxy.OPTIONS: Décrit les options de communication disponibles pour la ressource cible ou le serveur web. Il peut être utilisé pour vérifier les méthodes HTTP prises en charge par un serveur.TRACE: Effectue une boucle de diagnostic du chemin d'accès à la ressource. Le serveur renvoie la requête reçue.
Méthodes ajoutées après HTTP/1.1 :
PATCH: Introduite dans RFC 5789 (2010), cette méthode applique des modifications partielles à une ressource. Contrairement à PUT (qui remplace l'intégralité de la ressource), PATCH ne modifie qu'une partie de la ressource. Les requêtes PATCH sont idempotentes si les opérations sont idempotentes.
21. AJAX (Asynchronous JavaScript and XML)
AJAX, pour Asynchronous JavaScript and XML, est un ensemble de technologies web qui permet à une page web d'envoyer et de recevoir des données du serveur de manière asynchrone, sans avoir à recharger la page entière. Cela améliore considérablement l'interactivité et la réactivité des applications web.
Composants clés d'AJAX :
- JavaScript : Le langage de script côté client qui orchestre tout le processus.
XMLHttpRequest(XHR) ou l'APIFetch: Objets du navigateur utilisés pour envoyer des requêtes HTTP au serveur en arrière-plan et recevoir les réponses.- XML : Historiquement utilisé pour le format des données échangées, bien que JSON (JavaScript Object Notation) soit maintenant le format préféré en raison de sa légèreté et de sa facilité de manipulation en JavaScript.
- DOM (Document Object Model) : Utilisé par JavaScript pour manipuler et mettre à jour de manière dynamique des parties spécifiques de la page HTML avec les données reçues.
Avantages d'AJAX :
- Expérience utilisateur améliorée : Les pages se chargent plus rapidement car seule une partie du contenu est rafraîchie.
- Réactivité accrue : L'interface utilisateur reste interactive pendant que les données sont chargées en arrière-plan.
- Bande passante réduite : Seules les données nécessaires sont échangées avec le serveur, et non la page entière.
AJAX est à la base de la plupart des applications web modernes, rendant les expériences utilisateur plus fluides et dynamiques.
Systèmes UNIX/Linux
Mécanismes de communication inter-processus (IPC) sous UNIX/Linux
Les systèmes d'exploitation de type UNIX/Linux offrent divers mécanismes pour permettre aux processus de communiquer et de se synchroniser. Voici les principaux :
- Tuyaux (Pipes) :
- Anonymes : Permettent une communication unidirectionnelle entre processus ayant une relation parent-enfant. Le processus parent crée le tuyau, puis bifurque un processus enfant qui hérite des descripteurs de fichier du tuyau.
- Nommés (FIFO - First-In, First-Out) : Similaires aux tuyaux anonymes mais peuvent être utilisés pour la communication entre processus sans relation parent-enfant. Ils sont représentés par un nom de fichier spécial dans le système de fichiers.
- **Signaux (Signals) :**Mécanisme léger pour notifier un processus qu'un événement s'est produit (par exemple, Ctrl+C génère un signal SIGINT). Ils peuvent interrompre l'exécution normale d'un processus et déclencher une fonction de gestionnaire de signal (signal handler).
- **Files de messages (Message Queues) :**Permettent l'échange de blocs de données de taille variable entre processus. Les processus écrivent des messages dans une file, et d'autres processus peuvent les lire, généralement dans l'ordre FIFO. Fournissent une communication asynchrone et peuvent persister après la fin des processus émetteurs/récepteurs.
- **Mémoire partagée (Shared Memory) :**Le mécanisme IPC le plus rapide. Permet à plusieurs processus de mapper une même région de mémoire physique dans leur propre espace d'adressage virtuel. Les processus peuvent alors lire et écrire directement dans cette région. Nécessite des mécanismes de synchronisation (comme les sémaphores) pour éviter les conditions de concurrence.
- **Sémaphores (Semaphores) :**Des objets de synchronisation utilisés pour contrôler l'accès à des ressources partagées. Un sémaphore est une variable entière non négative. Les opérations principales sont
wait()(décrémente le sémaphore et bloque si sa valeur devient négative) etsignal()(incrémente le sémaphore et débloque un processus en attente si nécessaire). Utilisés pour l'exclusion mutuelle et la synchronisation des processus. - **Mappage de fichiers (Memory-Mapped Files) :**Permet de mapper un fichier ou un périphérique en mémoire, rendant son contenu accessible comme une région de mémoire. Les processus peuvent alors accéder au contenu du fichier comme s'il s'agissait de mémoire partagée, facilitant la communication et les opérations d'E/S.
- **Sockets :**Le mécanisme IPC le plus flexible et le plus général. Les sockets permettent la communication entre processus sur la même machine (IPC locale) ou sur des machines différentes (IPC réseau). Ils sont basés sur les protocoles TCP/IP et sont couramment utilisés pour la programmation réseau.
Structures de données
1. Arbres Rouge-Noir (Red-Black Trees)
Un arbre Rouge-Noir est un type d'arbre binaire de recherche auto-équilibré. Cela signifie qu'il garantit que l'arbre reste relativement équilibré (la hauteur de l'arbre est logarithmique par rapport au nombre de nœuds), assurant ainsi que les opérations de recherche, d'insertion et de suppression ont une complexité temporelle en O(log N).
L'équilibrage est maintenu par l'application de règles spécifiques sur la coloration des nœuds (rouge ou noir) et l'utilisation de rotations et de recolorations après chaque insertion ou suppression. Les règles des arbres Rouge-Noir sont :
- Chaque nœud est soit rouge, soit noir.
- La racine est noire.
- Les feuilles sont noires (généralement des nœuds NULL).
- Si un nœud est rouge, alors ses enfants sont noirs.
- Pour chaque nœud, tous les chemins simples de ce nœud vers une feuille descendante contiennent le même nombre de nœuds noirs.
Comparaison avec les arbres AVL :
- Les arbres AVL sont des arbres binaire de recherche strictement équilibrés : la différence de hauteur entre les sous-arbres gauche et droit de tout nœud ne dépasse jamais 1. Cela se traduit par un nombre potentiellement plus élevé de rotations lors des insertions et suppressions.
- Les arbres Rouge-Noir maintiennent un équilibre "relâché" : ils ne sont pas strictement équilibrés mais garantissent une hauteur logarithmique. Cela signifie qu'ils effectuent généralement moins de rotations que les AVL lors des insertions et suppressions, mais peuvent avoir une hauteur légèrement supérieure (et donc des recherches légèrement plus lentes dans le pire des cas, bien que toujours logarithmiques).
Quand choisir l'un ou l'autre :
- Si les opérations de recherche sont beaucoup plus fréquentes que les insertions et suppressions, un arbre AVL pourrait être légèrement plus performant en recherche grâce à son équilibre strict.
- Si les insertions et suppressions sont fréquentes (ou du même ordre de grandeur que les recherches), un arbre Rouge-Noir est souvent préféré car il effectue moins de rééquilibrages coûteux.
Les arbres Rouge-Noir sont largement utilisés dans de nombreuses structures de données et algorithmes, notamment pour les std::map et std::set en C++, ainsi que dans la planification de tâches pour les noyaux Linux.
Pour des explications approfondies :
Problèmes de programmation
1. Problème de l'escalier (Nombres de Fibonacci)
Problème : Une grenouille peut sauter 1 marche ou 2 marches à la fois. Combien y a-t-il de façons différentes pour la grenouille d'atteindre un escalier de n marches ?
Solution : Ce problème est un cas classique de la suite de Fibonacci. Soit f(n) le nombre de façons d'atteindre n marches.
- Pour 1 marche : 1 façon (saut de 1).
- Pour 2 marches : 2 façons (saut de 1, saut de 1 ; ou saut de 2).
- Pour
nmarches : la grenouille peut soit faire un premier saut de 1 (il resten-1marches), soit faire un premier saut de 2 (il resten-2marches). Donc,f(n) = f(n-1) + f(n-2).
Voici différentes implémentations en Python :
1.1. Récursivité simple (non optimisée)
fib_rec = lambda n: 1 if n <= 1 else (2 if n == 2 else fib_rec(n - 1) + fib_rec(n - 2))
# L'implémentation standard de fib(0)=0, fib(1)=1, fib(2)=1, fib(3)=2, etc.
# Pour ce problème, f(1)=1, f(2)=2, f(3)=3
# Si n=0, 1 façon (déjà en haut)
# Si n=1, 1 façon
# Si n=2, 2 façons
# Si n=3, f(3) = f(2)+f(1) = 2+1 = 3 façons (1,1,1; 1,2; 2,1)
# Adapter la formule à fib(0)=1, fib(1)=1, fib(2)=2, fib(3)=3
def calculer_sauts_fib(n_marches):
if n_marches == 0:
return 1
elif n_marches == 1:
return 1
else:
return calculer_sauts_fib(n_marches - 1) + calculer_sauts_fib(n_marches - 2)
print(f"Sauts pour 0 marches: {calculer_sauts_fib(0)}") # 1
print(f"Sauts pour 1 marche: {calculer_sauts_fib(1)}") # 1
print(f"Sauts pour 2 marches: {calculer_sauts_fib(2)}") # 2
print(f"Sauts pour 3 marches: {calculer_sauts_fib(3)}") # 3
1.2. Récursivité avec mémoïsation (cache)
Pour éviter les recalculs coûteux de la récursivité simple, on peut utiliser un cache.
def memoization(func):
cache_resultats = {}
def enveloppeur(*args):
if args not in cache_resultats:
cache_resultats[args] = func(*args)
return cache_resultats[args]
return enveloppeur
@memoization
def compter_sauts_fib_memo(n_marches):
if n_marches == 0:
return 1
elif n_marches == 1:
return 1
else:
return compter_sauts_fib_memo(n_marches - 1) + compter_sauts_fib_memo(n_marches - 2)
print(f"Sauts pour 10 marches (memo): {compter_sauts_fib_memo(10)}")
1.3. Approche itérative (optimale)
L'approche itérative est la plus efficace car elle ne nécessite pas de récursivité ni de cache explicite.
def compter_sauts_fib_iteratif(n_marches):
if n_marches == 0:
return 1
elif n_marches == 1:
return 1
precedent = 1 # Représente f(i-2)
actuel = 1 # Représente f(i-1)
for _ in range(2, n_marches + 1):
temp = actuel + precedent
precedent = actuel
actuel = temp
return actuel
print(f"Sauts pour 10 marches (itératif): {compter_sauts_fib_iteratif(10)}")
2. Problème de l'escalier (Variante : sauts multiples)
Problème : Une grenouille peut sauter 1, 2, ..., ou n marches à la fois. Combien y a-t-il de façons différentes pour la grenouille d'atteindre un escalier de n marches ?
Solution : Soit f(n) le nombre de façons d'atteindre n marches.
f(0) = 1(il y a 1 façon d'atteindre 0 marche : ne pas bouger).f(1) = 1(saut de 1).f(2) = f(1) + f(0) + 1(sauts de 1,1; 2; (1),2) = 2. Autre façon de voir: peut sauter 1 marche ou 2 marches. Donc f(2) = f(2-1) + f(2-2) = 1+1 = 2 façons. Non, c'est f(2) = (1,1) ou (2). 2 façons.
En général, pour n marches, le premier saut peut être de 1, 2, ..., jusqu'à n marches. Donc :
f(n) = f(n-1) + f(n-2) + ... + f(0)
On observe que :
f(n-1) = f(n-2) + f(n-3) + ... + f(0)
En substituant, on obtient :
f(n) = f(n-1) + f(n-1) = 2 * f(n-1) (pour n >= 1)
avec f(0) = 1.
def compter_sauts_multiples(n_marches):
if n_marches == 0:
return 1
elif n_marches == 1:
return 1
else:
return 2 * compter_sauts_multiples(n_marches - 1)
print(f"Sauts pour 0 marches (multiples): {compter_sauts_multiples(0)}") # 1
print(f"Sauts pour 1 marche (multiples): {compter_sauts_multiples(1)}") # 1
print(f"Sauts pour 2 marches (multiples): {compter_sauts_multiples(2)}") # 2
print(f"Sauts pour 3 marches (multiples): {compter_sauts_multiples(3)}") # 4
print(f"Sauts pour 4 marches (multiples): {compter_sauts_multiples(4)}") # 8
3. Problème de couverture de rectangle
Problème : Nous pouvons utiliser de petits rectangles de taille 2x1 pour couvrir un plus grand rectangle de taille 2xn. Les petits rectangles peuvent être placés horizontalement ou verticalement sans chevauchement. Combien de façons différentes y a-t-il de couvrir le rectangle 2xn ?
Solution : Ce problème est également lié à la suite de Fibonacci.
- Pour
n=1(rectangle2x1) : 1 seule façon (le rectangle2x1lui-même, placé verticalement). - Pour
n=2(rectangle2x2) : 2 façons.- Deux rectangles
2x1placés verticalement côte à côte. - Deux rectangles
1x2placés horizontalement l'un au-dessus de l'autre.
- Deux rectangles
Pour un rectangle 2xn, considérons la dernière colonne (ou les deux dernières colonnes) :
- Si le dernier rectangle
2x1est placé verticalement, il couvre la dernière colonne. Il reste un rectangle2x(n-1)à couvrir. Nombre de façons :f(n-1). - Si les deux derniers rectangles
1x2sont placés horizontalement, ils couvrent les deux dernières colonnes. Il reste un rectangle2x(n-2)à couvrir. Nombre de façons :f(n-2).
Ainsi, la relation de récurrence est f(n) = f(n-1) + f(n-2), avec les conditions initiales f(1) = 1 et f(2) = 2.
def compter_couvertures_rectangles(dimension_n):
if dimension_n <= 0: # Cas de base pour consistance
return 0
elif dimension_n == 1:
return 1
elif dimension_n == 2:
return 2
else:
return compter_couvertures_rectangles(dimension_n - 1) + compter_couvertures_rectangles(dimension_n - 2)
print(f"Couvertures pour 1: {compter_couvertures_rectangles(1)}") # 1
print(f"Couvertures pour 2: {compter_couvertures_rectangles(2)}") # 2
print(f"Couvertures pour 3: {compter_couvertures_rectangles(3)}") # 3
print(f"Couvertures pour 4: {compter_couvertures_rectangles(4)}") # 5
4. Recherche dans une matrice triée (matrice de Yang)
Problème : Étant donné une matrice m x n où chaque ligne est triée par ordre croissant de gauche à droite, et chaque colonne est triée par ordre croissant de haut en bas. Implémentez une fonction qui, étant donné cette matrice et un entier, détermine si l'entier est présent dans la matrice.
Solution (recherche linéaire pas-à-pas) : L'approche la plus efficace pour ce type de matrice est de commencer la recherche depuis un coin (par exemple, le coin supérieur droit ou inférieur gauche) et de réduire l'espace de recherche à chaque étape. Commençons par le coin supérieur droit.
- Si la valeur courante est égale à la cible, la cible est trouvée.
- Si la valeur courante est plus grande que la cible, cela signifie que toute la colonne actuelle contient des valeurs trop grandes (puisque la colonne est triée de bas en haut). On peut donc éliminer cette colonne en se déplaçant vers la gauche.
- Si la valeur courante est plus petite que la cible, cela signifie que toute la ligne actuelle contient des valeurs trop petites (puisque la ligne est triée de gauche à droite). On peut donc éliminer cette ligne en se déplaçant vers le bas.
def chercher_dans_matrice_triee(matrice_elements, valeur_cible):
if not matrice_elements or not matrice_elements[0]:
return False # Matrice vide
nb_lignes = len(matrice_elements)
nb_colonnes = len(matrice_elements[0])
ligne_courante = 0
colonne_courante = nb_colonnes - 1 # Commencer en haut à droite
while ligne_courante < nb_lignes and colonne_courante >= 0:
valeur_cellule = matrice_elements[ligne_courante][colonne_courante]
if valeur_cellule == valeur_cible:
return True
elif valeur_cellule > valeur_cible:
colonne_courante -= 1 # La valeur est trop grande, déplacer à gauche
else: # valeur_cellule < valeur_cible
ligne_courante += 1 # La valeur est trop petite, déplacer vers le bas
return False # La cible n'a pas été trouvée
# Exemple d'utilisation
matrice = [
[1, 4, 7, 11, 15],
[2, 5, 8, 12, 19],
[3, 6, 9, 16, 22],
[10, 13, 14, 17, 24],
[18, 21, 23, 26, 30]
]
print(f"Recherche 5: {chercher_dans_matrice_triee(matrice, 5)}") # True
print(f"Recherche 20: {chercher_dans_matrice_triee(matrice, 20)}") # False
print(f"Recherche 15: {chercher_dans_matrice_triee(matrice, 15)}") # True
5. Supprimer les éléments dupliqués d'une liste
Il existe plusieurs méthodes pour supprimer les éléments dupliqués d'une liste en Python, chacune avec ses avantages et inconvénients en termes de performance et de maintien de l'ordre d'origine.
1. En utilisant un ensemble (set)
Les ensembles sont des collections non ordonnées d'éléments uniques. C'est la méthode la plus simple et souvent la plus rapide pour supprimer les doublons, mais elle ne préserve pas l'ordre initial de la liste.
elements_avec_doublons = ['a', 'b', 'c', 'b', 'd', 'a', 'e']
elements_uniques = list(set(elements_avec_doublons))
print(f"Avec set (ordre non garanti): {elements_uniques}") # Exemple: ['a', 'b', 'c', 'd', 'e'] ou autre ordre
2. En utilisant un dictionnaire (fromkeys())
La méthode statique dict.fromkeys() crée un dictionnaire où les éléments de la liste deviennent des clés. Comme les clés de dictionnaire sont uniques, cela élimine les doublons. .keys() renvoie ensuite un objet vue des clés. L'ordre est généralement préservé en Python 3.7+.
elements_avec_doublons = ['a', 'b', 'c', 'b', 'd', 'a', 'e']
elements_uniques_dict = list(dict.fromkeys(elements_avec_doublons))
print(f"Avec dict.fromkeys (ordre préservé): {elements_uniques_dict}") # ['a', 'b', 'c', 'd', 'e']
3. En utilisant une boucle et une liste temporaire
Cette méthode maintient l'ordre original et est explicite, mais peut être moins performante pour de très grandes listes que les méthodes basées sur les sets ou les dicts.
elements_avec_doublons = ['a', 'b', 'c', 'b', 'd', 'a', 'e']
liste_sans_doublons = []
for element in elements_avec_doublons:
if element not in liste_sans_doublons:
liste_sans_doublons.append(element)
print(f"Avec boucle et liste temp (ordre préservé): {liste_sans_doublons}") # ['a', 'b', 'c', 'd', 'e']
4. En utilisant une compréhension de liste et un set (pour l'ordre)
Une variante pour préserver l'ordre si on souhaite combiner l'efficacité d'un set pour la vérification de l'unicité et une compréhension de liste.
elements_avec_doublons = ['a', 'b', 'c', 'b', 'd', 'a', 'e']
elements_vus = set()
liste_ordonnee_unique = [
x for x in elements_avec_doublons if x not in elements_vus and not elements_vus.add(x)
]
# La partie "and not elements_vus.add(x)" est une astuce pour ajouter à l'ensemble
# car elements_vus.add(x) renvoie None, et "not None" est True, permettant d'ajouter à la liste
print(f"Avec compréhension + set (ordre préservé): {liste_ordonnee_unique}") # ['a', 'b', 'c', 'd', 'e']
6. Échanger des nœuds par paires dans une liste chaînée
Problème : Étant donnée une liste chaînée, échanger chaque paire de nœuds adjacents. Par exemple, une liste 1->2->3->4 doit devenir 2->1->4->3.
Solution : Ce problème peut être résolu élégamment avec une approche récursive.
class NoeudListe:
def __init__(self, valeur=0, suivant=None):
self.val = valeur
self.next = suivant
class SolutionEchangePaires:
def echangerPaires(self, tete: NoeudListe) -> NoeudListe:
# Cas de base: Si la liste est vide ou n'a qu'un seul nœud,
# il n'y a pas de paires à échanger.
if not tete or not tete.next:
return tete
# 'prochain_paire_start' est le nœud qui sera le début de la paire suivante.
# Initialement, c'est le nœud après le deuxième nœud de la paire actuelle.
nouveau_debut_liste = tete.next
# Le nœud 'tete' (le premier de la paire actuelle) pointera vers
# le résultat de l'échange de paires à partir du troisième nœud.
tete.next = self.echangerPaires(nouveau_debut_liste.next)
# Le deuxième nœud de la paire actuelle pointera vers le premier nœud de la paire actuelle.
nouveau_debut_liste.next = tete
# Le nœud initialement deuxième est maintenant le nouveau début de cette section de liste.
return nouveau_debut_liste
# Fonction utilitaire pour créer une liste chaînée à partir d'une liste Python
def creer_liste_chaine(elements):
if not elements:
return None
tete = NoeudListe(elements[0])
courant = tete
for val in elements[1:]:
courant.next = NoeudListe(val)
courant = courant.next
return tete
# Fonction utilitaire pour imprimer une liste chaînée
def imprimer_liste_chaine(tete):
vals = []
courant = tete
while courant:
vals.append(str(courant.val))
courant = courant.next
print(" -> ".join(vals))
# Exemple d'utilisation
liste_originale = creer_liste_chaine([1, 2, 3, 4, 5, 6])
print("Liste originale:")
imprimer_liste_chaine(liste_originale) # 1 -> 2 -> 3 -> 4 -> 5 -> 6
solution = SolutionEchangePaires()
liste_echangee = solution.echangerPaires(liste_originale)
print("Liste après échange de paires:")
imprimer_liste_chaine(liste_echangee) # 2 -> 1 -> 4 -> 3 -> 6 -> 5
liste_impaire = creer_liste_chaine([1, 2, 3])
print("Liste impaire originale:")
imprimer_liste_chaine(liste_impaire) # 1 -> 2 -> 3
liste_impaire_echangee = solution.echangerPaires(liste_impaire)
print("Liste impaire après échange:")
imprimer_liste_chaine(liste_impaire_echangee) # 2 -> 1 -> 3
7. Méthodes de création de dictionnaires en Python
Python offre plusieurs façons de créer des dictionnaires, chacune adaptée à des scénarios différents.
1. Création directe avec des accolades {}
C'est la méthode la plus courante et la plus directe pour créer un dictionnaire avec des paires clé-valeur explicites.
parametres_serveur = {'nom': 'serveur_prod', 'port': 8080, 'env': 'production'}
print(parametres_serveur)
2. Utilisation du constructeur dict()
Le constructeur dict() peut être utilisé de plusieurs façons :
- À partir d'une liste de tuples (paires clé-valeur) :
liste_paires = [('couleur', 'bleu'), ('forme', 'carré')]
dict_depuis_liste = dict(liste_paires)
print(dict_depuis_liste)
- Avec des arguments nommés :
dict_avec_kwargs = dict(fruit='pomme', legume='carotte')
print(dict_avec_kwargs)
3. Utilisation de dict.fromkeys()
La méthode statique dict.fromkeys(keys, value=None) crée un nouveau dictionnaire à partir d'une séquence de clés et d'une valeur par défaut optionnelle (None par défaut).
cles_par_defaut = ('utilisateur', 'groupe', 'roles')
dict_avec_valeurs_defaut = dict.fromkeys(cles_par_defaut, 'inconnu')
print(dict_avec_valeurs_defaut) # {'utilisateur': 'inconnu', 'groupe': 'inconnu', 'roles': 'inconnu'}
dict_valeurs_nulles = dict.fromkeys(('cle1', 'cle2'))
print(dict_valeurs_nulles) # {'cle1': None, 'cle2': None}
8. Fusionner deux listes triées
Problème : Étant données deux listes triées, les fusionner en une seule liste triée.
Voici plusieurs approches pour résoudre ce problème :
1. Récursivité terminale (Tail Recursion)
Bien que Python n'optimise pas la récursivité terminale, cette approche démontre le concept.
def _fusionner_recursif_interne(liste1, liste2, resultat_partiel):
# Cas de base: si une des listes est vide, ajouter l'autre liste et retourner
if not liste1:
resultat_partiel.extend(liste2)
return resultat_partiel
if not liste2:
resultat_partiel.extend(liste1)
return resultat_partiel
# Comparer les premiers éléments et ajouter le plus petit
if liste1[0] < liste2[0]:
resultat_partiel.append(liste1.pop(0)) # pop(0) est coûteux pour les listes
else:
resultat_partiel.append(liste2.pop(0))
# Appel récursif avec les listes restantes
return _fusionner_recursif_interne(liste1, liste2, resultat_partiel)
def fusionner_listes_recursif(liste_a, liste_b):
# Créer des copies pour ne pas modifier les listes originales
return _fusionner_recursif_interne(list(liste_a), list(liste_b), [])
liste_un = [1, 3, 5, 7]
liste_deux = [2, 4, 6, 8]
print(f"Fusion récursive: {fusionner_listes_recursif(liste_un, liste_deux)}")
2. Algorithme itératif avec indices
Cette approche est généralement plus performante car elle évite le coût de pop(0) et la surcharge des appels récursifs.
def fusionner_listes_iteratif(liste1, liste2):
liste_fusionnee = []
idx1, idx2 = 0, 0
len1, len2 = len(liste1), len(liste2)
while idx1 < len1 and idx2 < len2:
if liste1[idx1] < liste2[idx2]:
liste_fusionnee.append(liste1[idx1])
idx1 += 1
else:
liste_fusionnee.append(liste2[idx2])
idx2 += 1
# Ajouter les éléments restants de l'une ou l'autre liste
while idx1 < len1:
liste_fusionnee.append(liste1[idx1])
idx1 += 1
while idx2 < len2:
liste_fusionnee.append(liste2[idx2])
idx2 += 1
return liste_fusionnee
liste_a = [10, 20, 30]
liste_b = [5, 15, 25, 35]
print(f"Fusion itérative: {fusionner_listes_iteratif(liste_a, liste_b)}")
3. Utilisation de pop(0) avec boucles séparées
Cette méthode est simple à écrire mais pop(0) sur une liste Python est une opération O(N) car tous les éléments suivants doivent être décalés. Elle peut être très lente pour de grandes listes.
def fusionner_listes_pop(liste_x, liste_y):
resultat = []
# Créer des copies pour pouvoir utiliser pop() sans modifier les listes originales
copie_x = list(liste_x)
copie_y = list(liste_y)
while copie_x and copie_y:
if copie_x[0] <= copie_y[0]:
resultat.append(copie_x.pop(0))
else:
resultat.append(copie_y.pop(0))
# Ajouter les éléments restants
resultat.extend(copie_x)
resultat.extend(copie_y)
return resultat
liste_items1 = [1, 2, 3, 7]
liste_items2 = [3, 4, 5]
print(f"Fusion avec pop(0): {fusionner_listes_pop(liste_items1, liste_items2)}")
9. Trouver l'intersection de deux listes chaînées
Problème : Étant données les têtes de deux listes chaînées potentiellement sécantes, trouver le nœud où elles s'intersectent. Si les deux listes chaînées n'ont pas d'intersection, retourner None.
Idée principale : Si deux listes chaînées s'intersectent, elles doivent partager les mêmes nœuds de ce point jusqu'à la fin de la liste. Cela signifie que leurs queues doivent être identiques. La stratégie consiste à aligner les têtes des listes chaînées de sorte qu'elles commencent à parcourir à la même distance de leur point d'intersection (ou de leur fin).
class NoeudDeListe:
def __init__(self, valeur=0):
self.val = valeur
self.next = None
def trouver_intersection(liste1_tete: NoeudDeListe, liste2_tete: NoeudDeListe) -> NoeudDeListe:
if not liste1_tete or not liste2_tete:
return None
# Calculer la longueur de chaque liste
longueur1 = 0
courant1 = liste1_tete
while courant1:
longueur1 += 1
courant1 = courant1.next
longueur2 = 0
courant2 = liste2_tete
while courant2:
longueur2 += 1
courant2 = courant2.next
# Avancer la tête de la liste la plus longue pour aligner les départs
if longueur1 > longueur2:
diff = longueur1 - longueur2
for _ in range(diff):
liste1_tete = liste1_tete.next
elif longueur2 > longueur1:
diff = longueur2 - longueur1
for _ in range(diff):
liste2_tete = liste2_tete.next
# Parcourir les deux listes simultanément jusqu'à trouver le nœud commun
while liste1_tete and liste2_tete:
if liste1_tete == liste2_tete:
return liste1_tete # Nœud d'intersection trouvé
liste1_tete = liste1_tete.next
liste2_tete = liste2_tete.next
return None # Aucune intersection trouvée
# --- Fonctions utilitaires pour les tests ---
def creer_liste_avec_intersection(vals1, vals_communs, vals2=None):
tete1 = None
courant1 = None
if vals1:
tete1 = NoeudDeListe(vals1[0])
courant1 = tete1
for val in vals1[1:]:
courant1.next = NoeudDeListe(val)
courant1 = courant1.next
if not tete1 and vals_communs: # Si vals1 était vide, la partie commune est le début de liste1
tete1 = NoeudDeListe(vals_communs[0])
courant1 = tete1
for val in vals_communs[1:]:
courant1.next = NoeudDeListe(val)
courant1 = courant1.next
elif vals_communs:
# Créer la partie commune
intersection_point = None
if vals_communs:
intersection_point = NoeudDeListe(vals_communs[0])
comm_courant = intersection_point
for val in vals_communs[1:]:
comm_courant.next = NoeudDeListe(val)
comm_courant = comm_courant.next
if courant1:
courant1.next = intersection_point
else:
tete1 = intersection_point # Si vals1 était vide
tete2 = None
if vals2:
tete2 = NoeudDeListe(vals2[0])
courant2 = tete2
for val in vals2[1:]:
courant2.next = NoeudDeListe(val)
courant2 = courant2.next
if courant2:
courant2.next = intersection_point
else:
tete2 = intersection_point # Si vals2 était vide
return tete1, tete2
def imprimer_liste(tete):
current = tete
elements = []
while current:
elements.append(str(current.val))
current = current.next
print(" -> ".join(elements))
# --- Exemples d'utilisation ---
# Cas 1: Intersection
common_nodes = NoeudDeListe(7)
common_nodes.next = NoeudDeListe(9)
common_nodes.next.next = NoeudDeListe(1)
common_nodes.next.next.next = NoeudDeListe(5)
l1_head = NoeudDeListe(1)
l1_head.next = NoeudDeListe(2)
l1_head.next.next = NoeudDeListe(3)
l1_head.next.next.next = common_nodes
l2_head = NoeudDeListe(4)
l2_head.next = NoeudDeListe(5)
l2_head.next.next = common_nodes
print("Liste 1:")
imprimer_liste(l1_head) # 1 -> 2 -> 3 -> 7 -> 9 -> 1 -> 5
print("Liste 2:")
imprimer_liste(l2_head) # 4 -> 5 -> 7 -> 9 -> 1 -> 5
intersection_node = trouver_intersection(l1_head, l2_head)
if intersection_node:
print(f"Nœud d'intersection (valeur): {intersection_node.val}") # 7
else:
print("Pas d'intersection.")
# Cas 2: Pas d'intersection
l3_head = creer_liste_chaine([10, 20, 30])
l4_head = creer_liste_chaine([40, 50, 60])
print("\nListe 3:")
imprimer_liste(l3_head)
print("Liste 4:")
imprimer_liste(l4_head)
intersection_node_no = trouver_intersection(l3_head, l4_head)
if intersection_node_no:
print(f"Nœud d'intersection (valeur): {intersection_node_no.val}")
else:
print("Pas d'intersection.") # Pas d'intersection.
Référence : Blog expliquant la logique.
10. Recherche binaire
Problème : Implémenter la recherche binaire pour trouver un élément dans une liste triée.
Solution : La recherche binaire est un algorithme de recherche efficace pour trouver un élément dans une liste ou un tableau trié. Elle fonctionne en divisant à plusieurs reprises par deux la partie de la liste où l'élément peut se trouver. L'algorithme compare l'élément cible avec l'élément du milieu de la liste. Si l'élément cible est plus petit, la recherche continue dans la moitié inférieure ; si plus grand, dans la moitié supérieure. Ce processus est répété jusqu'à ce que l'élément soit trouvé ou que la sous-liste devienne vide.
def recherche_binaire(liste_triee, element_a_chercher):
index_bas = 0
index_haut = len(liste_triee) - 1
while index_bas <= index_haut:
index_milieu = (index_bas + index_haut) // 2 # Utilise la division entière
valeur_milieu = liste_triee[index_milieu]
if valeur_milieu == element_a_chercher:
return index_milieu # L'élément est trouvé, retourne son index
elif valeur_milieu > element_a_chercher:
index_haut = index_milieu - 1 # L'élément est dans la moitié inférieure
else: # valeur_milieu < element_a_chercher
index_bas = index_milieu + 1 # L'élément est dans la moitié supérieure
return -1 # L'élément n'est pas dans la liste
# Exemple d'utilisation
ma_liste_ordonnee = [10, 25, 30, 42, 55, 60, 78, 90]
print(f"Recherche de 42: index {recherche_binaire(ma_liste_ordonnee, 42)}") # index 3
print(f"Recherche de 10: index {recherche_binaire(ma_liste_ordonnee, 10)}") # index 0
print(f"Recherche de 99: index {recherche_binaire(ma_liste_ordonnee, 99)}") # index -1
Référence : Explication de la recherche binaire.
11. Tri rapide (Quicksort)
Problème : Trier une liste d'éléments en utilisant l'algorithme Quicksort.
Solution : Le tri rapide est un algorithme de tri par comparaison et en place. C'est un algroithme de "diviser pour régner". Il sélectionne un élément appelé "pivot" dans la liste, puis partitionne le reste de la liste en deux sous-listes : les éléments plus petits que le pivot et les éléments plus grands que le pivot. Le tri rapide est ensuite appliqué récursivement aux deux sous-listes.
def tri_rapide(liste_elements):
# Cas de base: une liste avec 0 ou 1 élément est déjà triée
if len(liste_elements) < 2:
return liste_elements
else:
# Choisir le premier élément comme pivot
pivot_valeur = liste_elements[0]
# Partitionner la liste en éléments plus petits et plus grands que le pivot
elements_plus_petits = [element for element in liste_elements[1:] if element <= pivot_valeur]
elements_plus_grands = [element for element in liste_elements[1:] if element > pivot_valeur]
# Appliquer récursivement le tri rapide et combiner les résultats
liste_triee = tri_rapide(elements_plus_petits) + [pivot_valeur] + tri_rapide(elements_plus_grands)
return liste_triee
# Exemple d'utilisation
ma_liste_desordonnee = [20, 4, 60, 7, 10, 2, 5, 80, 1]
print(f"Liste originale: {ma_liste_desordonnee}")
print(f"Liste triée (Quicksort): {tri_rapide(ma_liste_desordonnee)}") # [1, 2, 4, 5, 7, 10, 20, 60, 80]
Pour plus de problèmes de tri, voir : Structures de données et algorithmes - Tri - Description Python.
12. Problème du rendu de monnaie
Problème : Étant donné un ensemble de dénominations de pièces et une somme d'argent, trouver le nombre minimum de pièces nécessaires pour rendre la monnaie.
Solution : Ce problème est un problème de programmation dynamique classique. On peut construire une solution de bas en haut, en calculant le nombre minimum de pièces pour chaque montant jusqu'à la somme cible.
def rendre_monnaie_min_pieces(denominations, montant_cible):
# Initialiser un tableau pour stocker le nombre minimum de pièces pour chaque montant
# min_pieces[i] sera le nombre minimum de pièces pour le montant i
min_pieces = [float('inf')] * (montant_cible + 1)
min_pieces[0] = 0 # 0 pièces pour un montant de 0
# Itérer sur chaque montant de 1 jusqu'au montant cible
for current_montant in range(1, montant_cible + 1):
# Pour chaque montant, essayer toutes les dénominations de pièces
for piece in denominations:
# Si la pièce est inférieure ou égale au montant courant
if piece <= current_montant:
# Calculer le nombre de pièces en utilisant cette dénomination
# C'est 1 (pour la pièce actuelle) + le min_pieces pour le montant restant
if min_pieces[current_montant - piece] != float('inf'):
min_pieces[current_montant] = min(min_pieces[current_montant], 1 + min_pieces[current_montant - piece])
# print(f"Montant: {current_montant}, Min pièces: {min_pieces[current_montant]}")
if min_pieces[montant_cible] == float('inf'):
return -1 # Impossible de rendre la monnaie
else:
return min_pieces[montant_cible]
# Exemple d'utilisation
valeurs_pieces = [1, 5, 10, 21, 25] # Dénominations de pièces disponibles
somme_a_rendre = 63
print(f"Nombre min de pièces pour {somme_a_rendre}: {rendre_monnaie_min_pieces(valeurs_pieces, somme_a_rendre)}")
# Pour 63 avec [1, 5, 10, 21, 25]: 3 (21 + 21 + 21)
print(f"Nombre min de pièces pour 11: {rendre_monnaie_min_pieces(valeurs_pieces, 11)}")
# Pour 11: 2 (10 + 1)
print(f"Nombre min de pièces pour 30: {rendre_monnaie_min_pieces(valeurs_pieces, 30)}")
# Pour 30: 2 (25 + 5 ou 10 + 10 + 10)
Référence : Explication du problème et Méthodes de résolution.
13. Parcours en largeur (BFS) et en profondeur (DFS) d'un arbre binaire
Problème : Étant donné un arbre binaire, implémenter les parcours en largeur et en profondeur. Construisons d'abord un arbre.
14. Représentation d'un nœud d'arbre binaire
class NoeudArbre:
def __init__(self, data_val, gauche=None, droite=None):
self.valeur = data_val
self.gauche = gauche
self.droite = droite
# Création d'un arbre d'exemple
# 1
# / \
# 3 2
# / \ / \
# 7 6 5 4
# /
# 0
arbre_exemple = NoeudArbre(1,
NoeudArbre(3,
NoeudArbre(7, NoeudArbre(0)),
NoeudArbre(6)),
NoeudArbre(2,
NoeudArbre(5),
NoeudArbre(4)))
15. Parcours en largeur (BFS - Level-order Traversal)
Le parcours en largeur explore l'arbre niveau par niveau. Il utilise une file d'attente (queue) pour stocker les nœuds à visiter.
from collections import deque
def parcours_largeur(racine: NoeudArbre):
if not racine:
return
file_nodes = deque([racine])
resultats = []
while file_nodes:
noeud_courant = file_nodes.popleft() # Récupère le premier nœud de la file
resultats.append(noeud_courant.valeur)
if noeud_courant.gauche:
file_nodes.append(noeud_courant.gauche)
if noeud_courant.droite:
file_nodes.append(noeud_courant.droite)
print(f"Parcours en largeur: {resultats}")
# Exemple d'utilisation
parcours_largeur(arbre_exemple) # [1, 3, 2, 7, 6, 5, 4, 0]
16. Parcours en profondeur (DFS - Depth-first Traversal)
Le parcours en profondeur explore autant que possible chaque branche avant de revenir en arrière. Il peut être implémenté récursivement ou avec une pile.
def parcours_profondeur(racine: NoeudArbre):
if not racine:
return [] # Retourne une liste vide pour la cohérence
resultats = []
# Utilisation d'une pile pour l'implémentation itérative du DFS
# L'ordre de push/pop détermine le type de parcours (pré-ordre, post-ordre...)
pile_nodes = [racine]
while pile_nodes:
noeud_courant = pile_nodes.pop() # Récupère le dernier nœud de la pile
resultats.append(noeud_courant.valeur)
# Pousser les enfants droit puis gauche pour obtenir un ordre pré-ordre (gauche puis droit)
if noeud_courant.droite:
pile_nodes.append(noeud_courant.droite)
if noeud_courant.gauche:
pile_nodes.append(noeud_courant.gauche)
# Pour un pré-ordre classique (racine, gauche, droite) en utilisant une pile,
# il faudrait pousser d'abord le droit, puis le gauche.
print(f"Parcours en profondeur (Pré-ordre itératif): {resultats}")
# Exemple d'utilisation
# parcours_profondeur(arbre_exemple) # [1, 3, 7, 0, 6, 2, 5, 4]
17. Parcours pré-ordre, in-ordre, post-ordre
Ce sont les trois types de parcours en profondeur, définis par l'ordre dans lequel le nœud racine est visité par rapport à ses sous-arbres gauche et droit. Ils sont généralement implémentés récursivement.
# Parcours pré-ordre (Racine, Gauche, Droite)
def parcours_pre_ordre(racine: NoeudArbre):
if not racine:
return
print(racine.valeur, end=" ")
parcours_pre_ordre(racine.gauche)
parcours_pre_ordre(racine.droite)
# Parcours in-ordre (Gauche, Racine, Droite)
def parcours_in_ordre(racine: NoeudArbre):
if not racine:
return
parcours_in_ordre(racine.gauche)
print(racine.valeur, end=" ")
parcours_in_ordre(racine.droite)
# Parcours post-ordre (Gauche, Droite, Racine)
def parcours_post_ordre(racine: NoeudArbre):
if not racine:
return
parcours_post_ordre(racine.gauche)
parcours_post_ordre(racine.droite)
print(racine.valeur, end=" ")
print("--- Parcours de l'arbre d'exemple ---")
print("Pré-ordre:")
parcours_pre_ordre(arbre_exemple) # 1 3 7 0 6 2 5 4
print("\nIn-ordre:")
parcours_in_ordre(arbre_exemple) # 0 7 3 6 1 5 2 4
print("\nPost-ordre:")
parcours_post_ordre(arbre_exemple) # 0 7 6 3 5 4 2 1
print()
18. Profondeur maximale d'un arbre binaire
Problème : Trouver la profondeur maximale (ou hauteur) d'un arbre binaire.
Solution : La profondeur maximale d'un arbre est la longueur du plus long chemin depuis la racine jusqu'à une feuille. On peut la calculer récursivement : la profondeur d'un nœud est 1 plus le maximum des profondeurs de ses enfants.
def calculer_profondeur_max(racine: NoeudArbre):
if not racine:
return 0 # Si l'arbre est vide, la profondeur est 0
profondeur_gauche = calculer_profondeur_max(racine.gauche)
profondeur_droite = calculer_profondeur_max(racine.droite)
return max(profondeur_gauche, profondeur_droite) + 1 # +1 pour le nœud courant
print(f"Profondeur maximale de l'arbre exemple: {calculer_profondeur_max(arbre_exemple)}") # 4
19. Vérifier si deux arbres binaires sont identiques
Problème : Étant données les racines de deux arbres binaires, déterminer s'ils sont structurellement identiques et si leurs nœuds ont les mêmes valeurs.
Solution : Deux arbres sont identiques s'ils sont tous deux vides, ou s'ils ont la même valeur de nœud racine et que leurs sous-arbres gauche sont identiques et leurs sous-arbres droit sont identiques. C'est une vérification récursive.
def sont_arbres_identiques(racine1: NoeudArbre, racine2: NoeudArbre):
# Si les deux nœuds sont nuls, ils sont identiques (point de terminaison de la récursion)
if not racine1 and not racine2:
return True
# Si un seul des nœuds est nul (l'autre ne l'est pas), ils ne sont pas identiques
if not racine1 or not racine2:
return False
# Vérifier la valeur du nœud courant et récursivement les sous-arbres
return (racine1.valeur == racine2.valeur and
sont_arbres_identiques(racine1.gauche, racine2.gauche) and
sont_arbres_identiques(racine1.droite, racine2.droite))
# Créons un deuxième arbre identique
arbre_copie = NoeudArbre(1,
NoeudArbre(3,
NoeudArbre(7, NoeudArbre(0)),
NoeudArbre(6)),
NoeudArbre(2,
NoeudArbre(5),
NoeudArbre(4)))
# Et un arbre différent
arbre_different = NoeudArbre(1, NoeudArbre(3), NoeudArbre(2, NoeudArbre(9)))
print(f"Arbre exemple et copie sont identiques: {sont_arbres_identiques(arbre_exemple, arbre_copie)}") # True
print(f"Arbre exemple et different sont identiques: {sont_arbres_identiques(arbre_exemple, arbre_different)}") # False
20. Reconstruire un arbre binaire à partir des parcours pré-ordre et in-ordre, puis faire un parcours post-ordre
Problème : Étant donnés les parcours pré-ordre et in-ordre d'un arbre binaire, reconstruire l'arbre, puis effectuer un parcours post-ordre sur l'arbre reconstruit.
Solution : La clé est que le premier élément du parcours pré-ordre est toujours la racine de l'arbre. Dans le parcours in-ordre, tous les éléments à gauche de la racine appartiennent au sous-arbre gauche, et tous les éléments à droite appartiennent au sous-arbre droit. Ces informations permettent de diviser récursivement les listes de parcours pour reconstruire l'arbre.
class NoeudArbreRec: # Une classe de nœud simplifiée pour la reconstruction
def __init__(self, valeur):
self.valeur = valeur
self.gauche = None
self.droite = None
def reconstruire_arbre(pre_ordre_list: list, in_ordre_list: list) -> NoeudArbreRec:
# Cas de base: si une des listes est vide, il n'y a pas d'arbre/sous-arbre
if not pre_ordre_list or not in_ordre_list:
return None
# Le premier élément du pré-ordre est la racine
valeur_racine = pre_ordre_list[0]
racine = NoeudArbreRec(valeur_racine)
# Trouver l'index de la racine dans le parcours in-ordre
# Cela sépare les éléments du sous-arbre gauche et droit
index_racine_in_ordre = in_ordre_list.index(valeur_racine)
# Reconstruire récursivement le sous-arbre gauche
# Pré-ordre gauche: du 2ème élément du pré-ordre jusqu'à la taille du sous-arbre gauche
# In-ordre gauche: les éléments avant la racine dans l'in-ordre
racine.gauche = reconstruire_arbre(
pre_ordre_list[1 : 1 + index_racine_in_ordre],
in_ordre_list[:index_racine_in_ordre]
)
# Reconstruire récursivement le sous-arbre droit
# Pré-ordre droit: les éléments après le sous-arbre gauche dans le pré-ordre
# In-ordre droit: les éléments après la racine dans l'in-ordre
racine.droite = reconstruire_arbre(
pre_ordre_list[1 + index_racine_in_ordre:],
in_ordre_list[index_racine_in_ordre + 1:]
)
return racine
# Fonction pour faire un parcours post-ordre sur l'arbre reconstruit
def parcours_post_ordre_reconstruit(racine: NoeudArbreRec):
if not racine:
return
parcours_post_ordre_reconstruit(racine.gauche)
parcours_post_ordre_reconstruit(racine.droite)
print(racine.valeur, end=" ")
# Exemple d'utilisation
# Pré-ordre: [3, 9, 20, 15, 7]
# In-ordre: [9, 3, 15, 20, 7]
# L'arbre serait:
# 3
# / \
# 9 20
# / \
# 15 7
pre_ordre_test = [3, 9, 20, 15, 7]
in_ordre_test = [9, 3, 15, 20, 7]
arbre_reconstruit = reconstruire_arbre(pre_ordre_test, in_ordre_test)
print("Parcours post-ordre de l'arbre reconstruit:")
parcours_post_ordre_reconstruit(arbre_reconstruit) # 9 15 7 20 3
print()
Référence : Explication de la reconstruction d'arbre.
21. Inverser une liste chaînée simple
Problème : Inverser l'ordre des nœuds dans une liste chaînée simple.
Solution : L'inversion d'une liste chaînée peut être réalisée en modifiant les pointeurs next de chaque nœud pour qu'ils pointent vers le nœud précédent au lieu du nœud suivant. Cela nécessite de suivre trois pointeurs : precedent, courant et temporaire (pour sauvegarder le nœud suivant avant de modifier courant.next).
class NoeudListeSimple:
def __init__(self, data_val=None, next_node=None):
self.valeur = data_val
self.suivant = next_node
# Création d'une liste chaînée d'exemple: 1 -> 2 -> 3 -> 4 -> 5
liste_chaine_originale = NoeudListeSimple(1, NoeudListeSimple(2, NoeudListeSimple(3, NoeudListeSimple(4, NoeudListeSimple(5)))))
def inverser_liste_chaine(tete: NoeudListeSimple) -> NoeudListeSimple:
noeud_precedent = None
noeud_courant = tete
while noeud_courant:
# Sauvegarder le nœud suivant avant de modifier le pointeur
noeud_temporaire_suivant = noeud_courant.suivant
# Inverser le pointeur 'suivant' du nœud actuel
noeud_courant.suivant = noeud_precedent
# Déplacer les pointeurs pour la prochaine itération
noeud_precedent = noeud_courant
noeud_courant = noeud_temporaire_suivant
return noeud_precedent # Le nouveau "head" est l'ancien "dernier" nœud
# Fonction utilitaire pour imprimer la liste
def afficher_liste_simple(tete_liste):
vals = []
current = tete_liste
while current:
vals.append(str(current.valeur))
current = current.suivant
print(" -> ".join(vals))
print("Liste originale:")
afficher_liste_simple(liste_chaine_originale) # 1 -> 2 -> 3 -> 4 -> 5
liste_chaine_inversee = inverser_liste_chaine(liste_chaine_originale)
print("Liste inversée:")
afficher_liste_simple(liste_chaine_inversee) # 5 -> 4 -> 3 -> 2 -> 1
Référence : Idées pour l'inversion et Méthodes détaillées.
22. Vérifier si deux chaînes sont des anagrammes
Problème : Étant données deux chaînes de caractères s1 et s2, déterminer si elles sont des anagrammes l'une de l'autre. Deux mots sont des anagrammes si l'un peut être obtenu en réarrangeant les lettres de l'autre (par exemple, "listen" et "silent").
Voici plusieurs solutions :
1. Solution par vérification directe (peu efficace)
Cette approche parcourt la première chaîne et tente de trouver chaque caractère dans la seconde chaîne. Si un caractère est trouvé, il est "marqué" dans la seconde chaîne pour ne pas être réutilisé. Si tous les caractères de s1 sont trouvés dans s2 (et vice versa), alors c'est un anagramme.
def sont_anagrammes_solution1(chaine1, chaine2):
if len(chaine1) != len(chaine2):
return False
liste_ch2 = list(chaine2)
for char1 in chaine1:
found_char = False
for i in range(len(liste_ch2)):
if liste_ch2[i] == char1:
liste_ch2[i] = None # Marquer comme utilisé
found_char = True
break
if not found_char:
return False
return True
print(f"Solution 1 ('coeur', 'cruoe'): {sont_anagrammes_solution1('coeur', 'cruoe')}") # True
print(f"Solution 1 ('hello', 'world'): {sont_anagrammes_solution1('hello', 'world')}") # False
2. Solution par tri (efficace)
Si deux chaînes sont des anagrammes, alors leurs versions triées doivent être identiques. C'est une méthode simple et efficace.
def sont_anagrammes_solution2(chaine1, chaine2):
if len(chaine1) != len(chaine2):
return False
liste_ch1_triee = sorted(list(chaine1))
liste_ch2_triee = sorted(list(chaine2))
return liste_ch1_triee == liste_ch2_triee
print(f"Solution 2 ('anagramme', 'grammean'): {sont_anagrammes_solution2('anagramme', 'grammean')}") # True
print(f"Solution 2 ('test', 'sett'): {sont_anagrammes_solution2('test', 'sett')}") # False
3. Solution par comptage de caractères (la plus efficace pour des alphabets fixes)
Utilise des tableaux de fréquence pour compter les occurrences de chaque caractère dans les deux chaînes. Si les tableaux de fréquence sont identiques, les chaînes sont des anagrammes. Supposons un alphabet ASCII ou étendu connu (ici, 26 lettres minuscules de 'a' à 'z').
def sont_anagrammes_solution3(chaine1, chaine2):
if len(chaine1) != len(chaine2):
return False
# Créer des tableaux de fréquence pour 26 lettres (si uniquement minuscules)
compteur1 = [0] * 26
compteur2 = [0] * 26
for char_val in chaine1:
pos = ord(char_val) - ord('a')
compteur1[pos] += 1
for char_val in chaine2:
pos = ord(char_val) - ord('a')
compteur2[pos] += 1
return compteur1 == compteur2
print(f"Solution 3 ('client', 'lentic'): {sont_anagrammes_solution3('client', 'lentic')}") # True
print(f"Solution 3 ('mot', 'tomm'): {sont_anagrammes_solution3('mot', 'tomm')}") # False (longueur différente, géré en amont)
23. Problèmes de programmation dynamique (DP)
La programmation dynamique est une technique de résolution de problèmes qui décompose un problème complexe en sous-problèmes plus petits, résout chaque sous-problème une seule fois, et stocke leurs solutions. Lorsque les mêmes sous-problèmes se présentent à nouveau, les solutions stockées sont réutilisées pour éviter des recalculs. Cela peut considérablement améliorer l'efficacité des algorithmes.
Les problèmes de programmation dynamique ont généralement deux propriétés principales :
- Structure optimale de sous-problèmes : Une solution optimale au problème global peut être construite à partir de solutions optimales à ses sous-problèmes.
- Sous-problèmes superposés : Les mêmes sous-problèmes sont rencontrés plusieurs fois lors de la résolution.
Deux approches courantes pour la DP sont :
- Mémoïsation (Top-down) : Résoudre le problème récursivement en stockant les résultats des sous-problèmes dans un cache (comme dans le problème de Fibonacci avec
@memoization). - Tabulation (Bottom-up) : Résoudre les sous-problèmes de manière itérative, en commençant par les plus petits et en construisant des solutions pour les problèmes plus grands à partir des plus petits déjà résolus (comme dans le problème du rendu de monnaie).
Pour une exploration plus approfondie des problèmes et solutions en programmation dynamique en Python, vous pouvez consulter : Organisation de la programmation dynamique (DP) - Description Python.