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:

Aby potwierdzić swoją subskrypcję, odbierz pocztę i kliknij w link potwierdzający:) jeśli maila nie ma to poczekaj chwile i/lub sprawdź folder spam/inne/oferty itp :)

Aby potwierdzić swoją subskrypcję, odbierz pocztę i kliknij w link potwierdzający:) jeśli maila nie ma to poczekaj chwile i/lub sprawdź folder spam/inne/oferty itp :)
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 :)

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
Mateusz M.

Pokaż komentarze

  • 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 oferuje. Pisanie nowej metaklasy powinno być na prawdę przemyślane :)

    Reasumując, chciałbym zwrócić uwagę czytelników na akapit "Co dalej z metaklasami?", w którym wspomniałeś o tym samym.

    Zabrakło mi również większego uwidocznienia mega istotnej według mnie cząstki związanej z metaklasami - czyli kiedy one na prawdę się inicjują (czyli cykl wykonania chociażby __new__ i różnicę z __init__). Wiem, że jest o tym wspomniane w artykule ale polecam zwyczajnie podebugować lub poczytać na ten temat.

    Pozdrawiam!

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

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

  • 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.

Ostatnie wpisy

Podsumowanie: luty i marzec 2024

Ostatnio tygodnie były tak bardzo wypełnione, że nie udało mi się napisać nawet krótkiego podsumowanie. Więc dziś zbiorczo podsumuję luty… Read More

2 tygodnie ago

Podsumowanie: styczeń 2024

Zapraszam na krótkie podsumowanie miesiąca. Książki W styczniu przeczytałem "Homo Deus: Historia jutra". Książka łudząco podoba do wcześniejszej książki tego… Read More

3 miesiące ago

Podsumowanie roku 2023

Cześć! Zapraszam na podsumowanie roku 2023. Książki Zacznijmy od książek. W tym roku cel 35 książek nie został osiągnięty. Niemniej… Read More

4 miesiące ago

Podsumowanie: grudzień 2023

Zapraszam na krótkie podsumowanie miesiąca. Książki W grudniu skończyłem czytać Mein Kampf. Nudna książka. Ciekawsze fragmenty można by było streścić… Read More

4 miesiące ago

Praca zdalna – co z nią dalej?

Cześć, ostatnio w Internecie pojawiło się dużo artykułów, które nie były przychylne pracy zdalnej. Z drugiej strony większość komentarzy pod… Read More

4 miesiące ago

Podsumowanie: listopad 2023

Zapraszam na krótkie podsumowanie miesiąca. Książki W listopadzie dokończyłem cykl "Z mgły zrodzony" Sandersona. Tylko "Stop prawa" mi nie do… Read More

5 miesięcy ago