Les dataclasses Python génèrent automatiquement __init__, __repr__ et __eq__ à partir des annotations de type. Dès qu’un attribut doit avoir une valeur par défaut mutable (liste, dictionnaire, ensemble), on se heurte à un problème fondamental du langage. field(default_factory=...) est la solution, et comprendre pourquoi elle est nécessaire change la façon dont on raisonne sur l’initialisation en Python.

Le piège des valeurs mutables par défaut

En Python, les valeurs par défaut des paramètres de fonction sont évaluées une seule fois au moment de la définition de la fonction, pas à chaque appel. C’est une propriété du langage, pas un bug.

def ajouter(valeur, liste=[]):
    liste.append(valeur)
    return liste

print(ajouter(1))  # [1]
print(ajouter(2))  # [1, 2]  ← la même liste est réutilisée

Les dataclasses refusent explicitement ce pattern :

from dataclasses import dataclass

@dataclass
class Panier:
    articles: list = []
    # ValueError: mutable default <class 'list'> for field articles is not allowed: use default_factory

Python lève une ValueError dès la définition de la classe, ce qui est plus sûr qu’un bug silencieux. Le message indique même la solution : use default_factory.

Ce qui se passerait sans la protection

Si Python autorisait les valeurs mutables par défaut dans les dataclasses, toutes les instances partageraient le même objet en mémoire. Pour le comprendre, le comportement est identique à celui d’une classe classique avec une liste définie au niveau de la classe :

class Panier:
    articles = []  # attribut de classe, partagé entre toutes les instances

a = Panier()
b = Panier()
a.articles.append("pomme")
print(b.articles)  # ['pomme'] — b est contaminé
print(a.articles is b.articles)  # True

C’est ce comportement que @dataclass refuse d’introduire silencieusement.

La solution : default_factory

field(default_factory=...) reçoit une callable sans argument et l’appelle à chaque création d’instance :

from dataclasses import dataclass, field

@dataclass
class Panier:
    articles: list = field(default_factory=list)

a = Panier()
b = Panier()

a.articles.append("pomme")
print(b.articles)            # []
print(a.articles is b.articles)  # False

list ici est la fonction builtin, pas une liste vide. À chaque Panier(), Python appelle list() pour créer une nouvelle liste indépendante.

Ce que génère @dataclass en interne

@dataclass génère le __init__ via exec() à la définition de la classe. Pour un champ avec default_factory, la factory est stockée dans l’objet Field, accessible via dataclasses.fields() :

import dataclasses
from dataclasses import dataclass, field

@dataclass
class Panier:
    articles: list = field(default_factory=list)

champ = dataclasses.fields(Panier)[0]
print(champ.default)           # <dataclasses._MISSING_TYPE object>  (pas de valeur fixe)
print(champ.default_factory)   # <class 'list'>
print(champ.default_factory()) # []  — appel direct de la factory

Le __init__ généré ressemble schématiquement à ceci. Le code réel utilise dataclasses._HAS_DEFAULT_FACTORY comme sentinel (qui s’affiche <factory>) :

def __init__(self, articles=_HAS_DEFAULT_FACTORY):
    if articles is _HAS_DEFAULT_FACTORY:
        self.articles = list()   # appel de la factory
    else:
        self.articles = articles

Le sentinel permet de distinguer “l’appelant n’a pas fourni de valeur” de “l’appelant a fourni None”. Résultat : on peut passer explicitement une liste à l’instanciation et la factory ne sera pas appelée.

panier_existant = ["pain", "lait"]
p = Panier(articles=panier_existant)
print(p.articles)  # ['pain', 'lait'] — la factory n'a pas été appelée

default vs default_factory : quelle règle ?

Pour une valeur immuable, l’assignation directe est la bonne syntaxe :

@dataclass
class Produit:
    count: int = 1
    label: str = "inconnu"

field() n’entre en jeu que dans deux situations : une valeur mutable, ou une valeur immuable combinée avec des options de configuration du champ.

@dataclass
class Produit:
    count: int = 1                          # assignation directe, pas de field()
    internal_id: int = field(default=1, repr=False)   # exclu du __repr__
    created_by: str = field(default="system", init=False)  # non passable au __init__
    articles: list = field(default_factory=list)      # mutable, factory obligatoire

repr=False exclut le champ de la représentation affichée par print(). init=False empêche de passer la valeur à l’instanciation, Python utilise alors toujours la valeur par défaut. Ce sont des options de comportement, pas de valeur, et elles nécessitent le wrapper field().

CasSyntaxeExemple
Valeur immuableAssignation directecount: int = 1
Valeur immuable + comportement (repr, init, compare…)field(default=)field(default=1, repr=False)
Valeur mutable (list, dict, set, …)field(default_factory=)field(default_factory=list)
Valeur calculée dynamiquementfield(default_factory=) avec lambdafield(default_factory=lambda: uuid.uuid4().hex)

Cas d’usage concrets

Identifiants auto-générés :

import uuid
from dataclasses import dataclass, field

@dataclass
class Transaction:
    id: str = field(default_factory=lambda: uuid.uuid4().hex)
    montant: float = 0.0

uuid.uuid4().hex retourne un UUID sans tirets (32 caractères hexadécimaux). Chaque instance reçoit un identifiant unique sans qu’on ait à le passer explicitement.

Configuration avec valeurs par défaut non triviales :

from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class RapportAudit:
    erreurs: list[str] = field(default_factory=list)
    metadata: dict = field(default_factory=dict)
    cree_le: datetime = field(default_factory=datetime.now)

datetime.now est passé comme référence de fonction, sans les parenthèses. Chaque rapport reçoit l’horodatage de sa création, pas un timestamp figé à la définition de la classe.

Factory personnalisée pour des objets initialisés :

from dataclasses import dataclass, field

def config_par_defaut() -> dict:
    return {"debug": False, "timeout": 30, "retries": 3}

@dataclass
class ServiceHTTP:
    url: str
    config: dict = field(default_factory=config_par_defaut)

Modifier service.config["debug"] = True n’affecte pas les autres instances. Chacune reçoit sa propre copie du dictionnaire de configuration.

Un mot sur Python 3.10+ et dataclass(slots=True)

Depuis Python 3.10, @dataclass(slots=True) génère automatiquement __slots__. default_factory continue de fonctionner normalement : le mécanisme d’initialisation est identique, seul le stockage des attributs change.

from dataclasses import dataclass, field

@dataclass(slots=True)
class Panier:
    articles: list = field(default_factory=list)

C’est la combinaison recommandée quand on veut à la fois des valeurs par défaut dynamiques et l’optimisation mémoire de __slots__. Pour une mesure concrète du gain mémoire et les pièges d’héritage, voir Python slots : optimiser la mémoire des instances.

Ce qu’il faut retenir

default_factory résout un problème fondamental de Python : les objets mutables en tant que valeurs par défaut sont partagés entre toutes les instances d’une classe. Le mécanisme interne est simple (un appel de callable au moment de __init__), mais il protège contre une catégorie de bugs difficiles à diagnostiquer, car le partage inattendu d’état n’est visible que quand on modifie la valeur. La factory personnalisée va plus loin : elle permet d’initialiser des attributs avec n’importe quelle logique, transformant @dataclass en outil adapté à des cas bien au-delà des simples conteneurs de données.