Atrybuty klas Pythona: zbyt dokładny przewodnik

Opublikowany: 2022-03-11

Niedawno miałem wywiad programistyczny, ekran telefonu, w którym korzystaliśmy z współpracującego edytora tekstu.

Poproszono mnie o zaimplementowanie określonego API i wybrałem to w Pythonie. Abstrahując od stwierdzenia problemu, powiedzmy, że potrzebuję klasy, której instancje przechowują niektóre data i other_data .

Wziąłem głęboki oddech i zacząłem pisać. Po kilku linijkach miałem coś takiego:

 class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...

Mój rozmówca mnie zatrzymał:

  • Przeprowadzający wywiad: „Ten wiersz: data = [] . Myślę, że to nie jest poprawny Python?”
  • Ja: „Jestem prawie pewien, że tak. To tylko ustawienie domyślnej wartości atrybutu instancji”.
  • Przeprowadzający wywiad: „Kiedy ten kod jest wykonywany?”
  • Ja: „Nie jestem pewien. Po prostu to naprawię, żeby uniknąć zamieszania.

Dla odniesienia i aby dać ci wyobrażenie o tym, do czego zmierzam, oto jak zmieniłem kod:

 class Service(object): def __init__(self, other_data): self.data = [] self.other_data = other_data ...

Jak się okazuje, oboje się myliliśmy. Prawdziwa odpowiedź leży w zrozumieniu różnicy między atrybutami klas Pythona a atrybutami instancji Pythona.

Atrybuty klas Pythona a atrybuty instancji Pythona

Uwaga: jeśli masz eksperta w zakresie atrybutów klas, możesz przejść od razu do przypadków użycia.

Atrybuty klas Pythona

Mój ankieter mylił się, że powyższy kod jest poprawny składniowo.

Ja też się myliłem, ponieważ nie ustawia „wartości domyślnej” dla atrybutu instancji. Zamiast tego definiuje data jako atrybut klasy o wartości [] .

Z mojego doświadczenia wynika, że ​​atrybuty klas Pythona to temat, o którym wiele osób coś wie, ale niewielu rozumie je całkowicie.

Zmienna klas Pythona a zmienna instancji: jaka jest różnica?

Atrybut klasy Pythona jest atrybutem klasy (określonym w kółko, wiem), a nie atrybutem instancji klasy.

Użyjmy przykładu klasy Pythona, aby zilustrować różnicę. Tutaj class_var jest atrybutem klasy, a i_var jest atrybutem instancji:

 class MyClass(object): class_var = 1 def __init__(self, i_var): self.i_var = i_var

Zwróć uwagę, że wszystkie instancje klasy mają dostęp do class_var i że można do niej również uzyskać dostęp jako właściwość samej klasy :

 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

Dla programistów Java lub C++ atrybut class jest podobny — ale nie identyczny — do statycznego elementu członkowskiego. Zobaczymy, czym się różnią później.

Klasy a przestrzenie nazw instancji

Aby zrozumieć, co się tutaj dzieje, porozmawiajmy krótko o przestrzeniach nazw Pythona .

Przestrzeń nazw to mapowanie nazw na obiekty, z właściwością, że istnieje zero relacji między nazwami w różnych przestrzeniach nazw. Są one zwykle implementowane jako słowniki Pythona, chociaż jest to abstrakcyjne.

W zależności od kontekstu może być konieczne uzyskanie dostępu do przestrzeni nazw przy użyciu składni z kropką (np. object.name_from_objects_namespace ) lub jako zmiennej lokalnej (np. object_from_namespace ). Jako konkretny przykład:

 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

Każda z klas Pythona i instancje klas ma własne odrębne przestrzenie nazw reprezentowane przez predefiniowane atrybuty, odpowiednio MyClass.__dict__ i instance_of_MyClass.__dict__ .

Kiedy próbujesz uzyskać dostęp do atrybutu z instancji klasy, najpierw patrzy na jego przestrzeń nazw instancji . Jeśli znajdzie atrybut, zwraca powiązaną wartość. Jeśli nie, to szuka w przestrzeni nazw klasy i zwraca atrybut (jeśli jest obecny, w przeciwnym razie zgłasza błąd). Na przykład:

 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

