Chuyển tới nội dung chính

Bài học: Lập Trình Hướng Đối Tượng (OOP) trong Python

Bài học dành cho người mới bắt đầu — lý thuyết dễ hiểu, gắn với ví dụ thực tế, kèm bài tập thực hành ở cuối mỗi phần.


Mục lục

  1. OOP là gì? Tại sao cần học?
  2. Class và Object
  3. Hàm khởi tạo __init__
  4. Thuộc tính và Phương thức
  5. Tính kế thừa (Inheritance)
  6. Tính đa hình (Polymorphism)
  7. Tính đóng gói (Encapsulation)
  8. Trừu tượng hóa (Abstraction)
  9. Magic Methods
  10. Bài tập tổng hợp cuối bài

1. OOP là gì? Tại sao cần học?

1.1 Vấn đề khi không dùng OOP

Giả sử bạn cần quản lý thông tin sinh viên: tên, tuổi, điểm. Nếu không dùng OOP, bạn có thể viết:

ten1 = "An"
tuoi1 = 20
diem1 = 8.5

ten2 = "Binh"
tuoi2 = 21
diem2 = 7.0

Với 100 sinh viên, bạn sẽ có... 300 biến rời rạc, rất khó quản lý và dễ nhầm lẫn.

1.2 OOP giải quyết vấn đề này như thế nào?

OOP (Object-Oriented Programming) cho phép bạn gom nhóm dữ liệu và hành vi liên quan vào một "đối tượng" — giống như ngoài đời thực, mỗi sinh viên là một thực thể có tên, tuổi, điểm và có thể "làm bài", "xem điểm"...

Liên hệ thực tế: Hãy nghĩ về một chiếc điện thoại. Nó có thuộc tính (màu sắc, dung lượng pin, thương hiệu) và hành vi (gọi điện, chụp ảnh, sạc pin). OOP mô phỏng đúng cách chúng ta nhìn nhận sự vật trong đời thực.

1.3 Bốn trụ cột của OOP

Trụ cộtÝ nghĩa đơn giản
Inheritance (Kế thừa)Một class có thể "thừa hưởng" đặc điểm từ class khác
Polymorphism (Đa hình)Nhiều đối tượng khác nhau có thể phản ứng khác nhau với cùng một hành động
Encapsulation (Đóng gói)Giấu chi tiết bên trong, chỉ lộ ra những gì cần thiết
Abstraction (Trừu tượng)Chỉ quan tâm đến "làm gì", không cần biết "làm như thế nào"

Các phần tiếp theo của bài học sẽ đi sâu vào từng khái niệm này.


2. Class và Object

2.1 Khái niệm

  • Class (Lớp): là bản thiết kế/khuôn mẫu. Ví dụ: bản vẽ thiết kế "xe hơi".
  • Object (Đối tượng): là sản phẩm cụ thể được tạo ra từ bản thiết kế đó. Ví dụ: chiếc xe Toyota màu đỏ của bạn.

Liên hệ thực tế: Class giống như khuôn bánh, Object giống như từng cái bánh được làm ra từ khuôn đó. Một khuôn có thể tạo ra nhiều bánh khác nhau về nhân, màu sắc nhưng cùng hình dạng.

2.2 Cú pháp tạo Class trong Python

class Xe:
pass # class rỗng, chưa có gì bên trong

# Tạo object (thực thể) từ class Xe
xe1 = Xe()
xe2 = Xe()

print(type(xe1)) # <class '__main__.Xe'>
print(xe1 is xe2) # False — hai object khác nhau, dù cùng class

2.3 Quy ước đặt tên

  • Tên Class: viết hoa chữ cái đầu mỗi từ — PascalCase. Ví dụ: HocSinh, TaiKhoanNganHang.
  • Tên biến, hàm: viết thường, cách nhau bằng dấu _snake_case. Ví dụ: hoc_sinh_1, tinh_diem().

3. Hàm khởi tạo __init__

3.1 __init__ là gì?

__init__ là phương thức đặc biệt được Python tự động gọi ngay khi object được tạo ra. Nó dùng để khởi tạo giá trị ban đầu cho các thuộc tính.

Liên hệ thực tế: Giống như khi bạn đăng ký tài khoản ngân hàng, nhân viên sẽ hỏi bạn tên, số CMND, số tiền gửi ban đầu — đó chính là quá trình "khởi tạo" một tài khoản mới.

class TaiKhoanNganHang:
def __init__(self, chu_tai_khoan, so_du_ban_dau=0):
self.chu_tai_khoan = chu_tai_khoan
self.so_du = so_du_ban_dau
print(f"Đã tạo tài khoản cho {chu_tai_khoan} với số dư {so_du_ban_dau:,}đ")

tk1 = TaiKhoanNganHang("Nguyễn Văn A", 1000000)
# Đã tạo tài khoản cho Nguyễn Văn A với số dư 1,000,000đ

3.2 Tham số mặc định

Bạn có thể đặt giá trị mặc định cho tham số, giống ví dụ trên (so_du_ban_dau=0). Nếu không truyền, Python sẽ tự lấy giá trị mặc định:

tk2 = TaiKhoanNganHang("Trần Thị B")
# Đã tạo tài khoản cho Trần Thị B với số dư 0đ

4. Thuộc tính và Phương thức

4.1 Thuộc tính — Dữ liệu của đối tượng

class Xe:
def __init__(self, hang, mau):
self.hang = hang # thuộc tính (attribute)
self.mau = mau

xe1 = Xe("Toyota", "Đỏ")
print(xe1.hang) # Toyota
print(xe1.mau) # Đỏ

4.2 Phương thức — Hành vi của đối tượng

Phương thức (method) là hàm được định nghĩa bên trong class, mô tả những gì đối tượng "có thể làm".

class Xe:
def __init__(self, hang, mau, xang):
self.hang = hang
self.mau = mau
self.xang = xang # % xăng còn lại

def chay(self):
if self.xang > 0:
self.xang -= 10
print(f"Xe {self.hang} đang chạy. Xăng còn {self.xang}%")
else:
print("Hết xăng rồi, cần đổ xăng!")

xe1 = Xe("Honda", "Đen", 50)
xe1.chay() # Xe Honda đang chạy. Xăng còn 40%
xe1.chay() # Xe Honda đang chạy. Xăng còn 30%

