所属文章系列:寻找尘封的银弹:设计模式精读
【一、从繁杂的代码中寻找简化之法】
【动机】
程序员都知道设计模式是好东西,一开始都能动力十足地去学习。但是,时间久了才发现:设计模式很难学,《设计模式》相关书籍里的细节非常复杂,学起来很吃力。即便学会了,用的地方也不多,因为项目的时间压力很大。即便有机会用,也会发现不知道何时该用哪种设计模式,这才是关键!因为这个问题一旦解决好了,项目压力再大,代码也会写得很快很漂亮,而且Bug少。
本文要讨论的“原型模式”,它的应用场景就有点模糊,《设计模式》书中举的“代码示例”,把抽象工厂模式和原型模式放在一起讲,导致我对这个模式的理解一度走偏。后来,经过不断的思考和实践,终于弄明白了。
我们以Windows下的“画图”应用程序为例,看看它的代码的痛点到底在哪里?当我们使用原型模式之后,再看看它会给我们带来什么惊喜?
先看一段未使用原型模式的代码,当然这些代码并不是Windows的源码:
当用户把一个图形元素拖拽到画布上时,会调用如下函数:
void GraphicTool::UserCreateGraphicItem(int userSelected) {
Graphic *newItem = NULL;
switch (userSelected)
case BUTTON: {
newItem = new Button(p1, p2, ...); // p1等参数需要从其他类中获取
break;
}
case LABEL: {
newItem = new Label(p1, p2, ...);
break;
}
...
default: {
return;
}
Canvas::InsertGraphicItem(newItem);
}
void Canvas::InsertGraphicItem(Graphic *newItem) {
ItemList.Add(newItem);
...
}
这些代码看起来中规中矩,似乎没有什么可改进的余地。但是,当有一个新需求到来的时候,我们新写了一些代码以支持新功能,代码都运行正常,却总是感觉哪里不对。
新需求是这样的:用户可以在菜单里选择“拷贝到新图片”,也就是说,把用户正在画的图全盘拷贝到一个新图片编辑窗口里,代码可以这样写:
void GraphicTool::UserClickClone() {
Canvas *newCanvas = new Canvas(...);
newCanvas->Brush = currentWindow->Canvas->Brush;
newCanvas->Font = currentWindow->Canvas->Font;
...
for (Graphic *item = newCanvas->ItemList.first(); item < newCanvas->ItemList.end(); ++item) {
switch (item->Type())
case BUTTON: {
//需要写一些获取p1, p2等参数的代码
newItem = new Button(p1, p2, ...);
break;
}
case LABEL: {
newItem = new Label(p1, p2, ...);
break;
}
...
default: {
return;
}
item = newItem;
}
currentWindow = new Window(); //切换到新窗口
currentWindow->Canvas = newCanvas;
}
代码算是可以工作了,但是总感觉不舒服,大概是因为GraphicTool::UserClickClone的前边只是为了克隆一个新对象,代码却写了那么多,而且构造过程的那些类名如Button、参数如p1,都需要了解得很清楚,而且为了取到p1这些参数,需要从很多个类中去取,费尽了周折。最为头疼的是,有时取到的参数并非想要的值,这种情况测试起来就比较困难,总会有一些Bug直接出现在用户面前。
有的人就会想:有没有一个好方法,能够让这段代码看起来很简单,而且不会出错?再贪心一点,代码在面对未来的需求变化时,能保持较小的改动?
【典型代码】
这里就是原型模式一展身手的地方了!
加入原型模式后,代码成为了如下的样子:
void GraphicTool::UserClickClone() {
Canvas *newCanvas = currentWindow->Canvas->Clone(); //原型模式
currentWindow = new Window();
currentWindow->Canvas = newCanvas;
}
不过需要Canvas支持Clone:
Canvas *Canvas::Clone() {
Canvas *newCanvas = new Canvas(...);
for (Graphic *item = ItemList.first(); item < ItemList.end(); ++item) {
newCanvas->ItemList.add(item->Clone());
}
...
return newCanvas;
}
还需要Graphic的所有子类支持Clone:
Graphic *Button::Clone() {
//对每个成员变量赋值
}
Graphic *Label::Clone() {
//对每个成员变量赋值
}
【优劣对比】
本质上,使用原型模式的代码,就是把GraphicTool::UserClickClone原有代码中克隆Canvas的实现代码,分散到了Canvas类、Graphic各个子类中去了。粗看起来总代码量没有多少变化,而实际上是减少了!
减少的那部分代码,就是靠Canvas::Clone做到的。它把原来需要知道Graphic各个子类的类名如Button的代码,用多态的方式给消除掉了。并且原来调用这些子类构造函数时获取参数的过程,也被转移到各个子类,而且直接从子类的成员变量中获取即可,不需求到其他类中寻找,非常方便而且不易出错。
使用原型模式的回报还远不止此:
1.代码量小,见前文。
2.在获取构造Graphic各个子类时,减少了获取构造参数的繁杂过程,也避免了第一次构造和克隆的代码重复。
3.不需要知道Graphic各个子类的类名,这样一来,扩展性就出来了,一旦再有Graphic的新子类加入,GraphicTool::UserClickClone和Canvas::Clone两个函数都不需要修改任何一行代码,只需要实现子类的Clone函数即可。
4.GraphicTool::UserClickClone和Canvas::Clone两个函数的思路非常清晰,代码一看就懂。
【二、模式核心】
【定义】
原型模式(Prototype):用一句话概括就是“省略构造细节的克隆技术”。
展开来讲,它就是一种克隆复杂对象的解决方案:客户代码在克隆的时候,不需要知道克隆对象的细节,包括对象所属的类、构造对象的参数等。
类图如下:
【适用场景】
在考虑使用原型模式之前,我们需要问自己一个问题:为什么要克隆,而不是使用已有的对象,或者直接构造一个?
看下面使用已有对象的例子,两个函数之间以共享的方式传递参数:
void Client1::DoSomething() {
Share *share = new Share();
share->Value = 1;
Client2 *client2 = new Client2();
client2->DoSomething(share);
if (share->Value) ...
}
void Client2::DoSomething(Share *share) {
share->Value = share->Value == 0 ? 1 : share->Value;
}
在这个例子中,根本不需要克隆。相反,如果克隆了对象,反倒错了,这是因为两个函数之间需要共享对象,而克隆对应的是不共享。
那什么时候必须使用克隆,而达到不共享的目的呢?由外部需求的驱动,例如前文的GraphicTool::UserClickClone,或者多线程的需要。我以标准化的形式来表达这种情况,参见下面的CloneRequirement::DoSomething:
void CloneRequirement::DoSomething() {
CloneBase *newClone = client2->Clone();
... //把newClone传递给某个对象或某个线程
}
CloneBase *Client2::Clone() {
CloneBase *newClone = new Client2(...);
newClone->share = this->share->Clone();
...
return newClone;
}
【思维进阶(一):原型与值对象的区别】
有一个“值对象模式”与原型模式相关。
原型的本质就是由客户代码来决定克隆一个对象,而值对象的本质是由模式内部代码决定每次构造一个新对象。
例如“拼接一个字符串”的表达式还可以写成这样:
strBuffer.append("<").append(">");
它是用简洁的形式来代替如下这种繁琐的表达式:
strBuffer.append("<");
strBuffer.append(">");
这里用到的技术就是“值对象模式”,这个模式出现在《领域驱动设计》一书中。
在append函数中,每调用一次都会生成一个新对象,这样就避免了共享对象时,有时会发生错误读写对象的问题。还有一个好处是让表达式准确、简洁地表达程序员的意图,代价就是C++需要自己考虑内存释放的问题,而Java则没有这个问题。
“值对象”的概念用一段文字不可能讲得清楚,由于它和原型模式有一些相关,本文只是粗略讲解一下。有机会我会单开一篇文章探讨它。
【思维进阶(二):单一职责】
上一篇《设计模式精读~单元测试的利器 ~ 抽象工厂模式》中讲过一些关于“两个维度的变化”以及“单一职责”的内容,这篇的原型模式也和这个有关,只不过是具体的维度有所不同罢了。
原型模式有两个大维度,每个大维度里各有两个小维度:
1.克隆维度:谁负责克隆,传递给谁。
2.构造细节维度:如何确定被克隆的子类是什么,如何为新对象的每个成员变量赋值。
“两个维度的变化”背后的原则是“单一职责”,也就是说,每个维度对于一个职责。“单一职责”的背后是人脑一次面对的概念越少越好,容易理解和记忆,也容易发现代码的漏洞。
如果人脑一次关注的维度过多,就自然会产生懈怠,导致很多代码漏洞的发生。
我们来看多个维度会产生怎样的变化,例如本节里刚刚提到的
“谁负责克隆”有三种情况,
“传递给谁”有四种情况,
“如何确定被克隆的子类是什么”有十种情况,
“如何为新对象的每个成员变量赋值”有二十种情况。
那么把这四个维度都考虑进来,就有3*4*10*20=2400种变化!人脑见到这种情况,一定会退避三舍,那么Bug自然就能找到生长的土壤。
另外,把维度分开或者单一职责还有一个好处,就是方便单元测试,因为每个用例只考虑一个变化,单独测试时只有3+4+10+20=42个用例,反观多个维度一起测试就是2400个用例!
【三、细节、例外】
【扩展】
像其他创建型模式一样,原型模式可以通过自己实现的内部注册表机制,来实现子类原型的动态载入。
【限制】
一、有些类不能修改,因为它们是一套第三方类库,或者是其他团队的代码,例如Graphic子类或者子类引用的一些下游类。那么只能在自己可控的范围内增加Clone函数,在其他地方实现克隆过程但分散到子类中,就像前文“未使用原型模式的代码”那样。
二、如果克隆对象之间,有循环引用的关系,就很难实现克隆了。虽然循环引用不好,但实际的代码总会有一些这种情况存在,而想改变循环引用的现状又很困难,只好退而求其次了。
【注意事项】
Prototype的子类都必须实现Clone,这有时会很困难。例如,当子类已经存在时,克隆会迫使你立刻做出决定:是不是所有的指针都需要克隆一份儿?也就是深拷贝还是浅拷贝的问题。
有时做这种决定是很困难的。不过在做这个决定的时候,有时会发现一些隐藏得很深的Bug。因为原来的代码也不知道哪些该克隆哪些该共享,而错误地使用了编译器默认实现的浅拷贝方式(即共享方式),最常见的例子就是两个对象共享一个字符串对象指针。
作于2018-5-17
附:这次的代码格式有点乱,等我找到一个好的代码格式再更新这篇文章,抱歉了。