dataclasses: lớp dữ liệu gọn trong Python

1. Tổng quan

Khi viết class chỉ để chứa dữ liệu (kiểu Order, Customer, Point…), bạn thường phải tự viết __init__, __repr__, __eq__ — toàn là code lặp đi lặp lại. Module dataclasses (có từ Python 3.7) giúp bạn sinh tự động các method này chỉ bằng một decorator. Đọc xong bài này, bạn sẽ biết cách dùng @dataclass để viết class dữ liệu gọn, validate dữ liệu với __post_init__, làm class immutable, và chuyển đổi qua dict/JSON.

2. Dataclass cơ bản là gì

@dataclass là decorator đọc type hint trong class, rồi tự sinh __init__, __repr__, __eq__ dựa trên các field đó.

from dataclasses import dataclass

@dataclass
class Customer:
    name: str
    email: str
    age: int = 0
  • @dataclass quét các attribute có type hint (name, email, age) và coi đó là field.
  • age: int = 0 có giá trị mặc định, giống tham số mặc định trong hàm.
  • Bạn không cần viết __init__ — nó được sinh tự động theo đúng thứ tự field.
c1 = Customer("An", "an@example.com")
print(c1)
Customer(name='An', email='an@example.com', age=0)

__repr__ sinh ra tự động hiển thị đầy đủ tên field và giá trị, rất tiện khi debug — class thường không có sẵn điều này.

3. So sánh với class thường và namedtuple

Viết tay class tương đương sẽ dài hơn nhiều:

class CustomerPlain:
    def __init__(self, name, email, age=0):
        self.name = name
        self.email = email
        self.age = age

    def __repr__(self):
        return f"CustomerPlain(name={self.name!r}, email={self.email!r}, age={self.age!r})"

    def __eq__(self, other):
        if not isinstance(other, CustomerPlain):
            return NotImplemented
        return (self.name, self.email, self.age) == (other.name, other.email, other.age)

@dataclass thay bạn viết toàn bộ phần này. So với namedtuple, dataclass khác ở chỗ:

  • Đối tượng mutable theo mặc định (bạn sửa được c1.age = 20), namedtuple thì không.
  • Hỗ trợ type hint rõ ràng, dễ tích hợp với type checker như mypy.
  • Hỗ trợ __post_init__, giá trị mặc định phức tạp, kế thừa — những thứ namedtuple không làm tốt.

Lưu ý: Nếu bạn chỉ cần tuple bất biến nhẹ, không cần method, namedtuple hoặc typing.NamedTuple vẫn là lựa chọn gọn hơn.

4. field() và giá trị mặc định mutable

Một lỗi rất hay gặp: dùng list/dict làm giá trị mặc định trực tiếp.

from dataclasses import dataclass, field

@dataclass
class Order:
    order_id: str
    items: list[str] = field(default_factory=list)
  • Nếu viết items: list[str] = [], Python sẽ báo lỗi ValueError: mutable default ... is not allowed — vì list mặc định sẽ bị chia sẻ giữa mọi instance, dẫn tới bug khó tìm.
  • field(default_factory=list) yêu cầu dataclass gọi list() mới cho mỗi instance.
o1 = Order("A1")
o2 = Order("A2")
o1.items.append("Sách")

print(o1.items, o2.items)
['Sách'] []

Hai object có items độc lập, đúng như mong đợi.

5. Validate dữ liệu với __post_init__

__init__ do dataclass sinh ra không cho bạn chèn logic validate trực tiếp — nhưng có hook __post_init__ chạy ngay sau khi __init__ xong.

from dataclasses import dataclass

@dataclass
class PaymentRequest:
    amount: float
    currency: str = "VND"

    def __post_init__(self):
        if self.amount <= 0:
            raise ValueError("amount phải lớn hơn 0")
        self.currency = self.currency.upper()
  • __post_init__ được gọi tự động sau khi gán xong các field.
  • Ở đây ta validate amount và chuẩn hóa currency thành chữ hoa.
try:
    PaymentRequest(amount=-100)
except ValueError as e:
    print(e)
amount phải lớn hơn 0

6. Class bất biến với frozen=True

Nếu muốn object không thể sửa sau khi tạo (giống record trong Java), dùng frozen=True.

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float
  • frozen=True khiến __setattr__ bị chặn — mọi lần gán sau khi khởi tạo sẽ raise lỗi.
  • Nhờ bất biến, object frozen có thể dùng làm key trong dict hoặc phần tử trong set (vì dataclass tự sinh __hash__ khi frozen=True và eq=True).
p = Point(1.0, 2.0)
try:
    p.x = 5.0
except Exception as e:
    print(type(e).__name__, e)
FrozenInstanceError cannot assign to field 'x'

Lưu ý: frozen=True chỉ chặn gán attribute cấp 1. Nếu field là list/dict (mutable), nội dung bên trong vẫn sửa được. Muốn bất biến thật sự, hãy dùng tuple hoặc kiểu immutable cho field đó.

7. Kế thừa dataclass

Dataclass hỗ trợ kế thừa bình thường như class Python, field của class con được nối tiếp sau field của class cha.

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int

