Mateusz Mazurek – programista z pasją

Python, architektura, ciekawostki ze świata IT

Programowanie Programowanie webowe

Metaklasy w Pythonie

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:
a w ramach prezentu otrzymasz całkowicie za darmo, dwa dokumenty PDF „6 (nie zawsze oczywistych) błędów popełnianych podczas nauki programowania” który jest jednym z efektów ponad siedmioletniej pracy i obserwacji rozwoju niejednego programisty oraz „Wstęp do testowania w Pythonie”, będący wprowadzeniem do biblioteki PyTest.
Jeśli to Cię interesuje to zapraszam również na swoje social media.

Jak i do ewentualnego postawienia mi kawy :)
Postaw mi kawę na buycoffee.to

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 strróż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ą.

Dzięki za wizytę,
Mateusz Mazurek

A może wolisz nowości na mail?

Subskrybuj
Powiadom o
guest

Witryna wykorzystuje Akismet, aby ograniczyć spam. Dowiedz się więcej jak przetwarzane są dane komentarzy.

11 komentarzy
Inline Feedbacks
View all comments
Michał

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 »

Mateusz Hyla

Metaklasy – mega skomplikowany temat. Jak dla mnie to jest to za trudne i za bardzo zawiłe.

Kubas

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.

Jakub

W przykładowym kodzie podłapałem:

for key, val in ns.items():
    if key in self.__prohibited_methods__:
    ns[key] = partial(alert_function, f_name=key)

 
„val” nie jest użyte
wystarczy skrócić:
 

for key in ns:

 
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 :)

Last edited 3 lat temu by Jakub
Radek

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.

Marcin

Bardzo fajnie napisane. Żeby wszystkie zagadnienia się tak miło czytało… pozdrawiam serdecznie.