Cześć! Dzisiejszy artykuł będzie dotyczył biblioteki dataclass która dostarcza możliwość tworzenia tzw. klas danych.
Czym są klasy danych?
Klasa danych to taka klasa której głównym celem jest przechowywanie jakiejś wartości. Taki zabieg nadaje tej wartości osobowość, dzięki czemu owa wartość w naszym kodzie przestaje być szarym, bezbarwny intem czy stringiem a zaczyna mieć swój własny typ. Brzmi to znajomo? Powinno, bo dataclassy są łatwym sposobem do implementacji DTO a także, po spełnieniu pewnych założeń – Value Objectów.
Co nam dają dataclasses?
Dają głównie wygodę. Dzięki temu rozwiązaniu możemy tworzyć klasy pisząc mniejszą ilość kodu niż musielibyśmy pisać, chcąc stworzyć klasę o tej samej funkcjonalności w tradycyjny sposób. Mówiąc mniej abstrakcyjnie: dataclasses są w stanie wygenerować nam metody takie jak init/repr czy metody porównujące typu eq/gte/lte.
Przykład
Bazując na tym co napisałem w poprzednim akapicie, możemy napisać kilka linijek kodu będącym przykładem tego jak dataclasses mogą zaoszczędzić nam pisania bezproduktywnego kodu:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | import dataclasses class Example: def __init__(self, param1: int, param2: str, param3: float): self.param1: int = param1 self.param2: str = param2 self.param3: float = param3 @dataclasses.dataclass() class Example2: param1: int param2: str param3: float |
W tym przykładzie mamy dwie klasy, jedna została zdefiniowana w sposób tradycyjny wraz z metodą init a druga ma tylko listę pól i dekorator dataclass(). W przypadku tej drugiej klasy, metoda init zostanie automatycznie wygenerowana na podstawie wskazanych pól, a więc dla obu tych klas tworzenie obiektu będzie wyglądało podobnie:
1 2 | Example2(1, '2', 4.3) Example(1, '2', 4.3) |
I mimo, że obiekt w obu przypadkach zostanie stworzony na postawie tych samych pól, to będą to dwa osobne zupełnie inne obiekty. Łatwo to zauważyć printując je:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | import dataclasses class Example: def __init__(self, param1: int, param2: str, param3: float): self.param1: int = param1 self.param2: str = param2 self.param3: float = param3 @dataclasses.dataclass() class Example2: param1: int param2: str param3: float print(Example2(1, '2', 4.3), Example(1, '2', 4.3)) |
Co da nam rezultat:
Example2(param1=1, param2='2', param3=4.3) <__main__.Example object at 0x7f715d2634f0>
Pytanie brzmi, dlaczego?
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 :)
Głębiej w dataclasses
Spójrzmy na taki kod:
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 | import dataclasses class Example: def __init__(self, param1: int, param2: str, param3: float): self.param1: int = param1 self.param2: str = param2 self.param3: float = param3 @dataclasses.dataclass() class Example2: param1: int param2: str param3: float for method_name in dir(Example): if callable(getattr(Example, method_name)): if getattr(Example, method_name) != getattr(Example2, method_name): print(method_name) """" Wynik wykonania to: __eq__ __hash__ __init__ __init_subclass__ __repr__ __subclasshook__ """ |
Printuje on wszystkie nazwy metod które różnią się pomiędzy klasami Example i Example2. Pomijając metody init_subclass i subclasshook które są metodami klasowymi, więc będą się zawsze różnić, pozostaje nam:
- eq
- hash
- init
- repr
Jak widać po tym, dekorator dataclass wygenerował nam metodę repr i stąd zmiana w wyświetlaniu obiektów w funkcji print. Zmianę metody init dostrzegliśmy sami, pozostają nam więc metody:
- eq
- hash
gdzie pierwsza jak i druga również zostały wygenerowana przez dekorator dataclass.
Oczywiście możemy sterować tym, co dekorator nam wygeneruje, ponieważ jego definicja pozwala na przekazywanie argumentów:
@dataclass(init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False, match_args=True, kw_only=False, slots=False, weakref_slot=False)
I tym samym wpływa na zachowanie się dekoratora:
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 | import dataclasses class Example: def __init__(self, param1: int, param2: str, param3: float): self.param1: int = param1 self.param2: str = param2 self.param3: float = param3 @dataclasses.dataclass(order=True, repr=False) # <- tu zmiana class Example2: param1: int param2: str param3: float for method_name in dir(Example): if callable(getattr(Example, method_name)): if getattr(Example, method_name) != getattr(Example2, method_name): print(method_name) """" Wynik wykonania to: __eq__ __ge__ __gt__ __hash__ __init__ __init_subclass__ __le__ __lt__ __subclasshook__ """ |
który po przekazaniu odpowiednich argumentów przestał nadpisywać metodę repr a zaczął metody służące do sortowania.
Ciekawym modyfikatorem jest tutaj argument frozen który potrafi sprawić, że nasze obiekty będą niemutowalne:
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 | import dataclasses @dataclasses.dataclass() class Example1: arg1: int = 1 @dataclasses.dataclass(frozen=True) class Example2: arg1: int = 1 a = Example1() a.arg1 = 2 print(a) a = Example2() a.arg1 = 2 print(a) """" Wynik wykonania to: Example1(arg1=2) dataclasses.FrozenInstanceError: cannot assign to field 'arg1' """ |
Praca z wygenerowanym initem
Super, że nie musimy już pisać ręcznie metody init, ale jej brak szybko staje się niewygodny. No bo jeśli chcemy np. podwoić wartość którą zapisujemy do pola obiektu, to jak to możemy zrobić? Albo co z domyślnymi wartościami w metodzie init?
Zacznijmy od wartości domyślnych. tuta jest bardzo prosto, może je przypisać do zdefiniowanych pól:
1 2 3 4 5 6 7 8 9 10 11 | import dataclasses @dataclasses.dataclass() class Example2: param1: int param2: float param3: str = 'jestemtestem' print(Example2(1, 3.2)) |
Jeśli natomiast brakuje nam możliwości które dawał init, możemy skorzystać z metody post_init:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import dataclasses @dataclasses.dataclass() class Example2: param1: int param2: float param3: str = 'jestemtestem' def __post_init__(self): self.param1 = 34 print(Example2(1, 3.2)) """ Co wyprodukuje: Example2(param1=34, param2=3.2, param3='jestemtestem') """ |
która wołana jest zaraz po wygenerowanym inicie.
Podrasowanie inita
Aktualnie każde pole które zdefiniowaliśmy pojawi się w inicie. Ale przecież nie zawsze tak musi być, prawda? Czasem mamy pola których wartość będzie obliczana np. w metodzie post_init. Na szczęście możemy modyfikować to, które pola w którym kontekście będą brane pod uwagę:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import dataclasses @dataclasses.dataclass() class Example2: param1: int param2: float = dataclasses.field(init=False, default=12) # <-- tu zmiana param3: str = 'jestemtestem' def __post_init__(self): self.param1 = 34 print(Example2(1)) """ Co wyprodukuje: Example2(param1=34, param2=12, param3='jestemtestem') """ |
I właśnie do takich modyfikacji służy metoda field. Jej definicja wygląda tak:
dataclasses.field(*, default=MISSING, default_factory=MISSING, init=True, repr=True, hash=None, compare=True, metadata=None, kw_only=MISSING)
co daje nam możliwość wyłączenia pola nie tylko z inita, ale także z metod typu hash, repr czy metod sortujących.
Na pierwszy rzut oka nieintuicyjne może być użycie pola default/default_factory. To pierwsze jest zarezerwowane dla zmiennych niemutowalnych a to drugie dla zmiennych mutowalnych. Taka próba zwróci nam błąd:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | import dataclasses from typing import List @dataclasses.dataclass() class Example2: param1: int param2: List[int] = dataclasses.field(default=[1, 2, 3]) param3: str = 'jestemtestem' def __post_init__(self): self.param1 = 34 print(Example2(1)) """ Co wyprodukuje: ValueError: mutable default <class 'list'=""> for field param2 is not allowed: use default_factory """ |
dopiero zmiana default na default factory poprawi sytuację:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | import dataclasses from typing import List @dataclasses.dataclass() class Example2: param1: int param2: List[int] = dataclasses.field(default_factory = lambda: [1, 2, 3]) param3: str = 'jestemtestem' def __post_init__(self): self.param1 = 34 print(Example2(1)) """ Co wyprodukuje: Example2(param1=34, param2=[1, 2, 3], param3='jestemtestem') """ |
Zauważ, że default_factory przyjmuje obiekt będący callablem.
Ciekawostki
Pakiet dataclasses dostarcza jeszcze metody:
- asdict
- astuple
które pozwalając przekształcić nasz obiekt w jeden z dwóch typów danych:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | import dataclasses from typing import List @dataclasses.dataclass() class Example2: param1: int param2: List[int] = dataclasses.field(default_factory = lambda: [1, 2, 3]) param3: str = 'jestemtestem' def __post_init__(self): self.param1 = 34 print(dataclasses.asdict(Example2(1))) print(dataclasses.astuple(Example2(1))) """ Co wyprodukuje: {'param1': 34, 'param2': [1, 2, 3], 'param3': 'jestemtestem'} (34, [1, 2, 3], 'jestemtestem') """ |
Nie tylko dataclasses
Podobną funkcjonalność dostarczają np:
- pydantic
- attrs
- namedtuples
Z czego ta ostatnia opcja jest wbudowana w Pythona, ale też jest ma najmniej możliwości. Natomiast pydantic i attrs dają większe możliwości niż dataclasses ale trzeba je sobie samemu doinstalować.
Podsumowując
Dataclasses to narzędzie które swoją sławę zyskało dzięki temu, że ściąga z barków programisty konieczność pisania nieproduktywnego kodu. To natomiast pozwala nam przekierować swoją uwagę na elementy bardziej produktywne jak architektura czy cleancode. Myślę, że warto stosować.
Mateusz Mazurek
Pewnie, że warto używać :)
Ciekawy artykuł:) Mogę tylko zasugerować, aby poprawić namespacing, bo trudno się wczytuje w różnice między Example1, a Example2, na chociażby BasicClass, DataClass itd.
Bardzo słuszna uwaga :) dzięki!
[…] Dataclasses w Pythonie […]
[…] Dataclasses w Pythonie […]