+4

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é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


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí