# Class và OOP trong Python: __init__, self, phương thức, kế thừa và dataclass

URL: https://skillaria.top/vi/guides/python/lop-python-oop

OOP trong Python xây dựng trên class: class mô tả các trường và phương thức, còn đối tượng (instance) được tạo bằng cách gọi class; __init__ gán giá trị cho các trường, còn self trỏ tới đối tượng hiện tại. Kế thừa cho phép dùng lại hành vi của lớp cha và ghi đè một phần phương thức, còn decorator @dataclass sinh sẵn __init__, __repr__ và __eq__ cho các class chủ yếu lưu dữ liệu.

Class trong Python giống như một bản thiết kế: nó mô tả đối tượng lưu những dữ liệu gì và làm được những việc gì với chúng. Đối tượng (instance) là sản phẩm cụ thể được tạo theo bản thiết kế đó. OOP trong Python dựa trên ba thứ: class với các phương thức, kế thừa, và các phương thức đặc biệt như `__init__` và `__repr__`. Dưới đây là toàn bộ chủ đề, theo một ví dụ xuyên suốt — quản lý chi tiêu.

## Vì sao cần class khi đã có dict

Dữ liệu có thể lưu trong dict: `{"title": "Cà phê", "amount": 250}`. Vấn đề là dict không biết khóa nào là đúng. Gõ nhầm `expense.get("amout", 0)` sẽ không báo lỗi — nó trả về số 0, và báo cáo âm thầm sai.

Với đối tượng của class, các trường được khai báo trước. Truy cập một thuộc tính không tồn tại sẽ lỗi ngay với `AttributeError`, và Python hiện đại còn gợi ý tên đúng. Ngoài ra class gom dữ liệu và thao tác trên dữ liệu vào cùng một chỗ: khoản chi tự tính được số tiền có thuế, báo cáo tự tính tổng.

## Cách tạo class trong Python

Class được khai báo bằng từ khóa `class`, tên theo quy ước PEP 8 viết kiểu CamelCase. Ví dụ class đơn giản nhất:

```
class Expense:
    def __init__(self, title, amount):
        self.title = title
        self.amount = amount

coffee = Expense("Cà phê", 250)
taxi = Expense("Taxi", 300)
print(coffee.title, coffee.amount)   # Cà phê 250
```

Lời gọi `Expense("Cà phê", 250)` tạo một instance mới. `coffee` và `taxi` là hai đối tượng khác nhau cùng kiểu, mỗi cái có giá trị trường riêng. Kiểm tra một đối tượng thuộc class nào bằng `isinstance(coffee, Expense)` — cho `True`.

### Constructor __init__: khởi tạo class

`__init__` thường được gọi là constructor của class, nhưng chính xác hơn là bộ khởi tạo. Việc tạo đối tượng do phương thức `__new__` đảm nhiệm, còn `__init__` được gọi ngay sau đó để đưa các tham số vào các trường. Nó không cần trả về gì (chính xác hơn: phải trả về `None`).

Trong `__init__`, rất tiện để kiểm tra dữ liệu đầu vào, để chương trình không bao giờ có đối tượng sai ngay từ đầu:

```
class Expense:
    def __init__(self, title, amount):
        if amount < 0:
            raise ValueError("Số tiền không thể âm")
        self.title = title
        self.amount = amount
```

### self trong Python là gì

`self` là tham chiếu tới chính đối tượng mà phương thức đang làm việc. Khi viết `coffee.with_tax()`, Python thực chất gọi `Expense.with_tax(coffee)` và truyền đối tượng làm tham số đầu tiên. Vì vậy trong khai báo phương thức, `self` luôn đứng đầu, còn khi gọi thì không viết.

`self` không phải từ khóa mà là quy ước: về kỹ thuật tham số có thể đặt tên khác, nhưng không ai làm vậy. Lỗi hay gặp của người mới là quên `self` trong khai báo phương thức. Khi đó lời gọi sẽ lỗi với `TypeError: Expense.with_tax() takes 0 positional arguments but 1 was given`.

## Phương thức của class và phương thức đặc biệt

Phương thức là hàm được khai báo bên trong class. Nó nhìn thấy các trường của đối tượng thông qua `self`:

```
class Expense:
    def __init__(self, title, amount):
        self.title = title
        self.amount = amount

    def with_tax(self, rate=0.2):
        return self.amount * (1 + rate)

    def __repr__(self):
        return f"Expense({self.title!r}, {self.amount})"

coffee = Expense("Cà phê", 250)
print(coffee.with_tax())   # 300.0
print(coffee)              # Expense('Cà phê', 250)
```

