设计模式精读 ~ 省略构造细节的克隆技术 ~ 原型

所属文章系列:寻找尘封的银弹:设计模式精读


【一、从繁杂的代码中寻找简化之法】

【动机】

程序员都知道设计模式是好东西,一开始都能动力十足地去学习。但是,时间久了才发现:设计模式很难学,《设计模式》相关书籍里的细节非常复杂,学起来很吃力。即便学会了,用的地方也不多,因为项目的时间压力很大。即便有机会用,也会发现不知道何时该用哪种设计模式,这才是关键!因为这个问题一旦解决好了,项目压力再大,代码也会写得很快很漂亮,而且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


附:这次的代码格式有点乱,等我找到一个好的代码格式再更新这篇文章,抱歉了。

你可能感兴趣的:(设计模式精读 ~ 省略构造细节的克隆技术 ~ 原型)