1/36
Klasy danych i trwałość obiektów

Wykład 6: Dataclasses i Serializacja danych

Jak uniknąć pisania setek linii powtarzalnego kodu? Jak zapisać stan programu na dysk i odczytać go po restarcie? Dzisiaj zajmiemy się nowoczesnym podejściem do struktur danych w Pythonie.

  • Dataclasses: Klasy bez zbędnego "balastu".
  • Automatyzacja: Magiczne metody generowane za nas.
  • Typowanie: Dlaczego podpowiedzi typów są kluczowe.
  • Serializacja: Zamiana obiektów na bajty lub tekst.
  • Pickle i JSON: Dwa światy przechowywania danych.
  • Trwałość: Zapis i odczyt obiektów z plików.
2/36
Problem: Boilerplate Code

Nadmiarowość kodu

W tradycyjnym programowaniu obiektowym, tworząc prostą klasę do przechowywania danych, musimy napisać dużo powtarzalnego kodu (tzw. boilerplate).

Musimy ręcznie zdefiniować:

  • __init__: aby przypisać wartości do pól.
  • __repr__: aby widzieć czytelny opis obiektu.
  • __eq__: aby móc porównywać dwa obiekty.

Zajmuje to czas i zwiększa ryzyko popełnienia błędu przy każdej zmianie pola w klasie.

3/36
Tradycyjna klasa vs Dane

Tak robiliśmy to do tej pory

class Uzytkownik:
    def __init__(self, login, email):
        self.login = login
        self.email = email

    def __repr__(self):
        return f"Uzytkownik(login='{self.login}', email='{self.email}')"

    def __eq__(self, inny):
        if not isinstance(inny, Uzytkownik): return False
        return self.login == inny.login and self.email == inny.email

A co jeśli dodamy trzecie pole? Musimy poprawić wszystkie trzy metody!

4/36
Rozwiązanie: @dataclass

Magia automatyzacji

Od wersji Pythona 3.7 mamy dostęp do modułu dataclasses, który robi to wszystko za nas automatycznie.

from dataclasses import dataclass

@dataclass
class Uzytkownik:
    login: str
    email: str

# To wystarczy! Python sam wygeneruje:
# __init__, __repr__ oraz __eq__.

u = Uzytkownik("admin", "biuro@firma.pl")
print(u) # Uzytkownik(login='admin', email='biuro@firma.pl')
5/36
Jak to działa "pod spodem"?

Dekorator klasy

Kiedy używamy @dataclass, Python przegląda definicję klasy i szuka pól z określonymi typami (tzw. Type Hints).

  • Nazwa pola: Staje się nazwą argumentu w __init__.
  • Typ pola: Informuje programistę (i IDE), jakich danych oczekujemy.
  • Domyślny Init: Przypisuje wartości do obiektu (self.pole = wartosc).
W dataclass musisz podać typ pola (np. : str lub : int). Bez tego pole zostanie zignorowane przez dekorator.
6/36
Wartości domyślne

Opcjonalne pola

Możemy łatwo zdefiniować wartości, które zostaną użyte, jeśli użytkownik ich nie poda.

@dataclass
class Produkt:
    nazwa: str
    cena: float
    dostępność: bool = True
    magazyn: int = 0

p = Produkt("Kawa", 25.50)
print(p) 
# Produkt(nazwa='Kawa', cena=25.5, dostępność=True, magazyn=0)

Pamiętaj: pola z wartościami domyślnymi muszą znajdować się po polach wymaganych.

7/36
Pułapka: Zmienne typy domyślne

Problem z listami

W Pythonie nie wolno używać pustej listy [] jako domyślnej wartości wprost, ponieważ byłaby ona dzielona między wszystkimi obiektami klasy!

# TO JEST BŁĄD (zgłoszony przez Pythona):
@dataclass
class Koszyk:
    produkty: list = []

Python chroni nas przed błędami, które w zwykłych klasach często umykają uwadze programistów.

8/36
Rozwiązanie: default_factory

Fabryka wartości

Aby każde nowe zamówienie miało własną, pustą listę, używamy funkcji field oraz parametru default_factory.

from dataclasses import dataclass, field

@dataclass
class Koszyk:
    produkty: list = field(default_factory=list)

k1 = Koszyk()
k1.produkty.append("Chleb")
k2 = Koszyk() # Dostaje nową, pustą listę

Funkcja list (bez nawiasów!) zostanie wywołana za każdym razem, gdy powstanie nowa instancja klasy.

