SOLID 五个原则#

SOLID 原则是一组软件设计原则,用于指导软件开发人员设计和实现高质量的、易于维护和扩展的软件。它由 Robert C. Martin 在其著作《Agile Software Development, Principles, Patterns, and Practices》中提出,是目前软件工程界被广泛接受的一种软件设计理念。

1. 单一职责原则(SRP)#

一个类只应该有一个职责。也就是说,一个类应该只有一个引起它变化的原因。以下范例:

class ShoppingCart:
    def __init__(self):
        self.items = []
        self.total = 0

    def add_item(self, item):
        self.items.append(item)
        self.total += item.price

    def remove_item(self, item):
        self.items.remove(item)
        self.total -= item.price

    def print_receipt(self):
        print('Items:')
        for item in self.items:
            print(f'{item.name} - ${item.price}')
        print(f'Total: ${self.total}')

这个 ShoppingCart 类同时负责购物车管理和输出这两类任务。它的 print_receipt() 方法应该被拆分到独立的类或方法中,以实现单一职责原则。

以下是改进后的代码:

class ShoppingCart:
    def __init__(self):
        self.items = []
        self.total = 0

    def add_item(self, item):
        self.items.append(item)
        self.total += item.price

    def remove_item(self, item):
        self.items.remove(item)
        self.total -= item.price

    def get_total(self):
        return self.total


class ReceiptPrinter:
    def print_receipt(self, shopping_cart):
        print('Items:')
        for item in shopping_cart.items:
            print(f'{item.name} - ${item.price}')
        print(f'Total: ${shopping_cart.get_total()}')


# 使用
shopping_cart = ShoppingCart()
# 假设 Item 类已经定义好了
item1 = Item("Product A", 10)
item2 = Item("Product B", 20)
shopping_cart.add_item(item1)
shopping_cart.add_item(item2)

receipt_printer = ReceiptPrinter()
receipt_printer.print_receipt(shopping_cart)

2. 开放封闭原则(OCP)#

软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。这意味着当需要添加新功能时,应该扩展现有的实体,而不是修改它们。

class ShoppingCart:
    def __init__(self):
        self.items = []

    def add_item(self, item):
        self.items.append(item)

    def remove_item(self, item):
        self.items.remove(item)

    def get_total_price(self):
        total_price = 0
        for item in self.items:
            total_price += item.price
        return total_price

class DiscountedShoppingCart(ShoppingCart):
    def get_total_price(self):
        total_price = super().get_total_price()
        return total_price * 0.9

在给出的代码中,ShoppingCart 类负责计算购物车中所有物品的总价。DiscountedShoppingCart 类继承了 ShoppingCart 并覆盖了 get_total_price 方法以应用折扣。这种实现方式违反了 OCP 原则:如果想要添加一种新的折扣策略,我们必须修改现有的 DiscountedShoppingCart 类,这可能会引入错误或破坏现有功能。

为了遵循 OCP 原则,我们可以采用策略模式(Strategy Pattern),将不同的折扣策略封装在不同的类中,这样就能通过新增策略类来扩展系统功能,而无需修改现有代码。

下面是改进后的代码示例:

from abc import ABC, abstractmethod

# 抽象折扣策略接口
class IDiscountStrategy(ABC):
    @abstractmethod
    def apply_discount(self, total_price):
        pass

# 无折扣策略
class NoDiscountStrategy(IDiscountStrategy):
    def apply_discount(self, total_price):
        return total_price

# 固定折扣策略
class FixedDiscountStrategy(IDiscountStrategy):
    def __init__(self, discount_rate):
        self.discount_rate = discount_rate

    def apply_discount(self, total_price):
        return total_price * self.discount_rate

# 购物车类
class ShoppingCart:
    def __init__(self, discount_strategy: IDiscountStrategy):
        self.items = []
        self.discount_strategy = discount_strategy

    def add_item(self, item):
        self.items.append(item)

    def remove_item(self, item):
        self.items.remove(item)

    def get_total_price(self):
        total_price = sum(item.price for item in self.items)
        return self.discount_strategy.apply_discount(total_price)

# 使用
item1 = Item("Product A", 10)
item2 = Item("Product B", 20)

# 创建购物车并应用固定折扣
discounted_cart = ShoppingCart(FixedDiscountStrategy(0.9))
discounted_cart.add_item(item1)
discounted_cart.add_item(item2)
print(discounted_cart.get_total_price())

