SOLID qua các ví dụ Python
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
Journal
dướ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ư
Journal
và 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,
Product
chứa các đặc tính của sản phẩm vàProductFilter
dùng để lọc ra sản phẩm theo theo thuộc tính của nó. - Giả sử lúc đầu, class
ProductFilter
chỉ có methodfilter_by_color
do 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_size
và bạn viết thêm vào classProductFilter
như 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_size
thì sao? Rõ ràng việc cứ viết thêm vào classProductFilter
sẽ 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
Product
có 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ó => classProductFilter
sẽ 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à
Sepcification
vàFilter
và classBetterFilter
như 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à
AndSpecification
kế 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
Square
kế thừa classRectangle
nhưng hàmuse_it
chỉ có thể hoạt động với class chaRectangle
mà 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
Rectangle
nhưng sai với classSquare
vì classSquare
đã thay đổi hành vi của các phương thức setter. - Ở hàm
use_it
ta lấy ra giá trị_width
từ đối tượng, sau đó gọi hàm setterheight
. Hàm này ở classRectangle
chỉ thay đổi thuộc tính_height
nê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_height
mà 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 caseRectangle
làSquare
(_width = _height
) - Tạo ra 1 base class
Shape
và 2 classRectangle
,Square
sẽ 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
Machine
dướ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
,fax
vàscan
như classMultiFunctionPrinter
bê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
Machine
thì sao?
class OldFashionedPrinter(Machine):
def print(self, document):
pass
def fax(self, document):
pass
def scan(self, document):
raise NotImplementedError('Printer cannot scan!')
- Ở class
OldFashionedPrinter
phí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àOldFashionedPrinter
thực ra có cảfax
và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
print
vàclass
từ cùng chung 1 classMachine
ra 2 classPrinter
vàScanner
như trên, ta thấy rằng: classMyPrinter
có thể thoải mái chỉ implement methodprint
nế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
MyPrinter
chỉ có methodprint
trong khiPhotocopier
có 2 methodprint
và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
Research
là 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 classResearch
sẽ 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"
RelationshipBrowser
vớ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
relations
của classRelationships
chuyển từ dữ liệu list sang dictionary, ta sẽ re-implement lại methodfind_all_children_of
trong chính classRelationships
này mà không cần thay đổi bất cứ gì ở class bậc caoResearch
nữ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