Attributi della classe Python: una guida eccessivamente approfondita
Pubblicato: 2022-03-11Di recente ho avuto un colloquio di programmazione, uno schermo del telefono in cui abbiamo utilizzato un editor di testo collaborativo.
Mi è stato chiesto di implementare una determinata API e ho scelto di farlo in Python. Astraendo la dichiarazione del problema, diciamo che avevo bisogno di una classe le cui istanze memorizzassero alcuni data
e alcuni other_data
.
Ho preso un respiro profondo e ho iniziato a scrivere. Dopo poche righe, ho avuto qualcosa del genere:
class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...
Il mio intervistatore mi ha fermato:
- Intervistatore: “Quella riga:
data = []
. Non penso che sia Python valido?" - Io: “Sono abbastanza sicuro che lo sia. Sta solo impostando un valore predefinito per l'attributo dell'istanza."
- Intervistatore: "Quando viene eseguito quel codice?"
- Io: “Non ne sono proprio sicuro. Mi limiterò a sistemarlo per evitare confusione".
Per riferimento e per darti un'idea di cosa stavo cercando, ecco come ho modificato il codice:
class Service(object): def __init__(self, other_data): self.data = [] self.other_data = other_data ...
A quanto pare, ci sbagliavamo entrambi. La vera risposta sta nella comprensione della distinzione tra gli attributi della classe Python e gli attributi dell'istanza Python.
Nota: se disponi di un handle esperto sugli attributi di classe, puoi passare ai casi d'uso.
Attributi della classe Python
Il mio intervistatore si sbagliava in quanto il codice sopra è sintatticamente valido.
Anche io ho sbagliato in quanto non sta impostando un "valore predefinito" per l'attributo dell'istanza. Al contrario, definisce i data
come un attributo di classe con valore []
.
Nella mia esperienza, gli attributi di classe Python sono un argomento di cui molte persone sanno qualcosa , ma pochi capiscono completamente.
Variabile di classe Python e variabile di istanza: qual è la differenza?
Un attributo di classe Python è un attributo della classe (circolare, lo so), piuttosto che un attributo di un'istanza di una classe.
Usiamo un esempio di classe Python per illustrare la differenza. Qui, class_var
è un attributo di classe e i_var
è un attributo di istanza:
class MyClass(object): class_var = 1 def __init__(self, i_var): self.i_var = i_var
Nota che tutte le istanze della classe hanno accesso a class_var
e che è possibile accedervi anche come proprietà della classe stessa :
foo = MyClass(2) bar = MyClass(3) foo.class_var, foo.i_var ## 1, 2 bar.class_var, bar.i_var ## 1, 3 MyClass.class_var ## <— This is key ## 1
Per i programmatori Java o C++, l'attributo class è simile, ma non identico, al membro statico. Vedremo come differiscono più avanti.
Spazi dei nomi di classi e istanze
Per capire cosa sta succedendo qui, parliamo brevemente degli spazi dei nomi Python .
Uno spazio dei nomi è una mappatura dai nomi agli oggetti, con la proprietà che esiste una relazione zero tra i nomi in spazi dei nomi diversi. Di solito sono implementati come dizionari Python, anche se questo è astratto.
A seconda del contesto, potrebbe essere necessario accedere a uno spazio dei nomi utilizzando la sintassi del punto (ad esempio, object.name_from_objects_namespace
) o come variabile locale (ad esempio, object_from_namespace
). Come esempio concreto:
class MyClass(object): ## No need for dot syntax class_var = 1 def __init__(self, i_var): self.i_var = i_var ## Need dot syntax as we've left scope of class namespace MyClass.class_var ## 1
Le classi Python e le istanze di classi hanno ciascuna i propri spazi dei nomi distinti rappresentati rispettivamente dagli attributi predefiniti MyClass.__dict__
e instance_of_MyClass.__dict__
.
Quando si tenta di accedere a un attributo da un'istanza di una classe, prima esamina lo spazio dei nomi dell'istanza . Se trova l'attributo, restituisce il valore associato. In caso contrario , cerca nello spazio dei nomi della classe e restituisce l'attributo (se è presente, generando un errore in caso contrario). Per esempio:
foo = MyClass(2) ## Finds i_var in foo's instance namespace foo.i_var ## 2 ## Doesn't find class_var in instance namespace… ## So look's in class namespace (MyClass.__dict__) foo.class_var ## 1
Lo spazio dei nomi dell'istanza ha la supremazia sullo spazio dei nomi della classe: se è presente un attributo con lo stesso nome in entrambi, lo spazio dei nomi dell'istanza verrà prima controllato e il suo valore restituito. Ecco una versione semplificata del codice (fonte) per la ricerca degli attributi:
def instlookup(inst, name): ## simplified algorithm... if inst.__dict__.has_key(name): return inst.__dict__[name] else: return inst.__class__.__dict__[name]
E, in forma visiva:
In che modo gli attributi di classe gestiscono l'assegnazione
Con questo in mente, possiamo dare un senso al modo in cui gli attributi della classe Python gestiscono l'assegnazione:
Se un attributo di classe viene impostato accedendo alla classe, sostituirà il valore per tutte le istanze. Per esempio:
foo = MyClass(2) foo.class_var ## 1 MyClass.class_var = 2 foo.class_var ## 2
A livello di spazio dei nomi... stiamo impostando
MyClass.__dict__['class_var'] = 2
. (Nota: questo non è il codice esatto (che sarebbesetattr(MyClass, 'class_var', 2)
) poiché__dict__
restituisce un dictproxy, un wrapper immutabile che impedisce l'assegnazione diretta, ma aiuta a scopo dimostrativo). Quindi, quando accediamo afoo.class_var
,class_var
ha un nuovo valore nello spazio dei nomi della classe e quindi viene restituito 2.Se una variabile di classe Paython viene impostata accedendo a un'istanza, sovrascriverà il valore solo per quell'istanza. Questo essenzialmente sovrascrive la variabile di classe e la trasforma in una variabile di istanza disponibile, intuitivamente, solo per quell'istanza . Per esempio:
foo = MyClass(2) foo.class_var ## 1 foo.class_var = 2 foo.class_var ## 2 MyClass.class_var ## 1
A livello di spazio dei nomi... stiamo aggiungendo l'attributo
class_var
afoo.__dict__
, quindi quando cerchiamofoo.class_var
, restituiamo 2. Nel frattempo, altre istanze diMyClass
non avrannoclass_var
nei loro spazi dei nomi di istanza, quindi continuano a trovareclass_var
inMyClass.__dict__
e quindi restituisce 1.
Mutabilità
Domanda del quiz: cosa succede se il tuo attributo di classe ha un tipo mutabile ? Puoi manipolare (mutilare?) l'attributo class accedendovi tramite una particolare istanza e, a sua volta, finire per manipolare l'oggetto referenziato a cui accedono tutte le istanze (come sottolineato da Timothy Wiseman).
Questo è meglio dimostrato dall'esempio. Torniamo al Service
che ho definito in precedenza e vediamo come il mio uso di una variabile di classe potrebbe aver portato a problemi lungo la strada.
class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...
Il mio obiettivo era avere l'elenco vuoto ( []
) come valore predefinito per data
e per ogni istanza di Service
avere i propri dati che sarebbero stati modificati nel tempo istanza per istanza. Ma in questo caso, otteniamo il seguente comportamento (ricorda che il Service
accetta un argomento other_data
, che è arbitrario in questo esempio):
s1 = Service(['a', 'b']) s2 = Service(['c', 'd']) s1.data.append(1) s1.data ## [1] s2.data ## [1] s2.data.append(2) s1.data ## [1, 2] s2.data ## [1, 2]
Questo non va bene: alterare la variabile di classe tramite un'istanza la altera per tutte le altre!
A livello di spazio dei nomi... tutte le istanze di Service
accedono e modificano lo stesso elenco in Service.__dict__
senza creare i propri attributi di data
nei rispettivi spazi dei nomi di istanza.
Potremmo aggirare questo usando l'assegnazione; ovvero, invece di sfruttare la mutevolezza dell'elenco, potremmo assegnare ai nostri oggetti di Service
le proprie liste, come segue:
s1 = Service(['a', 'b']) s2 = Service(['c', 'd']) s1.data = [1] s2.data = [2] s1.data ## [1] s2.data ## [2]
In questo caso, stiamo aggiungendo s1.__dict__['data'] = [1]
, quindi il Service.__dict__['data']
originale rimane invariato.
Sfortunatamente, ciò richiede che gli utenti del Service
abbiano una profonda conoscenza delle sue variabili ed è certamente soggetto a errori. In un certo senso, ci occuperemo dei sintomi piuttosto che della causa. Preferiremmo qualcosa che fosse corretto per costruzione.
La mia soluzione personale: se stai solo usando una variabile di classe per assegnare un valore predefinito a una potenziale variabile di istanza Python, non usare valori mutabili . In questo caso, ogni istanza di Service
alla fine avrebbe sovrascritto Service.data
con il proprio attributo di istanza, quindi l'utilizzo di un elenco vuoto come predefinito ha portato a un piccolo bug che è stato facilmente trascurato. Invece di quanto sopra, avremmo potuto:
- Bloccato completamente agli attributi di istanza, come dimostrato nell'introduzione.
Evitato di utilizzare l'elenco vuoto (un valore mutabile) come "predefinito":
class Service(object): data = None def __init__(self, other_data): self.other_data = other_data ...
Ovviamente, dovremmo gestire il caso
None
in modo appropriato, ma è un piccolo prezzo da pagare.
Quindi, quando dovresti usare gli attributi della classe Python?
Gli attributi di classe sono complicati, ma diamo un'occhiata ad alcuni casi in cui potrebbero tornare utili:
Memorizzazione delle costanti . Poiché è possibile accedere agli attributi di classe come attributi della classe stessa, è spesso utile usarli per archiviare costanti a livello di classe e specifiche della classe. Per esempio:
class Circle(object): pi = 3.14159 def __init__(self, radius): self.radius = radius def area(self): return Circle.pi * self.radius * self.radius Circle.pi ## 3.14159 c = Circle(10) c.pi ## 3.14159 c.area() ## 314.159
Definizione dei valori predefiniti . Come esempio banale, potremmo creare un elenco limitato (cioè un elenco che può contenere solo un certo numero di elementi o meno) e scegliere di avere un limite predefinito di 10 elementi:
class MyClass(object): limit = 10 def __init__(self): self.data = [] def item(self, i): return self.data[i] def add(self, e): if len(self.data) >= self.limit: raise Exception("Too many elements") self.data.append(e) MyClass.limit ## 10
Potremmo quindi creare istanze anche con i propri limiti specifici, assegnando all'attributo
limit
dell'istanza.foo = MyClass() foo.limit = 50 ## foo can now hold 50 elements—other instances can hold 10
Questo ha senso solo se desideri che la tua istanza tipica di
MyClass
contenga solo 10 elementi o meno, se stai dando a tutte le tue istanze limiti diversi,limit
dovrebbe essere una variabile di istanza. (Ricorda, però: fai attenzione quando usi valori mutabili come valori predefiniti.)Tracciamento di tutti i dati in tutte le istanze di una determinata classe . Questo è in qualche modo specifico, ma potrei vedere uno scenario in cui potresti voler accedere a un dato relativo a ogni istanza esistente di una determinata classe.
Per rendere lo scenario più concreto, supponiamo di avere una classe
Person
e ogni persona ha unname
. Vogliamo tenere traccia di tutti i nomi che sono stati utilizzati. Un approccio potrebbe essere quello di scorrere l'elenco di oggetti del Garbage Collector, ma è più semplice utilizzare le variabili di classe.Nota che, in questo caso,
names
saranno accessibili solo come una variabile di classe, quindi l'impostazione predefinita mutabile è accettabile.class Person(object): all_names = [] def __init__(self, name): self.name = name Person.all_names.append(name) joe = Person('Joe') bob = Person('Bob') print Person.all_names ## ['Joe', 'Bob']
Potremmo persino utilizzare questo modello di progettazione per tenere traccia di tutte le istanze esistenti di una determinata classe, anziché solo di alcuni dati associati.
class Person(object): all_people = [] def __init__(self, name): self.name = name Person.all_people.append(self) joe = Person('Joe') bob = Person('Bob') print Person.all_people ## [<__main__.Person object at 0x10e428c50>, <__main__.Person object at 0x10e428c90>]
Performance (una specie di... vedi sotto).
Sotto il cappuccio
Nota: se ti preoccupi delle prestazioni a questo livello, potresti non voler utilizzare Python in primo luogo, poiché le differenze saranno dell'ordine dei decimi di millisecondo, ma è comunque divertente curiosare un po', e aiuta a scopo illustrativo.
Ricordiamo che lo spazio dei nomi di una classe viene creato e compilato al momento della definizione della classe. Ciò significa che eseguiamo solo un'assegnazione, sempre , per una determinata variabile di classe, mentre le variabili di istanza devono essere assegnate ogni volta che viene creata una nuova istanza. Facciamo un esempio.
def called_class(): print "Class assignment" return 2 class Bar(object): y = called_class() def __init__(self, x): self.x = x ## "Class assignment" def called_instance(): print "Instance assignment" return 2 class Foo(object): def __init__(self, x): self.y = called_instance() self.x = x Bar(1) Bar(2) Foo(1) ## "Instance assignment" Foo(2) ## "Instance assignment"
Assegniamo a Bar.y
solo una volta, ma instance_of_Foo.y
su ogni chiamata a __init__
.
Come ulteriore prova, usiamo il disassembler Python:
import dis class Bar(object): y = 2 def __init__(self, x): self.x = x class Foo(object): def __init__(self, x): self.y = 2 self.x = x dis.dis(Bar) ## Disassembly of __init__: ## 7 0 LOAD_FAST 1 (x) ## 3 LOAD_FAST 0 (self) ## 6 STORE_ATTR 0 (x) ## 9 LOAD_CONST 0 (None) ## 12 RETURN_VALUE dis.dis(Foo) ## Disassembly of __init__: ## 11 0 LOAD_CONST 1 (2) ## 3 LOAD_FAST 0 (self) ## 6 STORE_ATTR 0 (y) ## 12 9 LOAD_FAST 1 (x) ## 12 LOAD_FAST 0 (self) ## 15 STORE_ATTR 1 (x) ## 18 LOAD_CONST 0 (None) ## 21 RETURN_VALUE
Quando osserviamo il codice byte, è di nuovo ovvio che Foo.__init__
deve eseguire due assegnazioni, mentre Bar.__init__
esegue solo una.
In pratica, che aspetto ha questo guadagno? Sarò il primo ad ammettere che i test di temporizzazione dipendono fortemente da fattori spesso incontrollabili e le differenze tra loro sono spesso difficili da spiegare con precisione.
Tuttavia, penso che questi piccoli frammenti (eseguiti con il modulo Python timeit) aiutino a illustrare le differenze tra le variabili di classe e di istanza, quindi le ho comunque incluse.
Nota: utilizzo un MacBook Pro con OS X 10.8.5 e Python 2.7.2.
Inizializzazione
10000000 calls to `Bar(2)`: 4.940s 10000000 calls to `Foo(2)`: 6.043s
Le inizializzazioni di Bar
sono più veloci di oltre un secondo, quindi la differenza qui sembra essere statisticamente significativa.
Allora perché è così? Una spiegazione speculativa : facciamo due incarichi in Foo.__init__
, ma solo uno in Bar.__init__
.
Incarico
10000000 calls to `Bar(2).y = 15`: 6.232s 10000000 calls to `Foo(2).y = 15`: 6.855s 10000000 `Bar` assignments: 6.232s - 4.940s = 1.292s 10000000 `Foo` assignments: 6.855s - 6.043s = 0.812s
Nota: non c'è modo di rieseguire il codice di configurazione su ogni prova con timeit, quindi dobbiamo reinizializzare la nostra variabile nella nostra prova. La seconda riga di tempi rappresenta i tempi precedenti con dedotti i tempi di inizializzazione calcolati in precedenza.
Da quanto sopra, sembra che Foo
solo circa il 60% del tempo di Bar
per gestire i compiti.
Perché è così? Una spiegazione speculativa : quando assegniamo a Bar(2).y
, cerchiamo prima nello spazio dei nomi dell'istanza ( Bar(2).__dict__[y]
), non riusciamo a trovare y
, quindi cerchiamo nello spazio dei nomi della classe ( Bar.__dict__[y]
), quindi effettuando l'assegnazione corretta. Quando assegniamo a Foo(2).y
, eseguiamo la metà delle ricerche rispetto a quelle che assegniamo immediatamente allo spazio dei nomi dell'istanza ( Foo(2).__dict__[y]
).
In sintesi, sebbene questi miglioramenti delle prestazioni non contino in realtà, questi test sono interessanti a livello concettuale. Semmai, spero che queste differenze aiutino a illustrare le distinzioni meccaniche tra le variabili di classe e di istanza.
In conclusione
Gli attributi di classe sembrano essere sottoutilizzati in Python; molti programmatori hanno impressioni diverse su come funzionano e perché potrebbero essere utili.
La mia opinione: le variabili di classe Python hanno il loro posto all'interno della scuola del buon codice. Se usati con cura, possono semplificare le cose e migliorare la leggibilità. Ma quando vengono gettati con noncuranza in una determinata classe, ti faranno sicuramente inciampare.
Appendice : variabili di istanza privata
Una cosa che volevo includere ma non avevo un punto di ingresso naturale...
Python non ha variabili private per così dire, ma un'altra relazione interessante tra la denominazione di classi e istanze deriva dalla modifica dei nomi.
Nella guida allo stile di Python, si dice che le variabili pseudo-private dovrebbero essere precedute da un doppio trattino basso: '__'. Questo non è solo un segno per gli altri che la tua variabile è destinata a essere trattata privatamente, ma anche un modo per impedirne l'accesso, in qualche modo. Ecco cosa intendo:
class Bar(object): def __init__(self): self.__zap = 1 a = Bar() a.__zap ## Traceback (most recent call last): ## File "<stdin>", line 1, in <module> ## AttributeError: 'Bar' object has no attribute '__baz' ## Hmm. So what's in the namespace? a.__dict__ {'_Bar__zap': 1} a._Bar__zap ## 1
Guarda questo: l'attributo dell'istanza __zap
viene automaticamente preceduto dal nome della classe per produrre _Bar__zap
.
Sebbene sia ancora impostabile e ottenibile utilizzando a._Bar__zap
, questa modifica del nome è un mezzo per creare una variabile "privata" poiché impedisce a te e ad altri di accedervi per errore o per ignoranza.
Modifica: come ha gentilmente sottolineato Pedro Werneck, questo comportamento è in gran parte inteso ad aiutare con la sottoclasse. Nella guida allo stile di PEP 8, lo considerano utile a due scopi: (1) impedire alle sottoclassi di accedere a determinati attributi e (2) prevenire conflitti di spazio dei nomi in queste sottoclassi. Sebbene utile, la modifica delle variabili non dovrebbe essere vista come un invito a scrivere codice con una presunta distinzione pubblico-privato, come è presente in Java.