Không có `__repr__`, kết quả in ra sẽ là `<__main__.Expense object at 0x7f...>` — không có ích gì cho việc gỡ lỗi. Các phương thức có hai dấu gạch dưới ở cả hai đầu được gọi là phương thức đặc biệt (dunder): Python tự gọi chúng. `__str__` chịu trách nhiệm hiển thị «đẹp» cho người dùng, `__eq__` so sánh bằng `==`, `__len__` phục vụ `len(obj)`.

### @classmethod và @staticmethod

Phương thức thông thường làm việc với instance. Phương thức của class với decorator `@classmethod` nhận chính class (`cls`) làm tham số đầu tiên — thường dùng như một constructor thay thế. Phương thức tĩnh `@staticmethod` không nhận cả instance lẫn class: đó là một hàm thông thường nhưng về mặt logic thuộc về class.

```
class Expense:
    def __init__(self, title, amount):
        self.title = title
        self.amount = amount

    @classmethod
    def from_csv(cls, line):
        title, amount = line.split(";")
        return cls(title, float(amount))

    @staticmethod
    def is_valid_amount(value):
        return value >= 0

e = Expense.from_csv("Cơm trưa;520")
print(e.amount)                    # 520.0
print(Expense.is_valid_amount(-1)) # False
```

### Destructor của class: __del__

Python có phương thức `__del__`, được gọi trước khi đối tượng bị hủy bởi bộ thu gom rác. Không nên dựa vào nó để giải phóng tài nguyên: thời điểm gọi không được đảm bảo. Với tệp và kết nối, hãy dùng trình quản lý ngữ cảnh `with` hoặc gọi tường minh phương thức `close()`.

## Thuộc tính của class và thuộc tính của instance

Các trường gán qua `self.name = ...` thuộc về một đối tượng cụ thể. Biến khai báo ngay trong thân class là thuộc tính của class: chúng chung cho mọi instance. Thường dùng cho các hằng số của class.

```
class Expense:
    CURRENCY = "VND"          # thuộc tính của class, hằng số

    def __init__(self, title, amount):
        self.title = title    # thuộc tính của instance
        self.amount = amount

a = Expense("Cà phê", 250)
b = Expense("Trà", 150)
print(a.CURRENCY, Expense.CURRENCY)   # VND VND
```

Cạm bẫy nguy hiểm là thuộc tính class có thể thay đổi. Nếu khai báo trong thân class `rows = []` và thêm phần tử qua `self.rows.append(...)`, danh sách sẽ được dùng chung cho mọi đối tượng. Các trường có thể thay đổi luôn phải tạo trong `__init__`.

Xem các trường của đối tượng bằng `vars(obj)` hoặc `obj.__dict__`, còn đọc thuộc tính theo tên lưu trong chuỗi bằng `getattr(obj, "amount")`.

## Property của class: @property

Property là một phương thức mà từ bên ngoài được đọc như một trường thông thường, không có dấu ngoặc. Điều này tiện cho các giá trị tính toán: tổng của báo cáo không cần lưu riêng và không bao giờ bị quên cập nhật.

```
class Report:
    def __init__(self):
        self.rows = []

    def add(self, expense):
        self.rows.append(expense)

    @property
    def total(self):
        return sum(e.amount for e in self.rows)

r = Report()
r.add(Expense("Cà phê", 250))
r.add(Expense("Taxi", 300))
print(r.total)   # 550
```

Nếu cần kiểm soát việc ghi, thêm setter bằng `@total.setter`. Không có setter, phép gán `r.total = 0` sẽ gây `AttributeError`.

Về đóng gói: Python không có trường private theo nghĩa nghiêm ngặt. Một dấu gạch dưới (`_cache`) là quy ước «đừng đụng vào từ bên ngoài», còn hai dấu (`__secret`) kích hoạt name mangling — tên được đổi để trường không vô tình bị lớp con ghi đè.

## Kế thừa class trong Python

Kế thừa cho phép tạo class con dựa trên class cha: con nhận mọi trường và phương thức, và có thể ghi đè những phương thức cần thay đổi. Class cha được ghi trong dấu ngoặc.

