面向对象设计的SOLID原则, 2022-09-28

(2022.09.28 Wed)
SOLID是计算科学家Robert C. Martin, a.k.a., Uncle Bob提出的面向对象设计和模式设计的原则,可保证代码的可懂、可读、可测试性("To create understandable, readable, and testable code that many developers can collaboratively work on."),即代码的稳定性和扩展性。SOLID原则探讨了如何在类中安排函数和数据结构,类之间如何联系,或者说面向对象的代码如何细分(split up),代码的哪些部分设计为面向内部或外部,代码如何复用其他代码等问题。这里虽用到了"类(class)",但并不代表该原则只适用于面向对象编程。类是函数和数据的耦合组(coupled grouping)。每个软件系统都有这样的组合,可能叫类或其他名字,SOLID原则正是适用于这些组合。

该原则的目标是创建中间层软件架构使其

  • 可被改变
  • 易懂
  • 成为其他软件系统的基本组件(the basis of components)

SOLID

  • The Single Responsibility Principle
  • The Open-Closed Principle
  • The Liskov Substitution Principle
  • The Interface Segregation Principle
  • The Dependency Inversion Principle

The Single Responsibility Principle, SRP独立责任/功能原则

A module should be responsible to one, and only one, actor.

你可能会顾名思义地将其理解为一个函数能且只能处理一个功能。这里更准确的理解是

A module should have one, and only one, reason to change.

但考虑到开发的需求提出者是人,这个说法的更准确表述是

A module should be responsible to one, and only one, user or stakeholder.

又考虑到提需求的可能是一组或一类人,或某角色的人,故更准确地说法是

A module should be responsible to one, and only one, actor.

该表述中出现了module(模块)一词,其最简单的定义是一个源文件(source file)。然而有的编程语言和开发环境不用源文件包含代码,所以module的描述可以是函数和数据结构的一个集合(cohesive set)。

Case

Uncle Bob给出的案例如下:

有一个类Employee,其中有三个方法
+ calculatePay - 财务部制定,汇报给CFO
+ reportHours - 人事部制定,汇报给COO
+ save - DBA制定,汇报给CTO
三个方法放在同一个类中,开发者将不同部分耦合在一起,会导致潜在的问题。

比如,calculatePayreportHours都需要计算非加班工时(non-overtime hours)。开发者为避免代码重合,设计了一个方法regularHours用于计算非加班工时。

现在CFO提出对非加班工时的计算方法提出修改,而COO不需要这样的修改。此时,开发者修改regularHours函数,结果可满足CFO要求,但能会导致COO想要的结果无法满足要求。

这种问题的发生源自开发者将不同角色的代码放在了一起,而SRP原则建议将不同角色需要/依赖的代码分开。

解决方案 -
最简单的解决方案是将数据从Employee分离,同时不同的方法单独创建类。

数据保存在类EmployeeData中,该类仅仅是包含数据的类,无其他方法。三个操作的类互相独立且隔离,分别为
PayCalculator
+ calculatePay
HourReporter
+ reportHours
EmployeeSaver
+ save

这种设计的缺点在于需要实例化三个类。解决该问题的模式为facade pattern(外观模式)。该模式简单来说就是当系统中含有多个子系统,且多个用户需要分别调用不同的子系统时,设置一个外观类/接口,该接口同时调用所有子系统,用户可通过该接口按需调用不同的子系统。类似于不同的电器各自有一个开关,设置了一个总开关控制所有电器,总开关即facade。比如用户对电脑开机,CPU执行freeze、jump和运行三个操作,内存执行load操作,硬盘执行read操作,而用户无需分别去操作这三个部分,只需要一个外观接口,即开关,即可实现对不同硬件部分的操作。

EmployeeFacade
+ PayCalculator.calculatePay
+ HourReporter.reportHours
+ EmployeeSaver.save

(2022.09.29 Thu)

The Open-Closed Principle, OCP 开闭原则

A module should be open for extension but closed for modification.

OCP原则被认为是在OOP编程中最重要的原则。简单来说,在设计模块时需要保证模块可被扩展,而不对模块做修改。

