(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
三个方法放在同一个类中,开发者将不同部分耦合在一起,会导致潜在的问题。
比如,calculatePay
和reportHours
都需要计算非加班工时(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为name
和salary
。不同类型的员工继承该基类,并定义各自的工作方法,比如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
如果想同时正确调用rectangle
和square
,需要用户端对形状做判断,并做出相应操作。考虑到这样的修改取决于调用的对象类型,因此对象就是不可替代的(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
。对teacher
和manager
使用多继承,从member
和payer
两个类。而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的源码因为疏忽,依赖了op2
和op3
。一旦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)中,use
、import
和include
等命令只能指向包含接口抽象类和其他抽象声明的源代码模块,不依赖任何具体类(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