Lưu ý quan trọng về self: self đại diện cho chính đối tượng đang gọi phương thức đó. Khi bạn gọi xe1.chay(), Python tự động truyền xe1 vào làm self. Nhờ vậy phương thức biết cần thao tác trên dữ liệu của xe1 chứ không phải xe2.

4.3 Phân biệt thuộc tính instance và thuộc tính class

class HocSinh:
truong = "THPT Nguyễn Trãi" # thuộc tính class — DÙNG CHUNG cho mọi object

def __init__(self, ten):
self.ten = ten # thuộc tính instance — RIÊNG cho từng object

hs1 = HocSinh("An")
hs2 = HocSinh("Bình")

print(hs1.truong) # THPT Nguyễn Trãi
print(hs2.truong) # THPT Nguyễn Trãi
print(hs1.ten) # An
print(hs2.ten) # Bình

5. Tính kế thừa (Inheritance)

5.1 Ý tưởng

Liên hệ thực tế: "Chó" và "Mèo" đều là "Động vật" — chúng đều biết ăn, ngủ, di chuyển. Nhưng "Chó" thì biết sủa, "Mèo" thì biết kêu meo. Thay vì viết lại toàn bộ đặc điểm chung cho từng loài, ta định nghĩa class DongVat chứa đặc điểm chung, rồi cho ChoMeo kế thừa từ đó.

5.2 Cú pháp cơ bản

class DongVat:
def __init__(self, ten):
self.ten = ten

def an(self):
print(f"{self.ten} đang ăn")

def ngu(self):
print(f"{self.ten} đang ngủ")

class Cho(DongVat): # Cho kế thừa từ DongVat
def sua(self):
print(f"{self.ten} sủa: Gâu gâu!")

class Meo(DongVat): # Meo kế thừa từ DongVat
def keu(self):
print(f"{self.ten} kêu: Meo meo!")

cho1 = Cho("Lu")
cho1.an() # Lu đang ăn (kế thừa từ DongVat)
cho1.sua() # Lu sủa: Gâu gâu! (riêng của Cho)

meo1 = Meo("Mun")
meo1.ngu() # Mun đang ngủ (kế thừa từ DongVat)
meo1.keu() # Mun kêu: Meo meo! (riêng của Meo)

5.3 Ghi đè phương thức (Method Overriding) và super()

Class con có thể định nghĩa lại một phương thức của class cha để thay đổi hành vi cho phù hợp:

class NhanVien:
def __init__(self, ten, luong_co_ban):
self.ten = ten
self.luong_co_ban = luong_co_ban

def tinh_luong(self):
return self.luong_co_ban

class QuanLy(NhanVien):
def __init__(self, ten, luong_co_ban, phu_cap):
super().__init__(ten, luong_co_ban) # gọi __init__ của class cha
self.phu_cap = phu_cap

def tinh_luong(self): # ghi đè phương thức của class cha
return self.luong_co_ban + self.phu_cap

nv = NhanVien("An", 10000000)
ql = QuanLy("Bình", 15000000, 5000000)

print(nv.tinh_luong()) # 10000000
print(ql.tinh_luong()) # 20000000

super() cho phép class con gọi lại phương thức của class cha thay vì viết trùng lặp code.


6. Tính đa hình (Polymorphism)

6.1 Ý tưởng

Đa hình nghĩa là "nhiều hình dạng" — cùng một hành động (tên phương thức giống nhau) nhưng mỗi đối tượng lại thực hiện theo cách riêng của mình.

Liên hệ thực tế: Bạn bấm nút "Play" (▶️) — trên loa nó phát nhạc, trên TV nó phát video, trên máy chiếu nó chiếu phim. Cùng một hành động "Play" nhưng mỗi thiết bị phản ứng khác nhau.

6.2 Ví dụ minh họa

class HinhVuong:
def __init__(self, canh):
self.canh = canh

def dien_tich(self):
return self.canh ** 2

class HinhTron:
def __init__(self, ban_kinh):
self.ban_kinh = ban_kinh

def dien_tich(self):
return 3.14 * self.ban_kinh ** 2

class HinhChuNhat:
def __init__(self, dai, rong):
self.dai = dai
self.rong = rong

def dien_tich(self):
return self.dai * self.rong

danh_sach_hinh = [HinhVuong(4), HinhTron(3), HinhChuNhat(5, 2)]

for hinh in danh_sach_hinh:
# Không cần biết đó là hình gì, chỉ cần gọi dien_tich()
print(f"{type(hinh).__name__}: {hinh.dien_tich():.2f}")

# HinhVuong: 16.00
# HinhTron: 28.26
# HinhChuNhat: 10.00

Đây chính là sức mạnh của đa hình: bạn viết một vòng lặp for duy nhất, xử lý được nhiều loại đối tượng khác nhau, miễn là chúng đều có phương thức dien_tich().


7. Tính đóng gói (Encapsulation)

7.1 Tại sao cần đóng gói?

Liên hệ thực tế: Bạn không thể tự ý mở nắp capo và chỉnh sửa động cơ xe hơi ngay khi đang chạy trên đường — điều đó nguy hiểm. Nhà sản xuất giấu đi các chi tiết kỹ thuật phức tạp, chỉ cho bạn thao tác qua vô-lăng, chân ga, chân phanh. Đó chính là ý tưởng của Encapsulation: giấu dữ liệu nhạy cảm, chỉ cho phép truy cập qua các "cổng" được kiểm soát.

7.2 Quy ước đặt tên trong Python

Tiền tốMức độÝ nghĩaVí dụ
Không tiền tốPublicTruy cập tự do từ bất kỳ đâuself.ten
_ (1 gạch dưới)ProtectedQuy ước "chỉ nên dùng trong nội bộ class và class con"self._so_du
__ (2 gạch dưới)PrivatePython "làm rối tên" (name mangling) để hạn chế truy cập trực tiếpself.__mat_khau

7.2.1 Public — Truy cập tự do

Public là mức mặc định trong Python — thuộc tính/phương thức không có tiền tố gạch dưới. Bất kỳ đoạn code nào cũng có thể đọc và ghi giá trị này.

Khi nào dùng? Khi thông tin đó không nhạy cảm và không cần kiểm soát — ví dụ: tên sản phẩm, tên người dùng, tiêu đề bài viết.