比如,在一个支付系统中,支付方式可以是信用卡,也可以是借记卡,因此可写成如下形式的类/模块

class PaymentProcessor:
    def pay_debit(self, order, security_code):
        print("Processing debit payment type")
        print(f"Verifying security code: {security_code}")
        order.status = "paid"

    def pay_credit(self, order, security_code):
        print("Processing credit payment type")
        print(f"Verifying security code: {security_code}")
        order.status = "paid"

此时,需要加入新的支付模式,比如在线支付方式,如AliPay、PayPal等。按现有模式和代码,需要修改类PaymentProcessor,违背OCP原则。

修改方案是用一个基类(PaymentProcessor)来定义底层支付逻辑,之后通过子类的创建,比如DebitPaymentProcessor来实现具体支付方法。这样每当添加一种新的支付方式,直接实现一个新的子类即可。

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def pay(self, order, security_code):
        pass

class DebitPaymentProcessor(PaymentProcessor):
    def pay(self, order, security_code):
        print("Processing debit payment type")
        print(f"Verifying security code: {security_code}")
        order.status = "paid"

class CreditPaymentProcessor(PaymentProcessor):
    def pay(self, order, security_code):
        print("Processing credit payment type")
        print(f"Verifying security code: {security_code}")
        order.status = "paid"

(2022.12.15 Thur)
另一个案例

首先看bad example。员工类做为基类,其中包括attributes为namesalary。不同类型的员工继承该基类,并定义各自的工作方法,比如tester的工作方法是test,developer的工作方法是develop。有一个公司类company,其中对员工的工作方法根据员工的工种来判断员工的工作行为。

class Employee:
    
    def __init__(self, name: str, salary: str):
        self.name = name
        self.salary = salary
    
class Tester(Employee):
    
    def __init__(self, name: str, salary: str):
        super().__init__(name, salary)
    
    def test(self):
        print("{} is testing".format(self.name))

class Developer(Employee):
    
    def __init__(self, name: str, salary: str):
        super().__init__(name, salary)
    
    def develop(self):
        print("{} is developing".format(self.name))


class Company:
    
    def __init__(self, name: str):
        self.name = name
    
    def work(self, employee):
        if isinstance(employee, Developer):
            employee.develop()
        elif isinstance(employee, Tester):
            employee.test()
        else:
            raise Exception("Unknown employee")

之所以认为这是一个糟糕案例,想想我们向company中加入新的工种,比如analyst。继承employee类,定义其工作方法,在company类中又要加入新的判断。每次有新的工种加入,company类就要被修改。

一个改进方案如下

from abc import ABC, abstractmethod

class Employee(ABC):

    def __init__(self, name: str, salary: str):
        self.name = name
        self.salary = salary

    @abstractmethod
    def work(self):
        pass

class Tester(Employee):

    def __init__(self, name: str, salary: str):
        super().__init__(name, salary)

    def test(self):
        print("{} is testing".format(self.name))

    def work(self):
        self.test()

class Developer(Employee):

    def __init__(self, name: str, salary: str):
        super().__init__(name, salary)

    def develop(self):
        print("{} is developing".format(self.name))

    def work(self):
        self.develop()

class Company:

    def __init__(self, name: str):
        self.name = name

    def work(self, employee: Employee):
        employee.work()

carbon = Company("Carbon")
developer = Developer("Nusret", "1000000")
tester = Tester("Someone", "1000000")
carbon.work(developer) # Will print Nusret is developing
carbon.work(tester) # Will print Someone is testing

employee抽象类有个抽象方法work,其子类需要实现work方法。Developer中的work方法调用develop方法,而tester的work方法调用test方法。在company类中,调用员工的方法仅需要调用employee类的work方法。如果employee类中新加了analyst类,进需要在analyst子类中实现work方法即可,而无需对company方法做任何修改。

The Liskov Substitution Principle, LSP 里式替换

Subclasses should be substitutable for their base classes

派生类(derived class)可替代基类(base class),即在派生类替代基类之后,本该基类(却改成调用派生类)的用户感受不到异常。

Rectangular-Square问题
从案例入手解释LSP,在本例中类square不是类rectangle的适合子类,因为rectangle中的高度和长度可以独立改变,而square中的两条边需要同时改变。而调用rectangle的用户如果改为调用square则会产生混乱。

