Mateusz Mazurek – programista z pasją

Python, architektura, ciekawostki ze świata IT

Programowanie Programowanie webowe

Dataclasses w Pythonie

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

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=MISSINGdefault_factory=MISSINGinit=Truerepr=Truehash=Nonecompare=Truemetadata=Nonekw_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ć.

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.

3 komentarzy
Inline Feedbacks
View all comments
Krzysztof

Pewnie, że warto używać :)

Last edited 1 rok temu by Krzysztof
Kuba

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.