- vừa được xem lúc

SOLID qua các ví dụ Python

0 0 7

Người đăng: Vũ Văn Định

Theo Viblo Asia

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ép save(), khi đó bạn sẽ phải update tất cả các class chứa phương thức này.
  • Để đả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 class Journal để đả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ó method filter_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 method filter_by_size và bạn viết thêm vào class ProductFilter 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 class ProductFilter 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ó => class ProductFilter 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à SepcificationFilter và class BetterFilter 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ư class BetterFilter đã 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ừ class Specification:
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 class Specification:
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 class Rectangle nhưng hàm use_it chỉ có thể hoạt động với class cha Rectangle mà không thể hoạt động với class con Square.
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 class Square vì class Square đã 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 setter height. Hàm này ở class Rectangle chỉ thay đổi thuộc tính _height nên kết quả cho ra là đúng, nhưng class Square đã 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 case RectangleSquare (_width = _height)
    • Tạo ra 1 base class Shape và 2 class Rectangle, Square sẽ kế thừa class này. Implement cẩn thận để hàm setter không ghi đè nhau.
  • 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, faxscan như class MultiFunctionPrinter 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ả faxscan, 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 printclass từ cùng chung 1 class Machine ra 2 class PrinterScanner như trên, ta thấy rằng: class MyPrinter có thể thoải mái chỉ implement method print nếu không cần tới method scan, 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ó method print trong khi Photocopier có 2 method printscan.
  • 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 class Relationships, thay vì dùng list để lưu relations self.relations = [], ta sẽ dùng dictionary self.relations = {}. Ngay lập tức, hàm __init__ của class Research 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ến relations => 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 method find_all_children_of. Và cả 2 class Relationships, Research đều phụ thuộc vào implementation của method find_all_children_of (của interface RelationshipBrowser) 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 class Relationships chuyển từ dữ liệu list sang dictionary, ta sẽ re-implement lại method find_all_children_of trong chính class Relationships này mà không cần thay đổi bất cứ gì ở class bậc cao Research 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

Bình luận

Bài viết tương tự

- vừa được xem lúc

Hiệp phương sai và hệ số tương quan tuyến tính trong Python

Giới thiệu. Làm việc với các biến trong phân tích dữ liệu luôn đặt ra câu hỏi: Các biến phụ thuộc, liên kết và thay đổi với nhau như thế nào? Các biện pháp hiệp phương sai và hệ số tương quan tuyến tính giúp thiết lập điều này.

0 0 55

- vừa được xem lúc

Không gian tên(namspace) và phạm vi(scope) trong Python

. Khi mình ngồi học và dịch bài "Class trong Python" cho sê-ri "Khám Phá Đại Bản Doanh Python", mình đã đụng hai bạn này, và các bạn thật là trừu tượng và khó gặm. Thế là mình tìm kiếm và viết bài này để hiểu rõ hơn về hai bạn ấy, hi vọng bạn đọc thêm để hiểu về Python nhé.

0 0 34

- vừa được xem lúc

Tản mạn một chút về kỹ thuật Streaming

Lời mở đầu. Hôm nay trong lúc rảnh rỗi tôi ngồi tìm hiểu kỹ thuật streaming và áp dụng nó bằng Python. Bài viết có thể có thiếu sót mong các bạn thông cảm. Stream là một kỹ thuật chuyển dữ liệu theo dòng ổn định và liên tục.

0 0 64

- vừa được xem lúc

Vì sao chọn FastAPI

Introduction. Gần đây, do nhu cầu phát triển theo mô hình microservice ngày càng phổ biến, mình chủ yếu code mảng Python - Backend nên được phép chọn một framework để phát triển project mới cho công ty, sau khi cân nhắc giữa 3 framework phổ biến hiện tại sử dụng Python là Django, Flask và FastAPI, m

0 0 33

- vừa được xem lúc

Introduction to Google Cloud AutoML Vision

With the rapid development of technology, a Data Scientist could achieve their job like training ML models faster. The Word "AutoML"(also known as Automated machine learning) comes and now plays a cru

0 0 38

- vừa được xem lúc

Telegram Bot - Cào Dữ Liệu Từ VnExpress Bằng Python

Chào mọi người, sau bao ngày với các bài viết về lỗi bảo mật thì hôm nay mình sẽ đổi gió tí nhỉ :v. Vì thế nên hôm nay mình sẽ hướng dẫn mọi người làm 1 con bot Telegram bằng Python nhé.

1 0 213