Python Python

De ce o valoare implicită mutabilă este evaluată o singură dată în Python

Dima Авг 30, 2026

Pe scurt

Expresiile din lista de parametri sunt evaluate o singură dată, la execuția instrucțiunii def — adică la import — nu la fiecare apel. Rezultatele sunt stocate în tuplul __defaults__ al obiectului funcție și reutilizate identic la fiecare apel care omite parametrul. Cu o valoare imuabilă asta nu se observă; cu o listă sau un dicționar, toate apelurile partajează același obiect, iar modificările se acumulează între ele. Remediul standard este None ca valoare implicită și construirea obiectului în corp, unde codul chiar rulează la fiecare apel.


Cum funcționează

def este o instrucțiune executabilă, nu o declarație. Când interpretorul o execută, evaluează expresiile pentru valorile implicite, evaluează adnotările, apoi construiește un obiect funcție și leagă rezultatele de el. Valorile implicite pozițional-sau-cheie ajung în tuplul __defaults__; cele exclusiv-cheie ajung în dicționarul __kwdefaults__. Corpul funcției nu se execută deloc în acest moment — este doar compilat într-un obiect cod.

La apel, mașina de legare a argumentelor completează parametrii lipsă citind din __defaults__. Citește, nu copiază. Parametrul devine încă un nume legat de exact același obiect, la fel ca o atribuire obișnuită. Dacă acel obiect este o listă, batch.append(sku) mută starea partajată vizibilă tuturor apelurilor viitoare.

Motivul pentru care limbajul funcționează așa este consistența cu restul modelului de execuție. Alternativa — reevaluarea expresiei la fiecare apel — ar face ca def f(x=expensive()) să apeleze expensive de fiecare dată, ar cere stocarea unui arbore sintactic în loc de o valoare și ar face imposibilă inspecția valorilor implicite prin __defaults__ sau inspect.signature. Python a ales evaluarea timpurie, la fel ca pentru orice altă expresie dintr-o instrucțiune executată.

Consecința nu este vizibilă pentru int, str, tuple sau None, pentru că nu ai cum să le modifici la loc. Este vizibilă pentru orice obiect mutabil, iar cazul cel mai insidios nu este lista, ci o valoare capturată la import: def open_ticket(opened_at=datetime.date.today()) îngheață ziua în care a pornit procesul, iar un serviciu care rulează săptămâni întregi raportează tăcut date greșite.

Invariantul de reținut: valoarea implicită este un obiect, nu o expresie. Dacă obiectul poate fi modificat sau devine învechit, valoarea implicită trebuie să fie None, iar construirea trebuie să se mute în corp.


Cum arată la rulare

Creează un fișier:

touch scan_batches.py

Rulează-l cu:

python3 scan_batches.py

Acumularea între apeluri independente

Cel mai mic caz: două apeluri fără nicio legătură aparentă, care ajung să vadă aceleași date.

def record_scan(sku, batch=[]):
    batch.append(sku)
    return batch


first = record_scan("SKU-4471")
second = record_scan("SKU-4472")

print("first :", first)
print("second:", second)
print("same object:", first is second)
print("default still holds:", record_scan.__defaults__)


first : ['SKU-4471', 'SKU-4472']
second: ['SKU-4471', 'SKU-4472']
same object: True
default still holds: (['SKU-4471', 'SKU-4472'],)


  • first și second nu sunt doar egale, ci același obiect — is confirmă. Ambele apeluri au returnat lista din __defaults__.
  • Ultima linie inspectează direct obiectul funcție: valoarea implicită a crescut. Nu există nicio „resetare” între apeluri, pentru că nimic nu o reconstruiește.

first conține și SKU-4472, deși a fost calculat înaintea celui de-al doilea apel. Numele first este legat de o listă care a fost modificată ulterior — un simptom tipic de aliasing, nu de recalculare.


Aceeași regulă, pentru orice tip de expresie

Următorul nivel arată că mecanismul nu are nimic de-a face cu listele: se aplică identic unui apel de funcție și unui dicționar. Înlocuiește fișierul cu:

import datetime


def open_ticket(subject, opened_at=datetime.date(2026, 3, 1), tags=[], meta={}):
    tags.append("new")
    meta["subject"] = subject
    return opened_at, tags, meta


print("defaults at import:", open_ticket.__defaults__)

open_ticket("cold-chain breach")
open_ticket("late delivery")

print("defaults after two calls:", open_ticket.__defaults__)


defaults at import: (datetime.date(2026, 3, 1), [], {})
defaults after two calls: (datetime.date(2026, 3, 1), ['new', 'new'], {'subject': 'late delivery'})


  • Tuplul __defaults__ există deja înainte de primul apel, cu toate cele trei valori construite. Instrucțiunea def le-a evaluat.
  • Data rămâne neschimbată — nu pentru că ar fi tratată special, ci pentru că datetime.date este imuabil și nimic din corp nu încearcă să o modifice.
  • Lista a acumulat două intrări, iar dicționarul păstrează doar ultimul subiect, suprascris de al doilea apel.

Dacă opened_at ar fi fost scris datetime.date.today(), tuplul ar fi conținut ziua importului și ar fi rămas acolo pentru toată viața procesului. Expresia nu se reevaluează niciodată, indiferent cât de dinamică arată.


Remediul și de ce funcționează