class SinhVien:
def __init__(self, ten, ma_sv):
self.ten = ten # public — ai cũng đọc/ghi được
self.ma_sv = ma_sv # public

sv = SinhVien("An", "SV001")
print(sv.ten) # An — đọc bình thường
sv.ten = "Bình" # ghi bình thường
print(sv.ten) # Bình

7.2.2 Protected — Quy ước "nội bộ"

Protected dùng 1 dấu gạch dưới phía trước (_ten_thuoc_tinh). Đây chỉ là quy ước — Python không ngăn bạn truy cập từ bên ngoài, nhưng nó là tín hiệu cho lập trình viên khác biết: "Đừng đụng vào cái này từ bên ngoài class, trừ khi bạn là class con."

Liên hệ thực tế: Giống như biển "Chỉ dành cho nhân viên" — không có ổ khóa, nhưng bạn hiểu mình không nên vào.

class TaiKhoan:
def __init__(self, ten, so_du):
self.ten = ten
self._so_du = so_du # protected — "nội bộ, không nên đụng từ ngoài"

def xem_so_du(self):
return self._so_du # class tự dùng thuộc tính của mình

class TaiKhoanVip(TaiKhoan):
def uu_dai(self):
# Class con có thể truy cập _so_du — đây là mục đích của protected
if self._so_du >= 10_000_000:
return "Bạn được hưởng ưu đãi VIP!"
return "Chưa đủ điều kiện VIP."

tk = TaiKhoan("An", 5_000_000)
print(tk._so_du) # 5000000 — Python cho phép, nhưng không nên làm vậy!

vip = TaiKhoanVip("Bình", 15_000_000)
print(vip.uu_dai()) # Bạn được hưởng ưu đãi VIP!

Lưu ý: _so_du vẫn truy cập được từ bên ngoài (tk._so_du). Protected chỉ là quy ước đạo đức lập trình, không phải rào cản kỹ thuật.


7.2.3 Private — Giới hạn thực sự bằng Name Mangling

Private dùng 2 dấu gạch dưới phía trước (__ten_thuoc_tinh). Khi gặp tiền tố này, Python tự động đổi tên thuộc tính ở cấp độ interpreter thành _TenClass__ten_thuoc_tinh — kỹ thuật này gọi là Name Mangling (làm rối tên). Nhờ vậy, truy cập trực tiếp từ bên ngoài sẽ thất bại với AttributeError.

Liên hệ thực tế: Giống như két sắt có mật mã — không chỉ là biển cấm, mà thực sự bị khóa lại. Muốn lấy tiền phải đi qua đúng quy trình.

class NguoiDung:
def __init__(self, ten, mat_khau):
self.ten = ten
self.__mat_khau = mat_khau # private

def kiem_tra_mat_khau(self, nhap_vao):
return nhap_vao == self.__mat_khau # class tự truy cập được

def doi_mat_khau(self, cu, moi):
if self.kiem_tra_mat_khau(cu):
self.__mat_khau = moi
print("Đổi mật khẩu thành công!")
else:
print("Mật khẩu cũ không đúng!")

nd = NguoiDung("An", "abc123")

# Truy cập đúng cách — qua phương thức
print(nd.kiem_tra_mat_khau("abc123")) # True
nd.doi_mat_khau("abc123", "xyz789") # Đổi mật khẩu thành công!

# Cố truy cập trực tiếp sẽ báo lỗi
# print(nd.__mat_khau) # AttributeError: 'NguoiDung' has no attribute '__mat_khau'

Cơ chế Name Mangling hoạt động thế nào?

class HopThu:
def __init__(self):
self.__bi_mat = "thông tin tối mật"

ht = HopThu()

# Truy cập thẳng — lỗi
# print(ht.__bi_mat) # AttributeError

# Python thực ra lưu với tên đã bị "làm rối":
print(ht._HopThu__bi_mat) # thông tin tối mật — vẫn truy cập được nếu biết tên thật!
print(dir(ht)) # thấy '_HopThu__bi_mat' trong danh sách thuộc tính

Kết luận: Private không phải là "bức tường thép" tuyệt đối — nó ngăn truy cập vô tìnhnhầm lẫn, nhưng người có chủ đích vẫn có thể dùng tên đã bị mangling. Trong Python, triết lý là: "chúng ta đều là người lớn có trách nhiệm" — cơ chế bảo vệ là để tránh tai nạn, không phải để chống hack tuyệt đối.


7.2.4 So sánh tổng kết ba mức độ

Mức độCú phápTruy cập từ ngoài classTruy cập từ class conCơ chế
Publicself.tenBình thườngBình thườngKhông giới hạn
Protectedself._tenĐược nhưng không nênKhuyến khíchChỉ là quy ước
Privateself.__tenAttributeErrorKhông trực tiếpName Mangling
class ViDu:
def __init__(self):
self.public = "ai cũng xem được"
self._protected = "chỉ nội bộ"
self.__private = "bị làm rối tên"

obj = ViDu()
print(obj.public) # ai cũng xem được
print(obj._protected) # chỉ nội bộ (được nhưng không nên)
# print(obj.__private) # AttributeError
print(obj._ViDu__private) # bị làm rối tên (name mangling)

7.3 Ví dụ thực tế: Tài khoản ngân hàng an toàn

class TaiKhoanNganHang:
def __init__(self, chu_tai_khoan, so_du_ban_dau=0):
self.chu_tai_khoan = chu_tai_khoan
self.__so_du = so_du_ban_dau # thuộc tính private

def xem_so_du(self):
return f"Số dư hiện tại: {self.__so_du:,}đ"

def nap_tien(self, so_tien):
if so_tien <= 0:
print("Số tiền nạp phải lớn hơn 0!")
return
self.__so_du += so_tien
print(f"Nạp thành công {so_tien:,}đ")

def rut_tien(self, so_tien):
if so_tien > self.__so_du:
print("Số dư không đủ!")
return
self.__so_du -= so_tien
print(f"Rút thành công {so_tien:,}đ")

tk = TaiKhoanNganHang("Nguyễn Văn A", 500000)
tk.nap_tien(200000)
tk.rut_tien(1000000) # Số dư không đủ!
print(tk.xem_so_du()) # Số dư hiện tại: 700,000đ