# 创建购物车并应用无折扣
no_discount_cart = ShoppingCart(NoDiscountStrategy())
no_discount_cart.add_item(item1)
no_discount_cart.add_item(item2)
print(no_discount_cart.get_total_price())

在这个改进版本中,我们做了以下几点改变:

  1. 定义了一个抽象类 IDiscountStrategy,它规定了应用折扣的接口。

  2. 实现了两种具体的折扣策略:NoDiscountStrategy 和 FixedDiscountStrategy。

  3. 修改了 ShoppingCart 类,使其接受一个 IDiscountStrategy 实例作为依赖,并在计算总价时应用该策略。

这样,如果想要添加新的折扣策略,只需实现 IDiscountStrategy 接口、创建新的策略类,然后将其传递给 ShoppingCart 即可,无需修改现有的 ShoppingCart 类。这种方式遵循了 OCP 原则,使系统更加灵活、易于维护。

3. 里氏替换原则(LSP)#

里氏替换原则描述的是子类应该能够替换它的基类。

意思是:给定 class B 是 class A 的子类,任何预期传入 class A 对象的方法,在传入 class B 的对象时都不应该出现异常。

这是继承的预期行为:继承假定子类继承了父类的一切。子类可以扩展行为,但不能收窄。

因此,当类违背这一原则时,会导致一些难以发现的讨厌 bug。

里氏替换原则容易理解,但很难在代码中识别。看一个例子:

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def set_width(self, width):
        self.width = width

    def set_height(self, height):
        self.height = height

    def area(self):
        return self.width * self.height

class Square(Rectangle):
    def __init__(self, size):
        self.width = size
        self.height = size

    def set_width(self, width):
        self.width = width
        self.height = width

    def set_height(self, height):
        self.width = height
        self.height = height

square = Square(5)
square.set_width(10)
square.set_height(5)
print(square.area())

在给出的代码中,Square 类继承自 Rectangle 类,但重写了 set_width 和 set_height 方法:设置宽度或高度时会同时改变另一个维度,以保持正方形的特性(即宽度和高度相等)。

当我们在代码中这样使用 Square 类时:

square = Square(5)
square.set_width(10)
square.set_height(5)
print(square.area())

我们期望得到正方形的面积,但由于 Square 类的特殊性质,当我们先设置宽度为 10、再设置高度为 5 时,Square 类会将宽度也设置为 5。因此,最终面积是 5 × 5 = 25,而不是预期的 10 × 5 = 50。

这违反了 LSP 原则:在 Rectangle 类中可以分别设置宽度和高度,但在 Square 类中这样做会导致意外行为,破坏了基类和子类之间的一致性——Square 不能替代 Rectangle,尤其是在期望宽度和高度可以独立变化的情况下。

为了遵循 LSP 原则,我们可以采取以下方法来解决这个问题:

class Shape:
    def area(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def set_width(self, width):
        self.width = width

    def set_height(self, height):
        self.height = height

    def area(self):
        return self.width * self.height

class Square(Shape):
    def __init__(self, size):
        self.size = size

    def set_size(self, size):
        self.size = size

    def area(self):
        return self.size ** 2

def print_area(shape):
    print(f"Area: {shape.area()}")

shapes = [Rectangle(5, 10), Square(5)]
for shape in shapes:
    print_area(shape)

在这个改进后的代码中,Rectangle 和 Square 类都继承自 Shape 类,并实现了 area 方法。这样,无论是 Rectangle 还是 Square,都可以作为 Shape 的实例使用,print_area 函数也能正确调用它们各自的 area 方法。由于 Square 类不再继承自 Rectangle 类,所以不会出现 Square 类中宽度和高度相互影响的问题。

通过这种方式,Rectangle 和 Square 可以互相替换,替换后程序行为不会改变,从而解决了 LSP 问题。

4. 接口隔离原则(ISP)#

隔离意味着保持独立,接口隔离原则探讨的就是接口的独立。

该原则主张:多个客户端特定的接口优于一个多用途接口。客户端不应该被强制实现它们不需要的接口。

这是一个简单的原则,易于理解和实践,直接看例子。

先来看一段违反 ISP 的代码:

class Machine:
    def print(self, document):
        pass

    def fax(self, document):
        pass

    def scan(self, document):
        pass

class MultiFunctionPrinter(Machine):
    def print(self, document):
        print("Printing")

    def fax(self, document):
        print("Faxing")

    def scan(self, document):
        print("Scanning")

在给出的代码中,Machine 类定义了三个方法:print、fax 和 scan。MultiFunctionPrinter 继承了 Machine 并实现了这三个方法。如果存在只支持打印、不支持传真或扫描的打印机,那么这些打印机就需要实现它们实际并不需要的接口,这就违反了 ISP 原则。

例如,假设有一个仅支持打印的 Printer 类,它也需要从 Machine 继承,那么它将被迫实现 fax 和 scan 方法,即使实际并不使用这些功能。这会导致代码冗余且难以维护。

为了遵循 ISP 原则,我们可以将 Machine 类分解为更小、更具体的接口,例如 Printer、Scanner 和 FaxMachine,这样只有真正需要这些功能的子类才会实现它们。下面是改进后的例子:

class Printer:
    def print(self, document):
        pass

class Scanner:
    def scan(self, document):
        pass

class FaxMachine:
    def fax(self, document):
        pass

class MultiFunctionPrinter(Printer, Scanner, FaxMachine):
    def print(self, document):
        print("Printing")

    def fax(self, document):
        print("Faxing")

    def scan(self, document):
        print("Scanning")

class SimplePrinter(Printer):
    def print(self, document):
        print("Printing")

在这个例子中,SimplePrinter 只实现打印功能,而 MultiFunctionPrinter 实现全部三种功能。这样,每个类都只需关心自己需要的功能,从而更好地遵循了 ISP 原则。

5. 依赖反转原则(DIP)#

依赖倒置原则描述的是:类应该依赖接口和抽象类,而不是具体的类和函数。

在这篇文章(2000)里,Bob 大叔如下总结该原则:

如果 OCP 声明了 OO 体系结构的目标,那么 DIP 则声明了主要机制。

这两个原则的确息息相关,讨论开闭原则时也要用到这一模式。

我们想要类对扩展开放,因此需要明确:依赖的是接口而不是具体的类。例如,PersistenceManager 类应依赖 InvoicePersistence 接口,而不是实现该接口的类。

先来看一个错误的范例:

class Logger:
    def log(self, message):
        print(f"Log: {message}")

class UserService:
    def __init__(self):
        self.logger = Logger()

    def register(self, username, password):
        try:
            # register user to database
            print(f"User {username} registered successfully")
            self.logger.log(f"User {username} registered successfully")
        except Exception as e:
            print(f"Error: {e}")
            self.logger.log(f"Error: {e}")

根据 DIP,高层次模块不应该依赖于低层次模块,而应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。

在这段代码中,UserService 类直接创建 Logger 实例并使用它,这导致几个问题:

  1. 直接依赖具体实现:UserService 直接依赖 Logger 类的具体实现。这意味着如果 Logger 的行为发生变化,或者想要更换不同的日志记录机制(比如从控制台输出改为文件记录),UserService 也需要相应修改。

  2. 紧密耦合:UserService 和 Logger 之间形成紧密耦合,使它们很难独立进行单元测试,也增加了代码的维护成本。

为了遵循 DIP,应该将 Logger 的行为抽象出来,并通过依赖注入将具体实现传入 UserService。这样能确保 UserService 只依赖抽象,并且很容易替换 Logger 的实现。

下面是一个改进的例子:

from abc import ABC, abstractmethod

# 定义一个抽象的日志接口
class ILogger(ABC):
    @abstractmethod
    def log(self, message):
        pass

# 具体的日志实现
class ConsoleLogger(ILogger):
    def log(self, message):
        print(f"Log: {message}")

# 用户服务类现在依赖于抽象的日志接口
class UserService:
    def __init__(self, logger: ILogger):
        self.logger = logger

    def register(self, username, password):
        try:
            # register user to database
            print(f"User {username} registered successfully")
            self.logger.log(f"User {username} registered successfully")
        except Exception as e:
            print(f"Error: {e}")
            self.logger.log(f"Error: {e}")

# 使用
console_logger = ConsoleLogger()
user_service = UserService(console_logger)
user_service.register("john_doe", "securepassword")

在这个改进版本中,我们做了以下改变:

  • 创建了一个抽象基类 ILogger,定义了 log 方法。

  • ConsoleLogger 类实现了 ILogger 接口。

  • UserService 类不再直接创建 Logger 实例,而是通过构造函数接收一个 ILogger 对象作为依赖。

  • 当创建 UserService 实例时,我们传入了一个具体的 ConsoleLogger 实例。

这样,UserService 只依赖抽象的 ILogger 接口,具体的实现(例如 ConsoleLogger)可以通过依赖注入提供,使整个系统更加灵活、可测试、易于维护。