Nguyên tắc SOLID trong lập trình

Table of contents
- S - Single Responsibility Principle (SRP) - Nguyên tắc Trách nhiệm Đơn nhất
- O - Open/Closed Principle (OCP) - Nguyên tắc Đóng/Mở
- L - Liskov Substitution Principle (LSP) - Nguyên tắc Thay thế Liskov
- I - Interface Segregation Principle (ISP) - Nguyên tắc Phân tách Interface
- D - Dependency Inversion Principle (DIP) - Nguyên tắc Đảo ngược Phụ thuộc
- Tổng kết
Khi mới bắt đầu, chúng ta thường chỉ tập trung viết code sao cho "chạy được". Nhưng khi dự án lớn dần, nhiều người tham gia, hoặc khi bạn cần bảo trì, nâng cấp, bạn sẽ thấy code "chạy được" thôi là chưa đủ. Code cần phải tốt – dễ đọc, dễ hiểu, dễ bảo trì, dễ mở rộng và dễ kiểm thử. SOLID chính là bộ nguyên tắc thiết kế giúp chúng ta đạt được điều đó.
SOLID là viết tắt của 5 chữ cái đầu trong tên của 5 nguyên tắc, do Robert C. Marti giới thiệu và tổng hợp:
S - Single Responsibility Principle (Nguyên tắc Trách nhiệm Đơn nhất)
O - Open/Closed Principle (Nguyên tắc Đóng/Mở)
L - Liskov Substitution Principle (Nguyên tắc Thay thế Liskov)
I - Interface Segregation Principle (Nguyên tắc Phân tách Interface)
D - Dependency Inversion Principle (Nguyên tắc Đảo ngược Phụ thuộc)
Nghe tên có vẻ hàn lâm phải không? Đừng lo, chúng ta sẽ đi vào từng nguyên tắc một cách chi tiết.
S - Single Responsibility Principle (SRP) - Nguyên tắc Trách nhiệm Đơn nhất
Ý nghĩa: Một lớp (class) chỉ nên giữ một trách nhiệm duy nhất, hay nói cách khác, chỉ nên có một lý do để thay đổi.
SRP khuyên chúng ta rằng, thay vì một "lớp đa năng" làm đủ thứ, hãy chia nhỏ nó ra. Một lớp chỉ nên làm một việc thôi.
Lợi ích:
Code dễ hiểu hơn: Bạn nhìn vào tên lớp là đoán được nó làm gì.
Dễ bảo trì và thay đổi: Khi cần sửa logic lưu database, bạn chỉ cần vào
UserRepository
mà không sợ ảnh hưởng đến việc quản lý thông tinUser
hay gửi email.Dễ kiểm thử (test): Bạn có thể test từng lớp riêng biệt dễ dàng hơn.
Giảm sự phụ thuộc lẫn nhau (coupling): Các thành phần ít dính vào nhau hơn.
Ví dụ, khi xây dựng một ứng dụng quản lý
KHÔNG nên (Vi phạm SRP):
Xây dựng lớp UserSettings với 3 chức năng:
Thay đổi username
Ghi dữ liệu vào database
Gửi email
→ Lớp UserSettings
lúc này có tới 3 lý do để thay đổi: thay đổi logic kiểm tra username, thay đổi cách lưu database, thay đổi cách gửi email.
class UserSettings:
def __init__(self, user):
self.user = user
def change_username(self, new_username):
if len(new_username) > 3:
print(f"Changing username for {self.user} to {new_username}")
# Logic thay đổi username...
else:
print("Username too short")
# !!! VI PHẠM SRP: Lớp này còn làm cả việc kết nối DB !!!
def save_to_database(self):
print(f"Connecting to database...")
print(f"Saving settings for {self.user}...")
# Logic lưu vào DB...
# !!! VI PHẠM SRP: Lớp này còn làm cả việc gửi email !!!
def send_confirmation_email(self):
print(f"Sending confirmation email to {self.user}...")
# Logic gửi email...
NÊN (Tuân thủ SRP):
Bây giờ, mỗi lớp chỉ làm đúng một việc.
Lớp
User
chỉ nên quản lý thông tin người dùng (tên, tuổi, email).→ Thay đổi cơ chế đổi username? Sửa
User
.Lớp
UserRepository
chỉ đảm nhận ghi thông tin vào database→ Thay đổi database? Sửa
UserRepository
.Lớp
EmailService
chỉ thực hiện gửi email→ Thay đổi cách gửi mail? Sửa
EmailService
.class User: def __init__(self, name): self.name = name def change_username(self, new_username): if len(new_username) > 3: self.name = new_username print(f"Username changed to {self.name}") return True else: print("Username too short") return False class UserRepository: def save(self, user): print(f"Connecting to database...") print(f"Saving user {user.name}...") # Logic lưu vào DB... class EmailService: def send_confirmation(self, user): print(f"Sending confirmation email to {user.name}...") # Logic gửi email... # Cách sử dụng: user = User("Alice") if user.change_username("AliceWonderland"): repo = UserRepository() repo.save(user) # UserRepository chịu trách nhiệm lưu mailer = EmailService() mailer.send_confirmation(user) # EmailService chịu trách nhiệm gửi mail
O - Open/Closed Principle (OCP) - Nguyên tắc Đóng/Mở
Ý nghĩa: Các thực thể (entity) phần mềm (lớp, module, hàm...) nên mở cho việc mở rộng (open for extension) nhưng đóng cho việc sửa đổi (closed for modification).
Code của bạn nên được thiết kế sao cho khi muốn thêm chức năng mới (ví dụ hỗ trợ thêm một loại báo cáo mới, một hình thức thanh toán mới), bạn chỉ cần viết thêm code mới (mở rộng) mà không cần phải sửa đổi code cũ đã chạy ổn định và đã được kiểm thử (đóng). Thường thì chúng ta đạt được điều này qua kế thừa (inheritance) hoặc đa hình (polymorphism) thông qua các lớp trừu tượng (abstract classes) hoặc giao diện (interfaces).
Lợi ích:
Giảm rủi ro gây lỗi: Không sửa code cũ thì không sợ làm hỏng chức năng cũ.
Dễ dàng thêm chức năng mới.
Tăng khả năng bảo trì.
Ví dụ Code (Python):
KHÔNG nên (Vi phạm OCP):
class DiscountCalculator: def calculate(self, customer_type, amount): discount = 0 if customer_type == 'standard': discount = amount * 0.05 elif customer_type == 'premium': discount = amount * 0.1 # !!! VI PHẠM OCP: Nếu thêm loại khách 'vip', phải sửa hàm này !!! elif customer_type == 'vip': # Phải thêm dòng này discount = amount * 0.2 return discount # Khi có thêm loại 'platinum', lại phải vào sửa hàm calculate # calculator = DiscountCalculator() # print(calculator.calculate('vip', 100)) # Phải sửa code cũ mới chạy được
Mỗi lần thêm loại khách hàng mới, bạn phải vào sửa đổi hàm
calculate
.NÊN (Tuân thủ OCP): Dùng chiến lược (Strategy Pattern) hoặc kế thừa.
from abc import ABC, abstractmethod # 1. Định nghĩa một "chuẩn" (interface/abstract class) cho việc tính discount class DiscountStrategy(ABC): @abstractmethod def calculate_discount(self, amount): pass # 2. Tạo các lớp cụ thể thực hiện "chuẩn" đó cho từng loại khách hàng class StandardDiscount(DiscountStrategy): def calculate_discount(self, amount): return amount * 0.05 class PremiumDiscount(DiscountStrategy): def calculate_discount(self, amount): return amount * 0.1 # !!! MỞ RỘNG: Thêm loại VIP mà không sửa code cũ !!! class VipDiscount(DiscountStrategy): def calculate_discount(self, amount): return amount * 0.2 # 3. Lớp sử dụng sẽ làm việc với "chuẩn" class DiscountCalculator: def calculate(self, strategy: DiscountStrategy, amount): # Chỉ cần gọi phương thức theo "chuẩn", không cần biết cụ thể là loại nào return strategy.calculate_discount(amount) # Cách sử dụng: calculator = DiscountCalculator() standard_strategy = StandardDiscount() premium_strategy = PremiumDiscount() vip_strategy = VipDiscount() # Tạo chiến lược mới print(f"Standard Discount: {calculator.calculate(standard_strategy, 100)}") print(f"Premium Discount: {calculator.calculate(premium_strategy, 100)}") print(f"VIP Discount: {calculator.calculate(vip_strategy, 100)}") # Sử dụng chiến lược mới # Nếu sau này có thêm loại 'Platinum', chỉ cần tạo lớp PlatinumDiscount mới # mà không cần sửa DiscountCalculator hay các lớp chiến lược cũ.
Bây giờ, để thêm loại khách hàng mới, bạn chỉ cần tạo một lớp
DiscountStrategy
mới. LớpDiscountCalculator
không cần sửa đổi.
L - Liskov Substitution Principle (LSP) - Nguyên tắc Thay thế Liskov
Ý nghĩa: Các đối tượng của lớp con (subclass/derived class) phải có thể thay thế được cho các đối tượng của lớp cha (superclass/base class) mà không làm thay đổi tính đúng đắn của chương trình.
Giải thích gần gũi: Nếu bạn có một hàm
lam_viec_voi_hinh(hinh: HinhChuNhat)
, hàm này mong đợi nhận vào một đối tượngHinhChuNhat
và có thể gọi các phương thức nhưset_chieu_rong()
,set_chieu_cao()
. Bây giờ bạn tạo lớpHinhVuong
kế thừa từHinhChuNhat
(vì về mặt toán học, hình vuông là một hình chữ nhật đặc biệt). Tuy nhiên, nếu trongHinhVuong
, việc gọiset_chieu_rong(5)
lại tự động làm cho chiều cao cũng bằng 5 (để đảm bảo nó vẫn là hình vuông), thì hành vi này khác với mong đợi của hàmlam_viec_voi_hinh
khi làm việc vớiHinhChuNhat
thông thường (nơi set chiều rộng không ảnh hưởng chiều cao). Như vậy,HinhVuong
không hoàn toàn thay thế được choHinhChuNhat
trong mọi ngữ cảnh, và có thể đã vi phạm LSP. Nói đơn giản: Con phải "cư xử" giống Cha ở những khía cạnh mà người khác mong đợi từ Cha.Lợi ích:
Đảm bảo tính đúng đắn của hệ thống phân cấp kế thừa.
Giúp sử dụng đa hình một cách an toàn.
Code dễ dự đoán hành vi hơn.
Ví dụ Code (Python):
CÓ THỂ Vi phạm LSP (Ví dụ kinh điển Hình chữ nhật - Hình vuông): Python
class Rectangle: def __init__(self, width, height): self._width = width self._height = height @property def width(self): return self._width @width.setter def width(self, value): self._width = value @property def height(self): return self._height @height.setter def height(self, value): self._height = value def area(self): return self._width * self._height class Square(Rectangle): def __init__(self, size): # Khởi tạo với width = height = size super().__init__(size, size) # Khi set width, phải set cả height để đảm bảo là hình vuông @Rectangle.width.setter def width(self, value): self._width = value self._height = value # !!! Thay đổi hành vi so với Rectangle gốc !!! # Khi set height, phải set cả width @Rectangle.height.setter def height(self, value): self._width = value # !!! Thay đổi hành vi so với Rectangle gốc !!! self._height = value # Hàm sử dụng Rectangle, mong đợi set width và height độc lập def use_rectangle(rect: Rectangle): w = 5 h = 10 rect.width = w # Mong đợi chỉ width thay đổi rect.height = h # Mong đợi chỉ height thay đổi # Mong đợi diện tích là w * h = 50 expected_area = w * h actual_area = rect.area() print(f"Expected Area: {expected_area}, Actual Area: {actual_area}") assert actual_area == expected_area # Kiểm tra r = Rectangle(2, 3) print("Using Rectangle:") use_rectangle(r) # Output: Expected Area: 50, Actual Area: 50 (Đúng) s = Square(4) print("\nUsing Square (as a Rectangle):") use_rectangle(s) # Output: Expected Area: 50, Actual Area: 100 (Sai!) # Vì khi gọi rect.height = h (tức là 10), width cũng bị set thành 10. # Assertion sẽ thất bại! => Vi phạm LSP
Square
không thể thay thế hoàn toàn choRectangle
vì nó thay đổi "hợp đồng" (contract) của phương thứcsetter
.Cách khắc phục: Có thể không cho
Square
kế thừaRectangle
, hoặc định nghĩa lại mối quan hệ, hoặc không sử dụng setter theo cách đó. LSP nhắc nhở chúng ta phải cẩn thận khi kế thừa.
I - Interface Segregation Principle (ISP) - Nguyên tắc Phân tách Interface
Ý nghĩa: Client (lớp sử dụng interface) không nên bị buộc phải phụ thuộc vào những phương thức (trong interface) mà chúng không sử dụng. Nên tạo ra các interface nhỏ, cụ thể thay vì một interface lớn, tổng quát.
Giải thích gần gũi: Giả sử bạn có một interface (bản thiết kế) tên là
Worker
(Công nhân) định nghĩa các hành động:work()
(làm việc),eat()
(ăn),sleep()
(ngủ). Điều này có vẻ ổn vớiHumanWorker
(Công nhân người). Nhưng nếu bạn cóRobotWorker
(Công nhân robot), nó chỉ cầnwork()
, không cầneat()
haysleep()
. NếuRobotWorker
buộc phải "implement" (thực thi) interfaceWorker
, nó sẽ phải viết code cho cảeat()
vàsleep()
(có thể là để trống hoặc báo lỗi), dù không dùng đến. ISP nói rằng, tốt hơn hết là chia nhỏWorker
thành các interface nhỏ hơn:IWorkable
(có thể làm việc) với phương thứcwork()
,IEatable
(có thể ăn) vớieat()
,ISleepable
(có thể ngủ) vớisleep()
. Khi đó,HumanWorker
sẽ implement cả 3, cònRobotWorker
chỉ cần implementIWorkable
.Lợi ích:
Giảm sự phụ thuộc không cần thiết.
Tăng tính gắn kết (cohesion) của interface (mỗi interface tập trung vào một nhóm hành vi liên quan).
Code linh hoạt hơn, dễ thay đổi và tái sử dụng hơn.
Ví dụ Code (Python): Dùng Abstract Base Classes (ABC) để mô phỏng interface.
KHÔNG nên (Vi phạm ISP):
from abc import ABC, abstractmethod # Interface "béo" (fat interface) class Worker(ABC): @abstractmethod def work(self): pass @abstractmethod def eat(self): pass # Robot không cần ăn! @abstractmethod def sleep(self): pass # Robot không cần ngủ! class HumanWorker(Worker): def work(self): print("Human working...") def eat(self): print("Human eating...") def sleep(self): print("Human sleeping...") class RobotWorker(Worker): def work(self): print("Robot working...") # Buộc phải implement dù không cần thiết def eat(self): print("Robot cannot eat!") # Hoặc pass, hoặc raise Exception def sleep(self): print("Robot does not sleep!") # Hoặc pass, hoặc raise Exception # Sử dụng human = HumanWorker() robot = RobotWorker() human.eat() # robot.eat() # Gọi hàm này có thể không như mong đợi
RobotWorker
bị ép phải định nghĩaeat
vàsleep
.NÊN (Tuân thủ ISP):
from abc import ABC, abstractmethod # Interface nhỏ, chuyên biệt class IWorkable(ABC): @abstractmethod def work(self): pass class IEatable(ABC): @abstractmethod def eat(self): pass class ISleepable(ABC): @abstractmethod def sleep(self): pass # Lớp nào cần gì thì implement cái đó class HumanWorker(IWorkable, IEatable, ISleepable): def work(self): print("Human working...") def eat(self): print("Human eating...") def sleep(self): print("Human sleeping...") # Robot chỉ cần làm việc class RobotWorker(IWorkable): def work(self): print("Robot working...") # Sử dụng human = HumanWorker() robot = RobotWorker() def manage_work(worker: IWorkable): print("-- Managing work --") worker.work() manage_work(human) manage_work(robot) # Robot chỉ cần work là đủ để hàm này chạy đúng # Chỉ Human mới có thể ăn if isinstance(human, IEatable): human.eat() # if isinstance(robot, IEatable): # Điều này sẽ là False # robot.eat()
Bây giờ
RobotWorker
chỉ phụ thuộc vào interfaceIWorkable
mà nó thực sự sử dụng.
D - Dependency Inversion Principle (DIP) - Nguyên tắc Đảo ngược Phụ thuộc
Ý nghĩa:
Các module cấp cao (high-level modules - chứa logic nghiệp vụ chính) không nên phụ thuộc vào các module cấp thấp (low-level modules - chứa chi tiết kỹ thuật như đọc file, kết nối DB). Cả hai nên phụ thuộc vào trừu tượng (abstractions - interfaces hoặc abstract classes).
Trừu tượng không nên phụ thuộc vào chi tiết (details). Chi tiết nên phụ thuộc vào trừu tượng.
Giải thích gần gũi: Tưởng tượng bạn đang xây nhà (module cấp cao). Bạn cần điện. Thay vì bạn tự đi nối dây trực tiếp vào nhà máy điện (module cấp thấp), bạn chỉ cần quan tâm đến việc có một cái ổ cắm điện (abstraction/interface) trên tường theo đúng chuẩn. Công ty điện lực (module cấp thấp) cũng phải đảm bảo họ cung cấp điện ra cái ổ cắm đó theo đúng chuẩn. Như vậy, cả bạn (người xây nhà) và công ty điện lực đều phụ thuộc vào cái "chuẩn ổ cắm" (abstraction). Bạn có thể đổi nhà cung cấp điện khác miễn là họ tuân theo chuẩn đó, mà không cần sửa lại hệ thống điện trong nhà. "Đảo ngược" ở đây nghĩa là thay vì module cấp cao phụ thuộc trực tiếp vào module cấp thấp, thì cả hai cùng phụ thuộc vào một cái gì đó trừu tượng ở giữa, và chiều phụ thuộc bị "đảo ngược" thông qua cái trừu tượng đó. Kỹ thuật phổ biến để thực hiện điều này là Dependency Injection (DI - Tiêm phụ thuộc).
Lợi ích:
Giảm sự phụ thuộc cứng (tight coupling) giữa các module. Module cấp cao không bị "dính chặt" vào chi tiết triển khai của module cấp thấp.
Tăng khả năng thay thế: Dễ dàng thay đổi module cấp thấp (ví dụ đổi từ lưu vào file sang lưu vào database) mà không ảnh hưởng module cấp cao.
Tăng khả năng kiểm thử: Dễ dàng "mock" (giả lập) các module cấp thấp khi test module cấp cao.
Code linh hoạt và dễ bảo trì hơn.
Ví dụ Code (Python):
KHÔNG nên (Vi phạm DIP):
Python
# Module cấp thấp (chi tiết) class FileLogger: def log_message(self, message): with open("log.txt", "a") as f: f.write(f"FILE LOG: {message}\n") # Module cấp cao (logic nghiệp vụ) class OrderProcessor: def __init__(self): # !!! VI PHẠM DIP: Phụ thuộc trực tiếp vào lớp cụ thể FileLogger !!! self.logger = FileLogger() def process_order(self, order_id): print(f"Processing order {order_id}...") # ... logic xử lý order ... self.logger.log_message(f"Order {order_id} processed.") # Sử dụng processor = OrderProcessor() processor.process_order("A123") # Nếu muốn đổi sang ghi log vào Database, phải sửa code của OrderProcessor
OrderProcessor
biết và trực tiếp tạo raFileLogger
. Chúng bị phụ thuộc cứng.NÊN (Tuân thủ DIP): Dùng Abstraction và Dependency Injection.
from abc import ABC, abstractmethod # 1. Định nghĩa Abstraction (Interface) cho việc logging class ILogger(ABC): @abstractmethod def log_message(self, message): pass # 2. Module cấp thấp (chi tiết) triển khai Abstraction class FileLogger(ILogger): # Chi tiết phụ thuộc vào Abstraction def log_message(self, message): with open("log.txt", "a") as f: f.write(f"FILE LOG: {message}\n") class DatabaseLogger(ILogger): # Chi tiết khác cũng phụ thuộc Abstraction def log_message(self, message): print(f"DATABASE LOG: {message}") # Giả lập ghi DB class ConsoleLogger(ILogger): # Thêm một chi tiết nữa dễ dàng def log_message(self, message): print(f"CONSOLE LOG: {message}") # 3. Module cấp cao phụ thuộc vào Abstraction (ILogger) class OrderProcessor: # !!! Dependency Injection: Nhận logger từ bên ngoài qua constructor !!! def __init__(self, logger: ILogger): # Phụ thuộc vào ILogger, không phải lớp cụ thể self.logger = logger # Lưu trữ tham chiếu đến Abstraction def process_order(self, order_id): print(f"Processing order {order_id}...") # ... logic xử lý order ... # Sử dụng logger thông qua Abstraction self.logger.log_message(f"Order {order_id} processed.") # 4. "Tiêm" (Inject) đối tượng cụ thể vào lúc sử dụng (ở đâu đó bên ngoài) # Chọn logger muốn dùng # file_logger = FileLogger() db_logger = DatabaseLogger() # console_logger = ConsoleLogger() # Tạo OrderProcessor và "tiêm" logger đã chọn processor_using_db = OrderProcessor(db_logger) processor_using_db.process_order("B456") # Nếu muốn đổi logger, chỉ cần thay đổi lúc khởi tạo console_logger = ConsoleLogger() processor_using_console = OrderProcessor(console_logger) processor_using_console.process_order("C789") # OrderProcessor không cần biết chi tiết File, DB hay Console, # nó chỉ làm việc với "hợp đồng" ILogger.
OrderProcessor
giờ chỉ phụ thuộc vàoILogger
(abstraction). Chúng ta có thể cung cấp (inject) bất kỳ logger nào (File, DB, Console) miễn là nó tuân thủILogger
, mà không cần sửa đổiOrderProcessor
.
Tổng kết
5 nguyên tắc SOLID không phải là luật lệ cứng nhắc bắt buộc phải tuân theo 100% mọi lúc mọi nơi, mà là những kim chỉ nam cực kỳ giá trị giúp bạn viết code tốt hơn.
SRP: Mỗi lớp làm một việc.
OCP: Mở rộng thoải mái, hạn chế sửa đổi code cũ.
LSP: Con phải thay thế được Cha mà không gây rối.
ISP: Đừng bắt người khác dùng thứ họ không cần (chia nhỏ interface).
DIP: Phụ thuộc vào trừu tượng, không phải chi tiết cụ thể.
Lợi ích chung của việc áp dụng SOLID là tạo ra các hệ thống phần mềm:
Dễ bảo trì (Maintainable): Sửa lỗi hoặc thay đổi ít ảnh hưởng đến các phần khác.
Linh hoạt (Flexible): Dễ dàng thích ứng với các yêu cầu mới.
Dễ mở rộng (Scalable/Extensible): Dễ dàng thêm chức năng mới.
Dễ hiểu (Understandable): Code rõ ràng, cấu trúc tốt.
Dễ kiểm thử (Testable): Các thành phần độc lập dễ dàng được kiểm thử riêng biệt.
Ban đầu, việc áp dụng SOLID có thể khiến bạn cảm thấy code dài dòng hơn một chút, phải tạo nhiều lớp, nhiều interface hơn. Nhưng tin tôi đi, khi dự án phức tạp lên, lợi ích mà nó mang lại là vô cùng lớn, tiết kiệm rất nhiều thời gian và công sức về lâu dài.
Hãy bắt đầu áp dụng từng nguyên tắc nhỏ trong các bài tập, dự án cá nhân của bạn. Đọc code của người khác, xem họ áp dụng chúng như thế nào. Dần dần, nó sẽ trở thành một phần trong tư duy thiết kế của bạn.
Subscribe to my newsletter
Read articles from Ha Ngoc Hieu directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