9/36
Niemodyfikowalność: frozen=True

Obiekty "zamrożone"

Czasami chcemy stworzyć strukturę, której nikt nie będzie mógł zmienić po utworzeniu (tzw. Immutable Object).

@dataclass(frozen=True)
class Punkt3D:
    x: int
    y: int
    z: int

p = Punkt3D(10, 20, 30)
p.x = 50 # BŁĄD: FrozenInstanceError

Dzięki frozen=True możemy używać takich obiektów jako kluczy w słownikach, bo są hashable (niezmienne).

10/36
Porównywanie: order=True

Automatyczne sortowanie

Dataclasses mogą automatycznie generować metody porównania (<, >, <=, >=).

@dataclass(order=True)
class Wynik:
    punkty: int
    imie: str

w1 = Wynik(100, "Adam")
w2 = Wynik(150, "Ewa")
print(w1 < w2) # True

Python porównuje pola po kolei – najpierw punkty, a jeśli są równe, to imie.

11/36
Walidacja: __post_init__

Logika po inicjalizacji

Skoro automat generuje __init__, to jak możemy sprawdzić, czy dane są poprawne? Do tego służy metoda __post_init__.

@dataclass
class Wiek:
    lata: int

    def __post_init__(self):
        if self.lata < 0:
            raise ValueError("Wiek nie może być ujemny!")

Jest ona wywoływana przez Pythona natychmiast po tym, jak __init__ przypisze wartości do pól.

12/36
Precyzyjna kontrola: field()

Konfiguracja pól

Możemy zdecydować, że dane pole ma być używane przy tworzeniu, ale np. ukryte w opisie (repr) lub pominięte przy porównywaniu.

@dataclass
class Pracownik:
    imie: str
    id_karty: str = field(repr=False)
    haslo: str = field(repr=False, compare=False)

p = Pracownik("Jan", "AX-50", "Tajne123")
print(p) # Pracownik(imie='Jan')

Pole haslo nie zostanie wyświetlone w konsoli i nie będzie brane pod uwagę przy ==.

13/36
Serializacja: Wstęp

Utrwalanie obiektów

Serializacja to proces zamiany obiektu żyjącego w pamięci RAM na format, który można zapisać (np. do pliku) lub przesłać przez sieć.

Deserializacja to proces odwrotny: odtworzenie obiektu z zapisanego formatu.

  • Format binarny: Wydajny, ale nieczytelny dla człowieka (np. Pickle).
  • Format tekstowy: Czytelny, uniwersalny (np. JSON, XML, YAML).
14/36
Moduł Pickle

"Marynowanie" obiektów

pickle to natywny dla Pythona mechanizm serializacji. Potrafi zapisać niemal każdy obiekt, w tym całe instancje klas.

import pickle

dane = {"wynik": 100, "gracz": "Neo"}

# Zamiana obiektu na bajty
bajty = pickle.dumps(dane)

# Odtworzenie obiektu
oryginał = pickle.loads(bajty)

Sufiks -s (dumps, loads) oznacza pracę na stringach (w tym przypadku bajtowych), a nie na plikach.

15/36
Pickle: Zapis do pliku

Praca z dyskiem

Aby zapisać obiekt do pliku, musimy otworzyć go w trybie binarnym (wb - write binary).

@dataclass
class Gracz:
    nick: str
    lvl: int

g = Gracz("Mag", 5)

with open("save.pkl", "wb") as f:
    pickle.dump(g, f)

Funkcja dump (bez s) zapisuje dane bezpośrednio do otwartego pliku.

16/36
Pickle: Odczyt z pliku

Wczytywanie stanu

Odczytujemy w trybie rb (read binary). Musimy mieć pewność, że definicja klasy Gracz jest dostępna w programie.

with open("save.pkl", "rb") as f:
    wczytany_gracz = pickle.load(f)

print(wczytany_gracz.nick) # Mag
Uwaga! Jeśli zmienisz nazwę klasy lub jej strukturę między zapisem a odczytem, pickle może zgłosić błąd (nie znajdzie klasy).
17/36
Bezpieczeństwo Pickle

Kwestia zaufania

pickle nie jest bezpieczny. Wykonywanie load na pliku z nieznanego źródła może spowodować wykonanie złośliwego kodu na Twoim komputerze.

Zasada: Używamy Pickle tylko do danych, które sami zapisaliśmy.

Cecha Pickle
Format Binarny
Uniwersalność Tylko Python
Bezpieczeństwo Niskie (code execution)
18/36
Moduł JSON

