Le module collections est dans la bibliothèque standard Python depuis la version 2.4. Il expose des structures de données spécialisées qui résolvent des problèmes récurrents sans dépendance externe. Pourtant, beaucoup de développeurs continuent d’écrire des boucles de comptage, des initialisations conditionnelles de clés, ou des classes Point avec x, y, z alors que Counter, defaultdict et namedtuple font exactement cela, mieux et plus lisiblement.
Voici les six structures que j’utilise régulièrement, avec les cas où elles changent vraiment quelque chose.
Counter : compter sans boucle
Counter prend n’importe quel itérable et retourne un dict-like où chaque élément est associé à son nombre d’occurrences. La clé la plus fréquente apparaît en premier.
from collections import Counter
fruits = ['apple', 'banana', 'orange', 'banana', 'apple', 'apple']
c = Counter(fruits)
print(c)
# Counter({'apple': 3, 'banana': 2, 'orange': 1})
most_common(n) retourne les n éléments les plus fréquents, par ordre décroissant :
c.most_common(2)
# [('apple', 3), ('banana', 2)]
Ce qui le rend vraiment utile en pratique, ce sont les opérations arithmétiques entre deux Counter. L’addition cumule les comptes, la soustraction garde uniquement les positifs, l’intersection (&) prend le minimum, l’union (|) prend le maximum :
c1 = Counter(a=3, b=1)
c2 = Counter(a=1, b=2)
print(c1 + c2) # Counter({'a': 4, 'b': 3})
print(c1 - c2) # Counter({'a': 2})
print(c1 & c2) # Counter({'a': 1, 'b': 1})
print(c1 | c2) # Counter({'a': 3, 'b': 2})
update() permet d’alimenter un Counter à partir d’un nouvel itérable sans repartir de zéro :
c1.update(['a', 'a', 'c'])
# Counter({'a': 5, 'b': 1, 'c': 1})
Quand l’utiliser. Analyse de fréquence, histogramme de données, comptage de tokens, statistiques sur des logs. Toute situation où on écrirait if key in d: d[key] += 1 else: d[key] = 1 est un candidat direct pour Counter.
defaultdict : plus jamais de KeyError
defaultdict est une sous-classe de dict qui crée automatiquement une valeur par défaut quand une clé inexistante est accédée. La factory est passée à la construction.
from collections import defaultdict
# Factory list : chaque nouvelle clé démarre avec une liste vide
groupes = defaultdict(list)
groupes['fruit'].append('apple')
groupes['fruit'].append('banana')
groupes['car'] # accès simple : crée la clé avec []
print(groupes)
# defaultdict(<class 'list'>, {'fruit': ['apple', 'banana'], 'car': []})
Avec int comme factory, chaque nouvelle clé démarre à 0, ce qui permet de compter sans vérification préalable :
compteur = defaultdict(int)
for mot in ['chat', 'chien', 'chat', 'perroquet', 'chien', 'chat']:
compteur[mot] += 1
# defaultdict(<class 'int'>, {'chat': 3, 'chien': 2, 'perroquet': 1})
Un cas très courant en pratique : grouper des données par clé sans tester si la clé existe déjà.
donnees = [('FR', 'Paris'), ('US', 'New York'), ('FR', 'Lyon'), ('US', 'Chicago')]
groupes = defaultdict(list)
for pays, ville in donnees:
groupes[pays].append(ville)
# defaultdict(<class 'list'>, {'FR': ['Paris', 'Lyon'], 'US': ['New York', 'Chicago']})
Quand l’utiliser. Chaque fois qu’on initialise un dict en testant if key not in d avant d’insérer. defaultdict supprime ce pattern et rend le code plus linéaire. Pour du comptage simple, Counter reste plus idiomatique.
namedtuple : des tuples avec des noms de champs
Un tuple ordinaire impose l’accès par index. namedtuple ajoute des noms de champs, ce qui rend le code auto-documenté sans le coût d’une classe complète.
from collections import namedtuple
Voiture = namedtuple('Voiture', ['marque', 'carburant'])
v = Voiture("BMW", "essence")
print(v.marque) # 'BMW'
print(v.carburant) # 'essence'
print(v[0]) # 'BMW' — l'accès par index reste possible
_asdict() retourne un dict standard (depuis Python 3.8, OrderedDict avant) :
print(v._asdict())
# {'marque': 'BMW', 'carburant': 'essence'}
_replace() crée une nouvelle instance avec un ou plusieurs champs modifiés. Le namedtuple est immuable, donc c’est la seule façon de “modifier” une valeur :
v2 = v._replace(carburant="électrique")
# Voiture(marque='BMW', carburant='électrique')
_fields expose les noms de champs, utile pour l’introspection :
Point = namedtuple('Point', 'x y z')
p = Point(1, 2, 3)
print(p._fields) # ('x', 'y', 'z')
Une instance namedtuple n’a pas de __dict__. C’est ce qui la rend plus légère en mémoire qu’une dataclass standard sur de grandes collections : une dataclass alloue un dictionnaire d’attributs par instance, un namedtuple non. Pour aller plus loin sur ce sujet, voir Python __slots__ et l’optimisation mémoire.
Quand l’utiliser. Retours de fonctions avec plusieurs valeurs nommées, représentation légère de données tabulaires, remplacement de tuples (x, y) ou (id, nom, date) où l’ordre finit toujours par devenir opaque. Pour des objets mutables avec logique métier, dataclass est plus adapté.
deque : insertions et suppressions aux deux extrémités
Une list Python est optimisée pour les opérations en fin de liste. list.insert(0, x) et list.pop(0) sont en O(n) car ils déplacent tous les éléments. deque (double-ended queue) fait ces mêmes opérations en O(1).
from collections import deque
d = deque([1, 2, 3, 4, 5])
d.append(6) # O(1) à droite : deque([1, 2, 3, 4, 5, 6])
d.appendleft(0) # O(1) à gauche : deque([0, 1, 2, 3, 4, 5, 6])
d.pop() # O(1) à droite : deque([0, 1, 2, 3, 4, 5])
d.popleft() # O(1) à gauche : deque([1, 2, 3, 4, 5])
rotate(n) effectue une rotation de n pas vers la droite (négatif pour la gauche) :
d = deque([1, 2, 3, 4, 5])
d.rotate(2) # rotation de 2 pas vers la droite : deque([4, 5, 1, 2, 3])
d.rotate(-2) # rotation de 2 pas vers la gauche : deque([1, 2, 3, 4, 5])
maxlen transforme la deque en fenêtre glissante. Quand la deque est pleine, chaque ajout à droite éjecte automatiquement l’élément le plus à gauche :
historique = deque(maxlen=3)
for i in range(6):
historique.append(i)
print(historique)
# deque([3, 4, 5], maxlen=3)
Quand l’utiliser. File FIFO (appendleft + pop ou append + popleft), pile LIFO, fenêtre glissante, buffer d’historique à taille fixe, BFS (breadth-first search). Dans un pipeline de traitement de fichiers, deque gère le buffer en mémoire pendant que shutil s’occupe des opérations disque (copie, archivage). Utiliser list quand les accès par index arbitraire sont fréquents : deque ne supporte pas l’accès en O(1) par index.
OrderedDict : quand l’ordre d’insertion compte pour la comparaison
Depuis Python 3.7, les dicts ordinaires maintiennent l’ordre d’insertion. Alors pourquoi OrderedDict existe encore ?
Parce qu’il se comporte différemment lors des comparaisons d’égalité. Deux dicts avec les mêmes paires clé-valeur mais dans des ordres différents sont égaux. Deux OrderedDict identiques mais dans des ordres différents ne le sont pas.
from collections import OrderedDict
d1 = {'a': 1, 'b': 2}
d2 = {'b': 2, 'a': 1}
print(d1 == d2) # True
od1 = OrderedDict([('a', 1), ('b', 2)])
od2 = OrderedDict([('b', 2), ('a', 1)])
print(od1 == od2) # False
move_to_end() est l’autre spécificité : déplacer une clé en fin ou en début de dict sans recréer l’objet.
d = OrderedDict([('a', 1), ('b', 2), ('c', 3)])
d.move_to_end('a') # 'a' passe à la fin
d.move_to_end('c', last=False) # 'c' passe au début
Quand l’utiliser. Cache LRU (le plus ancien est en tête, le plus récent en queue), protocoles où l’ordre des champs dans un message a une signification sémantique, tests où on veut vérifier non seulement les valeurs mais l’ordre. Pour du stockage ordinaire, dict suffit.
ChainMap : plusieurs dicts en lecture unifiée
ChainMap enchaîne plusieurs dicts et les expose comme une vue unique. Les recherches traversent les dicts de gauche à droite et s’arrêtent à la première correspondance. Les écritures ne touchent que le premier dict.
from collections import ChainMap
config_globale = {'debug': True, 'timeout': 30, 'retries': 3}
config_projet = {'timeout': 60}
config_locale = {'debug': False}
config = ChainMap(config_locale, config_projet, config_globale)
print(config['debug']) # False (trouvé dans config_locale)
print(config['timeout']) # 60 (trouvé dans config_projet)
print(config['retries']) # 3 (trouvé dans config_globale)
Les écritures ne propagent pas vers les dicts sous-jacents :
config['timeout'] = 5
print(config_locale) # {'debug': False, 'timeout': 5} — mis à jour
print(config_projet) # {'timeout': 60} — inchangé
new_child() ajoute une couche temporaire au-dessus de la chaîne existante, pratique pour une surcharge locale sans toucher aux autres niveaux :
config_temp = config.new_child({'retries': 1})
print(config_temp['retries']) # 1 (couche temporaire)
print(config['retries']) # 3 (chaîne originale inchangée)
Quand l’utiliser. Gestion de configuration en couches (valeurs par défaut < config projet < variables d’environnement < flags CLI), contextes d’exécution imbriqués, simulation de scoping de variables (comme le fait l’interpréteur Python avec les namespaces). Alternative légère à {**defaults, **overrides} qui crée une copie, alors que ChainMap ne copie rien.
Récapitulatif
| Structure | Remplace | Avantage principal |
|---|---|---|
Counter | boucle de comptage | opérations arithmétiques, most_common |
defaultdict | if key not in d | factory automatique, code linéaire |
namedtuple | tuple anonyme | accès par nom, immuable, léger |
deque | list en file/pile | O(1) aux deux extrémités, maxlen |
OrderedDict | dict | comparaison sensible à l’ordre, move_to_end |
ChainMap | fusion de dicts | vue unifiée sans copie, écriture isolée |
Ces six structures couvrent l’essentiel de ce que collections apporte. Elles sont toutes en C dans CPython, bien documentées, et éliminées chacune une catégorie de code boilerplate.