# Cố truy cập trực tiếp sẽ không được như ý muốn:
# print(tk.__so_du) # AttributeError!

Nhờ đóng gói, không ai có thể tự ý gán tk.__so_du = -999999999 để "hack" tài khoản — mọi thay đổi số dư đều phải đi qua các phương thức có kiểm tra logic hợp lệ.

7.4 Getter và Setter

7.4.1 Getter/Setter là gì?

GetterSetter là hai loại phương thức đặc biệt dùng để đọc (get) và ghi (set) giá trị của thuộc tính private/protected, thay vì cho phép truy cập trực tiếp.

Liên hệ thực tế: Hãy nghĩ về chiếc đồng hồ điện tử. Bạn không thể tự ý thò tay vào mạch điện để chỉnh giờ — bạn phải bấm nút chỉnh giờ (setter) và nhìn màn hình hiển thị (getter). Nhà sản xuất kiểm soát quy trình chỉnh giờ để đảm bảo bạn không nhập giờ sai (ví dụ: giờ 25, phút 61).

7.4.2 Tại sao cần Getter/Setter?

Khi thuộc tính là public, ai cũng có thể gán bất kỳ giá trị nào — kể cả giá trị vô lý:

class HocSinh:
def __init__(self, ten, tuoi):
self.ten = ten
self.tuoi = tuoi # public — không kiểm soát

hs = HocSinh("An", 16)
hs.tuoi = -5 # tuổi âm?! Python không ngăn cản
hs.tuoi = 99999 # tuổi 99999?! vẫn được

Với getter/setter, bạn có thể kiểm tra dữ liệu trước khi cho phép đọc/ghi:

class HocSinh:
def __init__(self, ten, tuoi):
self.ten = ten
self.__tuoi = tuoi # private — giấu đi

# GETTER — phương thức để ĐỌC giá trị
def get_tuoi(self):
return self.__tuoi

# SETTER — phương thức để GHI giá trị (có kiểm tra)
def set_tuoi(self, tuoi_moi):
if tuoi_moi < 0 or tuoi_moi > 150:
print("Tuổi không hợp lệ!")
return
self.__tuoi = tuoi_moi

hs = HocSinh("An", 16)
print(hs.get_tuoi()) # 16

hs.set_tuoi(17) # hợp lệ
print(hs.get_tuoi()) # 17

hs.set_tuoi(-5) # Tuổi không hợp lệ!
print(hs.get_tuoi()) # 17 — giá trị không thay đổi

7.4.3 Cách truyền thống vs. Cách Pythonic

Cách viết get_tuoi() / set_tuoi() ở trên là cách truyền thống (giống Java, C++). Nó hoạt động tốt, nhưng cú pháp gọi hơi rườm rà:

# Cách truyền thống — phải gọi method
print(hs.get_tuoi()) # đọc
hs.set_tuoi(17) # ghi

# Mong muốn — gọi như thuộc tính bình thường
print(hs.tuoi) # đọc
hs.tuoi = 17 # ghi

Python cung cấp decorator @property để bạn vừa kiểm soát được dữ liệu (như getter/setter), vừa gọi tự nhiên như thuộc tính bình thường. Đây là cách Pythonic — được ưa chuộng trong cộng đồng Python.

7.4.4 @property — Getter/Setter kiểu Pythonic

@property biến một phương thức thành thuộc tính ảo — bên ngoài gọi như thuộc tính, bên trong thực chất là hàm có kiểm tra logic.

class HocSinh:
def __init__(self, ten, tuoi):
self.ten = ten
self.__tuoi = tuoi # thuộc tính private thật sự

@property
def tuoi(self):
"""GETTER — được gọi khi đọc: hs.tuoi"""
return self.__tuoi

@tuoi.setter
def tuoi(self, tuoi_moi):
"""SETTER — được gọi khi ghi: hs.tuoi = 17"""
if not isinstance(tuoi_moi, int):
print("Tuổi phải là số nguyên!")
return
if tuoi_moi < 0 or tuoi_moi > 150:
print("Tuổi không hợp lệ!")
return
self.__tuoi = tuoi_moi

hs = HocSinh("An", 16)

# Đọc — Python tự động gọi @property (getter)
print(hs.tuoi) # 16

# Ghi hợp lệ — Python tự động gọi @tuoi.setter
hs.tuoi = 17
print(hs.tuoi) # 17

# Ghi không hợp lệ — setter ngăn chặn
hs.tuoi = -5 # Tuổi không hợp lệ!
hs.tuoi = "abc" # Tuổi phải là số nguyên!
print(hs.tuoi) # 17 — giá trị vẫn an toàn

Phân tích cú pháp @property:

Thành phầnVai tròKích hoạt khi nào
@propertyGetter — định nghĩa cách ĐỌC giá trịprint(hs.tuoi)
@tuoi.setterSetter — định nghĩa cách GHI giá trịhs.tuoi = 17
@tuoi.deleterDeleter — định nghĩa cách XÓA giá trị (ít dùng)del hs.tuoi

Lưu ý: Tên phương thức trong @property, @tuoi.setter@tuoi.deleter phải giống nhau (ở đây đều là tuoi). Đây là tên "thuộc tính ảo" mà bên ngoài sẽ dùng.

7.4.5 Ví dụ nâng cao: Property chỉ đọc (read-only)

Nếu bạn chỉ viết @propertykhông viết @setter, thuộc tính đó sẽ trở thành chỉ đọc — không thể gán giá trị từ bên ngoài.

class HinhTron:
def __init__(self, ban_kinh):
self.__ban_kinh = ban_kinh

@property
def ban_kinh(self):
return self.__ban_kinh

@ban_kinh.setter
def ban_kinh(self, gia_tri_moi):
if gia_tri_moi <= 0:
print("Bán kính phải lớn hơn 0!")
return
self.__ban_kinh = gia_tri_moi

@property
def dien_tich(self):
"""Thuộc tính tính toán — chỉ đọc, không có setter"""
return 3.14159 * self.__ban_kinh ** 2

@property
def chu_vi(self):
"""Thuộc tính tính toán — chỉ đọc"""
return 2 * 3.14159 * self.__ban_kinh

ht = HinhTron(5)
print(ht.ban_kinh) # 5
print(ht.dien_tich) # 78.53975
print(ht.chu_vi) # 31.4159

