设计模式初学者指南

引子

  • 为了真正有效地应用设计模式,必须知道如何设计以及设计的过程。不要期望简单地在代码中即兴使用设计模式就会得到好的代码,这样做不会使程序得到太大改进,甚至可能让程序变得更糟。
  • 编码是最后一步,是设计的实现。
  • 既然代码是设计的实现,想得到优雅代码需要有好的设计,得到好的设计需要有好的分析,过程为:用例 -> 分析 -> 设计 -> 代码。

面向对象与设计模式

究竟什么是设计模式

  1. 设计模式是被发现而不是被发明出来的。
  2. 设计模式使用来解决一类相关问题的通用技术,而不是及决问题的特解。解决具体问题时仍要具体问题具体分析。
  3. 每种设计模式不是“固定”的:每种模式描述了所处理的问题和对应的解决方法(“四人帮”书中成为“意图”(intent))。比如想设计一个通风的房子,总结出一个“穿堂风”模式,其意图为“允许空气在半人搞得高度直接水平地穿过房间,从而减少闷热并使房间更加舒适”。任何符合“意图”的架构机制都是该模式合法的具体化(reification)。
  4. 不能简单地通过结构(UML图)来识别模式,要弄清对象和类的意图。

模式的分类

image-20230119171459687

“四人帮”将模式分为两大类:类模式(class pattern)和对象模式(object pattern).

在每个大类内部,模式进一步被分为三类:

  • 创建型模式:关注的都是对象的创建,如抽象工厂模式提供了一种可以在不知道对象的实际类型的情况下生成对象。
  • 结构型模式:都是静态模型模式,关注与程序的机构化组织方式。
  • 行为型模式:都是动态型模式,关注与各个对象在运行时如何交互。

模式与设计

OOD(面向对象设计)与OOP(面向对象编程)有很大不同,很多人将编程和设计混淆了。

拿建筑行业做一个类比:建筑是由建筑师“设计”出来的,但却是由工人“建造”的。同样,面向对象系统是由面向对象设计人员设计、由面向对象程序员实现的。这两个角色可以由同一人充当,也可以由不同人充当。

优秀的编码者可以不懂设计,但可以将设计人员的设计方案转换为漂亮的代码。在华为,有专门的的SE做方案设计,然后由编码者进行实现。

不要将易于维护和降低复杂性混淆。面向对象的系统通常比面向过程的系统更加复杂,但更易于维护:其思想是将实际的软件系统内的必然的复杂性组织起来,而不是减少其复杂性——对于减少复杂性这一目标,面向对象设计人员认为是不可能达到的。

  • 编码是最后一步,是设计的实现。

  • 既然代码是设计的实现,想得到优雅代码需要有好的设计,得到好的设计需要有好的分析,过程为:用例 -> 分析 -> 设计 -> 代码。

  • 为了真正有效地应用设计模式,必须知道如何设计以及设计的过程。不要期望简单地在代码中即兴使用设计模式就会得到好的代码,这样做不会使程序得到太大改进,甚至可能让程序变得更糟。

  • 本篇讲的是模式,是关于将面向对象设计转变为具体的实现,关于OOAD自行学习。

使用接口和创建型模式编程

为什么组合优于继承

经常说 “组合由于继承”,这是为什么呢?工作中是如何实践的?有意识到遵循这项原则吗?

extends的问题

  • 强耦合
  • 脆弱的基类问题:用一种表面上安全的方式修改基类,但修改后,可能会导致派生类不能正常运行。

强耦合

例子:通过继承ArrayList实现栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Stack extends ArrayList { 
private int topOfStack = 0;

public void push( Object article ) {
add( topOfStack++, article );
}

public Object pop() {
return remove( --topOfStack );
}

public void pushMany( Object[] articles ) {
for( int i = 0; i < articles.length; ++i )
push( articles[i] );
}
}

好像没什么问题,考虑以下代码:

1
2
3
4
Stack aStack = new Stack(); 
aStack.push("1");
aStack.push("2");
aStack.clear();

调用clear方法时没有重置 topOfStack,导致下次调用push方法会存放到ArrayList的第三个位置,与预期不一致。

有人可能想到重写clear方法,调用stack的clear方法时直接抛出异常。这种做法并不好:

  • stack也是ArrayList,其他以ArrayList作为参数的方法,调用clear方法时不会期望得到异常
  • 将编译期的错误转移到了运行时。
  • 从概念上来看,stack不是ArrayList:ArrayList提供的方法对于Stack 来说大部分都不需要,这些方法对Stack是无意义的。

