前言
在正式介绍设计模式之前需要对如下内容了解:
- 面向对象编程基础(OOP),本文不详述
- 优先软件设计的特征、原则
- SOLID原则
本文着重回顾后两种。
1. 优秀设计的特征
总的来说,有两个特征:
- 方便复用
- 具有扩展性
其中,复用有三个层次:
- 底层复用类库与容器
- 中层是设计模式复用发挥作用的地方
- 最高层复用框架
2. 优秀设计的原则
- 封装变化的内容
- 面向接口开发,而不是面向实现
- 组合优于继承
2.1 封装变化的内容
本章节所有代码均改变自来源11.
修改前的方法:
def get_order_total(order):
total = 0.0
for item in order.item_lines:
total += item.price * item.quantity
# 美国营业税
if order.country == 'US':
total += total * 0.07
# 欧盟营业税
elif order.country == 'EU':
total += total * 0.2
return total
上面的示例中,如果营业税只计算美国和欧盟,代码还勉强可读。但如果全球190+国家和地区,如果日后需要再扩展,难道每次都要再加elif order.country == a_country
一类的代码吗? 原始方法get_order_total
并不关心它是哪里收的营业税,只关心最终总量。那么,获取营业税部分可以抽取到一个独立的方法中。下面是修改后的代码:
# 获取税率的方法单独抽出计算
def get_tax_rate(country):
if country == 'US':
return 0.07
if country == 'EU':
return 0.2
# 还扩展了中国的营业税,方便后续扩展
if country == 'China':
return 0.3
return -0.01
# 修改后的方法
def get_order_total(order):
total = 0.0
for item in order.item_lines:
total += item.price * item.quantity
# 这样一来,方法是不是简洁了很多
# get_order_total方法中,哪国的营业税不是主要关心项
# 获取营业税额才是
return total + total * get_tax_rate(order.country)
除了方法层面的封装外,类层面也可以实现类似的封装。例如,Order
类随着订单数量的扩大,随着贸易越来越国际化,可能扩展的税金计算方法会很多,例如按州计算的美国税,按国家计算的欧盟税,按商品计算的中国税等等。这时,算税的方法就可以抽象到一个大类TaxCalculator
中。UML图如下:
2.2 面向接口而不是面向实现
可以分为如下几个步骤思考:
- 确定一个对象对另一对象的确切需求:它需执行哪些方法?
- 在一个新的接口或抽象类中描述这些方法
- 让被依赖的类实现该接口
- 现在让有需求的类依赖于这个接口, 而不依赖于具体的类
例如一个公司的架构,修改前:
修改后:
所有公司基于Company
父类实现,所有员工实现Employee
接口。
2.3 组合优于继承
继承问题清单:
- 子类不能减少父类的接口
- 重写方法时,需要确保新行为与旧行为兼容
- 继承可能打破超类的封装
- 子类可能与超类紧密耦合
- 可能会出现平行体系,使得代码非常难以维护
继承代表be
的关系,而组合更像是have
的关系。例如,汽车is
交通工具,而汽车has
发动机。汽车用这一原则可以抽象成如下图:
3. SOLID原则
SOLID原则是面向对象编程和设计的五个基本原则,它们可以帮助我们理解设计模式和软件架构。这些原则是:
单一职责原则(Single Responsibility Principle, SRP):一个类应该只做一件事,一个类应该只有一个变化的原因。例如,如果一个类是一个数据容器,比如Book类或者Student类,只有当我们更改了数据定义时才能够修改这些字段。
开放封闭原则(Open Closed Principle, OCP):实体应该对扩展是开放的,对修改是封闭的。也就是说,我们应该能够在不修改现有代码的情况下,增加新的功能。
里氏替换原则(Liskov Substitution Principle, LSP):一个对象在其出现的任何地方,都可以用子类实例做替换,并且不会导致程序的错误。
接口隔离原则(Interface Segregation Principle, ISP):客户端不应该被强迫实现一些他们不会使用的接口。简单地说,就是使用多个专门的接口比使用单个接口要好很多。
依赖倒置原则(Dependency Inversion Principle, DIP):抽象不应该依赖于细节,细节应当依赖于抽象。换言之,要针对抽象(接口)编程,而不是针对实现细节编程。
3.1 单一职责原则 (SRP)
主要用于减少复杂度。核心思想是:一个类应该只负责一项职责。也就是说,一个类应该只有一个引起它变化的原因。换句话说,一个类要尽量留下只属于自己的部分,而可以和其他类共享的部分应该统统扔出去组成单独的类。
例如:
// 以上是日常业务代码
public interface IPhone {
void dial(String phoneNumber);
void chat(Object o);
void hangup();
}
如果按照严格的SRP来修改,应该要这么改:
public interface IDataTransfer {
void chat(Object o);
}
public interface IConnectionManager {
void dial(String phoneNumber);
void hangup();
}
// IPhone去实现两个接口的内容。因为两种行为都不是iPhone独有
public class IPhone implements IDataTransfer, IConnectionManager {
//...
}
3.2 开放封闭原则 (OCP)
本原则的主要理念是在实现新功能时能保持已有代码不变。对扩展开放,意味着有新的需求或变化时,可以对现有代码进行扩展,以适应新的情况。对修改封闭,意味着类一旦设计完成,就可以独立完成其工作,而不要对已有代码进行任何修改。
通过代码示例如下:
// 图形接口, 可以扩展
interface Shape {
double area();
}
// 矩形类,基于图形类
class Rectangle implements Shape {
private double width;
private double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}
// 圆形类,基于图形类
class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * Math.pow(radius, 2);
}
}
// 计算图形面积的类,对修改封闭
class AreaCalculator {
public double calculate(Shape shape) {
return shape.area();
}
}
3.3 里氏替换原则 (LSP)
当你扩展一个类时, 记住你应该要能在不修改客户端代码的情况下将子类的对象作为父类对象进行传递。子类可以扩展父类的功能,但不能修改父类已有的功能, 子类必须保持与父类行为的兼容。因此,有如下几点要求:
- 子类方法的参数类型必须与其超类的参数类型相匹配或更加抽象。例如,子类实现feed(投喂)方法时,喂动物好于喂猫,喂猫好于喂英短。
- 子类方法的返回值类型必须与超类方法的返回值类型或是其子类别相匹配,也就是说,子类应该返回一个更具体的类型。例如,子类实现birth(生娃)方法时,生猫比生只小动物好,生只串也比生只小猫好。
- 子类中的方法不应抛出基础方法预期之外的异常类型。
- 子类不应该加强其前置条件, 也不能削弱后置条件。换句话说,子类重写的时候,父类有一个
int
类型的参数,没有限制正负,子类也不能这么做;父类的逻辑子类尽量能不动就不动。 - 超类的常量必须保留。
- 子类不能修改超类中private值,尤其对于Python和js程序员来说很重要!
一句话,子类必须可以扩展超类的行为,并且不能覆盖超类的行为!
示例:
// 四边形接口
interface Quadrangle {
long getLength();
long getWidth();
}
// 长方形类
class Rectangle implements Quadrangle {
private long length;
private long width;
@Override
public long getLength() {
return this.length;
}
@Override
public long getWidth() {
return this.width;
}
public void setLength(long length) {
this.length = length;
}
public void setWidth(long width) {
this.width = width;
}
}
// 正方形类
class Square implements Quadrangle {
private long sideLength;
@Override
public long getLength() {
return this.sideLength;
}
@Override
public long getWidth() {
return this.sideLength;
}
public long getSideLength() {
return sideLength;
}
public void setSideLength(long sideLength) {
this.sideLength = sideLength;
}
}
假设我们有一个计算图形面积的程序,最初只需要处理矩形。后来,需求变更,需要处理正方形。按照里氏替换原则,我们应该在不修改原有“矩形”代码的情况下,添加新的“正方形”代码,以满足新的需求。在上述例子中,Rectangle
类和Square
类都实现了Quadrangle
接口,这样就可以在不修改原有代码的情况下添加新的代码,从而遵守了里氏替换原则。
3.4 接口隔离原则 (ISP)
程序设计应该建立单一接口,而不要建立臃肿的大接口。这个原则的目的是降低类之间的耦合度,使类具有高内聚性和低耦合性,提高软件的可维护性和可扩展性。你必须将“臃肿”的方法拆分为多个颗粒度更小的具体方法。换句话说,代码拆的细一些。
代码实例:
// 动物接口
interface Animal {
void eat();
void sleep();
}
// 狗类
class Dog implements Animal {
@Override
public void eat() {
System.out.println("Dog is eating!");
}
@Override
public void sleep() {
System.out.println("Dog is sleeping!");
}
public void bark() {
System.out.println("Dog is barking!");
}
}
// 鸟类
class Bird implements Animal {
@Override
public void eat() {
System.out.println("Bird is eating!");
}
@Override
public void sleep() {
System.out.println("Bird is sleeping!");
}
public void fly() {
System.out.println("Bird is flying!");
}
}
3.5 依赖倒置原则
高层次的类不应该依赖于低层次的类。 两者都应该依赖于抽象接口。抽象接口不应依赖于具体实现。具体实现应该依赖于抽象接口。反着说,就是抽象类中不要有具体实现,抽象类和interface仅作签名用,具体实现应该implement抽象类。
示例:
// 课程接口
interface ICourse {
void study();
}
// Java课程类
class JavaCourse implements ICourse {
@Override
public void study() {
System.out.println("正在学习Java课程");
}
}
// 设计模式课程类
class DesignPatternCourse implements ICourse {
@Override
public void study() {
System.out.println("正在学习设计模式课程");
}
}
// 学习者类
class Learner {
public void study(ICourse course) {
course.study();
}
}
// 使用示例
public static void main(String[] args) {
Learner learner = new Learner();
learner.study(new JavaCourse());
learner.study(new DesignPatternCourse());
}