Al treilea nivel aplică tiparul standard și verifică toate cele trei căi de apel. Înlocuiește fișierul cu:

def record_scan(sku, batch=None):
    if batch is None:
        batch = []
    batch.append(sku)
    return batch


first = record_scan("SKU-4471")
second = record_scan("SKU-4472")
existing = record_scan("SKU-4473", ["SKU-0001"])

print("first   :", first)
print("second  :", second)
print("existing:", existing)
print("same object:", first is second)
print("default is:", record_scan.__defaults__)


first   : ['SKU-4471']
second  : ['SKU-4472']
existing: ['SKU-0001', 'SKU-4473']
same object: False
default is: (None,)


  • None este un singleton imuabil, deci __defaults__ nu are ce acumula.
  • batch = [] este în corp, iar corpul rulează la fiecare apel — de aici o listă nouă de fiecare dată, confirmată de is care dă False.
  • Al treilea apel pasează o listă explicită; verificarea is None nu se declanșează, deci funcția scrie în lista apelantului, așa cum era intenția.

Verificarea se scrie if batch is None, nu if not batch. A doua ar înlocui și o listă goală pasată intenționat de apelant cu una nouă, iar apelantul nu ar mai vedea rezultatele scrise în ea.


Aceeași capcană în alte forme

Ultimul nivel arată unde apare aceeași regulă în afara parametrilor obișnuiți. Înlocuiește fișierul cu:

import dataclasses


@dataclasses.dataclass
class Batch:
    line: str
    scans: list = dataclasses.field(default_factory=list)


a = Batch("line-3")
b = Batch("line-4")
a.scans.append("SKU-4471")
print("factory per instance:", a.scans, b.scans)

try:
    @dataclasses.dataclass
    class BrokenBatch:
        line: str
        scans: list = []
except ValueError as exc:
    print("ValueError:", exc)


class Counter:
    seen = []

    def add(self, sku):
        self.seen.append(sku)


c1, c2 = Counter(), Counter()
c1.add("SKU-4471")
print("class attribute is shared:", c2.seen)


factory per instance: ['SKU-4471'] []
ValueError: mutable default <class 'list'> for field scans is not allowed: use default_factory
class attribute is shared: ['SKU-4471']


  • default_factory=list stochează funcția, nu rezultatul ei, iar __init__ generat o apelează la fiecare instanțiere — de aceea b.scans rămâne gol.
  • dataclasses refuză explicit lista ca valoare implicită. Este singurul loc din limbaj unde greșeala este prinsă la definire, tocmai pentru că apare atât de des.
  • Atributul de clasă seen este aceeași problemă fără nicio legătură cu parametrii: obiectul aparține clasei, iar self.seen.append(...) îl modifică pentru toate instanțele. Atributul de instanță se creează doar la atribuire, iar append nu este o atribuire.

Ultimul caz este cel mai greu de observat, pentru că self.seen arată ca stare per instanță. Modificarea la loc nu creează niciodată un atribut de instanță; doar self.seen = [...] ar face-o.


Cazuri limită și capcane

  • Valoarea implicită se poate modifica din exterior. record_scan.__defaults__[0].append("SKU-9999") schimbă comportamentul funcției pentru toate apelurile viitoare — tuplul este imuabil, lista din el nu.
  • Tiparul are o utilizare legitimă: memoizarea. def lookup(key, _cache={}) este un cache deliberat legat de funcție. Scrie-l cu prefix underscore și un comentariu, altfel arată ca un bug.
  • None nu este întotdeauna disponibil ca valoare santinelă. Dacă apelantul poate pasa legitim None, folosește o santinelă proprie: _MISSING = object(), apoi if batch is _MISSING.
  • Argumentele exclusiv-cheie sunt în __kwdefaults__, nu în __defaults__, deci codul de introspecție trebuie să le citească pe amândouă.
  • Adnotările se evaluează și ele la def, cu același cost. from __future__ import annotations le amână ca șiruri, dar nu afectează valorile implicite.
  • O valoare implicită imuabilă care încapsulează una mutabilă nu este sigură. def f(config=({}, )) are un tuplu ca valoare implicită, dar dicționarul din el este tot partajat.

Rezumat

Expresiile valorilor implicite sunt evaluate o singură dată, la execuția instrucțiunii def, iar rezultatele trăiesc în __defaults__ pe toată durata obiectului funcție. Fiecare apel care omite parametrul îl leagă de exact același obiect, deci un obiect mutabil acumulează modificări între apeluri fără nicio legătură între ele.

  • def este o instrucțiune executată la import; valorile implicite sunt obiecte gata construite, nu expresii reevaluate.
  • Legarea unui parametru la valoarea implicită este o atribuire de nume, nu o copiere.
  • Folosește None ca valoare implicită și construiește obiectul în corp, cu if x is None, nu cu if not x.
  • Aceeași regulă îngheață și valorile aparent dinamice, precum datetime.date.today(), la momentul importului.
  • În dataclasses, folosește field(default_factory=list); lista directă este respinsă cu ValueError la definirea clasei.
  • Un atribut mutabil de clasă este aceeași capcană: self.x.append(...) modifică obiectul partajat, pentru că doar atribuirea creează un atribut de instanță.

Чтобы получить доступ к облачной лаборатории, необходимо войти в систему.

Войти