再学设计模式

  这篇打算从Uncle Bob的大作中再学习整理一下设计模式,主要是感觉这本书相比起GoF的《设计模式》更容易入口一些,书中会使用简短精悍的例子告诉我们带有腐臭味的设计有什么样的毛病,然后通过之前的SOLID原则怎样演化改进这些问题设计。之前自己总是贪心着想把23种设计模式一下子学全,但是这种囫囵吞枣式地大满贯感觉收效甚微,毕竟设计模式具有极强的实践操作性,是需要不断地在学习中思考、在使用中感受的东西,所以与其贪多还不如找几个实用的模式研究透了更实在。任何时候我们都需要记住:我们需要的不是最初承诺的豪言壮语,而是在今后的行动中不忘初心,践行当初自己一点一滴的承诺。
  其实现在再仔细审视一下这些设计模式,也会发现一些有趣的现象:不少模式在项目中感觉也不会太多用到了,尤其现在微服务化浪潮下每个服务都设计的尽可能轻巧简洁,每一个服务的整体复杂度与上个世纪的巨无霸服务不可同日而语了;还有一些在实践上已经被用烂了,只是一直没有意识到它们的名字而已,比如FACADE、PROXY;此外设计模式提出来的时间也比较久了,而现代C++增加了许多新的特性,所以一些设计模式真的需要重新REVISE一下,比如最常见的OBSERVER有更完美的替代方案。

1. TEMPLATE METHOD

  根据DIP原则,我们希望通用的算法不要依赖于具体的实现,而TEMPLATE METHOD和STRATEGY都可以用来分离算法和具体上下文的耦合,使得通用算法和具体实现都依赖于抽象。不同的是TEMPLATE METHOD使用继承机制来解决该问题,而STRATEGY则是使用委托机制。
  在TEMPLATE METHOD中,可以将程序的通用结构分离出来,把他们放到一个抽象基类的普通实方法中,然后该实方法可以按应需调用抽象方法,所有的实现细节都由这些抽象方法在派生类来实现。通常上面的实方法是public的,而虚接口则是private的,成员方法的访问控制和虚特性是正交的,private virtual接口也可以在派生类中被override。
  这种方法的缺点是继承算是一种非常强的耦合关系,使得派生类和基类被绑定到了一起,而且在派生类中实现的算法细节将无法在别的类中被重用;同时产生新的通用算法框架,或者产生新的算法实现细节,对应放都需要做出改变或者重新实现一次,如果这两个因素都是易变的,则相比STRATEGY就不够的灵活了。

1
2
3
4
5
6
7
8
9
10
11
12
struct BubbleSorter {
int doSort() {
.... // call swap/outOfOrder
}
private:
virtual void swap(int idx) = 0;
virtual bool outoOfOrder(int idx) = 0;
};

struct IntBubbleSorter: public BubbleSorter {
// impl swap & outOfOrder
};

2. STRATEGY

  STRATEGY不是将通用算法放到一个抽象基类当中,而是通过委托的机制将其放到一个具体类当中,在这个具体类中可以设置和替换具体的实现细节。
  相比而言STRATEGY比TEMPLATE METHOD使用了更多类和更深的层次,可能会带来时间空间上的额外开销,但是STRATEGY完全遵循DIP原则。这种方式可以最大限度地重用通用算法框架和算法实现细节,因为允许对同一个通用算法传递不同的实现细节,也允许每个具体实现可以被施加不同的通用算法操纵,这两个维度可以自由增加、自由相互组合,减少了通用算法和具体细节之间的耦合性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct SortHandle { // Strategy
virtual void swap(int idx) = 0;
virtual bool outoOfOrder(int idx) = 0;
};

struct BubbleSorter { // Context
BubbleSorter(SortHandle* handle): itsSortHandle(handle) {}
int doSort() { // Algorithm
// ... call with itsSortHandle
}
void setStrategy(SortHandle* hd) {itsSortHandle = hd; }
private:
SortHandle* itsSortHandle;
};

struct IntBubbleSorter: public SortHandle { // ConcreteStrategy
// impl swap & outOfOrder
};

BubbleSorter* ctx = new BubbleSorter(new intBubbleSorter());
ctx->doSort();

  说到这里可以说一下DELEGATION。其实DELEGATION是通过构造时候保存并持有对某个类的指针,是相比继承方式扩充功能的更灵活的一个编程手法,相比而言更像是一个编程技巧而不像是一种复杂的设计模式,所以我们会在很多的设计模式中看到DELEGATION出现的身影,比如STRATEGY、PROXY等。

