Cześć! Dzisiejszy artykuł będzie dotyczył biblioteki dataclass która dostarcza możliwość tworzenia tzw. klas 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.
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.
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?
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:
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:
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' """ |
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.
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.
Pakiet dataclasses dostarcza jeszcze metody:
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') """ |
Podobną funkcjonalność dostarczają np:
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ć.
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ć.
Cześć. Dziś luźny artykuł, bo dziś pobawimy się jedną z pierwszy wersji Pythona. Skompilujemy go i zobaczymy co tam w… Read More
Nowy rok czas zacząć! Więc lećmy z podsumowaniem. Nowy artykuł Nie uwierzycie, ale pojawił się na blogu nowy artykuł! Piszę… Read More
Cześć! W Pythonie 3.13 dodano JITa! JIT, czyli just-in-time compiler to optymalizacja na która Python naprawdę długo czekał. Na nasze… Read More
Cześć! Zapraszam na podsumowanie roku 2024. Książki W sumie rok 2024 był pod względem ilości książek nieco podobny do roku… Read More
Podtrzymując tradycję, prawie regularnych podsumowań, zapraszam na wpis! Nie mogło obyć się bez Karkonoszy We wrześniu odwiedziłem z kolegą Karkonosze,… Read More
Oj daaawnoo mnie tu nie było. Ale wakacje to był czas dużej liczby intensywnych wyjazdów i tak naprawdę, dopiero jakoś… Read More
Pokaż komentarze
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!