ht.ban_kinh = 10 # hợp lệ — có setter
print(ht.dien_tich) # 314.159 — tự động cập nhật!

# ht.dien_tich = 100 # AttributeError — không có setter, chỉ đọc!

Điểm hay: dien_tichchu_vi luôn tự động tính lại dựa trên ban_kinh hiện tại — không bao giờ bị "lệch" dữ liệu. Đây là lợi ích lớn của property so với thuộc tính thông thường.

7.4.6 Ví dụ thực tế: Sản phẩm có giá và thuế

class SanPham:
def __init__(self, ten, gia):
self.ten = ten
self.__gia = gia

@property
def gia(self):
return self.__gia

@gia.setter
def gia(self, gia_moi):
if gia_moi < 0:
print("Giá không thể âm!")
else:
self.__gia = gia_moi

@property
def gia_sau_thue(self):
"""Tự động tính giá sau thuế VAT 10%"""
return self.__gia * 1.1

def __str__(self):
return f"{self.ten}: {self.gia:,.0f}đ (sau thuế: {self.gia_sau_thue:,.0f}đ)"

sp = SanPham("Áo thun", 150000)
print(sp) # Áo thun: 150,000đ (sau thuế: 165,000đ)

sp.gia = 200000 # gọi setter — hợp lệ
print(sp) # Áo thun: 200,000đ (sau thuế: 220,000đ)

sp.gia = -50000 # Giá không thể âm!
print(sp.gia) # 200000 — giá trị không thay đổi

7.4.7 Tổng kết Getter/Setter

Cách tiếp cậnƯu điểmNhược điểmKhi nào dùng
Thuộc tính public (self.x)Đơn giản, nhanhKhông kiểm soát đượcDữ liệu không nhạy cảm
Getter/Setter truyền thống (get_x(), set_x())Kiểm soát tốtCú pháp rườm ràKhi làm việc với Java/C++ developers
@property (Pythonic)Kiểm soát + cú pháp tự nhiênCần hiểu decoratorKhuyến khích dùng trong Python

Quy tắc vàng: Trong Python, hãy bắt đầu với thuộc tính public (self.x). Khi cần thêm logic kiểm tra, hãy chuyển sang @property — code bên ngoài không cần thay đổi vì cú pháp truy cập vẫn giống hệt thuộc tính bình thường.


8. Trừu tượng hóa (Abstraction)

8.1 Ý tưởng

Trừu tượng hóa là việc định nghĩa một "khung sườn" bắt buộc mà các class con phải tuân theo, nhưng không quan tâm chi tiết cài đặt bên trong ra sao.

Liên hệ thực tế: Ổ cắm điện là một "chuẩn giao tiếp trừu tượng" — bất kỳ thiết bị nào (quạt, tivi, sạc điện thoại) muốn dùng điện đều phải có phích cắm đúng chuẩn. Ổ cắm không quan tâm bên trong thiết bị hoạt động thế nào, chỉ quan tâm nó "cắm vào là chạy".

8.2 Cài đặt với module abc

from abc import ABC, abstractmethod

class HinhHoc(ABC): # Lớp trừu tượng — không thể tạo object trực tiếp
@abstractmethod
def dien_tich(self):
pass # bắt buộc class con phải cài đặt phương thức này

@abstractmethod
def chu_vi(self):
pass

class HinhVuong(HinhHoc):
def __init__(self, canh):
self.canh = canh

def dien_tich(self):
return self.canh ** 2

def chu_vi(self):
return self.canh * 4

# hh = HinhHoc() # Lỗi! Không thể khởi tạo class trừu tượng
hv = HinhVuong(5)
print(hv.dien_tich()) # 25
print(hv.chu_vi()) # 20

Nếu HinhVuong quên cài đặt chu_vi(), Python sẽ báo lỗi ngay khi bạn cố tạo object — giúp đảm bảo mọi class con đều tuân thủ "hợp đồng" mà class trừu tượng đặt ra.


9. Magic Methods

9.1 Magic Method là gì?

Magic Methods (còn gọi là Dunder Methods — "double underscore") là các phương thức đặc biệt có dạng __ten__. Chúng cho phép bạn định nghĩa cách một object phản ứng với các toán tử, hàm tích hợp và cú pháp đặc biệt của Python như +, -, ==, print(), len(), []...

Liên hệ thực tế: Giống như khi bạn dạy một người nước ngoài hiểu ngôn ngữ Việt Nam — bạn "dạy" object của mình cách hiểu các phép toán và câu lệnh quen thuộc của Python. Thay vì gọi san_pham.tinh_tong(), bạn có thể viết tự nhiên hơn: gio_hang_1 + gio_hang_2.

Tại sao cần Magic Methods?

Khi không có magic methods, code trở nên cồng kềnh và không tự nhiên:

# Không dùng magic methods — rườm rà
tong_gia = gio_hang.tinh_tong_gia()
print(gio_hang.mo_ta())
so_luong = gio_hang.dem_san_pham()

# Dùng magic methods — tự nhiên, giống Python built-in
tong_gia = sum(gio_hang)
print(gio_hang)
so_luong = len(gio_hang)

9.2 Phân loại Magic Methods

NhómMục đíchVí dụ
Biểu diễnKiểm soát cách hiển thị object__str__, __repr__
Toán họcĐịnh nghĩa phép toán số học__add__, __sub__, __mul__
So sánhĐịnh nghĩa phép so sánh__eq__, __lt__, __gt__
ContainerLàm object hoạt động như danh sách/từ điển__len__, __getitem__, __contains__
Vòng đờiKiểm soát tạo/hủy object__init__, __del__
Context ManagerDùng với with__enter__, __exit__

9.3 Nhóm Biểu diễn: __str____repr__

Hai magic method này kiểm soát cách object được hiển thị dưới dạng văn bản:

  • __str__: dành cho người dùng — thông báo dễ đọc, thân thiện.
  • __repr__: dành cho lập trình viên — thông tin kỹ thuật, dùng để debug.
class SanPham:
def __init__(self, ten, gia, so_luong):
self.ten = ten
self.gia = gia
self.so_luong = so_luong

def __str__(self):
# Được gọi khi print(object) hoặc str(object) hoặc f"{object}"
return f"[{self.ten}] — {self.gia:,}đ (còn {self.so_luong} cái)"