```
class MonthlyReport(Report):
    def __init__(self, month):
        super().__init__()        # gọi __init__ của cha
        self.month = month

    def add(self, expense):       # ghi đè phương thức
        print(f"[{self.month}] + {expense}")
        super().add(expense)      # giữ hành vi của cha

m = MonthlyReport("tháng 10")
m.add(Expense("Cà phê", 250))   # [tháng 10] + Expense('Cà phê', 250)
print(m.total)                  # 250 — property được kế thừa
```

`super()` trả về một đối tượng trung gian để gọi phương thức của cha. Quên `super().__init__()` trong `__init__` của lớp con là lỗi kinh điển: trường `rows` không được tạo, và lần `add` đầu tiên sẽ lỗi `AttributeError`.

Kiểm tra quan hệ kế thừa bằng `issubclass(MonthlyReport, Report)`. Mọi class trong Python 3 đều kế thừa ngầm từ `object` — đó là nguồn gốc của `__repr__`, `__eq__` mặc định và các phương thức khác. Python cũng hỗ trợ đa kế thừa (`class C(A, B)`); thứ tự tìm phương thức được thể hiện qua `C.__mro__`.

### Abstract class trong Python

Abstract class định nghĩa một giao diện: các lớp con bắt buộc phải cài đặt những phương thức nào. Không thể tạo instance của nó. Công cụ cho việc này là module `abc`:

```
from abc import ABC, abstractmethod

class Exporter(ABC):
    @abstractmethod
    def export(self, report): ...

class CsvExporter(Exporter):
    def export(self, report):
        return "\n".join(f"{e.title};{e.amount}" for e in report.rows)

Exporter()   # TypeError: Can't instantiate abstract class Exporter ...
```

Lớp con không cài đặt đủ các phương thức trừu tượng cũng vẫn là trừu tượng, và khi tạo đối tượng sẽ gặp đúng lỗi đó.

## Composition thay cho cây kế thừa sâu

Sau khi học kế thừa, người ta dễ muốn xây những chuỗi lớp: khoản chi cơ bản, khoản chi bằng tiền, khoản chi bằng rúp — rồi năm tầng cứ thế tiếp tục. Trong Python người ta hiếm khi viết như vậy. Kế thừa mô tả quan hệ «là một», còn composition mô tả quan hệ «chứa»: báo cáo không phải là một danh sách chi tiêu, nó chứa danh sách đó trong trường `rows`.

Quy tắc thực tế: nếu chỉ cần dùng một khả năng của đối tượng khác, hãy đặt đối tượng đó vào một trường thay vì kế thừa. Với dữ liệu, một dataclass thường là đủ, còn logic có thể để trong các hàm thông thường.

## Dataclass trong Python: class không cần viết code lặp

Với các class chủ yếu lưu dữ liệu, tự viết `__init__`, `__repr__` và `__eq__` rất mệt. Decorator `@dataclass` từ module `dataclasses` sinh ra chúng từ các chú thích kiểu của trường:

```
from dataclasses import dataclass, field

@dataclass
class Expense:
    title: str
    amount: float
    tags: list[str] = field(default_factory=list)

    def __post_init__(self):
        if self.amount < 0:
            raise ValueError("Số tiền không thể âm")

e = Expense("Cà phê", 250)
print(e)                           # Expense(title='Cà phê', amount=250, tags=[])
print(e == Expense("Cà phê", 250))   # True
```

Kiểm tra giá trị từng được viết trong `__init__` thì giờ chuyển sang `__post_init__` — nó được gọi ngay sau bộ khởi tạo do dataclass sinh ra. Giá trị mặc định kiểu thay đổi được chỉ khai báo qua `field(default_factory=list)`: viết `tags: list = []` thì dataclass không cho phép và sẽ báo `ValueError`.

### frozen và slots

Tham số `frozen=True` làm đối tượng bất biến, còn `slots=True` (từ Python 3.10) tạo `__slots__` và tiết kiệm bộ nhớ. Cố thay đổi trường của đối tượng bất biến sẽ gây `FrozenInstanceError`. Để có đối tượng với giá trị mới, người ta tạo bản sao:

```
from dataclasses import dataclass, replace

@dataclass(frozen=True, slots=True)
class Expense:
    title: str
    amount: float

e = Expense("Cà phê", 250)
# e.amount = 300   -> dataclasses.FrozenInstanceError
e2 = replace(e, amount=300)
print(e, e2)   # Expense(title='Cà phê', amount=250) Expense(title='Cà phê', amount=300)
```