Standard internetowy

JSON (JavaScript Object Notation) to format tekstowy. Jest bezpieczny, czytelny i rozumiany przez niemal każdy język programowania.

import json

dane = {"id": 1, "aktywny": True}
tekst_json = json.dumps(dane)

print(tekst_json) # {"id": 1, "aktywny": true}

Zauważ zmiany: Pythonowe True stało się JSON-owym true (mała litera).

19/36
JSON vs Python

Mapowanie typów

Python JSON
dict object {}
list, tuple array []
str string ""
int, float number
True / False true / false
None null
20/36
Dataclass a JSON

Problem braku wsparcia

Standardowy moduł json nie wie, jak zapisać obiekt klasy @dataclass. Zna tylko podstawowe typy (słowniki, listy).

@dataclass
class Pojazd:
    marka: str

p = Pojazd("Tesla")
json.dumps(p) # BŁĄD: TypeError: Object reflects not JSON serializable

Rozwiązaniem jest zamiana obiektu na słownik przed zapisem.

21/36
Metoda asdict()

Most do świata JSON

Moduł dataclasses oferuje funkcję asdict, która rekurencyjnie zamienia nasz obiekt w zwykły słownik Pythona.

from dataclasses import dataclass, asdict

@dataclass
class Sensor:
    nazwa: str
    dane: list

s = Sensor("Temp", [20, 22, 21])
slownik = asdict(s)
# Teraz json zadziała:
print(json.dumps(slownik))
22/36
Odtwarzanie z JSON (Kwargs)

Słownik na Obiekt

Aby odtworzyć obiekt dataclass ze słownika wczytanego z JSONa, używamy operatora rozpakowania słownika (**).

tekst_json = '{"nazwa": "Temp", "dane": [20, 22]}'
dane_z_pliku = json.loads(tekst_json)

# Tworzymy nową instancję klasy
s_nowy = Sensor(**dane_z_pliku)

Ważne: klucze w JSON muszą dokładnie odpowiadać nazwom pól w klasie!

23/36
Ładne formatowanie JSON

Czytelność dla człowieka

Domyślnie JSON jest zapisywany w jednej linii ("zbity"). Możemy to zmienić, używając parametru indent.

dane = {"a": 1, "b": [1,2,3]}

# Indentacja (wcięcie) ustawiona na 4 spacje
ładny_json = json.dumps(dane, indent=4)

print(ładny_json)

Pomaga to weryfikować pliki konfiguracyjne lub zapisy stanu gry "gołym okiem".

24/36
JSON: Praca z plikami

Tekstowy zapis danych

JSON to tekst, więc otwieramy plik w trybie tekstowym (w/r), pamiętając o kodowaniu UTF-8.

dane = {"statystyki": [10, 20, 30]}

# Zapis
with open("dane.json", "w", encoding="utf-8") as f:
    json.dump(dane, f, indent=4)

# Odczyt
with open("dane.json", "r", encoding="utf-8") as f:
    wczytane = json.load(f)
25/36
Porównanie: Pickle vs JSON

Kiedy czego używać?

Cecha JSON Pickle
Czytelność Tak (Tekst) Nie (Binarny)
Wsparcie Wszystkie języki Tylko Python
Bezpieczeństwo Wysokie Bardzo Niskie
Złożoność Proste typy + repo Niemal dowolne klasy

Złota zasada: Zorientuj się na JSON. Pickle stosuj rzadko i tylko do celów wewnętrznych.

26/36
Case Study: System Zapisów Gry

Projekt praktyczny

Zbudujemy prosty system, który pozwoli nam zapisać stan postaci w grze RPG do pliku JSON i go później odtworzyć.

Wymagania:

  1. Definicja Item i Player jako Dataclasses.
  2. Automatyczna walidacja poziomu energii.
  3. Konwersja na słownik i zapis do JSON.
  4. Bezpieczny odczyt i odtworzenie obiektów.
27/36
RPG: Struktura Danych

Krok 1: Definicja klas

@dataclass
class Przedmiot:
    nazwa: str
    waga: float

@dataclass
class Bohater:
    imie: str
    hp: int
    ekwipunek: list[Przedmiot] = field(default_factory=list)

Używamy podpowiedzi typu list[Przedmiot], co informuje nas o zawartości listy.

28/36
RPG: Zapisywanie

Krok 2: Export do pliku