def __repr__(self):
# Được gọi trong REPL, khi debug, hoặc repr(object)
return f"SanPham(ten='{self.ten}', gia={self.gia}, so_luong={self.so_luong})"

sp = SanPham("Áo thun", 150_000, 20)

print(sp) # [Áo thun] — 150,000đ (còn 20 cái) <- __str__
print(repr(sp)) # SanPham(ten='Áo thun', gia=150000, so_luong=20) <- __repr__

# Khi không có __str__, Python tự động dùng __repr__
# Khi không có cả hai: <__main__.SanPham object at 0x...>

Quy tắc vàng: Luôn viết __repr__ vì nó là fallback. Viết thêm __str__ khi cần thông báo thân thiện với người dùng.


9.4 Nhóm Toán học: __add__, __sub__, __mul__

Ví dụ thực tế: Giỏ hàng mua sắm — cộng hai giỏ hàng lại với nhau.

class GioHang:
def __init__(self, ten_khach):
self.ten_khach = ten_khach
self.san_pham = [] # danh sách (tên, giá)

def them(self, ten, gia):
self.san_pham.append((ten, gia))

def tong_tien(self):
return sum(gia for _, gia in self.san_pham)

def __add__(self, gio_khac):
"""Gộp hai giỏ hàng thành một giỏ mới"""
gio_gop = GioHang(f"{self.ten_khach} & {gio_khac.ten_khach}")
gio_gop.san_pham = self.san_pham + gio_khac.san_pham
return gio_gop

def __str__(self):
danh_sach = "\n".join(f" - {ten}: {gia:,}đ" for ten, gia in self.san_pham)
return f"Giỏ hàng của {self.ten_khach}:\n{danh_sach}\n Tổng: {self.tong_tien():,}đ"

gio_an = GioHang("An")
gio_an.them("Áo thun", 150_000)
gio_an.them("Quần jean", 350_000)

gio_binh = GioHang("Bình")
gio_binh.them("Giày thể thao", 600_000)

# Dùng toán tử + nhờ __add__
gio_chung = gio_an + gio_binh
print(gio_chung)
# Giỏ hàng của An & Bình:
# - Áo thun: 150,000đ
# - Quần jean: 350,000đ
# - Giày thể thao: 600,000đ
# Tổng: 1,100,000đ

9.5 Nhóm So sánh: __eq__, __lt__, __gt__

Ví dụ thực tế: So sánh nhiệt độ giữa hai thành phố.

class NhietDo:
def __init__(self, thanh_pho, do_c):
self.thanh_pho = thanh_pho
self.do_c = do_c # nhiệt độ Celsius

def __eq__(self, khac):
"""== : hai nhiệt độ bằng nhau"""
return self.do_c == khac.do_c

def __lt__(self, khac):
"""< : nhiệt độ này thấp hơn"""
return self.do_c < khac.do_c

def __gt__(self, khac):
"""> : nhiệt độ này cao hơn"""
return self.do_c > khac.do_c

def __le__(self, khac):
"""<= : nhỏ hơn hoặc bằng"""
return self.do_c <= khac.do_c

def __str__(self):
return f"{self.thanh_pho}: {self.do_c}°C"

ha_noi = NhietDo("Hà Nội", 22)
da_nang = NhietDo("Đà Nẵng", 28)
hcm = NhietDo("TP.HCM", 33)

print(ha_noi < da_nang) # True — Hà Nội lạnh hơn Đà Nẵng
print(hcm > da_nang) # True — HCM nóng hơn Đà Nẵng
print(ha_noi == da_nang) # False

# Nhờ __lt__, Python có thể tự sắp xếp!
danh_sach = [hcm, ha_noi, da_nang]
danh_sach_sap_xep = sorted(danh_sach) # gọi __lt__ ngầm
for tp in danh_sach_sap_xep:
print(tp)
# Hà Nội: 22°C
# Đà Nẵng: 28°C
# TP.HCM: 33°C

Mẹo: Khi bạn định nghĩa __lt__, sorted()min()/max() sẽ tự hoạt động với danh sách chứa các object của bạn.


9.6 Nhóm Container: __len__, __getitem__, __contains__

Những magic method này giúp object của bạn hoạt động giống như list hoặc dict.

Ví dụ thực tế: Danh sách học sinh trong lớp học.

class LopHoc:
def __init__(self, ten_lop):
self.ten_lop = ten_lop
self._hoc_sinh = [] # danh sách tên học sinh

def them_hoc_sinh(self, ten):
self._hoc_sinh.append(ten)

def __len__(self):
"""Gọi khi dùng len(lop)"""
return len(self._hoc_sinh)

def __getitem__(self, chi_so):
"""Gọi khi dùng lop[0], lop[1:3], ..."""
return self._hoc_sinh[chi_so]

def __contains__(self, ten):
"""Gọi khi dùng 'An' in lop"""
return ten in self._hoc_sinh

def __iter__(self):
"""Gọi khi dùng for hs in lop"""
return iter(self._hoc_sinh)

def __str__(self):
return f"Lớp {self.ten_lop} ({len(self)} học sinh)"

lop = LopHoc("12A1")
lop.them_hoc_sinh("An")
lop.them_hoc_sinh("Bình")
lop.them_hoc_sinh("Chi")
lop.them_hoc_sinh("Dung")

print(len(lop)) # 4 <- __len__
print(lop[0]) # An <- __getitem__
print(lop[1:3]) # ['Bình', 'Chi'] <- __getitem__ với slice

print("An" in lop) # True <- __contains__
print("Hoa" in lop) # False

# Duyệt qua lớp như một list bình thường nhờ __iter__
for hoc_sinh in lop:
print(f" - {hoc_sinh}")
# - An
# - Bình
# - Chi
# - Dung

9.7 Context Manager: __enter____exit__

Magic method này cho phép object dùng được với từ khóa with, tự động dọn dẹp tài nguyên khi xong.

Ví dụ thực tế: Quản lý kết nối cơ sở dữ liệu (mô phỏng).

class KetNoiDatabase:
def __init__(self, ten_db):
self.ten_db = ten_db
self.ket_noi = False