3. FACADE & MEDIATOR & PROXY

  FACADE可以为一组具有复杂、全面接口的对象提供一个简单特定的接口,这就是我们在C++中最常做的事情——封装,因为相当多的库只提供了C接口,为了使用上的方便安全,都会做一个C++接口封装的操作。通常的子系统会因为功能全面而面临着使用复杂的问题,使用FACADE可以为其客户提供一个缺省简单够用的试图,简化了客户端的操作。通常在使用FACADE的时候有一个潜在约定:客户使用FACADE的接口之后,就不应该再绕过封装接口直接访问被封装对象接口了,即FACADE封装得到的接口是访问原始资源的唯一代理。
  FACADE关注的是让客户和子系统进行通信,这是通过明显且受限的方式来施加策略,而MEDIATOR则是以隐蔽且不受限的方式来施加策略,该模式关注的是各个复杂对象之间的通信。
  MEDIATOR下Mediator成为整个模式中最为关键的角色,他可以提供Register接口让新的Colleague参与进来,同时也提供一个HandleMsg实现消息分发功能,当任何一个Colleague调用PostMsg发布消息的时候,Mediator都能够将消息通知给需要的Colleague,在PostMsg通常会捎带发送消息者的身份标识。该模式简化了对象之间的交互,任何参与者只需要直接同Mediator交互就可以了,将原先多对多的网状交互模式简化成了集中式的星形交互模式,实现了各Colleague之间的解耦,Colleague的变化和登入登出都不再互相影响。在Windows下图形开发最常见的Message Loop机制就很像中介者模式,任何一个控件产生了消息后,相关联的其他控件都可以依据这个消息做出对应的响应。
  对于PROXY来说,如果FACADE只是方便对原始资源的访问,那么PROXY可能是处于更高一层的抽象考虑了。比如现在很多应用会直接拼接字符串操作来访问数据库,那么应用逻辑和存储细节就相互耦合在一起了,类似的情况还存在于散落在应用程序各处的第三方API。为了解决类似接口污染问题,我们需要引入一个隔离层来隔离业务规则和第三方API之间的直接联系,在实际操作中我们会根据业务层的需要定义抽象接口(依赖关系倒置原则),然后再由PROXY实现这些接口,这样就使得业务逻辑完全不依赖于PROXY,相反PROXY依赖于业务逻辑规定的接口了。如果PROXY后端代理的对象还有不同的类型可以选择,那么可以考虑使用STRATEGY进行抽象。不过PROXY的噩梦是每当第三方API变更的时候,PROXY就要相对应地做出修改,不过这总比修改散落在业务层中的各个接口调用处要好很多了。

4. SINGLETON & MONOSTATE

  单例通常在程序启动的时候创建出来,并且直到程序结束的时候才被自动删除,单例常常被作为系统中的基础对象来创建或管理系统中许多其他对象,比如:工厂对象来创建系统中其他对象;管理器对象来管理和控制系统中其它对象或访问。
  最为常见实现SINGLETON的方式是将构造函数变成private,然后添加一个public静态访问函数Instance()和一个局部私有的静态对象,外部只能通过静态方法来访问实例。为了保证只有一个实例,我们还会将类的拷贝控制成员设置为private或者delete的,甚至是只声明不定义它们,以防止通过拷贝创建出新的对象出来,不过我一般都是通过继承boost::noncopyable代劳的。
  这种最广为常见的SINGLETON也有其弱点:我们无法销毁这个实例,因为通过公有静态方法总能得到一个实例;该特性不能被继承,派生类必须按照上述方法重新实现这一套操作,而且私有的构造函数也必须对派生类放开权限,不过话说SINGLETON结合继承总让人感觉怪怪的;对于调用者来说操作不友好,从访问接口强制该类是个SINGLETON。不过通过强制接口控制使用SINGLETON,就能确保所有的访问都是受限的,那么就可以轻松在访问该类的接口中放入检查、计数、锁等机制以实现访问控制等方面的约定。
  MONOSTATE是另外一种实现对象单一性的方法,该类设计上需要确保所有的成员变量都是静态的,所有的成员方法都不是静态的。通过这种技巧,无论创建多少个MONOSTATE实例,他们的行为都会表现地像一个对象一样,而且即使将所有的实例都销毁也不会丢失数据。
相比较而言,使用MONOSTATE的优势有:对象是透明的,使用者不知道该对象本质上是否是单一性的;该结构和特性允许被派生;MONOSTATE的成员方法是常规函数,这样就可以使用虚函数性质在派生类中改写它们的行为。
  总结来说,SINGLETON是通过强调结构上的单一性,在结构上强制防止创建出多个实例;而MONSTATE则强制行为上的单一性,没有结构方面的限制,对使用者来说是透明的。

