Tracks
Data class là một trong những tính năng của Python mà một khi bạn đã biết đến, bạn sẽ chẳng muốn quay lại cách cũ nữa. Hãy xem lớp thông thường sau:
class Exercise:
def __init__(self, name, reps, sets, weight):
self.name = name
self.reps = reps
self.sets = sets
self.weight = weight
Với tôi, định nghĩa lớp này rất kém hiệu quả — trong phương thức __init__, bạn lặp lại mỗi tham số ít nhất ba lần. Nghe có vẻ không nghiêm trọng, nhưng hãy nghĩ tới tần suất bạn viết các lớp trong suốt sự nghiệp với nhiều tham số hơn thế.
So sánh với lựa chọn data class cho đoạn mã trên:
from dataclasses import dataclass
@dataclass
class Exercise:
name: str
reps: int
sets: int
weight: float # Weight in lbs
Đoạn mã trông khiêm tốn này tốt hơn lớp thông thường rất nhiều lần. Decorator nhỏ @dataclass sẽ tự động triển khai các phương thức __init__, __repr__, __eq__ ở phía sau, vốn sẽ tốn ít nhất 20 dòng nếu viết tay.
Ngoài ra, nhiều tính năng khác như toán tử so sánh, sắp xếp đối tượng và tính bất biến đều chỉ cách một dòng để “tự sinh” cho lớp của chúng ta.
Vì vậy, mục tiêu của hướng dẫn này là cho bạn thấy vì sao data class là một trong những điều tuyệt vời nhất xảy đến với Python nếu bạn yêu thích lập trình hướng đối tượng.
Bắt đầu thôi!
Những điều cơ bản về Data Class trong Python
Hãy điểm qua một số khái niệm nền tảng của data class trong Python khiến chúng hữu ích đến vậy.
Một số phương thức được tạo tự động trong data class
Bất chấp mọi tính năng, data class vẫn là các lớp thông thường nhưng cần ít mã hơn nhiều để triển khai cùng chức năng. Đây là lớp Exercise một lần nữa:
from dataclasses import dataclass
@dataclass
class Exercise:
name: str
reps: int
sets: int
weight: float
ex1 = Exercise("Bench press", 10, 3, 52.5)
# Verifying Exercise is a regular class
ex1.name
'Bench press'
Hiện tại, Exercise đã có sẵn các phương thức __repr__ và __eq__. Hãy kiểm tra:
repr(ex1)
"Exercise(name='Bench press', reps=10, sets=3, weight=52.5)"
Biểu diễn đối tượng của repr phải trả về đoạn mã có thể tái tạo chính nó, và ta thấy điều đó đúng hệt với ex1.
Trong khi đó, Exercise định nghĩa theo cách cũ sẽ trông như sau:
class Exercise:
def __init__(self, name, reps, sets, weight):
self.name = name
self.reps = reps
self.sets = sets
self.weight = weight
ex3 = Exercise("Bench press", 10, 3, 52.5)
ex3
<__main__.Exercise at 0x7f6834100130>
Trông khá tệ và vô dụng!
Giờ hãy kiểm tra sự tồn tại của __eq__, tức toán tử bằng nhau:
# Redefine the class
@dataclass
class Exercise:
name: str
reps: int
sets: int
weight: float
ex1 = Exercise("Bench press", 10, 3, 52.5)
ex2 = Exercise("Bench press", 10, 3, 52.5)
So sánh lớp với chính nó và với một lớp khác có tham số giống hệt nhau phải trả về True:
ex1 == ex2
True
ex1 == ex1
True
Và đúng là như vậy! Với các lớp thông thường, viết logic này sẽ rất phiền phức.
Data class yêu cầu gợi ý kiểu
Như bạn có thể thấy, data class yêu cầu gợi ý kiểu khi định nghĩa trường. Thực tế, data class chấp nhận mọi kiểu từ mô-đun typing. Ví dụ, đây là cách tạo một trường có thể nhận kiểu dữ liệu Any:
from typing import Any
@dataclass
class Dummy:
attr: Any
Tuy nhiên, “cá tính” của Python là dù data class yêu cầu gợi ý kiểu, các kiểu lại không thực sự bị ép buộc.
Chẳng hạn, tạo một thể hiện của lớp Exercise với kiểu dữ liệu hoàn toàn sai vẫn chạy không lỗi:
silly_exercise = Exercise("Bench press", "ten", "three sets", 52.5)
silly_exercise.sets
“Three sets”
Nếu bạn muốn ép kiểu dữ liệu, bạn phải dùng các công cụ kiểm tra kiểu như Mypy.
Data class cho phép giá trị mặc định trong các trường
Cho tới giờ, chúng ta chưa thêm mặc định nào vào lớp. Hãy sửa điều đó:
@dataclass
class Exercise:
name: str = "Push-ups"
reps: int = 10
sets: int = 3
weight: float = 0
# Now, all fields have defaults
ex5 = Exercise()
ex5
Exercise(name='Push-ups', reps=10, sets=3, weight=0)
Lưu ý rằng các trường không mặc định không thể đứng sau các trường mặc định. Ví dụ, đoạn mã dưới đây sẽ báo lỗi:
@dataclass
class Exercise:
name: str = "Push-ups"
reps: int = 10
sets: int = 3
weight: float # NOT ALLOWED
ex5 = Exercise()
ex5
TypeError: non-default argument 'weight' follows default argument
Trong thực tế, bạn hiếm khi định nghĩa mặc định bằng cú pháp name: type = value.
Thay vào đó, bạn sẽ dùng hàm field, cho phép kiểm soát nhiều hơn đối với từng định nghĩa trường:
from dataclasses import field
@dataclass
class Exercise:
name: str = field(default="Push-up")
reps: int = field(default=10)
sets: int = field(default=3)
weight: float = field(default=0)
# Now, all fields have defaults
ex5 = Exercise()
ex5
Exercise(name='Push-up', reps=10, sets=3, weight=0)
Hàm field có thêm nhiều tham số, chẳng hạn:
reprinitcomparedefault_factory
v.v. Chúng ta sẽ bàn tới trong các phần sau.
Có thể tạo data class bằng một hàm
Một ghi chú cuối về cơ bản của data class là định nghĩa của chúng còn có thể ngắn hơn nữa với hàm make_dataclass:
from dataclasses import make_dataclass
Exercise = make_dataclass(
"Exercise",
[
("name", str),
("reps", int),
("sets", int),
("weight", float),
],
)
ex3 = Exercise("Deadlifts", 8, 3, 69.0)
ex3
Exercise(name='Deadlifts', reps=8, sets=3, weight=69.0)
Nhưng bạn sẽ phải đánh đổi tính dễ đọc, nên tôi không khuyến nghị dùng hàm này.
Data Class nâng cao trong Python
Trong phần này, chúng ta sẽ bàn về các tính năng nâng cao của data class mang lại nhiều lợi ích hơn. Một trong số đó là default factory.
Default factory
Để giải thích default factory, hãy tạo một lớp khác tên là WorkoutSession nhận hai trường:
from dataclasses import dataclass
from typing import List
@dataclass
class Exercise:
name: str = "Push-ups"
reps: int = 10
sets: int = 3
weight: float = 0
@dataclass
class WorkoutSession:
exercises: List[Exercise]
duration_minutes: int
Bằng cách dùng kiểu List, ta chỉ định rằng WorkoutSession nhận một danh sách các thể hiện Exercise.
# Define the Exercise instances for HIIT training
ex1 = Exercise(name="Burpees", reps=15, sets=3)
ex2 = Exercise(name="Mountain Climbers", reps=20, sets=3)
ex3 = Exercise(name="Jump Squats", reps=12, sets=3)
exercises_monday = [ex1, ex2, ex3]
hiit_monday = WorkoutSession(exercises=exercises_monday, duration_minutes=30)
Hiện tại, mỗi thể hiện buổi tập đều cần có bài tập khi khởi tạo. Nhưng điều này không phản ánh cách mọi người tập — trước tiên họ bắt đầu một buổi (có lẽ trong ứng dụng), rồi thêm bài tập khi tập.
Vì vậy, chúng ta cần có thể tạo buổi tập không có bài tập và không có thời lượng. Hãy làm điều này bằng cách thêm danh sách rỗng làm mặc định cho exercises:
@dataclass
class WorkoutSession:
exercises: List[Exercise] = []
duration_minutes: int = None
hiit_monday = WorkoutSession("25-02-2024")
ValueError: mutable default <class 'list'> for field exercises is not allowed: use default_factory
Tuy nhiên, ta gặp lỗi — hóa ra data class không cho phép giá trị mặc định có thể thay đổi (mutable).
May mắn là ta có thể sửa bằng cách dùng default factory:
@dataclass
class WorkoutSession:
exercises: List[Exercise] = field(default_factory=list) # PAY ATTENTION
duration_minutes: int = 0
hiit_monday = WorkoutSession()
hiit_monday
WorkoutSession(exercises=[], duration_minutes=0)
Tham số default_factory nhận một hàm trả về giá trị khởi tạo cho một trường của data class. Tức là nó có thể nhận bất kỳ hàm tùy ý nào:
tupledictset- Bất kỳ hàm tùy biến do người dùng định nghĩa
Điều này đúng bất kể kết quả của hàm là có thể thay đổi hay không.
Giờ nghĩ kỹ, hầu hết mọi người bắt đầu tập bằng các bài khởi động khá giống nhau cho mọi kiểu buổi tập. Vậy khởi tạo buổi tập mà không có bài tập có thể không đúng với một số người.
Thay vào đó, hãy tạo một hàm trả về ba Exercise khởi động:
def create_warmup():
return [
Exercise("Jumping jacks", 30, 1),
Exercise("Squat lunges", 10, 2),
Exercise("High jumps", 20, 1),
]
@dataclass
class WorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5 # Increase the default duration as well
hiit_monday = WorkoutSession()
hiit_monday
WorkoutSession(exercises=[Exercise(name='Jumping jacks', reps=30, sets=1, weight=0), Exercise(name='Squat lunges', reps=10, sets=2, weight=0), Exercise(name='High jumps', reps=20, sets=1, weight=0)], duration_minutes=5)
Giờ đây, mỗi khi tạo buổi tập, chúng sẽ đi kèm một vài bài khởi động đã được ghi lại. Phiên bản mới của WorkoutSession có thời lượng mặc định năm phút để phản ánh điều đó.
Thêm phương thức vào data class
Vì data class là các lớp thông thường, việc thêm phương thức không thay đổi. Hãy thêm hai phương thức cho data class WorkoutSession của chúng ta:
@dataclass
class WorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
def add_exercise(self, exercise: Exercise):
self.exercises.append(exercise)
def increase_duration(self, minutes: int):
self.duration_minutes += minutes
Sử dụng các phương thức này, giờ ta có thể ghi lại mọi hoạt động mới vào một buổi tập:
hiit_monday = WorkoutSession()
# Log a new exercise
new_exercise = Exercise("Deadlifts", 6, 4, 60)
hiit_monday.add_exercise(new_exercise)
hiit_monday.increase_duration(15)
Nhưng có một vấn đề:
hiit_monday
WorkoutSession(exercises=[Exercise(name='Jumping jacks', reps=30, sets=1, weight=0), Exercise(name='Squat lunges', reps=10, sets=2, weight=0), Exercise(name='High jumps', reps=20, sets=1, weight=0), Exercise(name='Deadlifts', reps=6, sets=4, weight=60)], duration_minutes=20)
Khi in buổi tập, biểu diễn mặc định quá dài và khó đọc vì chứa mã để tái tạo đối tượng. Hãy sửa điều đó.
__repr__ và __str__ trong data class
Data class tự động triển khai __repr__ nhưng không có __str__. Vì vậy, khi gọi print, lớp sẽ dùng tạm __repr__.
Vậy hãy ghi đè hành vi này bằng cách tự định nghĩa __str__:
@dataclass
class Exercise:
name: str = "Push-ups"
reps: int = 10
sets: int = 3
weight: float = 0
def __str__(self):
base = f"{self.name}: {self.reps}/{self.sets}"
if self.weight == 0:
return base
return base + f", {self.weight} lbs"
ex1 = Exercise(name="Burpees", reps=15, sets=3)
ex1
Exercise(name='Burpees', reps=15, sets=3, weight=0)
__repr__ vẫn như cũ, nhưng khi gọi print:
print(ex1)
Burpees: 15/3
Biểu diễn dạng chuỗi của lớp dễ chịu hơn nhiều. Giờ hãy sửa cả WorkoutSession:
@dataclass
class WorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5 # Increase the default duration as well
def add_exercise(self, exercise: Exercise):
self.exercises.append(exercise)
def increase_duration(self, minutes: int):
self.duration_minutes += minutes
def __str__(self):
base = ""
for ex in self.exercises:
base += str(ex) + "\n"
base += f"\nSession duration: {self.duration_minutes} minutes."
return base
hiit_monday = WorkoutSession()
print(hiit_monday)
Jumping jacks: 30/1
Squat lunges: 10/2
High jumps: 20/1
Session duration: 5 minutes.
Lưu ý: Hãy dùng nút “Giải thích mã” ở cuối đoạn để xem giải thích từng dòng.
Giờ chúng ta đã có đầu ra gọn gàng và dễ đọc.
So sánh trong data class
Với nhiều lớp, việc so sánh các đối tượng theo một logic nào đó là hợp lý. Với các buổi tập, có thể so sánh theo thời lượng, cường độ bài tập hoặc mức tạ.
Trước hết, hãy xem điều gì xảy ra nếu ta thử so sánh hai buổi tập ở trạng thái hiện tại:
hiit_wednesday = WorkoutSession()
hiit_wednesday.add_exercise(Exercise("Pull-ups", 7, 3))
print(hiit_wednesday)
Jumping jacks: 30/1
Squat lunges: 10/2
High jumps: 20/1
Pull-ups: 7/3
Session duration: 5 minutes.
hiit_monday > hiit_wednesday
TypeError: '>' not supported between instances of 'WorkoutSession' and 'WorkoutSession'
Ta nhận TypeError vì data class không triển khai toán tử so sánh. Nhưng có thể khắc phục dễ dàng bằng cách đặt tham số order thành True:
@dataclass(order=True)
class WorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
...
hiit_monday = WorkoutSession()
# hiit_monday.add_exercise(...)
hiit_monday.increase_duration(10)
hiit_wednesday = WorkoutSession()
hiit_monday > hiit_wednesday
True
Lần này phép so sánh hoạt động, nhưng ta đang so sánh cái gì?
Trong data class, phép so sánh được thực hiện theo thứ tự các trường được định nghĩa. Lúc này, các lớp được so sánh dựa trên thời lượng buổi tập vì trường đầu tiên, exercises, chứa các đối tượng không chuẩn.
Ta có thể kiểm tra bằng cách tăng thời lượng buổi tập thứ Tư:
hiit_monday = WorkoutSession()
# hiit_monday.add_exercise(...)
hiit_wednesday = WorkoutSession()
hiit_wednesday.increase_duration(10)
hiit_monday > hiit_wednesday
False
Đúng như dự đoán, chúng ta nhận False.
Nhưng điều gì sẽ xảy ra nếu trường đầu tiên của Workout là kiểu khác, ví dụ một chuỗi? Hãy thử:
@dataclass(order=True)
class WorkoutSession:
date: str = None # DD-MM-YYYY
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
...
hiit_monday = WorkoutSession("25-02-2024")
hiit_monday.increase_duration(10)
hiit_wednesday = WorkoutSession("27-02-2024")
hiit_monday > hiit_wednesday
False
Dù buổi Thứ Hai kéo dài hơn, phép so sánh cho biết nó nhỏ hơn Thứ Tư. Lý do là “25” đứng trước “27” trong so sánh chuỗi của Python.
Vậy làm sao giữ nguyên thứ tự trường mà vẫn sắp xếp buổi tập theo thời lượng? Rất đơn giản với hàm field:
@dataclass(order=True)
class WorkoutSession:
date: str = field(default=None, compare=False)
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
...
hiit_monday = WorkoutSession("25-02-2024")
hiit_monday.increase_duration(10)
hiit_wednesday = WorkoutSession("27-02-2024")
hiit_monday > hiit_wednesday
True
Bằng cách đặt compare thành False cho bất kỳ trường nào, ta loại trừ nó khỏi việc so sánh, như kết quả trên cho thấy.
Xử lý trường sau khởi tạo (post-init)
Hiện tại, ta đặt thời lượng mặc định là năm phút để tính cho bài khởi động. Tuy nhiên, điều này chỉ hợp lý nếu người dùng bắt đầu bằng khởi động. Nếu họ bắt đầu bằng bài khác thì sao:
new_session = WorkoutSession([Exercise("Diamond push-ups", 10, 3)])
new_session.duration_minutes
5
Với chỉ một bài tập, tổng thời lượng là năm phút là không hợp lý. Mỗi buổi tập phải ước tính thời lượng động dựa trên số set của mỗi bài. Tức là ta nên làm cho duration_minutes phụ thuộc vào trường exercises.
Hãy triển khai:
@dataclass
class WorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = field(default=0, init=False)
def __post_init__(self):
set_duration = 3
for ex in self.exercises:
self.duration_minutes += ex.sets * set_duration
...
Lần này, ta định nghĩa duration_minutes với init đặt là False để trì hoãn khởi tạo trường.
Sau đó, trong phương thức đặc biệt __post_init__, ta cập nhật giá trị dựa trên tổng số set của mỗi Exercise.
Giờ khi khởi tạo WorkoutSession, duration_minutes sẽ tăng động thêm ba phút cho mỗi set trong mỗi bài tập.
# Adding an exercise with three sets
hiit_friday = WorkoutSession([Exercise("Diamond push-ups", 10, 3)])
hiit_friday.duration_minutes
9
Nhìn chung, nếu bạn muốn định nghĩa một trường phụ thuộc các trường khác của data class, bạn có thể dùng logic __post_init__.
Tính bất biến trong Data Class
Data class WorkoutSession của chúng ta gần như sẵn sàng; chỉ còn cần được bảo vệ. Hiện tại, nó có thể bị sửa bừa rất dễ:
hiit_friday.duration_minutes = 1000
hiit_friday.duration_minutes
1000
del hiit_friday.exercises
Chúng ta muốn bảo vệ mọi trường của lớp để chỉ có thể sửa theo cách ta mong muốn. Để làm điều này, decorator @dataclass cung cấp tham số tiện lợi frozen:
@dataclass(frozen=True)
class FrozenExercise:
name: str
reps: int
sets: int
weight: int | float = 0
ex1 = FrozenExercise("Muscle-ups", 5, 3)
Giờ nếu muốn sửa bất kỳ trường nào, ta sẽ nhận lỗi:
ex1.sets = 5
FrozenInstanceError: cannot assign to field 'sets'
Đặt frozen là True sẽ tự động thêm các phương thức __deleteattr__ và __setattr__ cho mỗi trường để ngăn xóa hoặc cập nhật sau khi khởi tạo. Ngoài ra, người khác cũng không thể thêm trường mới:
ex1.new_field = 10
FrozenInstanceError: cannot assign to field 'new_field'
Chức năng này sẽ tốn hàng chục dòng nếu chúng ta dùng lớp truyền thống.
Tuy nhiên, lưu ý rằng ta không thể làm cho lớp thực sự bất biến. Ví dụ, hãy viết lại WorkoutSession với frozen là True:
@dataclass(frozen=True)
class ImmutableWorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
session1 = ImmutableWorkoutSession()
Như dự đoán, ta không thể sửa trực tiếp danh sách bài tập:
session1.exercises = [Exercise()]
Tuy nhiên, exercises là một danh sách, hoàn toàn có thể thay đổi, khiến thao tác sau khả thi:
# Thay đổi một phần tử trong danh sách
# Changing one of the elements in a list
session1.exercises[1] = FrozenExercise("Totally new exercise", 5, 5)
print(session1)
ImmutableWorkoutSession(exercises=[Exercise(name='Jumping jacks', reps=30, sets=1, weight=0), FrozenExercise(name='Totally new exercise', reps=5, sets=5, weight=0), Exercise(name='High jumps', reps=20, sets=1, weight=0)], duration_minutes=5)
Vì vậy, để tránh thay đổi ngoài ý muốn, khuyến nghị dùng các đối tượng bất biến như tuple cho giá trị trường.
Kế thừa trong data class
Điểm cuối cùng chúng ta sẽ đề cập là thứ tự các trường trong lớp cha và lớp con.
Vì data class là các lớp thông thường, kế thừa hoạt động như bình thường:
@dataclass(frozen=True)
class ImmutableWorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
@dataclass(frozen=True)
class CardioWorkoutSession(ImmutableWorkoutSession):
pass
Nhưng vì trường cuối trong lớp cha (ImmutableWorkoutSession) có giá trị mặc định, tất cả trường trong lớp con cũng phải có giá trị mặc định.
Ví dụ, điều này là không được phép:
@dataclass(frozen=True)
class ImmutableWorkoutSession:
exercises: List[Exercise] = field(default_factory=create_warmup)
duration_minutes: int = 5
@dataclass(frozen=True)
class CardioWorkoutSession(ImmutableWorkoutSession):
intensity_level: str # Not allowed, must have a default
TypeError: non-default argument 'intensity_level' follows default argument
Nhược điểm của Data Class và Tài nguyên tham khảo
Data class đã liên tục được cải thiện kể từ Python 3.7 (vốn dĩ đã rất tốt) và bao phủ nhiều trường hợp sử dụng khi bạn cần viết lớp. Nhưng chúng có thể không phù hợp trong các tình huống sau:
__init__tùy chỉnh__new__tùy chỉnh- Nhiều mô hình kế thừa khác nhau
Và còn nhiều nữa, như đã thảo luận trong chủ đề Reddit rất hay này. Nếu bạn muốn lý do chi tiết hơn về việc tại sao data class được giới thiệu và vì sao chúng không phải là sự thay thế “thả vào là chạy” cho định nghĩa lớp truyền thống, hãy đọc PEP 557.
Nếu bạn quan tâm đến lập trình hướng đối tượng nói chung, đây là một khóa học để tiếp tục hành trình:
Về bản chất, data class là các cấu trúc “sang chảnh” hơn để lưu trữ và truy xuất dữ liệu hiệu quả hơn. Tuy nhiên, Python có nhiều cấu trúc dữ liệu khác thực hiện nhiệm vụ này theo cách tương tự. Ví dụ, bạn có thể tìm hiểu về counter, defaultdict và namedtuple trong chương cuối của khóa Data Types for Data Science.

Tôi là người sáng tạo nội dung về khoa học dữ liệu với hơn 2 năm kinh nghiệm và là một trong những tài khoản có lượng theo dõi lớn nhất trên Medium. Tôi thích viết các bài chuyên sâu về AI và ML với chút giọng điệu mỉa mai, vì bạn cũng phải làm gì đó để chúng bớt nhàm chán. Tôi đã xuất bản hơn 130 bài viết và một khóa học trên DataCamp, và đang ấp ủ thêm một khóa nữa. Nội dung của tôi đã tiếp cận hơn 5 triệu lượt xem, trong đó có 20 nghìn người trở thành người theo dõi trên cả Medium và LinkedIn.