Przestrzeń nazw instancji przejmuje nadrzędność nad przestrzenią nazw klasy: jeśli istnieje atrybut o tej samej nazwie w obu, przestrzeń nazw instancji zostanie najpierw sprawdzona, a jej wartość zwrócona. Oto uproszczona wersja kodu (źródła) do wyszukiwania atrybutów:

 def instlookup(inst, name): ## simplified algorithm... if inst.__dict__.has_key(name): return inst.__dict__[name] else: return inst.__class__.__dict__[name]

A w formie wizualnej:

wyszukiwanie atrybutów w formie wizualnej

Jak atrybuty klas obsługują przypisanie

Mając to na uwadze, możemy zrozumieć, w jaki sposób atrybuty klas Pythona obsługują przypisanie:

  • Jeśli atrybut klasy zostanie ustawiony poprzez dostęp do klasy, zastąpi on wartość dla wszystkich wystąpień. Na przykład:

     foo = MyClass(2) foo.class_var ## 1 MyClass.class_var = 2 foo.class_var ## 2

    Na poziomie przestrzeni nazw… ustawiamy MyClass.__dict__['class_var'] = 2 . (Uwaga: to nie jest dokładny kod (którym byłby setattr(MyClass, 'class_var', 2) ), ponieważ __dict__ zwraca dictproxy, niezmienne opakowanie, które zapobiega bezpośredniemu przypisywaniu, ale pomaga ze względu na demonstrację). Następnie, gdy uzyskujemy dostęp do foo.class_var , class_var ma nową wartość w przestrzeni nazw klasy, a zatem zwracane jest 2 .

  • Jeśli zmienna klasy Paython jest ustawiona przez dostęp do instancji, zastąpi ona wartość tylko dla tej instancji . To zasadniczo zastępuje zmienną klasy i zamienia ją w zmienną instancji dostępną, intuicyjnie, tylko dla tej instancji . Na przykład:

     foo = MyClass(2) foo.class_var ## 1 foo.class_var = 2 foo.class_var ## 2 MyClass.class_var ## 1

    Na poziomie przestrzeni nazw… dodajemy atrybut class_var do foo.__dict__ , więc kiedy szukamy foo.class_var , zwracamy 2. Tymczasem inne instancje MyClass nie będą miały class_var w swoich przestrzeniach nazw instancji, więc nadal znajdują class_var w MyClass.__dict__ i w ten sposób zwróć 1.

Zmienność

Pytanie quizu: Co się stanie, jeśli atrybut klasy ma zmienny typ ? Możesz manipulować (okaleczyć?) atrybutem klasy, uzyskując do niego dostęp poprzez konkretną instancję, a w efekcie manipulować obiektem, do którego mają dostęp wszystkie instancje (jak wskazał Timothy Wiseman).

Najlepiej widać to na przykładzie. Wróćmy do Service , którą zdefiniowałem wcześniej i zobaczmy, jak moje użycie zmiennej klasy mogło doprowadzić do dalszych problemów.

 class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...

Moim celem było, aby pusta lista ( [] ) była domyślną wartością dla data , a każda instancja Service miała własne dane , które byłyby zmieniane w czasie dla każdej instancji. Ale w tym przypadku otrzymujemy następujące zachowanie (przypomnij sobie, że Service przyjmuje jakiś argument other_data , który w tym przykładzie jest dowolny):

 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]

To nie jest dobre — zmiana zmiennej klasy za pomocą jednej instancji zmienia ją dla wszystkich pozostałych!

Na poziomie przestrzeni nazw… wszystkie instancje Service uzyskują dostęp do tej samej listy w Service.__dict__ i modyfikują ją bez tworzenia własnych atrybutów data w swoich przestrzeniach nazw instancji.

Moglibyśmy to obejść za pomocą przypisania; to znaczy, zamiast wykorzystywać zmienność listy, moglibyśmy przypisać nasze obiekty Service tak, aby miały własne listy, w następujący sposób:

 s1 = Service(['a', 'b']) s2 = Service(['c', 'd']) s1.data = [1] s2.data = [2] s1.data ## [1] s2.data ## [2]

W tym przypadku dodajemy s1.__dict__['data'] = [1] , więc oryginalny Service.__dict__['data'] pozostaje niezmieniony.

Niestety wymaga to od użytkowników Service dokładnej znajomości jego zmiennych i z pewnością jest podatne na błędy. W pewnym sensie zajęlibyśmy się objawami, a nie przyczyną. Wolelibyśmy coś, co było poprawne konstrukcyjnie.