Từ Python 3.13, hàm đa năng `copy.replace()` làm được điều tương tự. Các đối tượng dataclass bất biến có thể đưa vào set và dùng làm khóa dict — chúng có sẵn `__hash__`.

## Có gì mới trong class: Self, @override, __slots__

Để gõ kiểu cho class có hai công cụ hữu ích. `typing.Self` (Python 3.11, PEP 673) nghĩa là «trả về đối tượng cùng class» — hoạt động cả trong các phương thức được kế thừa. Decorator `@override` (Python 3.12, PEP 698) đánh dấu phương thức bắt buộc phải ghi đè phương thức của cha: nếu bạn gõ nhầm tên, mypy hoặc pyright sẽ báo không có phương thức gốc nào như vậy.

```
from typing import Self, override

class Report:
    def __init__(self):
        self.rows = []

    def add(self, e) -> Self:
        self.rows.append(e)
        return self              # có thể gọi nối tiếp

class MonthlyReport(Report):
    @override
    def add(self, e) -> Self:
        return super().add(e)
```

Trong Python 3.15 đã gỡ một hạn chế hẹp: lớp con của `tuple` được phép có `__slots__` không rỗng, còn `__dict__` và `__weakref__` có thể khai báo trong `__slots__` của bất kỳ class nào. Ở 3.14, khai báo như vậy ở lớp con của tuple gây `TypeError: nonempty __slots__ not supported for subtype of 'tuple'`.

## Lỗi thường gặp khi làm việc với class

| Lỗi | Triệu chứng | Cách sửa |
| --- | --- | --- |

| Thiếu `self` trong phương thức | `TypeError: ... takes 0 positional arguments but 1 was given` | Tham số đầu tiên của phương thức luôn là `self` |

| Trường trong `__init__` không có `self.` | Giá trị biến mất sau khi `__init__` kết thúc | Viết `self.amount = amount` |

| Danh sách làm thuộc tính class | Dữ liệu «chảy» giữa các đối tượng | Tạo danh sách trong `__init__` hoặc qua `default_factory` |

| Thiếu `super().__init__()` | `AttributeError` với trường của lớp cha | Gọi bộ khởi tạo của lớp cha |

| Gọi property có dấu ngoặc | `TypeError: 'int' object is not callable` | Đọc property không có ngoặc: `r.total` |

## Tổng kết

OOP trong Python bắt đầu từ điều đơn giản: class mô tả trường và phương thức, `__init__` điền giá trị vào trường, còn `self` trỏ tới đối tượng hiện tại. Kế thừa và `super()` cho phép mở rộng hành vi, abstract class định nghĩa giao diện, còn `@property` tạo các trường tính toán. Với class chủ yếu chứa dữ liệu, hãy dùng `@dataclass`, và với cấu trúc phức tạp, ưu tiên composition hơn là cây kế thừa sâu. Chủ đề này được thực hành trong bài giảng khóa học Python qua ví dụ trình theo dõi chi tiêu.

## FAQ

**Class khác đối tượng trong Python ở điểm nào?**

Class là mô tả kiểu: nó có những trường và phương thức nào. Đối tượng (instance) được tạo bằng cách gọi class, ví dụ Expense("Cà phê", 250), và lưu những giá trị cụ thể của các trường.

**Có bắt buộc phải viết __init__ không?**

Không. Nếu không khai báo __init__, class dùng bản kế thừa từ object, và nó không nhận tham số nào. Với dataclass, decorator sẽ tự sinh __init__.

**__str__ khác __repr__ ở điểm nào?**

__repr__ là biểu diễn không nhập nhằng dành cho lập trình viên và trình gỡ lỗi; __str__ là cách hiển thị dễ đọc cho người dùng khi dùng print và f-string. Nếu không có __str__, Python dùng __repr__.

**Khi nào chọn dataclass, khi nào chọn class thường?**

Dataclass phù hợp khi class chủ yếu lưu dữ liệu và cần sẵn __init__, __repr__ cùng khả năng so sánh. Class thường tiện hơn khi logic khởi tạo phức tạp hoặc hành vi quan trọng hơn dữ liệu.

**Python có trường private không?**

Không có tính private nghiêm ngặt. Một dấu gạch dưới ở đầu tên là quy ước «không dành cho dùng bên ngoài», còn hai dấu gạch dưới kích hoạt name mangling, bảo vệ trường khỏi việc bị ghi đè ngẫu nhiên trong lớp con.


---
Source: Skillaria.Top (https://skillaria.top)