def zapisz_gre(bohater, nazwa_pliku):
    dane = asdict(bohater)
    with open(nazwa_pliku, "w", encoding="utf-8") as f:
        json.dump(dane, f, indent=2)

player = Bohater("Geralt", 100)
player.ekwipunek.append(Przedmiot("Miecz", 5.5))
zapisz_gre(player, "save.json")
29/36
RPG: Odczytywanie (Wyzwanie)

Krok 3: Problem zagnieżdżenia

Sam json.load zwróci nam słowniki, a nie obiekty Przedmiot. Musimy je "przepakować" ręcznie.

def wczytaj_gre(nazwa_pliku):
    with open(nazwa_pliku, encoding="utf-8") as f:
        d = json.load(f)
    
    # Zamieniamy słowniki przedmiotów na obiekty
    eq = [Przedmiot(**p) for p in d['ekwipunek']]
    
    # Tworzymy bohatera
    return Bohater(imie=d['imie'], hp=d['hp'], ekwipunek=eq)
30/36
Biblioteki pomocnicze (Bonus)

Dacite i Pydantic

W profesjonalnych projektach nikt nie robi "przepakowywania" ręcznie. Używa się bibliotek, które automatycznie mapują JSON na klasy danych.

  • dacite: Prosta biblioteka do tworzenia dataclasses ze słowników.
  • Pydantic: Mega-potężny standard branżowy do walidacji i serializacji danych (używany np. w FastAPI).

Działają one na podstawie podanych przez Ciebie typów (Type Hints).

31/36
Pliki tekstowe vs Binarne

Ważne różnice

W Pythonie musimy jawnie wskazać, czy pracujemy na tekście (str), czy na bajtach (bytes).

  • Tryb tekstowy: "r", "w", "a". Python automatycznie obsługuje kodowanie (np. zamienia \n zależnie od systemu).
  • Tryb binarny: "rb", "wb". Przesyłamy "surowe" bajty. Niezbędne dla obrazów, dźwięku i pickle.
Zawsze używaj encoding="utf-8" przy zapisie tekstu. To uchroni Cię przed problemami z polskimi znakami na różnych systemach (Windows vs Linux).
32/36
Zapisywanie dużych list

Wydajność

Jeśli masz miliony rekordów, format JSON może stać się powolny i zajmować dużo miejsca. Wtedy warto rozważyć:

  • Kompresję: Biblioteka gzip może skompresować Twoje pliki JSON "w locie".
  • Bazy danych: Jeśli dane są stale modyfikowane, lepiej użyć bazy SQL (np. sqlite3).
  • CSV: Dla prostych, tabelarycznych danych moduł csv jest szybszy i oszczędniejszy.
33/36
Serializacja Obrazów?

Dane w tekście

Czasami trzeba przesłać dane binarne (np. małą ikonkę) w formacie tekstowym (JSON). Używa się do tego kodowania Base64.

Base64 zamienia dowolne bajty na ciąg liter i cyfr (ASCII), zwiększając rozmiar o około 33%, ale umożliwiając bezpieczny transport tekstem.

34/36
Pobieranie plików z Sieci

JSON w Internecie

Większość nowoczesnych usług (API) wymienia dane za pomocą formatu JSON. Biblioteki takie jak requests posiadają wbudowane metody do obsługi tego formatu.

import requests

r = requests.get("https://api.nbp.pl/api/exchangerates/...")
dane = r.json() # Automatyczna deserializacja
35/36
Dobre Praktyki

Jak pisać czysty kod?

  • Używaj @dataclass wszędzie tam, gdzie tworzysz obiekty przechowujące głównie stan, a nie zachowanie.
  • Zawsze określaj typy pól (Type Hints).
  • Wybieraj JSON jako domyślny format zapisu.
  • Przy zapisie do pliku używaj with open(...).
  • Nie przechowuj haseł i wrażliwych danych w jawnych formatach (JSON/Pickle) bez szyfrowania.
36/36
Podsumowanie i Projekt

Co dalej?

Poznałeś nowoczesne narzędzia do zarządzania danymi w Pythonie. Wiedza ta przyda Ci się przy projektowaniu systemów bazodanowych, aplikacji webowych oraz gier.

Zadanie dla Ciebie:

Stwórz klasę Zadanie (nazwa, priorytet, termin) i zaimplementuj prostą listę zadań (To-Do List), która zapisuje stan do pliku terminarz.json przy każdym zamknięciu programu.

Na kolejnym wykładzie: Klasy Abstrakcyjne i Interfejsy.