Moje osobiste rozwiązanie: jeśli po prostu używasz zmiennej klasy do przypisania domyślnej wartości do przyszłej zmiennej instancji Pythona, nie używaj zmiennych wartości . W tym przypadku każda instancja Service miała w końcu zastąpić Service.data własnym atrybutem instancji, więc użycie pustej listy jako domyślnej doprowadziło do małego błędu, który można łatwo przeoczyć. Zamiast powyższego moglibyśmy:

  1. Całkowicie utknąłem w atrybutach instancji, jak pokazano we wstępie.
  2. Uniknięto używania pustej listy (zmiennej wartości) jako naszej „domyślnej”:

     class Service(object): data = None def __init__(self, other_data): self.other_data = other_data ...

    Oczywiście musielibyśmy odpowiednio zająć się przypadkiem None , ale to niewielka cena do zapłacenia.

Kiedy więc należy używać atrybutów klas Pythona?

Atrybuty klas są trudne, ale spójrzmy na kilka przypadków, w których mogą się przydać:

  1. Przechowywanie stałych . Ponieważ atrybuty klasy mogą być dostępne jako atrybuty samej klasy, często dobrze jest ich używać do przechowywania stałych dla całej klasy i specyficznych dla klasy. Na przykład:

     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
  2. Definiowanie wartości domyślnych . Jako trywialny przykład możemy utworzyć listę ograniczoną (tj. listę, która może zawierać tylko określoną liczbę elementów lub mniej) i wybrać domyślny limit 10 elementów:

     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

    Moglibyśmy wtedy tworzyć instancje z ich własnymi określonymi limitami, przypisując je do atrybutu limit instancji.

     foo = MyClass() foo.limit = 50 ## foo can now hold 50 elements—other instances can hold 10

    Ma to sens tylko wtedy, gdy chcesz, aby typowa instancja MyClass zawierała tylko 10 elementów lub mniej — jeśli nadasz wszystkim instancjom różne limity, limit powinien być zmienną instancji. (Pamiętaj jednak: zachowaj ostrożność podczas używania wartości mutowalnych jako wartości domyślnych.)

  3. Śledzenie wszystkich danych we wszystkich instancjach danej klasy . To trochę specyficzne, ale mogłem zobaczyć scenariusz, w którym możesz chcieć uzyskać dostęp do danych związanych z każdą istniejącą instancją danej klasy.

    Aby scenariusz był bardziej konkretny, załóżmy, że mamy klasę Person , a każda osoba ma name . Chcemy śledzić wszystkie użyte nazwy. Jednym z podejść może być iteracja po liście obiektów modułu odśmiecania pamięci, ale prościej jest użyć zmiennych klas.

    Zauważ, że w tym przypadku names będą dostępne tylko jako zmienna klasy, więc mutowalna wartość domyślna jest akceptowalna.

     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']

    Moglibyśmy nawet użyć tego wzorca projektowego do śledzenia wszystkich istniejących wystąpień danej klasy, a nie tylko niektórych powiązanych danych.

     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>]
  4. Wydajność (coś w rodzaju… patrz poniżej).

Powiązane: Najlepsze praktyki i wskazówki Pythona autorstwa Toptal Developers

Pod maską

Uwaga: Jeśli martwisz się wydajnością na tym poziomie, możesz nie chcieć używać Pythona, ponieważ różnice będą rzędu dziesiątych części milisekundy — ale nadal fajnie jest pogrzebać, i pomaga ze względu na ilustrację.

Przypomnij sobie, że przestrzeń nazw klasy jest tworzona i wypełniana w momencie definiowania klasy. Oznacza to, że wykonujemy tylko jedno przypisanie — zawsze — dla danej zmiennej klasy, podczas gdy zmienne instancji muszą być przypisywane za każdym razem, gdy tworzona jest nowa instancja. Weźmy przykład.

 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"

Przypisujemy do Bar.y tylko raz, ale instance_of_Foo.y przy każdym wywołaniu __init__ .

Jako kolejny dowód użyjmy deasemblera Pythona:

 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

Kiedy spojrzymy na kod bajtowy, znowu jest oczywiste, że Foo.__init__ musi wykonać dwa przypisania, podczas gdy Bar.__init__ robi tylko jedno.

W praktyce, jak naprawdę wygląda ten zysk? Będę pierwszym, który przyzna, że ​​testy czasowe są w dużym stopniu zależne od często niekontrolowanych czynników, a różnice między nimi są często trudne do dokładnego wyjaśnienia.