def __enter__(self):
"""Được gọi khi bắt đầu khối with"""
print(f"🔌 Đang kết nối đến {self.ten_db}...")
self.ket_noi = True
return self # trả về chính object để dùng trong khối with

def __exit__(self, loai_loi, gia_tri_loi, traceback):
"""Được gọi khi kết thúc khối with (dù có lỗi hay không)"""
self.ket_noi = False
print(f"🔒 Đã đóng kết nối đến {self.ten_db}")
return False # không nuốt lỗi, để lỗi tiếp tục lan ra ngoài

def truy_van(self, lenh_sql):
if not self.ket_noi:
raise RuntimeError("Chưa kết nối!")
print(f" ▶ Thực thi: {lenh_sql}")

# Dùng với từ khóa with — tự động đóng kết nối khi xong
with KetNoiDatabase("csdl_ban_hang.db") as db:
db.truy_van("SELECT * FROM san_pham")
db.truy_van("UPDATE san_pham SET gia = 200000 WHERE id = 1")

# 🔌 Đang kết nối đến csdl_ban_hang.db...
# ▶ Thực thi: SELECT * FROM san_pham
# ▶ Thực thi: UPDATE san_pham SET gia = 200000 WHERE id = 1
# 🔒 Đã đóng kết nối đến csdl_ban_hang.db

# Sau khối with, kết nối đã đóng tự động
# db.truy_van("...") # sẽ báo RuntimeError

Liên hệ thực tế: Giống như khi bạn vào phòng máy lạnh (bật điều hòa), làm việc, rồi ra ngoài (tắt điều hòa) — with đảm bảo bước "tắt điều hòa" luôn được thực hiện, kể cả khi có sự cố xảy ra bên trong.


9.8 Ví dụ tổng hợp: Class PhanSo

Ví dụ dưới đây kết hợp nhiều magic methods vào một class hoàn chỉnh — Phân số trong toán học.

import math

class PhanSo:
def __init__(self, tu_so, mau_so):
if mau_so == 0:
raise ValueError("Mẫu số không thể bằng 0!")
# Tự động rút gọn phân số
uoc_chung = math.gcd(abs(tu_so), abs(mau_so))
self.tu = tu_so // uoc_chung
self.mau = mau_so // uoc_chung
# Đảm bảo mẫu luôn dương
if self.mau < 0:
self.tu, self.mau = -self.tu, -self.mau

# --- Biểu diễn ---
def __str__(self):
if self.mau == 1:
return str(self.tu)
return f"{self.tu}/{self.mau}"

def __repr__(self):
return f"PhanSo({self.tu}, {self.mau})"

# --- Toán học ---
def __add__(self, khac):
"""a/b + c/d = (a*d + c*b) / (b*d)"""
tu_moi = self.tu * khac.mau + khac.tu * self.mau
mau_moi = self.mau * khac.mau
return PhanSo(tu_moi, mau_moi)

def __sub__(self, khac):
"""a/b - c/d = (a*d - c*b) / (b*d)"""
tu_moi = self.tu * khac.mau - khac.tu * self.mau
mau_moi = self.mau * khac.mau
return PhanSo(tu_moi, mau_moi)

def __mul__(self, khac):
"""a/b * c/d = (a*c) / (b*d)"""
return PhanSo(self.tu * khac.tu, self.mau * khac.mau)

def __truediv__(self, khac):
"""a/b ÷ c/d = (a*d) / (b*c)"""
return PhanSo(self.tu * khac.mau, self.mau * khac.tu)

# --- So sánh ---
def __eq__(self, khac):
return self.tu * khac.mau == khac.tu * self.mau

def __lt__(self, khac):
return self.tu * khac.mau < khac.tu * self.mau

def __float__(self):
"""Chuyển sang số thực khi dùng float(phan_so)"""
return self.tu / self.mau


# --- Sử dụng ---
p1 = PhanSo(1, 2) # 1/2
p2 = PhanSo(1, 3) # 1/3
p3 = PhanSo(2, 4) # tự rút gọn thành 1/2

print(p1) # 1/2
print(p2) # 1/3
print(p1 + p2) # 5/6 (1/2 + 1/3 = 5/6)
print(p1 - p2) # 1/6 (1/2 - 1/3 = 1/6)
print(p1 * p2) # 1/6 (1/2 * 1/3 = 1/6)
print(p1 / p2) # 3/2 (1/2 ÷ 1/3 = 3/2)

print(p1 == p3) # True (1/2 == 2/4, đã rút gọn)
print(p2 < p1) # True (1/3 < 1/2)

# Sắp xếp danh sách phân số — tự động dùng __lt__
ds = [PhanSo(3,4), PhanSo(1,2), PhanSo(2,3)]
print(sorted(ds)) # [1/2, 2/3, 3/4]

print(float(p1)) # 0.5

9.9 Bảng tổng kết Magic Methods

Magic MethodKích hoạt khi nàoVí dụ sử dụng
__init__Tạo objectobj = MyClass()
__str__print(obj), str(obj)Hiển thị thân thiện
__repr__repr(obj), trong REPLDebug, logging
__add__a + bCộng hai object
__sub__a - bTrừ hai object
__mul__a * bNhân hai object
__truediv__a / bChia hai object
__eq__a == bKiểm tra bằng nhau
__lt__a < bSo sánh nhỏ hơn
__gt__a > bSo sánh lớn hơn
__len__len(obj)Độ dài/số lượng
__getitem__obj[i]Truy cập bằng chỉ số
__contains__x in objKiểm tra tồn tại
__iter__for x in objDuyệt qua object
__enter__with obj as x:Mở tài nguyên
__exit__Kết thúc khối withĐóng tài nguyên
__float__float(obj)Chuyển sang float
__bool__bool(obj), if obj:Kiểm tra truthy/falsy

10. Bài tập tổng hợp cuối bài