有如下测试过程,rectangle可通过,但square无法通过。

rectangle r  = ...
r.setW(5)
r.setH(10)
assert r.area == 50

如果想同时正确调用rectanglesquare,需要用户端对形状做判断,并做出相应操作。考虑到这样的修改取决于调用的对象类型,因此对象就是不可替代的(not substitutable)。

LSP适用于类、JAVA接口、REST接口等。从架构层面理解LSP的最佳方式是看看违背LSP将会带来什么。

开发者开发了出租车分配系统,通过URI调用不同公司的出租车用于传输信息和订车。不同的公司使用了相同的URI规则。比如预约司机John的车可通过如下的URI

url = 'purpletaxi.com/'
/driver/bob
/destination/ord
/pickupTime/1530
/pickupAddr/24maplest

不同公司的API都遵从如上所示的规则。假设A公司有个新来的开发者,不曾阅读API规范文档,将/destination改成了/dest,使得接口不可替代。这将导致分配系统在遇到A公司的订单时失败。(如运行正常将在订单系统中加入对出租车公司的判断。)

(2022.12.15 Thur)
另一个案例。学校成员有老师、学生和管理者。不同成员从成员基类member继承生成。

from abc import ABC, abstractmethod

class Member(ABC):
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    @abstractmethod
    def save_database(self):
        pass

class Teacher(Member):
    def __init__(self, name: str, age: int, teacher_id: str):
        super().__init__(name, age)
        self.teacher_id = teacher_id

    def save_database(self):
        print("Saving teacher data to database")

class Student(Member):
    def __init__(self, name: str, age: int , student_id: str):
        super().__init__(name, age)
        self.student_id = student_id

    def save_database(self):
        print("Saving student data to database")

下面给成员加入新pay方法,支付工资。学生无法支付,因为他们没有工资。

from abc import ABC, abstractmethod


class Member(ABC):
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @abstractmethod
    def save_database(self):
        pass

    @abstractmethod
    def pay(self):
        pass


class Teacher(Member):
    def __init__(self, name, age, teacher_id):
        super().__init__(name, age)
        self.teacher_id = teacher_id

    def save_database(self):
        print("Saving teacher data to database")

    def pay(self):
        print("Paying")


class Manager(Member):
    def __init__(self, name, age, manager_id):
        super().__init__(name, age)
        self.manager_id = manager_id

    def save_database(self):
        print("Saving manager data to database")

    def pay(self):
        print("Paying")


class Student(Member):
    def __init__(self, name, age, student_id):
        super().__init__(name, age)
        self.student_id = student_id

    def save_database(self):
        print("Saving student data to database")

    def pay(self):
        raise NotImplementedError("It is free for students!")

运行时将抛出异常,这与Liskov substitution原则不符。解决这个问题,将pay方法从member类中移除,并创建新类payer。对teachermanager使用多继承,从memberpayer两个类。而student则只继承member一个类。

from abc import ABC, abstractmethod

class Payer(ABC):
    @abstractmethod
    def pay(self):
        pass

class Member(ABC):
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @abstractmethod
    def save_database(self):
        pass


class Teacher(Member, Payer):
    def __init__(self, name, age, teacher_id):
        super().__init__(name, age)
        self.teacher_id = teacher_id

    def save_database(self):
        print("Saving teacher data to database")

    def pay(self):
        print("Paying")


class Manager(Member, Payer):
    def __init__(self, name, age, manager_id):
        super().__init__(name, age)
        self.manager_id = manager_id

    def save_database(self):
        print("Saving manager data to database")

    def pay(self):
        print("Paying")


class Student(Member):
    def __init__(self, name, age, student_id):
        super().__init__(name, age)
        self.student_id = student_id

    def save_database(self):
        print("Saving student data to database")


payers: List[Payer] = [Teacher("John", 30, "123"), Manager("Mary", 25, "456")]
for payer in payers:
    payer.pay()

至此,不符合Liskov substitution原则的问题得到解决。

The Interface Segregation Principle, ISP 接口隔离原则