Myślę jednak, że te małe fragmenty (uruchamiane z modułem Python timeit) pomagają zilustrować różnice między zmiennymi klas i instancji, więc i tak je zamieściłem.

Uwaga: używam MacBooka Pro z systemem OS X 10.8.5 i Python 2.7.2.

Inicjalizacja

 10000000 calls to `Bar(2)`: 4.940s 10000000 calls to `Foo(2)`: 6.043s

Inicjalizacje Bar są szybsze o ponad sekundę, więc różnica wydaje się być statystycznie istotna.

Więc dlaczego tak jest? Jedno spekulatywne wyjaśnienie: wykonujemy dwa zadania w Foo.__init__ , ale tylko jedno w Bar.__init__ .

Zadanie

 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

Uwaga: nie ma możliwości ponownego uruchomienia kodu instalacyjnego w każdej wersji próbnej z timeit, więc musimy ponownie zainicjować naszą zmienną w naszej wersji próbnej. Drugi wiersz czasów przedstawia powyższe czasy z odjętymi wcześniej obliczonymi czasami inicjalizacji.

Z powyższego wygląda na to, że Foo zajmuje tylko około 60% czasu, w którym Bar zajmuje się zadaniami.

Dlaczego tak jest? Jedno spekulatywne wyjaśnienie: kiedy przypisujemy do Bar(2).y , najpierw zaglądamy do przestrzeni nazw instancji ( Bar(2).__dict__[y] ), nie znajdujemy y , a następnie szukamy w przestrzeni nazw klas ( Bar.__dict__[y] ), a następnie dokonując właściwego przypisania. Kiedy przypisujemy do Foo(2).y , wykonujemy o połowę mniej wyszukiwań, niż od razu przypisujemy do przestrzeni nazw instancji ( Foo(2).__dict__[y] ).

Podsumowując, chociaż te wzrosty wydajności nie będą miały znaczenia w rzeczywistości, testy te są interesujące na poziomie koncepcyjnym. Jeśli już, mam nadzieję, że te różnice pomogą zilustrować mechaniczne różnice między zmiennymi klas i instancji.

Na zakończenie

Atrybuty klas wydają się być niedostatecznie wykorzystywane w Pythonie; wielu programistów ma różne wyobrażenia o tym, jak pracują i dlaczego mogą być pomocni.

Moje zdanie: zmienne klas Pythona mają swoje miejsce w szkole dobrego kodu. Ostrożnie używane mogą uprościć rzeczy i poprawić czytelność. Ale gdy zostaną nieostrożnie wrzucone do danej klasy, z pewnością cię podbiją.

Dodatek : Zmienne prywatnej instancji

Jedną rzecz, którą chciałem uwzględnić, ale nie miałem naturalnego punktu wejścia…

Python nie posiada zmiennych prywatnych , że tak powiem, ale inna interesująca relacja między nazewnictwem klas i instancji jest związana z przerabianiem nazw.

W przewodniku po stylu Pythona jest napisane, że zmienne pseudoprywatne powinny być poprzedzone podwójnym podkreśleniem: „__”. Jest to nie tylko znak dla innych, że twoja zmienna ma być traktowana prywatnie, ale także pewnego rodzaju sposób na uniemożliwienie dostępu do niej. Oto, co mam na myśli:

 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

Spójrz na to: atrybut instancji __zap jest automatycznie poprzedzony nazwą klasy, aby uzyskać _Bar__zap .

Chociaż nadal można ustawiać i pobierać za pomocą a._Bar__zap , ta zmiana nazwy jest sposobem na utworzenie zmiennej „prywatnej”, ponieważ uniemożliwia tobie i innym dostęp do niej przez przypadek lub przez ignorancję.

Edycja: jak uprzejmie zauważył Pedro Werneck, to zachowanie ma w dużej mierze pomóc w podklasowaniu. W przewodniku po stylu PEP 8 widzą, że służy on dwóm celom: (1) zapobieganiu dostępowi podklas do niektórych atrybutów oraz (2) zapobieganiu kolizjom przestrzeni nazw w tych podklasach. Chociaż jest to użyteczne, nie powinno być postrzegane jako zaproszenie do pisania kodu z założonym rozróżnieniem na publiczno-prywatne, jak to ma miejsce w Javie.

Powiązane: Stań się bardziej zaawansowany: unikaj 10 najczęstszych błędów popełnianych przez programistów Pythona