@dataclass
class Employee(Person):
    salary: float = 0.0
  • Employee kế thừa name, age từ Person, rồi thêm salary.
  • Thứ tự tham số trong __init__ sinh ra là: name, age, salary — giữ nguyên thứ tự khai báo.
e = Employee("Bình", 30, salary=15_000_000)
print(e)
Employee(name='Bình', age=30, salary=15000000.0)

Lưu ý: Field không có default phải đứng trước field có default trong toàn bộ chuỗi kế thừa. Nếu class cha có field với default mà class con thêm field không default, Python sẽ báo TypeError lúc định nghĩa class.

8. So sánh và sắp xếp với order=True

Mặc định dataclass chỉ sinh __eq__. Muốn so sánh <, >, <=, dùng order=True.

from dataclasses import dataclass

@dataclass(order=True)
class Product:
    price: float
    name: str = field(compare=False)
  • order=True sinh thêm __lt__, __le__, __gt__, __ge__, so sánh theo thứ tự field (giống so sánh tuple).
  • field(compare=False) loại name ra khỏi phép so sánh — chỉ so sánh theo price.
products = [Product(50_000, "Bút"), Product(20_000, "Vở")]
print(sorted(products))
[Product(price=20000, name='Vở'), Product(price=50000, name='Bút')]

sorted() dùng __lt__ do dataclass sinh ra, không cần viết key=lambda p: p.price thủ công.

9. Chuyển đổi qua dict và tuple

dataclasses cung cấp hàm tiện ích asdict và astuple để serialize object.

from dataclasses import dataclass, asdict, astuple

@dataclass
class Address:
    city: str
    country: str

@dataclass
class Customer2:
    name: str
    address: Address
cust = Customer2("Chi", Address("Hà Nội", "Việt Nam"))
print(asdict(cust))
print(astuple(cust))
{'name': 'Chi', 'address': {'city': 'Hà Nội', 'country': 'Việt Nam'}}
('Chi', ('Hà Nội', 'Việt Nam'))
  • asdict đệ quy chuyển cả nested dataclass (Address) thành dict con — rất tiện để trả JSON qua API.
  • astuple tương tự nhưng trả tuple lồng tuple.

Lưu ý: asdict/astuple deepcopy toàn bộ dữ liệu. Với object lớn hoặc chứa field không thể copy (file handle, connection…), gọi hàm này sẽ chậm hoặc lỗi.

10. Viết test cho dataclass

Vì __eq__ được sinh tự động theo giá trị field, test so sánh object rất tự nhiên với pytest.

import pytest
from dataclasses import FrozenInstanceError

def test_customer_equality():
    c1 = Customer("An", "an@example.com", 20)
    c2 = Customer("An", "an@example.com", 20)
    assert c1 == c2

def test_payment_request_rejects_non_positive_amount():
    with pytest.raises(ValueError):
        PaymentRequest(amount=0)

def test_point_is_frozen():
    p = Point(1.0, 2.0)
    with pytest.raises(FrozenInstanceError):
        p.x = 10.0
  • test_customer_equality kiểm tra hai object khác instance nhưng cùng giá trị field vẫn == nhau — đúng nhờ __eq__ tự sinh.
  • test_payment_request_rejects_non_positive_amount kiểm tra __post_init__ validate đúng.
  • test_point_is_frozen xác nhận frozen=True chặn gán attribute.

11. Lỗi hay gặp và khi nào không nên dùng

  • Quên default_factory cho mutable default: dùng = [] hoặc = {} trực tiếp sẽ lỗi ngay khi khai báo class, phải dùng field(default_factory=...).
  • Trộn field có default và không default sai thứ tự khi kế thừa: gây TypeError lúc load module, khó debug nếu class nằm rải ở nhiều file.
  • Tưởng frozen=True là deep immutable: field kiểu list/dict bên trong vẫn sửa được nội dung, chỉ không gán lại attribute.
  • Dùng dataclass cho logic nghiệp vụ phức tạp: nếu class có nhiều method xử lý, quan hệ phức tạp với object khác, dataclass chỉ giúp phần khởi tạo/so sánh — phần logic bạn vẫn phải tự viết, đừng lạm dụng để “giả vờ” class đó đơn giản hơn thực tế.
  • Cần tương thích Python < 3.7: dataclasses không có sẵn, phải cài package dataclasses từ PyPI (backport) hoặc dùng attrs.

Lưu ý: Nếu cần validate phức tạp, custom serialization, hoặc tích hợp sâu với JSON schema, các thư viện như pydantic thường mạnh hơn dataclasses thuần — đáng xem xét khi project lớn.

12. Kết luận

dataclasses giúp bạn loại bỏ phần lớn boilerplate khi viết class chứa dữ liệu: tự sinh __init__, __repr__, __eq__, hỗ trợ validate qua __post_init__, bất biến qua frozen=True, và serialize dễ dàng qua asdict/astuple. Với các class dữ liệu đơn giản tới trung bình, đây là lựa chọn gọn và dễ đọc hơn nhiều so với viết tay. Khi nhu cầu validate hoặc serialize phức tạp hơn, bạn có thể cân nhắc pydantic hoặc attrs thay thế.

pythondataclassesoopclean-code