设计Stack类更好的方法是使用封装而不是派生来进行设计,这样讲就无需担心不想要的方法会被继承下来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Stack { 
private int topOfStack = 0;
private ArrayList theData = new ArrayList();

public void push( Object article ) {
theData.add( topOfStack++, article );
}

public Object pop(){
return theData.remove( --topOfStack );
}

public void pushMany( Object[] articles ) {
for( int i = 0; i < articles.length; ++i )
push( articles[i] );
}

public int size() { // current stack size.
return theData.size();
}
}

错弱的基类问题

假设想丰富stack的能力,希望能跟踪一段时间内栈最大值和最小值。

通过增加“高水位”和“低水位”标记的方法实现该功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MonitorableStack extends Stack {
private int highWaterMark = 0;
private int lowWaterMark = 0;

public void push( Object o ) {
push(o);
if( size() > highWaterMark )
highWaterMark = size();
}

public Object pop() {
Object poppedItem = pop();
if( size() < lowWaterMark )
lowWaterMark = size();

return poppedItem;
}

public int maximumSize() { return highWaterMark; }
public int minimumSize() { return lowWaterMark; }
public void resetMarks () { highWaterMark = lowWaterMark = size(); }
}

看上去是正常的。该类没有重写pashMany()方法,pushMany方法是通过push()方法来工作。

调用了push(),highWaterMark才会变更。

某天,某人发现基类stack性能有问题,为提高性能,使用数组替换ArrayList:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Stack {
private int topOfStack = -1;
private Object[] theData = new Object[1000];

public void push( Object article ) {
theData[ ++topOfStack ] = article;
}

public Object pop() {
Object popped = theData[ topOfStack-- ];
theData[topOfStack] = null; // prevent memory leak
return popped;
}

public void pushMany( Object[] articles ) {
assert (topOfStack + articles.length) < theData.length;
System.arraycopy(articles, 0, theData, topOfStack+1, articles.length);
topOfStack += articles.length;
}

public int size() { // current stack size.
return topOfStack + 1;
}
}

注意新的pushMany()不再多次调用push()。导致子类MonitorableStack再也不能正常工作。


不仅修改已有的方法可能导致问题,在基类添加方法也可能有问题:

假设要提供批量清空栈的能力,但不希望通过显示地弹出栈中每一个元素来完成,于是在stack中加入以下代码:

1
2
3
4
public void discardAll() { 
stack = new Object[1000];
topOfStack = -1;
}

似乎安全且合理。但discardAll()没有调用pop()方法,导致MonitorableStack虽然清空了,但高水位和低水位没有被更新。


所以,每次修改基类时,为避免发生错误,需要子类重写每个方法

当重写所有方法时,实际上就不是继承,而是实现接口。

使用接口减少脆弱基类问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import java.util.*; 
interface Stack {
void push( Object o );
Object pop();
void pushMany( Object[] articles );
int size();
}

class SimpleStack implements Stack {
private int topOfStack = 0;
private ArrayList theData = new ArrayList();

public void push( Object article ){
theData.add( topOfStack++, article );
}

public Object pop() {
return theData.remove( --topOfStack );
}

public void pushMany( Object[] articles ) {
for( int i = 0; i < articles.length; ++i )
push( articles[i] );
}

public int size() { // current stack size.
return theData.size();
}
}

class MonitorableStack implements Stack {
private int highWaterMark = 0;
private int lowWaterMark = 0;

SimpleStack stack = new SimpleStack();

public void push( Object o ) {
stack.push(o);
if( size() > highWaterMark )
highWaterMark = size();
}

public Object pop() {
Object returnValue = stack.pop();
if( stack.size() < lowWaterMark )
lowWaterMark = stack.size();

return returnValue;
}

public void pushMany( Object[] articles ) {
for( int i = 0; i < articles.length; ++i )
push( articles[i] );

if( stack.size() > highWaterMark )
highWaterMark = stack.size();
}

public int maximumSize() { return highWaterMark; }
public int minimumSize() { return lowWaterMark; }
public void resetMarks () { highWaterMark = lowWaterMark = size(); }
public int size() { return stack.size(); }
}

如何替换extends

优先使用组合

