SOLID qua các ví dụ Python
Bài đăng này đã không được cập nhật trong 2 năm
Khi lập trình phần mềm, tuân thủ các quy tắc phát triển (nhất là các best-practise) là rất quan trọng. Bài này mình sẽ sử dụng các ví dụ Python để các bạn có thể hiểu bộ nguyên tắc SOLID trong phát triển phần mềm nhé.
1. Single Responsibility Principle
SRP hay SOC (Seperation of Concerns) : Mỗi class chỉ nên có một và chỉ một chức năng duy nhất.
- Class
Journaldưới đây vi phạm nguyên tắc này do nó vừa mang chức năng quản lý (thêm/xóa entry), vừa có chức năng lưu thông tin vào file dữ liệu.
class Journal:
def __init__(self):
self.entries = []
self.count = 0
def add_entry(self, text):
self.count += 1
self.entries.append(f"{self.count}: {text}")
def remove_entry(self, pos):
del self.entries[pos]
def __str__(self):
return '\n'.join(self.entries)
def save(self, filename):
file = open(filename, 'w')
file.write(str(self))
file.close()
j = Journal()
j.add_entry("I cried today.")
j.add_entry("I ate a bug.")
print(f"Journal entries:\n{j}")
Output trả về là:
Journal entries:
1: I cried today.
2: I ate a bug.
- Vậy tại sao thiết kế class không tuân thủ SRP như này thì không tốt?
- Trả lời: trong code của bạn sẽ có nhiều class giống như
Journalvà cũng cần chức năng lưu thông tin vào file dữ liệu. Khi đó, sẽ xảy ra 2 vấn đề:- Lặp code: tất cả các class sẽ đều phải chứa phương thức
save()để thực hiện lưu dữ liệu - Khó bảo trì: giả sử bạn muốn thay đổi phương thức
save()này. VD bạn muốn check xem người dùng có quyền save hay không trước khi cho phépsave(), khi đó bạn sẽ phải update tất cả các class chứa phương thức này.
- Lặp code: tất cả các class sẽ đều phải chứa phương thức
- Để đảm bảo SRP, ta refactor đoạn code trên như sau:
class Journal:
def __init__(self):
self.entries = []
self.count = 0
def add_entry(self, text):
self.count += 1
self.entries.append(f"{self.count}: {text}")
def remove_entry(self, pos):
del self.entries[pos]
def __str__(self):
return '\n'.join(self.entries)
class PersistanceManager:
@staticmethod
def save(self, filename):
file = open(filename, 'w')
file.write(str(self))
file.close()
-
Ở đây, ta đã bỏ phương thức
save()khỏi classJournalđể đảm bảo class này chỉ có 1 chức năng (responsibility) duy nhất là quản lý entry. Chức năng lưu dữ liệu được chuyển sang class khác, làPersistanceManager. Class này cũng chỉ có chức năng là tương tác với file dữ liệu. -
Bonus:
- Ta thường gặp khái niệm anti-pattern để chỉ việc viết code không tuân thủ theo quy tắc (pattern) được khuyến nghị.
- God class là những class có nhiều hơn 1 chức năng (như class
Journalở trên trước khi refactor). God class là một anti-pattern.
2. Open-close Principle
OCP (open for extension, close for modification): khi đã viết và test xong 1 class thì không nên sửa nó nữa, chỉ nên kế thừa nó.
- Hãy xét 1 ví dụ không tuân thủ OCP dưới đây:
from enum import Enum
class Color(Enum):
RED = 1
GREEN = 2
BLUE = 3
class Size(Enum):
SMALL = 1
MEDIUM = 2
LARGE = 3
class Product:
def __init__(self, name, color, size):
self.name = name
self.color = color
self.size = size
class ProductFilter:
def filter_by_color(self, products, color):
for p in products:
if p.color == color: yield p
def filter_by_size(self, products, size):
for p in products:
if p.size == size: yield p
- Ta có 2 class chính,
Productchứa các đặc tính của sản phẩm vàProductFilterdùng để lọc ra sản phẩm theo theo thuộc tính của nó. - Giả sử lúc đầu, class
ProductFilterchỉ có methodfilter_by_colordo yêu cầu lúc đó chỉ có thế. Nhưng sau đó yêu cầu thay đổi, sếp của bạn yêu cầu bạn viết thêm methodfilter_by_sizevà bạn viết thêm vào classProductFilternhư trên. - Nếu sếp bạn lại yêu cầu bạn viết thêm 1 method
filter_by_color_and_sizethì sao? Rõ ràng việc cứ viết thêm vào classProductFiltersẽ khiến class này không ngừng thay đổi. - State space explosion: (bùng nổ không gian trạng thái) dùng để chỉ việc này, theo đó, nếu class
Productcó 2 thuộc tính, sếp của bạn có thể yêu cầu thay đổi 3 lần như trên. Nếu có 3 thuộc tính thì sẽ là 7 yêu cầu có thể có => classProductFiltersẽ không ổn định mà liên tục thay đổi. - Vấn đề này được giải quyết bằng cách định nghĩa thêm 2 abstract class là
SepcificationvàFiltervà classBetterFilternhư sau:
class Specification:
def is_satisfied(self, item):
pass
class Filter:
def filter(self, items, spec):
pass
class BetterFilter(Filter):
def filter(self, items, spec):
for item in items:
if spec.is_satified(): yield item
-
Với cách triển khai này, khi có yêu cầu mới từ sếp bạn không cần vào classs
Filterđể thêm các method mới nữa. Thay vào đó, ta chỉ cần kế thừa giống như classBetterFilterđã làm ở trên. -
OK, giờ hãy test hoạt động của các đoạn code viết phía trên. Đầu tiên ta sẽ dùng đoạn code sử dụng class
ProductFilter(vi phạm OCP).
apple = Product("Apple", Color.GREEN, Size.SMALL)
tree = Product("Tree", Color.GREEN, Size.LARGE)
house = Product("House", Color.BLUE, Size.LARGE)
products = [apple, tree, house]
pf = ProductFilter()
print("Green products (old):")
for p in pf.filter_by_color(products, Color.GREEN):
print(f"{p.name} is green.")
- Kết quả như sau:
Green products (old):
Apple is green.
Tree is green.
- Giờ hãy dùng class
BetterFilterđể so sánh kết quả.
bf = BetterFilter()
print("Green products (new):")
green = ColorSpecification(Color.GREEN)
for p in bf.filter(products, green):
print(f"{p.name} is green.")
- Và tất nhiên là kết quả in ra y hệt.
Green products (new):
Apple is green.
Tree is green.
- Vậy nếu muốn filter ra products LARGE và BLUE thì sao? Hãy định nghĩa thêm 1 class là
AndSpecificationkế thừa từ classSpecification:
class AndSpecification(Specification):
def __init__(self, *args):
self.args = args
def is_satisfied(self, item):
return all(map(
lambda spec: spec.is_satisfied(item), self.args
))
- Từ đó, ta có thể dễ dàng sử dụng class này như sau:
print("Large blue items:")
and_spec = AndSpecification(
ColorSpecification(Color.BLUE),
SizeSpecification(Size.LARGE)
)
for p in bf.filter(products, and_spec):
print(f"{p.name} is large and blue.")
- Kết quả in ra là:
Large blue items:
House is large and blue.
- Một refactor nhỏ để khiến code của bạn trông fancy hơn là hãy ghi đè operator
&thông qua method__and__của classSpecification:
class Specification:
def is_satisfied(self, item):
pass
def __and__(self, other):
return AndSpecification(self, other)
- Ta có thể sử dụng operator
&này như sau:
print("Large blue items:")
blue_large = ColorSpecification(Color.BLUE) & SizeSpecification(Size.LARGE)
for p in bf.filter(products, blue_large):
print(f"{p.name} is large and blue.")
- Kết quả tương tự như dùng trực tiếp class
AndSpecification:
Large blue items:
House is large and blue.
Chốt lại xíu, Tại sao OCP lại quan trọng nhỉ?
- Khi viết 1 class và test nó kỹ càng, thậm chí push lên production rồi thì tâm lý chung là không ai muốn sửa nó, do rủi ro rất cao, không chỉ ảnh hưởng đến chức năng mới mà có thể làm các chức năng cũ không hoạt động như kỳ vọng nữa => Đây là lúc tuân thủ OCP phát huy tác dụng: Theo đó, muốn triển thêm chức năng mới ta sẽ kế thừa class đã có để phát triển thêm vào chứ không sửa class đang hoạt động.
3. Liskov Substitution Principle
LSP: Đối tượng của lớp con (subclass) phải có thể thay thế đối tượng của lớp cha (superclass) mà không làm thay đổi tính đúng đắn của chương trình.
- Dưới đây sẽ là 1 ví dụ vi phạm LSP. theo đó, class
Squarekế thừa classRectanglenhưng hàmuse_itchỉ có thể hoạt động với class chaRectanglemà không thể hoạt động với class conSquare.
class Rectangle:
def __init__(self, width, height):
self._width = width
self._height = height
@property
def area(self): return self._width * self._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 __str__(self):
return f'Width: {self.width}, height: {self.height})'
class Square(Rectangle):
def __init__(self, size):
super().__init__(size, size)
@Rectangle.width.setter
def width(self, value):
self._width = self._height = value
@Rectangle.height.setter
def height(self, value):
self._height = self._width = value
def use_it(rec):
w = rec.width
rec.height = 10
expected = int(w*10)
print(f'Expected an area of {expected}, got {rec.area}')
rc = Rectangle(2, 3)
use_it(rc)
sq = Square(5)
use_it(sq)
- Kết quả là:
Expected an area of 20, got 20
Expected an area of 50, got 100
- Đoạn code vi phạm LSP đưa ra kết quả đúng với class
Rectanglenhưng sai với classSquarevì classSquaređã thay đổi hành vi của các phương thức setter. - Ở hàm
use_itta lấy ra giá trị_widthtừ đối tượng, sau đó gọi hàm setterheight. Hàm này ở classRectanglechỉ thay đổi thuộc tính_heightnên kết quả cho ra là đúng, nhưng classSquaređã ghi đè lại hàm setter này, làm nó không những thay đổi_heightmà còn thay đổi cả_width, do đó, giá trị_widthđược lấy ra lúc đầu không còn là giá trị được cập nhật mới nhất nữa. - Có nhiều cách để xử lý vi phạm LSP với đoạn code trên, có thể kể đến như:
- Thêm 1 phương thức trong class
Rectangleđể xử lý với caseRectanglelàSquare(_width = _height) - Tạo ra 1 base class
Shapevà 2 classRectangle,Squaresẽ kế thừa class này. Implement cẩn thận để hàm setter không ghi đè nhau.
- Thêm 1 phương thức trong class
- Tại sao cần tuân thủ LSP?
- Dễ maintain, dễ tái sử dụng, tránh bug: Đảm bảo subclass dùng thay thế base class sẽ làm cho codebase dễ maintain hơn + dev có thể vô tư tái sử dụng mà không lo bị lỗi (các class kế thừa nhau có behaviour giống nhau rõ ràng dễ dùng lại, dễ maintain và khó gặp bug hơn đúng không nào).
- Là best-practise.
4. Interface Segregation Principle
ISP: Đừng bỏ quá nhiều thứ vào interface.
- Nguyên tắc này tương đối giống với SRP. Điểm khác biệt là nó tập trung vào interface (abstract class để cho các class khác kế thừa/implement).
- Hãy xem xét ví dụ vi phạm ISP của class
Machinedưới đây:
class Machine:
def print(self, document):
raise NotImplementedError
def fax(self, document):
raise NotImplementedError
def scan(self, document):
raise NotImplementedError
- Class này "bắt buộc" các class kế thừa nó phải implement lại 3 method
print,faxvàscannhư classMultiFunctionPrinterbên dưới:
class MultiFunctionPrinter(Machine):
def print(self, document):
pass
def fax(self, document):
pass
def scan(self, document):
pass
- Vậy nếu người dùng không muốn có cả 3 method của
Machinethì sao?
class OldFashionedPrinter(Machine):
def print(self, document):
pass
def fax(self, document):
pass
def scan(self, document):
raise NotImplementedError('Printer cannot scan!')
- Ở class
OldFashionedPrinterphía trên, giả sử người dùng chỉ muốn tạo ra class chỉ có 1 method làprint, 2 method còn lại có thể đểpass(không làm gì),raise Error,... thì khi khi đọc đoạn code nhiều khi vẫn làm ta confuse làOldFashionedPrinterthực ra có cảfaxvàscan, và chúng hoàn toàn có thể gọi được. - OK, giờ ta sẽ refactor đoạn code trên để không vi phạm ISP.
from abc import abstractmethod
class Printer:
@abstractmethod
def print(self, document):
pass
class Scanner:
@abstractmethod
def scan(self, document):
pass
class MyPrinter(Printer):
def print(self, document):
print(document)
class Photocopier(Printer, Scanner):
def print(self, document):
pass
def scan(self, document):
pass
- Với việc tách các method
printvàclasstừ cùng chung 1 classMachinera 2 classPrintervàScannernhư trên, ta thấy rằng: classMyPrintercó thể thoải mái chỉ implement methodprintnếu không cần tới methodscan, và việc đọc code cũng trở lên dễ dàng hơn. - Có thể dễ dàng nhìn thấy
MyPrinterchỉ có methodprinttrong khiPhotocopiercó 2 methodprintvàscan. - Ví dụ trên đã chỉ ra rõ lợi ích của ISP rồi đúng không. Giờ quay lại câu hỏi là ISP khác SRP ở chỗ nào? Chốt lại thì chúng có điểm khác nhau cơ bản sau đây: SRP đảm bảo cho mỗi class có 1 và chỉ 1 nhiệm vụ trong khi ISP đảm bảo các interface được thiết kế nhỏ, tập trung nhằm tránh người dùng phải quan tâm hay phụ thuộc vào các method thừa thãi.
5. Dependency Inversion Principle
DIP: class/module mức cao không nên phụ thuộc trực tiếp vào mức thấp, thay vào đó hãy cùng phụ thuộc vào 1 class trừu tượng.
- Ví dụ dưới đây vi phạm DIP:
from enum import Enum
class Relationship(Enum):
PARENT = 1
CHILD = 2
SIBLING = 3
class Person:
def __init__(self, name):
self.name = name
class Relationships:
def __init__(self):
self.relations = []
def add_parent_and_child(self, parent, child):
self.relations.append(
(parent, Relationship.PARENT, child)
)
self.relations.append(
(child, Relationship.CHILD, parent)
)
class Research:
def __init__(self, relationships):
relations = relationships.relations
for r in relations:
if r[0].name == 'John' and r[1] == Relationship.PARENT:
print(f'John has a child called {r[0].name}.')
parent = Person('John')
child1 = Person('Chris')
child2 = Person('Matt')
relationships = Relationships()
relationships.add_parent_and_child(parent, child1)
relationships.add_parent_and_child(parent, child2)
Research(relationships)
- Kết quả trả về:
John has a child called John.
John has a child called John.
- Ta có, class
Researchlà 1 class mức cao, phụ thuộc trực tiếp vào class mức thấp làRelationships. Do đó, giả sử thay đổi hàm__init__của classRelationships, thay vì dùng list để lưu relationsself.relations = [], ta sẽ dùng dictionaryself.relations = {}. Ngay lập tức, hàm__init__của classResearchsẽ lỗi, do class này sử dụng những phép toán với kiểu dữ liệu list của biếnrelations=> Vi phạm DIP. - Từ ví dụ này ta có thể thấy vi phạm DIP sẽ gây ra 1 vấn đề là: làm cho các class/module liên kết với nhau chặt chẽ => khó sửa đổi/thay thế mà không gây ảnh hưởng lẫn nhau.
- Giờ cùng refactor đoạn code trên để tuân thủ DIP nào:
class RelationshipBrowser:
@abstractmethod
def find_all_children_of(self, name): pass
class Relationships(RelationshipBrowser):
def __init__(self):
self.relations = []
def add_parent_and_child(self, parent, child):
self.relations.append(
(parent, Relationship.PARENT, child)
)
self.relations.append(
(child, Relationship.CHILD, parent)
)
def find_all_children_of(self, name):
for r in self.relations:
if r[0].name == name and r[1] == Relationship.PARENT:
yield r[2].name
- Ở đây đã tạo ra 1 "interface"
RelationshipBrowservới methodfind_all_children_of. Và cả 2 classRelationships,Researchđều phụ thuộc vào implementation của methodfind_all_children_of(của interfaceRelationshipBrowser) này thay vì class bậc cao (Research) phụ thuộc vào class bậc thấp (Relationships) như trên. Do đó, DIP đã được tuân thủ. - Dễ dàng nhận thấy đoạn code này dễ bảo trì/thay đổi hơn nhiều. Giả sử thuộc tính
relationscủa classRelationshipschuyển từ dữ liệu list sang dictionary, ta sẽ re-implement lại methodfind_all_children_oftrong chính classRelationshipsnày mà không cần thay đổi bất cứ gì ở class bậc caoResearchnữa.
Rất cảm ơn bạn đã đọc hết bài viết khá dài này. Nếu thấy hữu ích hãy cho mình 1 upvote nha 
Tham khảo: Design Pattern in Python - Udemy
All rights reserved