Skip to content

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 boucles for+append par 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

python
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.

python
# ❌ 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 history

tuple — 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 ».

python
# 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)

python
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

python
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.

python
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 :

python
# ❌ 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.

python
# ❌ 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.

python
# ❌ 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.

python
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

python
# é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.

python
# 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() suffisent

Les 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.

python
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

python
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)

python
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 += token dans une boucle est O(n²) (chaque + recopie toute la chaîne, immuable). On accumule dans une list puis "".join(...) une seule fois — O(n).

Boucle tool-use : dédup et agrégation par collections

python
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

python
@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. Toujours None + sentinelle.
  • Itérer en mutant : for x in lst: lst.remove(x) saute des éléments / lève RuntimeError. 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)]).
  • KeyError non géré : d[k] plante si absent. Utilise .get(k, default), dict.setdefault, ou collections.defaultdict. En endpoint, un KeyError non capturé = 500.
  • Aliasing : b = a ne copie pas, b est le même objet. a.append(...) modifie b. Copie superficielle a.copy() / a[:], ou copy.deepcopy si imbriqué.

Performance

  • in sur list = O(n) ; sur set/dict = O(1). Tout test d'appartenance répété → set.
  • list.insert(0, x) / list.pop(0) = O(n). File ? Utilise collections.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/dict d'allowlist (noms d'outils autorisés) plutôt qu'une suite de if 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.Counter donne une distribution en une ligne : Counter(m["role"] for m in messages).

Tradeoffs senior

  • list vs générateur : le générateur est à usage unique (épuisé après une itération) et tu ne peux pas faire len() 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/frozenset immuables = 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 for imbriqué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 set seen comme mémoire, une comprehension avec walrus :

python
def 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.add renvoie None → 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, ou itertools.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 :

python
cleaned = (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. Endpoint async + 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] ; logge len(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 :

python
stream = (len(t) for t in tokens)
print("total:", sum(stream))
print("max:", max(stream))   # ValueError: max() arg is an empty sequence

Explique 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 calcules sum/max en un seul passage avec itertools.tee ou un reduce. 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.

Bibliothèque tech perso — Achref