Many client specific interfaces are better than one general purpose interface

ISP是支持COM一类组件底层的技术(enabling tech supporting component substrates such as COM),可使得组件和类更有用和便携(portable)。

ISP的核心比较简单。类如果有多个用户,则针对每个用户创建专用接口,仅调用该用户使用的方法,而不是在一个类中调用所有用户需要的所有方法。

比如,三个用户都需要调用OPS类的操作,设user1只用OPS类中的op1方法,user2只用op2方法,user3只用op3方法。
假设OPS类是用Java实现的类。user1的源码因为疏忽,依赖了op2op3。一旦op2的源码变更,则user1需要重新编译和重新部署,即便变更的部分和user1的业务无关。

这个问题可通过隔离的方法解决。OPS类不变,派生子类U1OPS,派生类中只有op1方法,U1OPS专供user1只用,user1不再直接以来OPS类。此时OPS类的变更将不会导致user1的重编译和重部署(redeployed)。

简言之,用户需要什么就提供什么,不需要的一概不提供。

The Dependency Inversion Principle, DIP 依赖倒置原则

Depend upon Abstractions. Do not depend upon concretions.

按照DIP原则,最灵活的系统是其中的源代码仅依赖抽象(abstractions)而非具体类或具体实现(concretions)。

在Java一类的静态语言(statically typed)中,useimportinclude等命令只能指向包含接口抽象类和其他抽象声明的源代码模块,不依赖任何具体类(concrete)。在动态类型语言中,如Ruby和Python,这个原则同样适用。源码依赖不该引用具体模块(concrete modules),但在动态语言中定义具体模块相对复杂,特别地,可定义为其中函数被调用的模块。

当然,将这个想法作为标准并不现实,因为软件系统一定会依赖很多具体实现。比如Java的String类是具体而且很难迫使它改为抽象类。对具体类java.lang.string的调用也不可且不该避免。String类非常稳定,修改该类的行为很罕见且被严格控制。开发者无需焦虑对String的频繁修改。基于此,我们倾向于在实践DIP时忽略操作系统和平台的稳定性。容忍具体依赖因为信任其不变和稳定。不稳定的具体类(volatile concrete)才是应该避免依赖的,也就是那些处在活跃开发和频繁更新的类。

案例如前面提到的支付类

class PaymentProcessor:
    def pay_debit(self, order, security_code):
        print("Processing debit payment type")
        print(f"Verifying security code: {security_code}")
        order.status = "paid"

    def pay_credit(self, order, security_code):
        print("Processing credit payment type")
        print(f"Verifying security code: {security_code}")
        order.status = "paid"

类初始化时,security_code不当设置成短信验证、邮箱验证等特定的方式,而应设置为一个抽象类,在类中判断验证类型。

class Authorizer(ABC):
    @abstractmethod
    def is_authorized(self) -> bool:
        pass

class SMSAuth(Authorizer):
    authorized = False

    def verify_code(self, code):
        print(f"Verifying code {code}")
        self.authorized = True

    def is_authorized(self) -> bool:
        return self.authorized

class PaymentProcessor(ABC):
    @abstractmethod
    def pay(self, order):
        pass

class DebitPaymentProcessor(PaymentProcessor):
    def __init__(self, security_code, authorizer: Authorizer):
        self.security_code = security_code
        self.authorizer = authorizer
        self.verified = False

    def pay(self, order):
        if not self.authorizer.is_authorized():
            raise Exception("Not authorized")
        print("Processing debit payment type")
        print(f"Verifying security code: {self.security_code}")
        order.status = "paid"

DIP同样可以应用于微服务开发。比如服务间的直接通信可由message bus或消息队列代替。采用消息队列时,服务将消息放到一个单一的普通位置,而无须介意是哪个服务会使用该消息。

Reference

1 Robert C. Martin, Design Principles and Design Patterns
2 Robert C. Martin, Clean Architecture, Prentice Hall
3 RollingStarky, Uncle Bob 的 SOLID 软件设计原则——Python 实例讲解,
4 SOLID principle with Python, Muhammet Nusret Özateş, medium

你可能感兴趣的:(面向对象设计的SOLID原则, 2022-09-28)