Python 5 — OOP & Clean Code
Mở đầu
Lập trình hướng đối tượng (OOP — Object-Oriented Programming) là cách tổ chức
code xoay quanh các "đối tượng" — những thực thể gói chung dữ liệu (thuộc
tính) và hành vi (phương thức). Thay vì rải các biến và hàm rời rạc khắp file,
ta nhóm chúng lại theo khái niệm có ý nghĩa trong miền bài toán: một User, một
BankAccount, một Order.
Bài này đi sâu từ con số không (class là gì) tới mức "hero" (dunder methods,
dataclass, type hints, SOLID, công cụ chuẩn hoá code). Mọi ví dụ đều bám sát tình
huống thực tế chứ không chỉ là Animal hay Dog cho có.
Class & instance: viên gạch đầu tiên
Một class là bản thiết kế (blueprint). Một instance (thể hi) là một vật
thể cụ thể được đúc ra từ bản thiết kế đó. Lớp Car mô tả "xe nói chung"; còn
my_car = Car(...) là chiếc xe cụ thể của bạn.
class BankAccount:
"""Một tài khoản ngân hàng đơn giản."""
def __init__(self, owner: str, balance: float = 0.0) -> None:
self.owner = owner # thuộc tính của instance
self.balance = balance
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Số tiền nạp phải dương")
self.balance += amount
def withdraw(self, amount: float) -> None:
if amount > self.balance:
raise ValueError("Số dư không đủ")
self.balance -= amount
acc = BankAccount("Lan", 100_000) # tạo instance
acc.deposit(50_000)
acc.withdraw(30_000)
print(acc.balance) # 120000
__init__ và self
__init__là constructor — chạy ngay sau khi instance được tạo, dùng để khởi tạo trạng thái ban đầu. Nó không "tạo" object (việc đó do__new__lo), mà chỉ thiết lập thuộc tính.selflà tham chiếu tới chính instance đang gọi phương thức. Nó luôn là tham số đầu tiên của phương thức instance. Khi viếtacc.deposit(50_000), Python tự dịch thànhBankAccount.deposit(acc, 50_000)—accchính làself.
Thuộc tính class vs thuộc tính instance
Thuộc tính khai báo trong thân class (ngoài phương thức) được chia sẻ cho mọi
instance; còn self.x = ... là riêng từng instance.
class BankAccount:
bank_name = "NCB" # thuộc tính class — dùng chung
def __init__(self, owner: str) -> None:
self.owner = owner # thuộc tính instance — riêng
a = BankAccount("Lan")
b = BankAccount("Hùng")
print(a.bank_name, b.bank_name) # NCB NCB (chung)
BankAccount.bank_name = "NCB Plus"
print(a.bank_name) # NCB Plus (đổi 1 lần, áp cho tất cả)
Lưu ý: đừng dùng thuộc tính class kiểu mutable (list, dict) làm giá trị mặc định chung, vì mọi instance sẽ vô tình chia sẻ cùng một đối tượng.
Kế thừa & super()
Kế thừa (inheritance) cho phép một lớp con tái sử dụng và mở rộng lớp cha. Quan
hệ "là một" (is-a): SavingsAccount là một BankAccount.
class SavingsAccount(BankAccount):
def __init__(self, owner: str, balance: float = 0.0,
rate: float = 0.05) -> None:
super().__init__(owner, balance) # gọi __init__ của lớp cha
self.rate = rate
def add_interest(self) -> None:
self.balance += self.balance * self.rate
s = SavingsAccount("Mai", 1_000_000, rate=0.06)
s.add_interest()
print(s.balance) # 1060000.0
s.deposit(40_000) # kế thừa được từ BankAccount
super() trả về một "proxy" tới lớp cha, giúp gọi phương thức cha mà không
hard-code tên lớp. Điều này quan trọng khi có đa kế thừa: super() tuân theo
MRO (Method Resolution Order) — thứ tự Python tìm phương thức theo thuật toán
C3. Bạn có thể xem MRO bằng SavingsAccount.__mro__.
Ưu tiên composition (chứa đối tượng khác làm thuộc tính) hơn kế thừa sâu khi có thể. Kế thừa nhiều tầng dễ trở nên cứng nhắc và khó suy luận.
Đa hình & duck typing
Đa hình (polymorphism): cùng một lời gọi phương thức cho ra hành vi khác nhau tuỳ kiểu đối tượng. Python theo triết lý duck typing: "nếu nó kêu quạc như vịt thì coi nó là vịt" — không quan tâm kiểu cụ thể, chỉ cần có phương thức cần dùng.
class Circle:
def __init__(self, r: float) -> None:
self.r = r
def area(self) -> float:
return 3.14159 * self.r ** 2
class Rectangle:
def __init__(self, w: float, h: float) -> None:
self.w, self.h = w, h
def area(self) -> float:
return self.w * self.h
def total_area(shapes) -> float:
# Không cần Circle/Rectangle kế thừa cùng cha — chỉ cần có .area()
return sum(shape.area() for shape in shapes)
print(total_area([Circle(2), Rectangle(3, 4)])) # 24.566...
Khi muốn quy ước một "giao diện" chung rõ ràng, dùng abc.ABC (abstract base
class) với @abstractmethod để buộc lớp con phải hiện thực phương thức.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
Encapsulation: public / _protected / __private
Python không có từ khoá private cứng như Java. Thay vào đó là quy ước:
| Cú pháp | Ý nghĩa quy ước |
|---|---|
name | public — dùng tự do |
_name | "protected" — nội bộ, đừng đụng từ ngoài |
__name | "private" — bị name mangling thành _Class__name |
class Wallet:
def __init__(self) -> None:
self.currency = "VND" # public
self._fee = 0.01 # nội bộ (chỉ là quy ước)
self.__pin = "1234" # bị đổi tên thành _Wallet__pin
w = Wallet()
print(w.currency) # OK
print(w._fee) # chạy được nhưng "không nên" đụng
# print(w.__pin) # AttributeError
print(w._Wallet__pin) # "1234" — name mangling, không phải bảo mật thật
Mục tiêu của encapsulation là che giấu chi tiết và tạo ranh giới rõ ràng giữa
"API công khai" và "ruột bên trong", chứ không phải bảo mật. Hai gạch dưới __
chủ yếu để tránh xung đột tên khi kế thừa.
Dunder / magic methods
Các phương thức dạng __xxx__ ("double underscore" = dunder) cho phép object của
bạn tích hợp với cú pháp và hàm dựng sẵn của Python.
class Money:
def __init__(self, amount: int, currency: str = "VND") -> None:
self.amount = amount
self.currency = currency
def __repr__(self) -> str:
# Dành cho lập trình viên: nên tái tạo được object
return f"Money(amount={self.amount}, currency={self.currency!r})"
def __str__(self) -> str:
# Dành cho người dùng cuối: dễ đọc
return f"{self.amount:,} {self.currency}"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented
return (self.amount, self.currency) == (other.amount, other.currency)
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("Không cộng được hai loại tiền khác nhau")
return Money(self.amount + other.amount, self.currency)
m = Money(1_000_000)
print(str(m)) # 1,000,000 VND (__str__)
print(repr(m)) # Money(amount=1000000, currency='VND')
print(Money(50) == Money(50)) # True (__eq__)
print(Money(50) + Money(70)) # Money(...120...) (__add__)
Một số dunder hay dùng:
__repr__/__str__: biểu diễn chuỗi (debug vs hiển thị).__eq__,__lt__,__hash__: so sánh và dùng làm key trong set/dict.__len__: hỗ trợlen(obj).__getitem__,__setitem__: hỗ trợobj[key].__iter__,__next__: hỗ trợ vòng lặpfor.__enter__,__exit__: hỗ trợwith(context manager).
class Playlist:
def __init__(self, songs: list[str]) -> None:
self._songs = songs
def __len__(self) -> int:
return len(self._songs)
def __getitem__(self, i: int) -> str:
return self._songs[i]
p = Playlist(["A", "B", "C"])
print(len(p)) # 3
print(p[1]) # B
for song in p: # __getitem__ cũng đủ để lặp
print(song)
@property: getter/setter kiểu Pythonic
@property biến một phương thức thành thuộc tính "ảo", cho phép kiểm soát truy cập
mà giao diện vẫn trông như thuộc tính thường (không cần get_x() / set_x()).
class Temperature:
def __init__(self, celsius: float = 0.0) -> None:
self._celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("Dưới độ không tuyệt đối")
self._celsius = value
@property
def fahrenheit(self) -> float: # thuộc tính chỉ-đọc, tính toán
return self._celsius * 9 / 5 + 32
t = Temperature(25)
print(t.fahrenheit) # 77.0 — gọi như thuộc tính, không phải t.fahrenheit()
t.celsius = 30 # đi qua setter, được kiểm tra
# t.celsius = -300 # ValueError
@classmethod & @staticmethod
@classmethod: nhậncls(chính lớp) thay vìself. Thường dùng làm factory (constructor thay thế).@staticmethod: không nhậnselflẫncls— chỉ là một hàm tiện ích đặt trong namespace của lớp.
from datetime import date
class User:
def __init__(self, name: str, birth_year: int) -> None:
self.name = name
self.birth_year = birth_year
@classmethod
def from_age(cls, name: str, age: int) -> "User":
# factory: tạo User từ tuổi thay vì năm sinh
return cls(name, date.today().year - age)
@staticmethod
def is_adult(age: int) -> bool:
return age >= 18
u = User.from_age("Bình", 20) # classmethod -> cls = User
print(u.birth_year) # 2006
print(User.is_adult(16)) # False — không cần instance
@classmethod tôn trọng kế thừa: nếu Admin(User) gọi Admin.from_age(...) thì
cls là Admin, tạo đúng kiểu con.
dataclass: bớt code lặp
Với những lớp chủ yếu để chứa dữ liệu, viết tay __init__, __repr__, __eq__
rất nhàm. @dataclass sinh tự động chúng cho bạn.
from dataclasses import dataclass, field
@dataclass
class Product:
name: str
price: float
tags: list[str] = field(default_factory=list) # tránh mutable mặc định
in_stock: bool = True
p = Product("Bàn phím", 590_000, tags=["gear"])
print(p) # Product(name='Bàn phím', price=590000.0, ...)
print(p == Product("Bàn phím", 590_000, tags=["gear"])) # True
Các tuỳ chọn hữu ích:
@dataclass(frozen=True): tạo object bất biến (immutable) và hashable, dùng được làm key trong dict/set.@dataclass(order=True): tự sinh__lt__,__le__... để so sánh và sắp xếp.field(default_factory=list): dùng cho giá trị mặc định mutable (KHÔNG viếttags: list = []vì sẽ chia sẻ chung một list).
Type hints & mypy
Type hints (PEP 484) thêm chú thích kiểu cho biến, tham số, giá trị trả về. Python không ép kiểu lúc chạy — hint chủ yếu phục vụ con người, IDE và công cụ kiểm tra tĩnh như mypy / pyright.
from typing import Optional
def find_user(users: dict[int, str], uid: int) -> Optional[str]:
# Optional[str] == str | None
return users.get(uid)
def total(prices: list[float]) -> float:
return sum(prices)
Cú pháp hiện đại (Python 3.10+):
list[int],dict[str, int]thay choList,Dicttừtyping.int | Nonethay choOptional[int].from __future__ import annotationscho phép dùng tên lớp chưa định nghĩa.
Chạy kiểm tra: mypy app.py. mypy bắt được lỗi như truyền str vào chỗ cần
int, gọi phương thức không tồn tại, hoặc quên xử lý None — trước khi code chạy.
Sơ đồ lớp
Nguyên tắc clean code
Code chạy đúng là chưa đủ — nó còn phải dễ đọc, dễ sửa. Code được đọc nhiều hơn được viết.
Đặt tên có ý nghĩa
# Tệ
def calc(d, r):
return d * (1 + r)
# Tốt
def apply_interest(principal: float, rate: float) -> float:
return principal * (1 + rate)
Tên biến/hàm nên nói rõ ý định. Tránh viết tắt khó hiểu, tránh data, tmp,
x cho biến quan trọng.
Hàm nhỏ, làm một việc
Mỗi hàm nên làm một việc ở một mức trừu tượng. Nếu phải cuộn màn hình mới đọc hết, hoặc tên hàm có chữ "and", hãy tách ra. Hàm nhỏ dễ đặt tên, dễ test, dễ tái dùng.
DRY (Don't Repeat Yourself)
Tránh lặp logic. Khi thấy copy-paste, hãy trích thành hàm/lớp dùng chung. (Nhưng đừng trừu tượng hoá quá sớm — hai đoạn trông giống nhau chưa chắc là một khái niệm.)
SOLID (giới thiệu)
Năm nguyên tắc thiết kế hướng đối tượng:
- S — Single Responsibility: mỗi lớp một lý do để thay đổi.
- O — Open/Closed: mở để mở rộng, đóng để sửa đổi (thêm hành vi qua kế thừa/composition, không sửa code cũ).
- L — Liskov Substitution: lớp con phải thay thế được lớp cha mà không phá vỡ hành vi mong đợi.
- I — Interface Segregation: nhiều giao diện nhỏ tốt hơn một giao diện to cồng kềnh.
- D — Dependency Inversion: phụ thuộc vào trừu tượng, không vào hiện thực cụ thể (truyền dependency vào thay vì tự tạo bên trong).
Ở mức này, chỉ cần ghi nhớ tinh thần: mỗi thứ một trách nhiệm rõ ràng, và phụ thuộc lỏng lẻo (loose coupling).
PEP8 & công cụ: ruff, black
PEP8 là hướng dẫn phong cách chính thức của Python: thụt lề 4 dấu cách, dòng
≤ 79 (nhiều team dùng 88/100), snake_case cho hàm/biến, PascalCase cho lớp,
UPPER_CASE cho hằng, khoảng trắng quanh toán tử...
Thay vì nhớ và canh tay, dùng công cụ:
- black — code formatter "không thoả hiệp": tự định dạng lại file theo một phong cách thống nhất. Hết tranh cãi về dấu cách.
- ruff — linter (và formatter) cực nhanh viết bằng Rust: bắt lỗi style, import thừa, biến không dùng, và nhiều quy tắc khác. Có thể thay cho flake8 + isort + nhiều plugin.
pip install ruff black
black app.py # tự định dạng
ruff check app.py # tìm lỗi/style
ruff check --fix . # tự sửa những lỗi sửa được
Tích hợp vào pre-commit hoặc CI để mọi commit đều sạch và đồng nhất, không phụ
thuộc thói quen từng người.
Tóm tắt
- Class là blueprint, instance là vật thể cụ thể;
__init__khởi tạo trạng thái,selftrỏ tới chính instance. - Kế thừa tái sử dụng lớp cha,
super()gọi phương thức cha theo MRO; ưu tiên composition khi kế thừa trở nên sâu. - Đa hình & duck typing: chỉ cần object có phương thức cần dùng;
abcdùng khi muốn giao diện rõ ràng. - Encapsulation trong Python là quy ước (
_,__+ name mangling), không phải bảo mật cứng. - Dunder methods tích hợp object với cú pháp Python (
__str__,__repr__,__eq__,__len__...);@propertycho getter/setter Pythonic;@classmethod/@staticmethodcho factory và hàm tiện ích. - dataclass giảm code lặp cho lớp chứa dữ liệu; type hints + mypy bắt lỗi sớm; clean code (tên rõ, hàm nhỏ, DRY, SOLID) + PEP8 với ruff/black giữ code dễ đọc và đồng nhất.
Tự kiểm tra
- Phân biệt
__str__và__repr__— mỗi cái dành cho ai và khi nào được gọi? - Vì sao không nên dùng
tags: list = []làm tham số mặc định, vàdataclassgiải quyết bằng cách nào? super().__init__()trong lớp con có tác dụng gì? Điều gì xảy ra nếu bỏ quên?- Giải thích duck typing bằng ví dụ hàm
total_areaở trên — vì sao không cầnCirclevàRectanglechung lớp cha? @classmethodkhác@staticmethodở điểm nào? Cho một tình huống dùng@classmethodlàm factory.- Nêu hai khác biệt giữa
ruffvàblackvề vai trò của chúng.
Đọc tiếp
Bài viết liên quan
Vì sao Python là ngôn ngữ số một của data engineer: vai trò trong pipeline (ingest/transform/orchestrate), hệ sinh thái thư viện (pandas/polars/pyarrow/sqlalchemy), quản lý môi trường (venv/uv/poetry), và khi nào dùng Python vs SQL/Spark.
Định nghĩa hàm, tham số, *args/**kwargs, lambda, module/package, pip và virtualenv.
Kết nối CSDL với SQLAlchemy, gọi REST API với requests/httpx, và lập trình bất đồng bộ asyncio.
Exception handling, context manager (with), đọc/ghi file, JSON/CSV và logging đúng cách.