Cześć!
Dziś temat, którego nie zamknąłbym w szufladce „podstawy Pythona”. Poza samym faktem, że warto takie rzeczy wiedzieć, bo są najzwyczajniej w świecie ciekawe, to pozwalają lepiej zrozumieć jak działa obiektowość i dowiedzieć się jakie fajne rzeczy można z nią robić :)
Wszystko jest obiektem
W Pythonie wszystko jest obiektem. Od tego zdania zaczniemy nasze rozważania. Skoro tak jest, to można zawsze sprawdzić typ obiektów, np. w taki sposób:
1 2 3 | >>> number = 5 >>> type(number) <class 'int'> |
Podobnie sprawa wygląda dla obiektów, które są instancjami naszych klas:
1 2 3 4 5 6 7 8 9 10 | class Person: def __init__(self, name: str, surname: str, year_of_birth: int): self.name = name self.surname = surname self.year_of_birth = year_of_birth p = Person('Mateusz', 'Mazurek', 1991) print(type(p)) # wyświetli: <class '__main__.Person'> |
I do tego momentu sprawa pewnie jest jasna. Ale skoro wszystko jest obiektem, to czy klasa (jeszcze nie instancja!) jest również obiektem? Tak! Spójrz na taki kod:
1 2 3 4 5 6 7 8 | class Person: def __init__(self, name: str, surname: str, year_of_birth: int): self.name = name self.surname = surname self.year_of_birth = year_of_birth print(type(Person)) # wyświetli: <class 'type'> |
Podobnie ma się sprawa z innymi klasami:
1 2 3 4 5 6 7 8 | >>> for t in int, float, dict, list, tuple: ... print(type(t)) ... <class 'type'> <class 'type'> <class 'type'> <class 'type'> <class 'type'> |
Według tych kawałków kodu, klasa jest obiektem typu type. A to znaczy, że używając type, powinniśmy móc stworzyć klasę… Ale zaraz, przecież wyżej użyłem jej do sprawdzenia typu!
Jak to jest z tą funkcją type?
Funkcja type, poza powszechnie znanym użyciem, ma jeszcze inne możliwości. A dokładniej – pozwala na dynamiczne tworzenie klas! Posiada ona bowiem możliwość przyjęcia innego zbioru argumentów niż najczęściej stosowane. Zerknij na taki przykład:
1 2 3 4 5 6 7 8 9 10 11 12 13 | def __init__(self, name: str, surname: str, year_of_birth: int): self.name = name self.surname = surname self.year_of_birth = year_of_birth attrs = { '__init__': __init__ } Person = type('Person', (), attrs) print(type(Person)) # wyświetli: <class 'type'> |
Jak widać stworzyliśmy klasę Person, używając do tego funkcji type. A efekt otrzymaliśmy ten sam, co tradycyjną definicją z użyciem słówka „class”.
Oczywiście nie jest to takie bezproblemowe. Jeśli chcielibyśmy, używając funkcji type, stworzyć taką klasę:
1 2 3 4 5 6 7 8 9 10 11 12 13 | from dataclasses import dataclass from datetime import datetime @dataclass class Person: name: str surname: str year_of_birth: int @property def age(self): return datetime.now().year - self.year_of_birth |
to będzie to droga przez mękę. Zrobiłem to na potrzeby tego wpisu i wolę już więcej nie robić. Żeby zmusić klasę tworzoną funkcją type do współpracy z dataclasses, musiałem zrozumieć, jak one wewnętrzne działają. W sumie było to kształcące, ale kod który powstał, razi w oczy:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | from dataclasses import dataclass, Field from datetime import datetime params = { 'name': Field(None, None, True, True, True, True, None), 'surname': Field(None, None, True, True, True, True, None), 'year_of_birth': Field(None, None, True, True, True, True, None) } def age(self): return datetime.now().year - self.year_of_birth Person = dataclass(type('Person', (), {'__annotations__': params, 'age': property(age)})) p = Person('Mateusz', 'Mazurek', 1991) print(p) print(p.age) |
Ale działa! To, co jest tu ciekawe, to świadome użycie wiedzy na temat dziania dekoratorów i tego, że to zwykłe funkcje. Mam na myśli tutaj oczywiście dekoratory dataclass i property.
Ale skoro wszystko jest obiektem, to jakiego typu jest obiekt type?
1 2 | >>> type(type) <class 'type'> |
Nie jest to do końca intuicyjne.
Czekaj, stop!
Podoba Ci się to co tworzę? Jeśli tak to zapraszam Cię do zapisania się na newsletter:Jeśli to Cię interesuje to zapraszam również na swoje social media.
Jak i do ewentualnego postawienia mi kawy :)
Tak czy siak – skoro tworzenie klas za pomocą funkcji type jest takie toporne, co więcej, PyCharm w ogóle sobie z podpowiedziami do takich klas nie radzi, to czemu o nich mówię?
Zauważ jedną rzecz:
Wyżej wspomniałem, że klasa jest obiektem typu type i tak, jak tworzymy własne obiekty, używając do tego zapisu a = A()
tak tu, analogicznie, możemy stworzyć obiekt klasy używając tego samego zapisu A = type('A', (), {})
. Porównałbym type do str – różnica jest taka że za pomocą pierwszego tworzysz klasę a za pomocą drugiego – stringa:
1 2 3 4 5 6 7 8 9 10 11 12 13 | >>> text = str(12345) >>> type(text) <class 'str'> >>> type(str) <class 'type'> >>> >>> >>> _class = type('Example', (), {}) >>> a = _class() >>> type(a) <class '__main__.Example'> >>> type(_class) <class 'type'> |
Z tego wynika, że hierarchia wygląda tak:
Obiekt, którego instancją jest klasa, nazywamy metaklasą.
Metaklasy
Możemy stworzyć własną metaklasę i na jej podstawie tworzyć kolejne klasy. Za metaklasę uznaje się tę klasę, która dziedziczy po (wszechobecnym w tym artykule) type. Natomiast żeby zastosować swoją metaklasę do swoich klas, trzeba ją przekazać jako argument o nazwie metaclass. Od strony praktycznej wygląda to tak:
1 2 3 4 5 6 | class ExampleMetaClass(type): pass class ExampleClass(metaclass=ExampleMetaClass): pass |
Przykłady
Zerknij na taki kod:
1 2 3 4 5 6 7 8 9 10 | >>> class ExampleMetaClass(type): ... def __new__(cls, *args, **kwargs): ... print("I'm creating new class: ", cls) ... return super().__new__(cls, *args, **kwargs) ... >>> >>> class ExampleClass(metaclass=ExampleMetaClass): ... pass ... I'm creating new class: <class '__main__.ExampleMetaClass'> |
Zauważ, że nigdzie nie jest tworzona instancja klasy ExampleClass. A wynik funkcji print dostajemy! Funkcja new uruchamiana jest przed stworzeniem swojej instancji, czyli przed stworzeniem klasy. Klasa została stworzona, więc stąd owy print.
Napiszmy przykład. Używając metaklas możemy ograniczyć ilość tworzonych klas, np. do 3:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | class ExampleMetaClass(type): _max_class_instances = 3 def __new__(cls, *args, **kwargs): if cls._max_class_instances > 0: cls._max_class_instances -= 1 return super().__new__(cls, *args, **kwargs) raise RuntimeError(f'{cls} could have only 3 subclasess!') class ExampleClass(metaclass=ExampleMetaClass): pass class ExampleClass1(metaclass=ExampleMetaClass): pass class ExampleClass2(metaclass=ExampleMetaClass): pass |
więc taki kod nie rzuci wyjątku. Natomiast dodanie kolejnej klasy już tak:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | >>> class ExampleMetaClass(type): ... _max_class_instances = 3 ... ... def __new__(cls, *args, **kwargs): ... if cls._max_class_instances > 0: ... cls._max_class_instances -= 1 ... return super().__new__(cls, *args, **kwargs) ... raise RuntimeError(f'Our implementation allow to have only three object of {cls}!') ... >>> >>> class ExampleClass(metaclass=ExampleMetaClass): ... pass ... >>> >>> class ExampleClass1(metaclass=ExampleMetaClass): ... pass ... >>> >>> class ExampleClass2(metaclass=ExampleMetaClass): ... pass ... >>> >>> class Ex(ExampleClass2): ... pass ... Traceback (most recent call last): File "<input>", line 1, in <module> class Ex(ExampleClass2): File "<input>", line 8, in __new__ raise RuntimeError(f'Our implementation allow to have only three object of {cls}!') RuntimeError: Our implementation allow to have only three object of <class '__main__.ExampleMetaClass'>! |
Analizując dalej tę funkcję – możemy stworzyć dzięki temu mechanizmowi coś, czego Python nie oferuje – tj. klasy finalne, czyli takie, po których nie można dziedziczyć!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | >>> class Final(type): ... def __new__(cls, name, bases, classdict): ... for b in bases: ... if isinstance(b, Final): ... raise TypeError("type '{0}' could not be base class - it's marked as final class".format(b.__name__)) ... return type.__new__(cls, name, bases, dict(classdict)) ... >>> >>> class ExampleClass(metaclass=Final): ... pass ... >>> >>> class SecondClass(ExampleClass): ... pass ... Traceback (most recent call last): File "<input>", line 1, in <module> class SecondClass(ExampleClass): File "<input>", line 5, in __new__ raise TypeError("type '{0}' could not be base class - it's marked as final class".format(b.__name__)) TypeError: type 'ExampleClass' could not be base class - it's marked as final class |
A jeżeli tego nam mało, to możemy np. podmienić ciało wybranych metod:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | >>> from functools import partial >>> >>> >>> class MetaClass(type): ... __prohibited_methods__ = ['get_name'] ... ... def __new__(self,name,base,ns): ... def alert_function(f_name): ... raise RuntimeError(f'Method {f_name} is prohibited in this class!') ... ... for key, val in ns.items(): ... if key in self.__prohibited_methods__: ... ns[key] = partial(alert_function, f_name=key) ... ... return type.__new__(self,name,base,ns) ... >>> >>> class ExampleClass(metaclass=MetaClass): ... ... def __init__(self, name): ... self.name = name ... ... def get_name(self): ... return self.name ... >>> >>> ex = ExampleClass('name') >>> >>> ex.get_name() Traceback (most recent call last): File "<input>", line 1, in <module> ex.get_name() File "<input>", line 6, in alert_function raise RuntimeError(f'Method {f_name} is prohibited in this class!') RuntimeError: Method get_name is prohibited in this class! |
Co dalej z metaklasami?
Nic. Jest to w gruncie rzeczy narzędzie jak każde inne.
Jeśli trudno znaleźć Ci przykład na użycie tego mechanizmu, to nie bądź zdziwiony – nie jest to zbyt często pojawiający się element Pythona. Metaklasy można spotkać we frameworkach np. są obecne w Django czy w SqlAlchemy. Nie szukaj jednak na siłę zastosowań – po prostu zapamiętaj, że istnieją, a może kiedyś okaże się to nieocenioną wiedzą.
Mateusz Mazurek
Bardzo ładnie i czytelnie opisane, dobre przykłady użycia. Jeśli ktoś się przebije przez zawiłości 'type’ to kolejne akapity jasno opisują po co to wszystko :) Wtrąciłbym swoje trzy grosze do tematu. Osobiście, z własnego doświadczenia uważam, że pisanie własnych Metaklas niestety zazwyczaj mocno zmniejsza czytelność i prostotę utrzymania kodu. Jeśli czyta to klasyczny webdeveloper, który pisze kolejną aplikacje, to niech nie ponosi go wyobraźnia i kusząca chęć zrobienia w końcu coś nowego. Wyjątkiem jest, kiedy piszemy bibliotekę, która nie będzie raczej debugowana przez zwykłego użytkownika (programistę), który mając dokumentacje nie potrzebuje wchodzić w szczegóły Metaklas, tylko użyć metod, które dana biblioteka… Czytaj więcej »
Cześć!
Dzięki za komentarz. Całkowicie się zgadzam z tym co napisałeś.
Metaklasy to „some kind of magic” i używanie ich tylko po to by poczuć powiew nowości to strzał, nie tylko sobie, ale całemu zespołowi, i to w kolano, a nie tylko w stopę.
Oczywiście nie można iść w drugą skrajność – to że część osób nie zna tego mechanizmu i że nie jest on prosty nie znaczy że mamy z niego od razu rezygnować:)
Wszystko z umiarem! A sprawimy tym podejściem że świat będzie o 0.00000001% szczęśliwszy:)
Metaklasy – mega skomplikowany temat. Jak dla mnie to jest to za trudne i za bardzo zawiłe.
Czy w Py3.8 nie dołożyli typing.Final i można zrobić @final na klasie ?
no ale to przy sprawdzaniu typów, a nie w runtimie.
Tak, dodany został dekorator final:) pozwala on trochę osiągnąć klasę finalna ale tak jak napisałeś – nie w runtime’ie:)
W przykładowym kodzie podłapałem:
„val” nie jest użyte
wystarczy skrócić:
A poza tym to merytorycznie bardzo fajne i czytelne.
Jak generować kod nie-wprost też pisałem u siebie:
https://www.sporna.dev/post/waz-w-kieszeni
Zapraszam :)
Dzięki! <3
Dla mnie metaclassa to zawsze nadpisanie wywoływań, trzeba znać te podstawowe i wiedzieć co robią a następnie modyfikujemy je do własnych potrzeb o ile to konieczne.
Tak, w gruncie rzeczy tak, trudność tkwi w „trzeba znać te podstawowe i wiedzieć co robią” :D
Bardzo fajnie napisane. Żeby wszystkie zagadnienia się tak miło czytało… pozdrawiam serdecznie.
Dzięki! :D