Hoàn thành lần lượt các bài tập sau, theo đúng thứ tự kiến thức đã học ở trên. Mỗi bài đều đánh dấu phần liên quan để bạn dễ ôn lại lý thuyết.

  1. (Mục 2 – Class & Object) Tạo một class tên DienThoai (chưa cần thuộc tính gì). Tạo 3 object từ class đó, đặt tên dt1, dt2, dt3. In ra kiểu dữ liệu (type) của từng object và kiểm tra xem chúng có phải cùng một object hay không (dùng is).

  2. (Mục 3 – __init__) Tạo class SanPham với __init__ nhận vào ten_san_pham, gia, và so_luong_ton_kho (mặc định = 0). Trong __init__, in ra thông báo "Sản phẩm [tên] đã được thêm vào kho với giá [giá]đ". Viết thêm phương thức nhap_them_hang(so_luong) để tăng tồn kho và ban_hang(so_luong) để giảm tồn kho (kiểm tra không cho bán quá số lượng tồn).

  3. (Mục 4 – Thuộc tính & Phương thức) Tạo class TaiKhoanMangXaHoi với thuộc tính ten_nguoi_dungso_luong_bai_post (mặc định = 0). Viết phương thức dang_bai() — mỗi lần gọi sẽ tăng so_luong_bai_post lên 1 và in ra thông báo "đã đăng bài, tổng cộng: X bài". Tạo 2 tài khoản khác nhau, gọi dang_bai() vài lần và kiểm tra xem số bài của mỗi tài khoản có độc lập với nhau không.

  4. (Mục 5 – Inheritance) Tạo class cha PhuongTien với thuộc tính ten và phương thức di_chuyen() in ra "[tên] đang di chuyển". Tạo class con XeMayMayBay kế thừa từ PhuongTien, trong đó MayBay ghi đè di_chuyen() để in "[tên] đang bay trên không". Sau đó tạo class NhanVienBanHang kế thừa NhanVien (ở ví dụ mục 5), có thêm hoa_hong và ghi đè tinh_luong() để cộng thêm hoa hồng.

  5. (Mục 6 – Polymorphism) Tạo class ThongBao với phương thức gui(). Tạo 3 class con: ThongBaoEmail, ThongBaoSMS, ThongBaoPush, mỗi class ghi đè gui() để in ra thông báo khác nhau. Tạo một danh sách chứa cả 3 loại thông báo, dùng vòng lặp for để gọi gui() cho từng cái.

  6. (Mục 7 – Encapsulation) Tạo class HopThuDienTu (email) với thuộc tính private __mat_khau. Viết phương thức doi_mat_khau(mat_khau_cu, mat_khau_moi) — chỉ đổi được nếu mat_khau_cu đúng. Sau đó dùng @property để tạo thuộc tính tuoi cho class NguoiDung, không cho phép gán tuổi âm hoặc lớn hơn 150.

  7. (Mục 8 – Abstraction) Tạo class trừu tượng PhuongThucThanhToan với 2 phương thức trừu tượng: thanh_toan(so_tien)hoan_tien(so_tien). Tạo 2 class con ThanhToanTheATMThanhToanViDienTu, cài đặt đầy đủ 2 phương thức trên theo cách riêng. Thử bỏ sót 1 phương thức ở class con và quan sát lỗi Python báo ra là gì.

  8. (Mục 9 – Magic Methods) Tạo class PhanSo (a/b) với __init__(self, tu_so, mau_so). Cài đặt __str__ để in phân số dạng "a/b". Cài đặt __add__ để cộng 2 phân số (gợi ý: a/b + c/d = (a*d + c*b)/(b*d)). Cài đặt __eq__ để so sánh 2 phân số có bằng nhau không (gợi ý: so sánh a*d == c*b).

Bài tập thử thách (tổng hợp toàn bộ kiến thức)

Xây dựng hệ thống quản lý cửa hàng bán đồ điện tử, kết hợp mọi khái niệm đã học:

  • SanPham — class trừu tượng (dùng abc), có thuộc tính ten, _gia (kiểm soát bằng @property, không cho âm), so_luong; có phương thức trừu tượng mo_ta() và phương thức thường tong_gia_tri().
  • DienThoai, LapTop — kế thừa SanPham, mỗi class override mo_ta() theo cách riêng (đa hình).
  • KhoHang — chứa danh sách sản phẩm, có them_san_pham(), tong_gia_tri_kho() (cộng dồn tong_gia_tri() của mọi sản phẩm — không cần biết là điện thoại hay laptop), và in_bao_cao() (gọi mo_ta() của từng sản phẩm).

Gợi ý khung code:

from abc import ABC, abstractmethod

class SanPham(ABC):
def __init__(self, ten, gia, so_luong):
self.ten = ten
self._gia = gia
self.so_luong = so_luong

@property
def gia(self):
return self._gia

@gia.setter
def gia(self, gia_moi):
if gia_moi < 0:
raise ValueError("Giá không thể âm")
self._gia = gia_moi

def tong_gia_tri(self):
return self._gia * self.so_luong

@abstractmethod
def mo_ta(self):
pass


class DienThoai(SanPham):
def __init__(self, ten, gia, so_luong, dung_luong_pin):
super().__init__(ten, gia, so_luong)
self.dung_luong_pin = dung_luong_pin

def mo_ta(self):
return f"{self.ten} - Pin {self.dung_luong_pin}mAh - {self.gia:,}đ"


# TODO: Bạn hãy tự viết tiếp class LapTop và class KhoHang!

Sau khi hoàn thành khung trên, hãy thử thêm class PhuKien (tai nghe, sạc...) kế thừa SanPham và thêm vào kho — quan sát việc không cần sửa code của KhoHang mà hệ thống vẫn chạy đúng. Đây chính là lợi ích lớn nhất mà OOP mang lại: dễ mở rộng, không phá vỡ code cũ.


Tổng kết

Khái niệmCâu hỏi cốt lõiTừ khóa Python
Class & ObjectBản thiết kế là gì, sản phẩm là gì?class, ()
Attributes & MethodsĐối tượng có gì, làm được gì?self.x, def
__init__Khởi tạo dữ liệu ban đầu ra sao?__init__(self, ...)
InheritanceCái gì kế thừa từ cái gì?class Con(Cha):, super()
PolymorphismCùng hành động, khác cách làm?ghi đè phương thức
EncapsulationGiấu gì, lộ gì?_, __, @property
Getter/SetterKiểm soát đọc/ghi dữ liệu ra sao?@property, @x.setter
AbstractionBắt buộc phải có gì?ABC, @abstractmethod
Magic MethodsLàm sao "dạy" toán tử cho object?__str__, __add__, __eq__

Chúc bạn học tốt và áp dụng OOP thành công vào các dự án Python thực tế!