对于面向对象的三大特性(封装、继承、多态),几乎是所有人都能张口就来的,但学习设计模式之前,依旧应该好好复习一下面向对象的设计原则,不仅是由于拘泥于背定义的前提下去理解的原则永远只是纸面功夫,更是因为这六大原则几乎贯穿所有设计模式之中,甚至可以基于他们来构建属于自己的设计模式。
首先来认识一下,对象到底是什么?
- 从语言层面来讲,对象是封装了代码和数据的结合体
- 从规格层面来讲,对象是一系列可以被使用的公共接口,
- 从概念层面来讲,对象是一种拥有责任的抽象体。
基于上述的理解,这种面向对象的形式,从宏观层面看来,更能适应软件的变化,减小变化所带来的影响,提升稳定性和可复用性;而从微观看来,对象更强调每个类所负责的那部分责任,只需要每个类负责好自己的工作即可。
复习过对象的概念,就可以开始设计模式之旅的准备工作了:所有设计模式都要遵守的设计原则
1.依赖倒置原则(Dependence Inversion Principle)
定义: 高层模块(稳定)不应该依赖于底层模块(变化),二者都应该依赖抽象(稳定);抽象(稳定)不应该依赖细节(变化),细节应该依赖抽象(稳定)
这点通过虚函数的例子也就很好理解:例如画图类Draw需要描画各种类型Circle、Square…,而在Draw类内去一个添加每一个形状的方法,这样不仅让Draw类“瞎操心”,做了自己不该做的事,也让以后有更多的形状想被书写时的修改添加了困难。
所以上述提到的有缺陷的方式即可表示为:
class Draw{
//+每增加一个形状,需要在这里增加一类vector
std::vector<Circle> circles;
std::vector<Square> squares;
void draw_all(){
//+每增加一个形状,需要在这里增加一种属于其类的单独的draw方法
for(auto circle:circles){
//how to draw...
}
for(auto square:squares){
//how to draw...
}
}
};
//+每增加一个字母,需要在这里增加一个类
class Circle{
//args...
};
class Square{
//args...
};
可见Draw直接依赖抽象的形状类,通过依赖倒置原则的规划,将上述代码优化为下方格式将会改观许多:
class Draw{
std::vector<Shape*> vec;
void draw_all(){
//增加新类,不再需要修改draw类的过程
for(auto shape:vec){
shape->draw();
}
}
};
//增加抽象类作为接口,让每个形状“各司其职”
class Shape{
//args...
virtual void draw() = 0;
}
//+每增加一个形状,需要在这里增加一个类,并实现自己的纯虚方法
class A:public Shape{
void draw(){
//...
}
};
从原来的Draw动作类(稳定)直接依赖每一个形状(变化),变为Draw动作类(稳定)依赖一个抽象符号类(稳定),每一个形状(实现细节)再去依赖于抽象,
通过这种方式,我们便可以实现隔离变化。
2.开放封闭原则(Open Closed Principle)
定义:一个类模块应该对扩展开放(可扩展),对更改封闭(不可修改)。
在升级和维护软件库的时候,会对旧代码流程进行修改,这会可能破坏原有功能,更坏的情况下可能会导致原模块进行重构并重新测试。
所以尽可能遵循开闭原则,使新功能不影响旧功能。
3.单一职责原则(Single Responsibility Principle)
定义:一个类应该仅有一个引起他变化的原因,变化的方向隐藏着这个类的责任。
每个类应尽量有且仅有他自身的职责,尽量避免一个类中出现过多的责任。
这样做的好处较为明显,控制类的粒度大小、降低类的复杂度、可读性和可维护性,对于变更引起的风险也较低。避免修改引起其他功能的影响。
在上面的优化后的例子中各种继承自Shape类的子类和Draw就都遵循了该原则。
4.里氏替换原则(Liskov Substitution Principle)
定义:子类必须能够替换基类,可以扩展父类的功能,但不能改变父类原有的功能。
在实际应用场景中,如子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法和重载父类的方法。
举个较容易理解的例子:企鹅、鸵鸟从生物学的角度来划分,它们属于鸟类;但从类的继承关系来看,由于它们不能继承“鸟”会飞的功能,所以它们不能定义成“鸟”的子类。
5.接口隔离原则(Interface Segregation Principl)
定义:用户不应该依赖它不需要的接口;接口应该小而完备
在实际场景中,尽量只给用户提供他们所需求的接口,减少暴露没必要的接口将其设为protect/private,防止用户对过多的功能形成依赖,即需要保持更多接口的稳定,对后续修改/升级带来更多工作量,也防止自身更改影响用户稳定性
6.迪米特原则(Least Knowlegde Principle)
定义:一个对象应当尽可能少的去了解其他对象。
类与类之间的关系越密切时,其耦合度也就越大,这是改变其中一个类,另一个类也容易受到影响,
迪米特原则从原理上看,他是和依赖倒置原则相辅相成的,违背了依赖倒置往往也就违背了迪米特原则。
所以通过减少各部分之间的依赖关系,来实现高内聚,低耦合的设计结构。
7.合成复用原则(Composite Reuse Principle)
定义:尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。
类的继承通常是“白箱复用”,对象组合通常为“黑箱复用”,
虽然继承复用相对来讲比较容易实现,但他可能存在以下缺点:
- 破坏了类的封装性,将父类的实现细节暴露给子类,所以这个过程父类对子类是透明的(白箱复用)
- 父子类间耦合度高,父类的实现的任何改变都会导致子类的实现发生变化,不利于类的扩展与维护。
- 限制了复用的灵活性,由于子类静态继承父类,在编译时已定义,所以在运行时不可能发生变化。
而采用组合或聚合复用时,可以将已有对象纳入新对象中,使之成为新对象的一部分,新对象可以调用已有对象的功能,它有以下优点:
- 维持了类的封装性。此时源对象的内部细节是新对象看不见的(黑箱复用)
- 新旧类之间的耦合度低,复用所需的依赖较少。
- 复用的灵活性高,可以在运行时动态进行
该原则在实际应用中,主要通过现有对象作为新对象的成员对象来实现的,以新对象调用现有对象方法来实现复用。
写在前面
再讲设计模式之前,还有些关键的点需要我们注意:
设计模式的应用不宜先入为主,一上来就是用设计模式或者是为了使用设计模式而强行使用,这本身或许是设计模式的最大误用!
现代软件的设计特征是“需求频繁变化”,而设计模式的作用就是在稳定中寻找变化,变化处应用适合的设计模式。所以什么时候,什么场景选用什么模式,比理解设计模式本身更为重要。
换句话说,没有一步到位的设计模式,敏捷开发提倡的是"Refactoring to Patterns"即“通过重构选择模式”,这也是目前普遍被公认为好的设计模式的使用方式。
另外在日后的相关学习中,也推荐两本经典书籍:《重构》、《重构与模式》
下面本专栏将根据黑皮书的顺序,将GOF中23种设计模式的三大类(创建型模式、结构型模式、行为型模式)的每一种设计模式依次解析并举例。