5. FACTORY

  DIP原则要求我们的应该优先依赖于接口(抽象),避免依赖于具体的类,尤其对于那些不稳定的类便更是如此,理论上来说对于易变类,任何使用new操作创建其对象的位置都违反了DIP原则,所以说”new operator considered harmful”。
  FACTORY允许我们只依赖于抽象接口就能创建出相同或相关系列具体类型的实例,这对于高度易变的具体类是十分有用的,其实FACTORY就是将这种不稳定关系集中在了Factory局部位置(通常这个位置使用全局或者Singleton的形式实现),使得高层模块在创建类实例的时候无需依赖这些类的具体实现。

1
2
3
4
5
6
7
8
9
struct ShapeFactory {
virtual Shape* makeShape(std::string shapeName) = 0;
};

struct ShapeFactoryImpl: public ShapeFactory {
Shape* makeShape(std::string shapeName) {
// ... new
}
};

  上面的实现框架称之为简单工厂模式,简单工厂模式只需要Client传递一个标识符表明自己需要创建的对象就可以了,因而对于使用者来说是简单友好的。不过工厂类负责所有实体类的构造细节,任何具体类的初始化改动都需要修改工厂类的实现,至少增加或者删除一个实体类就需要修改一个判断选择分支,这严重违背了开放-封闭原则,所以一般只在简单的情况下才会使用。
  FACTORY METHOD(工厂方法模式)就是为了解决简单工厂的上述缺陷而更加抽象出来的模式,它将简单工厂类中带参数的创建函数抽象成一个虚接口,然后依据每个实体类型派生出与之对应的工厂类,这样就可以使用统一的创建接口创建特定类型的对象了。FACTORY METHOD相比简单工厂模式增加了一个抽象层次,每当需要支持一个新实体类型的时候,就需要产生一个与之对应的工厂类,所以灵活的代价是增加了类的个数。
  FACTORY METHOD的缺点是每一个新的工厂类只能创建一种类型的对象,显得工厂类的利用率不够高,所以还可以再将FACTORY METHOD进一步扩充利用:让派生的工厂类抽象构造方法像简单工厂模式中的创建方法样支持一个标识符参数,这样每一个创建工厂类可以通过这个额外的参数创建多个种类的对象,这就是所谓的ABSTRACT FACTORY(抽象工厂模式)。让抽象创建接口增加一个标识符参数,也使得抽象工厂模式像简单工厂模式一样违反了的开放-封闭原则。
  正如直觉所料,越深层次的抽象就意味着越臃肿的结构,所以一般使用工厂的时候都是按照上面的顺序从简单工厂开始,当项目确实需要越来越高的灵活性的时候,再慢慢演化成FACTORY METHOD甚至ABSTRACT FACTORY。

6. OBSERVER

  OBSERVER就是为了增加新观察对象时可以无需修改被观察对象,这样被观察对象就可以保持封闭了。虽然这种模式在实际开发中十分的有用,不过这里不针对这个设计模式展开讨论了,可以参考之前的文章《function和bind真的是C++的救赎》的讨论。

7. ABSTRACT SERVER & ADAPTER

  ABSTRACT SERVER是基于抽象(接口)编程的一个简单范例。在具体类设计中我们会从接口使用者(客户端)的角度来进行命名,比如Switchable,因为接口属于它的客户而不是它的派生类,接口和其客户之间的绑定关系要强于和派生类之间的绑定关系。

1
2
3
4
5
6
7
8
struct Switchable {
virtual void turnON() = 0;
virtual void turnOff() = 0;
};

struct Light: public Switchable {
// impl turnOn/Off
};

  如果Switchable和现有的Light类因为接口兼容性问题无法工作在一起,此时就需要引入ADAPTER进行接口适配,适配器类继承Switchable类,并在接口实现中将实际的操作委托给Light类。ADATPER是用来将一个类的接口转换成客户希望的另外一个接口,让原先因为接口不兼容而不能工作在一起的类可以在一起工作,该模式既可以单独使用,同时在其他模式中也见到作为配角的身影出现。
  ADAPTER可以细分为类适配器对象适配器两种形式,分别是通过继承和委托的方式实现的。假设对于目标接口Target::Request(),现有的类Adaptee::SpecifyRequest()是无法同Target的Client一同工作的,就需要产生Adapter::Request()来让Client::Reqeust()和Adaptee::SpecifyRequest()相互能够正常工作在一起。
  a. 类适配器

1
2
3
4
5
struct Adapter : public Target, private Adaptee {
void Request() {
SpecifyRequest();
}
};

  类适配器是通过多继承的方式来实现的,因为是继承体系所以除了可以直接调用Adaptee的方法之外,还可以对其虚函数进行override,因为是实现继承所以这里对Adaptee的继承是private类型的。这种适配器实现虽然比较高效,不过因为继承是很强的耦合关系,意味着Adapter会受Adaptee的改动而变得不稳定,而且Adapter无法和Adaptee的派生类在一起工作。
  b. 对象适配器
  对象适配器通过委托的手段来实现,这意味着Adapter对Adaptee的一些变化(比如增加虚函数)是不敏感的,且同Adaptee的派生类也可以很好的工作在一起。绝大多数情况下,都推荐有限使用对象适配器模式,正如对象的组合优先于继承。