总是能通过实现接口而不用extends就能得到继承。其做法是将默认实现放在接口中,并在接口中定义方法来访问所包含的默认实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface Base {
void f();
static class Implementation implements Base {
public void f(){/*...*/}
}
}

// Effectively extend both Something and Base.Implementation:
class Derived extends Something implements Base {
Base delegate = new Base.Implementation();
public void f() {
delegate.f();
}
}

使用创建型模式

进行重构时,将extend改为接口,原来new某个类的地方都要改为new 某个接口,需要进行大量的修改。

最好将new抛弃或者隐藏。

抽象工厂

抽象工厂有不同的变种,如工厂是具体的。但都有一个共同主题:使用工厂来创建确切类型尚不知道的对象。

只知道创建的对象实现的接口,但并不知道对象具体的类。

Java中的collection是抽象工厂的优秀例子:工厂和产品都是抽象的

1
2
3
4
5
6
7
8
9
10
11
12
13
interface Collection{
Iterator iterator();
}

interface Iterator{
Object next();
boolean hasNext();
}

void client(Collection c){
for(Iterator i = c.iterator(); c.hasNext();){
doSth(i.next());
}

抽象工厂的主要优点是 隔离创建对象的过程,严格按照接口编程。

命令和策略模式

策略模式也能以抽象的方式创建对象,策略模式是命令模式的特例。

命令模式的基本思想是将如何作模式的知识封装在对象中,进而可以将其到处传送。如Java中创建线程的方式。

1
2
3
4
5
6
7
class CommandObject implements Runnable{ 
public void run(){
// stuff to do on the thread goes here }
};

Thread controller = new Thread( new CommandObject() );
controller.start(); //fire up the thread

Commond对象封装了线程中要运行的代码。Thread对象完全是通用的,只负责创建及管理线程,单不需要负责线程会做什么事情。

命令模式的主要特性是 客户类(使用命令对象的类)不知道命令对象将会做什么事情。

策略模式思想也很简单:使用命令对象来定义某些操作的策略,并在运行时传递给其他对象

在实践中,大部分代码只是给类起名叫XXXStrategy,直接实例化类并调用方法,并没有传递。

Java中使用策略模式的优秀例子是java.awt.Container及其派生类使用的LayoutManager。可以向Container添加可视化对象(按钮等),Container通过委派策略对象来处理布局。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Frame container = new Frame();
// 添加四个按钮并排布局
container.setLayout( new FlowLayout() );
container.add( new Button("1") );
container.add( new Button("2") );
container.add( new Button("3") );
container.add( new Button("3") );

Frame container = new Frame();
// 添加四个按钮,2*2的方式布局
container.setLayout( new GridLayout(2,2) );
container.add( new Button("1") );
container.add( new Button("2") );
container.add( new Button("3") );
container.add( new Button("3") );

如果使用继承方式,则必须写两个子类,一个叫FlowFrame,完成流式风格的布局,还要写一个GridFrame,完成网格风格的布局。

而使用LayoutManager策略对象(实现了LayoutManager接口但不继承任何类)免除了使用继承,并简化实现。

策略模式是工厂方法的很好替代品,使用策略模式就不需要使用继承对创建对象的方法进行重写.

总结

为了在需求变化时能够容易的对程序进行修改,代码中超过80%的内容应该通过接口来编写而不是具体的类。

以下几种模式可以做到只知道接口而不知道实际类来创建对象:

  1. 单例:一个对象,一种类型只有一个
  2. 抽象工厂:一个“工厂”,由其创建一组相关对象。仅知道对象的接口,但具体的类是隐藏(外部看不到,内部可以看到,因为总要在工厂里创建具体的对象)。
  3. 模板方法:一个占位方法,位于基类层次中,在派生类中被重写(不推荐,有脆弱的基类问题)。要有节制的使用。另外模板方法只是多态的普通应用,不配叫“模式”。
  4. 工厂方法:一种模板方法,在具体类 未知的情况下创建对象。工厂方法的名字容易造造成误解,认为任何创建对象的方法都是工厂方法,但还有其他创建型的方法。与抽象工厂的区别在于工厂是具体的。
  5. 命令:一个对象,封装了一个未知的算法。
  6. 策略:一个对象,封装了解决已知问题的策略。在创建型模式的上下文中,可以向创建者传递一个工厂对象,工厂中封装了实例化其他对象的策略。
于大帅 wechat
打钱! 打钱! 打钱😡😡😡
打赏完了让我夸夸你🤨