Collections & comprehensions
TL;DR — Python te donne quatre conteneurs natifs (
list,tuple,set,dict) et une syntaxe déclarative — les comprehensions — pour les construire en une ligne lisible et rapide. La règle senior : choisis le conteneur selon ses invariants (ordre ? unicité ? mutabilité ? lookup O(1) ?), pas par habitude ; remplace tes bouclesfor+appendpar des comprehensions quand elles transforment/filtrent (jamais quand elles ont des effets de bord) ; et bascule sur des générateurs dès que le volume est gros ou infini (streaming de tokens LLM, lecture de fichiers). Une comprehension n'est pas du sucre cosmétique : c'est plus rapide (boucle en C), atomique, et ça documente l'intention.
🧠 Mental model
Venant de PHP/TS, ton réflexe est array (PHP) ou Array/Object/Map/Set (TS). Python est plus explicite : chaque conteneur encode un contrat différent. Pense à une cuisine professionnelle :
┌──────────────┬───────────┬──────────┬──────────────┬─────────────────────────────┐
│ Conteneur │ Ordonné ? │ Unique ? │ Mutable ? │ Métaphore │
├──────────────┼───────────┼──────────┼──────────────┼─────────────────────────────┤
│ list │ oui │ non │ oui │ la file de commandes │
│ tuple │ oui │ non │ NON (figé) │ le ticket imprimé (record) │
│ set │ non* │ OUI │ oui │ le panier d'ingrédients │
│ frozenset │ non* │ OUI │ NON (figé) │ une recette gravée │
│ dict │ oui (ins) │ clés OUI │ oui │ le tableau "plat → table" │
└──────────────┴───────────┴──────────┴──────────────┴─────────────────────────────┘
* set/frozenset : pas d'ordre garanti, ne JAMAIS s'appuyer dessus.En TS, [...] est une liste et un tuple et une stack ; tu encodes l'intention dans ta tête. En Python, tu l'encodes dans le type. Un tuple qui ne peut pas muter empêche une classe entière de bugs. Un set te donne le in en O(1) au lieu de O(n). Un dict est l'équivalent direct du Map de TS, et depuis 3.7 il préserve l'ordre d'insertion (contrat du langage, pas un détail d'implémentation).
La comprehension, elle, c'est la chaîne de transformation déclarative — l'équivalent mental de array.map().filter() en TS, mais fusionné en une seule expression compilée :
résultat = [ transform(x) for x in source if predicate(x) ]
└── map ──┘ └── itère ──┘ └── filter ──┘Les quatre conteneurs : contrats et pièges
list — séquence ordonnée mutable
from __future__ import annotations
scores: list[int] = [10, 7, 7, 3]
scores.append(1) # O(1) amorti en fin
scores.insert(0, 99) # O(n) en tête — coûteux, évite en boucle chaude
first, *rest = scores # unpacking : first=99, rest=[10,7,7,3,1]Piège classique d'ex-dev d'autres langages : le default argument mutable.
# ❌ FAUX — le [] est créé UNE fois, à la définition, et partagé entre appels
def add_message(content: str, history: list[str] = []) -> list[str]:
history.append(content)
return history
add_message("a") # ['a']
add_message("b") # ['a', 'b'] <-- surprise : l'état fuit entre les appels !
# ✅ CORRECT — sentinelle None
def add_message(content: str, history: list[str] | None = None) -> list[str]:
history = history if history is not None else []
history.append(content)
return historytuple — record immuable
Le tuple n'est pas « une liste qu'on peut pas modifier ». C'est un record hétérogène : sa position porte un sens. Il est hashable (donc utilisable comme clé de dict ou élément de set), et signale au lecteur « ces valeurs vont ensemble et ne bougeront pas ».
# coordonnées d'un usage tokens : (input, output) — l'ordre EST le contrat
usage: tuple[int, int] = (1_200, 350)
# clé composite dans un cache LLM : (model, prompt_hash)
cache: dict[tuple[str, str], str] = {}
cache[("claude-opus-4-8", "a1b2c3")] = "réponse mise en cache"Pour des records nommés et typés, préfère un NamedTuple ou un @dataclass(frozen=True) plutôt qu'un tuple anonyme à 5 champs (sinon result[3] devient illisible).
set — appartenance et dédup en O(1)
seen_tool_ids: set[str] = set() # PAS {} — ça, c'est un dict vide !
seen_tool_ids.add("toolu_01")
"toolu_01" in seen_tool_ids # O(1) ── vs O(n) sur une list
# dédup d'allowlist en gardant la sémantique d'ensemble
allowed = {"read_file", "search", "read_file"} # -> {'read_file', 'search'}
a, b = {1, 2, 3}, {2, 3, 4}
a & b # intersection -> {2, 3}
a | b # union -> {1, 2, 3, 4}
a - b # différence -> {1}Règle de perf qui revient en entretien : tester l'appartenance dans une boucle ? Convertis ta liste en set d'abord. if x in big_list répété N fois est O(N·M) ; via un set, c'est O(N).
dict — la table de hachage du langage
model_pricing: dict[str, tuple[float, float]] = {
"claude-opus-4-8": (5.0, 25.0), # (input, output) USD / Mtok @1M
"claude-sonnet-4-6": (3.0, 15.0),
"claude-haiku-4-5": (1.0, 5.0),
}
# accès défensif : .get évite le KeyError
price = model_pricing.get("claude-opus-4-8", (0.0, 0.0))
# fusion (3.9+)
defaults = {"temperature": 1.0, "max_tokens": 1024}
overrides = {"max_tokens": 4096}
config = defaults | overrides # {'temperature': 1.0, 'max_tokens': 4096}Comprehensions : la forme idiomatique
Une comprehension remplace le pattern « créer un conteneur vide, boucler, ajouter ». Elle existe en quatre saveurs.
nums = [1, 2, 3, 4, 5, 6]
squares = [n * n for n in nums] # list
evens = {n for n in nums if n % 2 == 0} # set
index_by_val = {n: i for i, n in enumerate(nums)} # dict
lazy_squares = (n * n for n in nums) # GÉNÉRATEUR (parenthèses)La version manuelle vs la version idiomatique :
# ❌ Verbeux, mutation explicite, 4 lignes pour une transformation pure
result: list[str] = []
for msg in messages:
if msg["role"] == "user":
result.append(msg["content"].strip())
# ✅ Idiomatique : intention déclarée en une expression
result = [msg["content"].strip() for msg in messages if msg["role"] == "user"]Quand NE PAS utiliser une comprehension
La comprehension est pour construire une valeur. Si le corps a un effet de bord (log, écriture réseau, mutation externe), garde un for classique — sinon tu caches l'effet et tu crées une liste de None inutile.
# ❌ ABUS : on construit une list de None juste pour ses effets de bord
[logger.info(line) for line in lines]
# ✅ Une boucle dit clairement « j'agis, je ne collecte pas »
for line in lines:
logger.info(line)Autre anti-pattern : la comprehension illisible. Au-delà de deux for ou d'une condition complexe, repasse à une boucle ou extrais une fonction nommée.
# ❌ Trois niveaux imbriqués + ternaire : personne ne relit ça
flat = [transform(c) if c.ok else fallback(c)
for block in blocks for row in block.rows for c in row.cells if c.active]Comprehensions imbriquées : l'ordre des for
L'ordre des clauses for suit l'ordre de lecture d'une boucle imbriquée (de l'extérieur vers l'intérieur). Erreur fréquente : les inverser.
matrix = [[1, 2, 3], [4, 5, 6]]
# aplatir : lis « pour chaque row, pour chaque x dans row »
flat = [x for row in matrix for x in row] # [1, 2, 3, 4, 5, 6]
# transposer
transposed = [[row[i] for row in matrix] for i in range(len(matrix[0]))]Walrus := pour ne pas calculer deux fois
# évite d'appeler expensive(x) deux fois (filtre + transform)
results = [y for x in data if (y := expensive(x)) is not None]Générateurs : la performance qui compte vraiment
Une comprehension entre () est un générateur : il ne matérialise rien en mémoire, il produit à la demande. C'est le concept senior de cette leçon, parce qu'il fait la différence entre un service qui tient la charge et un OOM en prod.
# liste : 10M ints matérialisés → ~400 Mo de RAM d'un coup
total = sum([n * n for n in range(10_000_000)])
# générateur : un int à la fois → RAM constante
total = sum(n * n for n in range(10_000_000)) # parenthèses de sum() suffisentLes générateurs sont la base mentale du streaming de tokens LLM : on ne reçoit pas la réponse d'un agent d'un bloc, on itère sur des deltas au fur et à mesure.
from collections.abc import Iterator
def token_lengths(stream: Iterator[str]) -> Iterator[int]:
"""Pipeline lazy : transforme un flux de tokens sans rien bufferiser."""
for token in stream:
yield len(token)🤖 Application : façonner les payloads d'un agent Claude
Tout l'art d'appeler un agent, c'est de transformer des collections : construire la liste de messages, filtrer/dédupliquer les tool calls, agréger l'usage. Les comprehensions sont exactement l'outil.
Construire la conversation et router selon le modèle
from __future__ import annotations
from dataclasses import dataclass
from anthropic import AsyncAnthropic
client = AsyncAnthropic() # lit ANTHROPIC_API_KEY, retries SDK activés par défaut
@dataclass(frozen=True)
class Turn:
role: str # "user" | "assistant"
content: str
# transformer des Turn typés -> le format dict attendu par l'API (comprehension)
def to_messages(turns: list[Turn]) -> list[dict[str, str]]:
return [{"role": t.role, "content": t.content} for t in turns]
# router : on choisit le modèle selon la complexité, pas au hasard
MODELS = {
"cheap": "claude-haiku-4-5", # 1 / 5 USD / Mtok
"default": "claude-sonnet-4-6",
"smart": "claude-opus-4-8", # 5 / 25 USD / Mtok @1M
}
async def ask(turns: list[Turn], tier: str = "default") -> str:
resp = await client.messages.create(
model=MODELS[tier],
max_tokens=1024,
messages=to_messages(turns),
)
# le content est une LISTE de blocs -> on filtre/concatène par comprehension
return "".join(b.text for b in resp.content if b.type == "text")Streaming de tokens (le générateur en vrai)
async def stream_answer(prompt: str) -> str:
chunks: list[str] = []
async with client.messages.stream(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
) as stream:
async for text in stream.text_stream: # itération lazy, token par token
chunks.append(text)
print(text, end="", flush=True)
# join final O(n) — bien plus rapide que str += dans la boucle (O(n²))
return "".join(chunks)Détail perf qui sépare le junior du senior : concaténer avec
s += tokendans une boucle est O(n²) (chaque+recopie toute la chaîne, immuable). On accumule dans unelistpuis"".join(...)une seule fois — O(n).
Boucle tool-use : dédup et agrégation par collections
async def run_tool_loop(prompt: str, tools: list[dict]) -> str:
messages: list[dict] = [{"role": "user", "content": prompt}]
seen_tool_ids: set[str] = set() # dédup défensive des tool_use blocks
while True:
resp = await client.messages.create(
model="claude-opus-4-8",
max_tokens=2048,
tools=tools,
messages=messages,
)
# extraire les blocs tool_use jamais vus -> comprehension + set
calls = [b for b in resp.content
if b.type == "tool_use" and b.id not in seen_tool_ids]
seen_tool_ids.update(c.id for c in calls)
if resp.stop_reason != "tool_use":
return "".join(b.text for b in resp.content if b.type == "text")
messages.append({"role": "assistant", "content": resp.content})
# exécuter chaque outil et construire les tool_result en une comprehension
messages.append({
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": c.id,
"content": dispatch(c.name, c.input)}
for c in calls
],
})Agréger l'usage et estimer le coût
@dataclass(frozen=True)
class Usage:
input_tokens: int
output_tokens: int
def total_cost(usages: list[Usage], model: str = "claude-opus-4-8") -> float:
in_price, out_price = model_pricing[model] # USD / Mtok
# somme via générateurs : aucune liste intermédiaire matérialisée
tin = sum(u.input_tokens for u in usages)
tout = sum(u.output_tokens for u in usages)
return (tin * in_price + tout * out_price) / 1_000_000⚙️ En production
Failure modes
- Default mutable (
def f(x, acc=[])) : l'état fuit entre requêtes — en prod, ça veut dire que l'historique d'un user contamine un autre. Bug de sécurité, pas seulement de logique. ToujoursNone+ sentinelle. - Itérer en mutant :
for x in lst: lst.remove(x)saute des éléments / lèveRuntimeError. Itère sur une copie (for x in lst[:]) ou construis une nouvelle liste par comprehension (lst = [x for x in lst if keep(x)]). KeyErrornon géré :d[k]plante si absent. Utilise.get(k, default),dict.setdefault, oucollections.defaultdict. En endpoint, unKeyErrornon capturé = 500.- Aliasing :
b = ane copie pas,best le même objet.a.append(...)modifieb. Copie superficiellea.copy()/a[:], oucopy.deepcopysi imbriqué.
Performance
insurlist= O(n) ; surset/dict= O(1). Tout test d'appartenance répété →set.list.insert(0, x)/list.pop(0)= O(n). File ? Utilisecollections.deque(O(1) aux deux bouts).- Comprehension > boucle
for+append: la boucle tourne en C (~2× plus rapide) et évite les lookups répétés de.append. - Gros volume ou flux (logs, tokens, lignes de fichier) → générateur, RAM constante. Ne fais jamais
list(stream)sur un flux non borné. "".join(parts)au lieu de+=en boucle : O(n) vs O(n²).
Sécurité
- Ne fais jamais confiance à une clé venant du client comme index :
messages[user_index]peut lever ou fuiter. Valide d'abord. set/dictd'allowlist (noms d'outils autorisés) plutôt qu'une suite deif name == ...: O(1), centralisé, auditable.
Observabilité
- Une comprehension qui filtre silencieusement masque les rejets. Si tu drops des éléments (messages mal formés, tool_results invalides), compte-les et logge le delta :
dropped = len(src) - len(kept). - Pour le debug,
collections.Counterdonne une distribution en une ligne :Counter(m["role"] for m in messages).
Tradeoffs senior
listvs générateur : le générateur est à usage unique (épuisé après une itération) et tu ne peux pas fairelen()dessus. Si tu dois ré-itérer ou indexer, matérialise (list(...)). Choix conscient : lazy par défaut, matérialise au point où tu en as réellement besoin.tuple/frozensetimmuables = hashables = clés de cache fiables et thread-safe. L'immutabilité a un coût ergonomique mais élimine des classes de bugs en concurrence (asyncio, multiples requêtes).- Lisibilité > concision : une comprehension à trois
forimbriqués est plus lente à relire qu'une boucle. Le code senior optimise le temps de lecture du prochain dev.
🏋️ Exercices
1. Dédup en préservant l'ordre — Objectif : maîtriser set + invariant d'ordre
Écris dedup_keep_order(items: list[str]) -> list[str] qui supprime les doublons en gardant le premier ordre d'apparition. set(items) ne marche pas (perd l'ordre).
Indice/Solution : un
setseencomme mémoire, une comprehension avec walrus :pythondef dedup_keep_order(items: list[str]) -> list[str]: seen: set[str] = set() return [x for x in items if not (x in seen or seen.add(x))](
seen.addrenvoieNone→ falsy → l'élément passe la première fois.) Variante plus lisible :dict.fromkeys(items)préserve l'ordre nativement.
2. Indexer une conversation par rôle — Objectif : dict comprehension + groupement
À partir de turns: list[Turn], produis dict[str, list[str]] qui regroupe les content par role. Une dict comprehension simple écrase ; il faut grouper.
Indice/Solution :
collections.defaultdict(list)puis une boucle d'accumulation, ouitertools.groupby(attention : exige un tri préalable par clé). Mesure la différence de complexité entre les deux approches.
3. Pipeline de tokens paresseux — Objectif : générateurs chaînés, RAM constante
Implémente une chaîne raw_tokens -> strip vides -> lower -> compter qui ne matérialise jamais la liste complète. Vérifie avec tracemalloc que la RAM ne croît pas avec la taille du flux.
Indice/Solution : enchaîne des generator expressions :
pythoncleaned = (t.lower() for t in (s.strip() for s in raw if s.strip())) n = sum(1 for _ in cleaned)Chaque maillon est lazy ; rien n'est stocké. Compare la RAM avec la version
[...].
4. Production-grade : agrégateur d'usage robuste — Objectif : collections en endpoint FastAPI
Expose POST /usage/cost qui reçoit une liste d'usages et renvoie le coût total + une ventilation par modèle. Gère : modèle inconnu (.get avec défaut + log du drop), liste vide, et compte les entrées rejetées.
Indice/Solution :
Counter/defaultdict(lambda: [0,0])pour agréger(in, out)par modèle ; comprehension pour filtrer les modèles connus ;dropped = len(payload) - len(valid)dans la réponse. Endpointasync+ modèle Pydantic v2 en entrée.
5. Break-then-fix : le filtre qui ment — Objectif : observabilité d'un drop silencieux
On te donne un service qui fait valid = [m for m in messages if m.get("role") in {"user", "assistant"}]. En prod, 30 % des messages disparaissent sans trace. Reproduis le bug (messages avec role à None/typo), puis rends-le observable et corrige.
Indice/Solution : sépare filtre et rejet —
rejected = [m for m in messages if m.get("role") not in ROLES]; loggelen(rejected)et un échantillon ; décide explicitement : drop+métrique ou 422. Le bug n'était pas le code, c'était le silence.
6. Break-then-fix : générateur épuisé — Objectif : piège du single-pass
Ce code logge 0 token puis renvoie une réponse vide :
stream = (len(t) for t in tokens)
print("total:", sum(stream))
print("max:", max(stream)) # ValueError: max() arg is an empty sequenceExplique pourquoi et corrige sans casser la lazyness là où elle compte.
Indice/Solution : un générateur est épuisé après le premier
sum. Soit tu matérialises une fois (sizes = [len(t) for t in tokens]) si tu dois le parcourir deux fois, soit tu calculessum/maxen un seul passage avecitertools.teeou unreduce. Lazy par défaut, mais matérialise quand tu ré-itères.
🎤 En entretien
Q : Pourquoi ne jamais mettre une liste vide comme valeur par défaut d'un argument ? Parce que le défaut est évalué une seule fois à la définition de la fonction et partagé entre tous les appels — l'état fuit ; on utilise None + sentinelle à l'intérieur.
Q : list vs tuple, quand l'un plutôt que l'autre ?tuple quand le contenu est figé et que la position porte un sens (record), qu'on veut un objet hashable (clé de dict/élément de set) ou de la sûreté en concurrence ; list quand la séquence est mutable et homogène.
Q : Comprehension ou boucle for ? Comprehension pour construire/transformer/filtrer une valeur (atomique, plus rapide, déclaratif) ; boucle for dès qu'il y a un effet de bord ou que la logique imbriquée devient illisible.
Q : Liste vs générateur pour traiter un flux de tokens LLM ? Générateur : RAM constante et latence de premier token minimale (on traite à la demande) ; on matérialise en list seulement si l'on doit ré-itérer, indexer ou prendre la len(), en assumant le coût mémoire.