1
2
3
4
5
6
7
8
9
10
11
struct Adapter : public Target {
Adapter(Adaptee* adaptee): adaptee_(adaptee) {}
void Request() {
adaptee_->SpecifyRequest();
}
private:
Adaptee *adaptee_;
};

Target* tg = new Adapter(new Adaptee());
tg->Request();

  还可以使用模板参数的形式,对Adapter进行更高层次的抽象封装,以被适配类型T和被适配的特定接口函数FUNC作为参数,这样在Adapter的Reqeust()中就可以进行统一调用处理了,不过只有当需要适配的类型比较多的时候才考虑这种类型的抽象,不要进行过度设计。

8. BRIDGE

  对于上面的ABSTACT SERVER,通过之前强调的面向抽象编程,可以让接口的Client不会因为接口实现者的变化而变化,具体来说就是Switch不会因为对应控制对象Switchable的Implementor(Light、Fan…)之变化而变化,所以这一层抽象委托是很有意义的。此时如果Switch的种类也是可以变化的,即新进入一个变化自由度,我们就可以直接让新类型直接派生Switch生成SwitchTypeA、SwitchTypeB…,Swtich的变化和Implementor的变化就形成了相互正交的独立关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct Abstraction {
Abstraction(Implementor* impl): impl_(impl) {}
virtual void Operator() = 0
private:
Implementor* impl_;
};

struct Implementor {
virtual void OperationImpl() = 0;
};

struct RefinedAbstraction: public Abstraction {
RefinedAbstraction(Implementor* impl): Abstraction(impl){}
void Operation() override {
impl_->OperationImpl();
}
};

struct ConcreteImplementorType1: public Implementor {
void OperationImpl() override {}
};

Abstraction * pa = new RefinedAbstraction(new ConcreteImplementorType1());
pa->Operation();

  上面的结构展示出来:Abstraction和Implementor两个方向可以独立的演化增加新的实现,客户端通过Abstraction指针引用特定类型的RefinedAbstraction,任何操作都通过Abstraction代理到指定类型的Implementor中去实现。
  BRIDGE解决的问题就是针对上述的需求,很多开发都是以Abstraction接口为基准,在派生RefinedAbstraction后再次派生ImplementorType1…,这种方式一方面会导致生成的特定类型的派生类会非常的多,同时RefinedAbstraction或ImplementorType1中任何一个发生改变,所有他们的派生类都要进行变更,导致整个结构不稳定。所以在已有的项目中如果嗅探到有两个独立方向的演化被柔和在了一起,同时通过继承的方式产生了大量的组合子类型,那么就应该对这样的结构尝试用BRIDGE进行重构了。

9. DECORATOR

  DECORATOR是和BRIDAGE一样用以控制派生类膨胀的设计模式。DECORATOR实现了运行时动态增加一个对象的功能方法,相比使用继承来增加功能的方法更加的灵活:
  DECORATOR功能增加是在运行期间动态实现的,而通过继承实现功能的增加则是静态绑定的,这一方面不够灵活,而且有些时候对象所应该具有的功能在设计的时候是不能够被确定的;DECORATEOR允许使用任意不同的装饰类按照一定顺序进行组合,在不增加派生类的情况下就可以产生很多的装饰类型结果,而如果使用继承的方式实现则派生类的数量会不可控地急剧增长。
  该模式实现要求ConcreteComponent和Decorator都要继承Component类,然后ConcreteDecorator再从Decorator类进行派生,在这些类中都使用了相同的接口名Operation(),这样可以保证整个行为可以无限串连起来。我们关注Decorator类,在其构造函数中接收一个被装饰对象的指针并将其保存在private成员变量中,然后在Operation()的缺省实现中将其操作转发给其所持有的对象;然后在具体的装饰类中我们在执行默认行为之后,再执行定制化的其他代码。

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
struct Component {
virtual void Operation() = 0;
};

struct ConcreteComponent: public Component {
// override Operation()
};

struct Decorator: public Component {
Decorator(Component* ptr): ptr_(ptr) {}
void Operation() override {
ptr_->Operation();
}
private:
Component* ptr_;
};

struct DecoratorA: public Decorator {
DecoratorA(Component* ptr): Decorator(ptr){}
void Operation() {
Decorator::Operation();
// other stuffs
}
};

Component* ptr = new ConcreteComponent();
DecoratorA* pDa = new DecoratorA(ptr);
pDa->Operation();

本文完!

参考