Python 5 — OOP & Clean Code

13 thg 7, 2026 6 lượt xem
#python
#clean-code
#oop
#type-hints

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__self

  • __init__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.
  • self là 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ết acc.deposit(50_000), Python tự dịch thành BankAccount.deposit(acc, 50_000)acc chí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
namepublic — 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ặp for.
  • __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ận cls (chính lớp) thay vì self. Thường dùng làm factory (constructor thay thế).
  • @staticmethod: không nhận self lẫn cls — 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ì clsAdmin, 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ết tags: 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 cho List, Dict từ typing.
  • int | None thay cho Optional[int].
  • from __future__ import annotations cho 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 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ụ:

  • blackcode 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.
  • rufflinter (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, self trỏ 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; abc dù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__...); @property cho getter/setter Pythonic; @classmethod/@staticmethod cho 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

  1. Phân biệt __str____repr__ — mỗi cái dành cho ai và khi nào được gọi?
  2. Vì sao không nên dùng tags: list = [] làm tham số mặc định, và dataclass giải quyết bằng cách nào?
  3. super().__init__() trong lớp con có tác dụng gì? Điều gì xảy ra nếu bỏ quên?
  4. Giải thích duck typing bằng ví dụ hàm total_area ở trên — vì sao không cần CircleRectangle chung lớp cha?
  5. @classmethod khác @staticmethod ở điểm nào? Cho một tình huống dùng @classmethod làm factory.
  6. Nêu hai khác biệt giữa ruffblack về vai trò của chúng.

Use case thực tế

Đội Core Banking của NCB cần một module tính lãi và phí cho nhiều loại sản phẩm tài khoản. Ban đầu code là một hàm tinh_lai(loai, so_du, ...) khổng lồ với chuỗi if loai == "TIET_KIEM" ... elif loai == "THANH_TOAN" ... dài hàng trăm dòng, mỗi lần thêm sản phẩm mới lại phải sửa vào giữa hàm cũ — vi phạm nguyên tắc Open/Closed và rất dễ gây lỗi hồi quy.

Áp dụng OOP, đội mô hình hoá lại: một lớp trừu tượng Account(ABC) với @abstractmethod compute_interest(), rồi các lớp con SavingsAccount, CheckingAccount, TermDepositAccount mỗi lớp tự hiện thực công thức riêng (kế thừa deposit/withdraw chung từ lớp cha, gọi super().__init__). Số dư dùng lớp Money với dunder __add__/__eq__ để tránh sai sót cộng tiền khác loại; hạn mức và số dư âm được chặn ngay trong @property setter. Hàm tổng hợp lãi toàn danh mục chỉ cần lặp qua danh sách account gọi compute_interest() — duck typing, không quan tâm kiểu cụ thể.

Kết quả (minh hoạ): thêm một sản phẩm mới giờ chỉ là viết một lớp con ~30 dòng, không đụng code cũ; số bug hồi quy khi phát hành sản phẩm giảm rõ rệt, và bộ test đơn vị cho từng lớp chạy độc lập, dễ review hơn nhiều so với hàm if/elif cũ.

Ghi nhớ

  • Class là blueprint, instance là vật thể cụ thể; __init__ khởi tạo trạng thái, self trỏ tới chính instance đang gọi.
  • Kế thừa + super() tái sử dụng lớp cha theo MRO (thuật toán C3); ưu tiên composition khi kế thừa trở nên sâu và cứng nhắc.
  • Đa hình & duck typing: chỉ cần object có phương thức cần dùng, không cần chung lớp cha; dùng abc.ABC + @abstractmethod khi muốn ép một giao diện rõ ràng.
  • Encapsulation trong Python là quy ước (_protected, __private + name mangling), không phải cơ chế bảo mật cứng.
  • Dunder methods tích hợp object với cú pháp Python (__repr__, __str__, __eq__, __len__, __getitem__, __enter__…).
  • @property cho getter/setter Pythonic; @classmethod làm factory tôn trọng kế thừa; @staticmethod là hàm tiện ích trong namespace lớp.
  • dataclass giảm code lặp cho lớp chứa dữ liệu; nhớ field(default_factory=…) cho giá trị mutable và frozen=True khi cần bất biến/hashable.
  • Type hints + mypy bắt lỗi kiểu trước khi chạy; clean code (tên rõ, hàm nhỏ, DRY, SOLID) + PEP8 với ruff/black giữ code dễ đọc và đồng nhất.

Nguồn tham khảo

Đọc tiếp

Python 6 — Lỗi, File & Logging

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.

13 thg 7, 2026 6

Biến script thành pipeline đáng tin cậy: cấu trúc project & packaging (uv/poetry), type hints & pydantic, kiểm thử với pytest, logging & cấu hình, đóng gói Docker, và tích hợp CI cho code dữ liệu.

13 thg 7, 2026 5

Nhập môn Python cho người mới: vì sao Python thống trị data và AI, cách cài đặt Python 3 và kiểm tra phiên bản. Phân biệt REPL với script .py, tạo biến, các kiểu dữ liệu cơ bản (int, float, str, bool, None), ép kiểu, f-string và giao tiếp input/print. Khép lại bằng comment và quy ước PEP8 để viết code dễ đọc.

13 thg 7, 2026 5

Học cách tổ chức code Python: định nghĩa hàm với tham số vị trí/từ khoá/mặc định, *args/**kwargs, lambda và hàm bậc cao, closure, decorator, generator với yield. Đóng gói code thành module và package, cô lập thư viện bằng môi trường ảo venv, quản lý phụ thuộc với pip và requirements.txt để dự án tái lập được trên mọi máy.

13 thg 7, 2026 5

Cảm nhận của bạn

Bình luận

Bạn cần để viết bình luận.

Chưa có bình luận. Hãy là người đầu tiên chia sẻ!