前言
随着时间的推移,软件代码越来越庞大,随着而来的就是如何维护日趋庞大的软件系统。在面向对象开发出现之前,使用的是面向过程开发来设计大型的软件程序,面向过程开发将软件分成一个个单独的模块,模块之间使用函数进行组合,最后完成系统的开发,每次需要修改软件,如果不涉及好各个模块的关系,就会导致软件系统难以维护,从而导致软件变得不可使用。面向对象方法用对象模拟问题域中的实体,以对象间的联系刻画实体间联系。面向对象具有以下优点:
1)系统的稳定性好:当系统的功能需求变化时,不会引起软件结构的整体变化,仅需做一些局部的修改。由于现实世界中的实体是相对稳定的,因此,以对象为中心构造的软件系统也会比较稳定。
2) 可重用性好:面向对象方法具有的继承性和封装性支持软件复用。有两种方法可以重复使用一个对象类。一是创建类的实例,从而直接使用它;二是从它派生出一个满足需要的新类,子类可以重用其父类的数据结构和程序代码,并且可以在父类的基础上方便地修改和扩充,而且子类的修改并不影响父类的使用。
3)可维护性好:由于面向对象的软件稳定性比较好,容易修改、容易理解、易于测试和调试,因而软件的可维护性就会比较好。
4)易于理解:传统的结构化软件开发方法是面向过程的,以算法为核心,数据和过程作为相互独立的部分,数据和过程分离,忽略了数据和操作之间的内在的联系,问题空间和解空间并不是一致的。面向对象的方法是以对象为核心,尽可能接近人类习惯的抽象思维方法,易于理解,并尽量一致地描述问题空间和解空间,从而自然而然地解决问题。
5)较易于开发大型软件产品:用面向对象方法开发大型软件时,把大型软件产品看作是一系列相互独立的小产品,采用RUP(统一开发过程)的迭代开发模型,可以降低开发时的技术难度和开发工作管理的难度。
为了充分利用面向对象的一系列特点,一些开发者就将自己多年来的开发经验整合起来,提出了解决某个特定问题的解决思路。从而形成了设计模式。
基础
由于设计模式是以面向对象为基础的,因此有必要先了解一下类模型和相应的类图,为后面的讲解打下一个基础。
类模型
耦合性:也称块间联系。指软件系统结构中各模块间相互联系紧密程度的一种度量。模块之间联系越紧密,其耦合性就越强,模块的独立性则越差。模块间耦合高低取决于模块间接口的复杂性、调用的方式及传递的信息
内聚性:又称块内联系。指模块的功能强度的度量,即一个模块内部各个元素彼此结合的紧密程度的度量。若一个模块内各元素(语名之间、程序段之间)联系的越紧密,则它的内聚性就越高。
所谓高内聚是指一个软件模块是由相关性很强的代码组成,只负责一项任务,也就是常说的单一责任原则。
耦合:一个软件结构内不同模块之间互连程度的度量。
对于低耦合,粗浅的理解是:一个完整的系统,模块与模块之间,尽可能的使其独立存在。也就是说,让每个模块,尽可能的独立完成某个特定的子功能。模块与模块之间的接口,尽量的少而简单。如果某两个模块间的关系比较复杂的话,最好首先考虑进一步的模块划分。这样有利于修改和组合。
高内聚低耦合是软件工程中的概念,在类模型的体现就是一个类要尽可能包含某类对象的所有属性和实现与之对应的功能,同时类与其他类的依赖要尽可能的小。
类继承(不存在虚函数)和对象组合
图1 继承父类(不存在虚函数)和将对象作为自己的属性
图1使用了2种方式来复用已有的属性。 图1上半部分是一个简单的子类继承父类,下半部分是将父属性作为自己的属性,从而达到复用已有的属性。这2种方式在内存中的布局都是base和subject的其他属性是连续的。这2种方式复用属性都具有很强的耦合。其表现就是subject对base的依赖很强,base不能随意更新,如果一旦更新(比如添加了一个属性),subject会添加大量的代码。,在我们的开发中,要尽量避免这样的情况。
组合对象指针
这与组合对象有明显的区别,在subject中存放Base指针,这样,Subject对象在内存中的布局就是一个base指针(4字节或者8个字节)+subject,这样,base就可以指向任意的Base类型的对象(Base类对象或者Base子类对象),而不仅仅是Base类对象,这样,Subject和Base的耦合度就大大降低
继承(存在虚函数)
同不存在虚函数的继承相比而言,存在虚函数的情况,对象的内存布局中存在一个虚函数指针(vptr),vptr在父类和不同的子类中分别指向不同的地址,这也是实现多态的原因。设计模式中的面向接口编程也使用了虚函数。
类图
如果想了解设计模式中的对象中的关系,需要看懂类图,对象与对象之间的关系主要包括关联关系,聚合关系,组合关系,依赖关系,继承关系,实现关系,具体的可以参考博客UML类图
概述
为什么需要设计模式
设计模式是只在软件开发中,经过验证的,用于解决在特定环境下,重复出现的,特定问题的解决方案。
使用设计模式的主要作用包括
(1)重用设计,重用设计比重用代码更有意义,它会自动带来代码重用
(2)在重构中学习设计模式, 在空余时间中对代码进行持续重构.
(3) 增加代码的可维护性, 可扩展性.
设计模式分类
根据设计的类型,主要将设计模式分为:创建型模式,结构型模式和行为型 模式
网上也有很多关于各种设计模式的介绍 和说明,这里我就不一一例举出所有的设计模式了,因为在我们的工作中,一般就使用几种常用的设计模式或者对于刚刚编程的同学,根本不会考虑设计模式,只要把功能实现即可。我们对待设计模式的态度也是如果没有足够的编程经验,也不要过分的考虑设计模式,因为意义不大。
如何设计设计模式
当我们想增加软件的可维护型和可扩展性的时候,需要设计设计模式时,主要遵循以下两个观点:
(1)找出稳定点和变化点,稳定点使用固定的编程模式,然后将变化点隔离出来,进行各自的设计,设计设计模式也主要时设计变化点,根据变化点的不同设计出不同的设计模式。
(2)设计原则优于设计模式,设计时优先满足设计原则,然后慢慢迭代出设计模式。
设计原则
前面提到在设计设计模式时,需要优先考虑设计原则。设计原则主要包括以下几个
依赖倒置
高层模块 (稳定) 不应该依赖低层模块 (变化) , 两者都依赖抽象 (稳定)
抽象(稳定)不依赖于具体实现 (变化), 具体实现依赖于抽象 (稳定).
如何理解: 降低耦合性, 让高层模块和底层模块解耦合.
如何解释这个问题.
具体的类是不稳定的,是变化的. 高层模块一般是稳定的, 稳定的模块不应该依赖一个变化的模块.
依赖倒置原则是最常用, 也是最容易使用的设计原则 (一般在看见稳定依赖低层模块(变化)的代码的时候) 我们完全可以考虑将这个具体类中的特征抽象出来一个抽象层, 接口类(稳定点)
以自动驾驶为例:
自动驾驶系统公司是高层,汽车生产厂商为低层,它们不应该互相依赖,一方变动另一方也会跟着变动;而应该抽象一个自动驾驶行业标准,高层和低层都依赖它;这样以来就解耦了两方的变动;自动驾驶系统、汽车生产厂商都是具体实现,它们应该都依赖自动驾驶行业标准(抽象);
开放封闭
一个类应该对扩展(组合和继承)开放,对修改关闭;
面向接口
不将变量类型声明为某个特定的具体类,而是声明为某个接口;
客户程序无需获知对象的具体类型,只需要知道对象所具有的接口;
减少系统中各部分的依赖关系,从而实现“高内聚、松耦合”的类型设计方案;
单一职责
一个类应该仅有一个引起它变化的原因;
相同的职责放到一起,不同的职责分解到不同的接口和实现中去,这个是最容易也是最难运用的原则,关键还是要从业务出发,从需求出发,识别出同一种类型的职责。举个例子:人的行为分析,包括了生理、生活和工作等行为的分析,生理行为包括吃、跑、睡等行为,生活行为包括穿衣等行为,工作行为包括上下班,开会等行为
里氏替换
子类型必须能够替换掉它的父类型;主要出现在子类覆盖父类实现,原来使用父类型的程序可能出现错误;覆盖了父类方法却没有实现父类方法的职责;
接口隔离
不应该强迫客户依赖于它们不用的方法;
一般用于处理一个类拥有比较多的接口,而这些接口涉及到很多职责;
常见设计模式
模板与方法
定义
模板与方法应该是最常使用的设计模式,在GOF(设计模式)中的定义:定义一个操作中的算法的骨架 ,而将一些步骤延迟到子类中。 Template Method使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。
要点
最常用的设计模式,子类可以复写父类子流程,使父类的骨架流程丰富;
反向控制流程的典型应用;
父类 protected 保护子类需要复写的子流程;这样子类的子流程只能父类来调用;
本质
通过固定算法骨架来约束子类的行为;
结构图
示例代码
背景:比如使用播放器播放不同类型的视频(文件,摄像头,网络流等)
不使用模板方法来实现可以按照以下代码来模拟实现过程
class Player { public: Player(int type = 1) : _type(type) {} public: void play() { if(initial()){ doplay(); uninitial(); } } // 接口隔离 不要让用户去选择它们不需要的接口 private: bool initial() { cout << _type << "initial" << endl; return true; } void doplay() { if (_type == 1) { cout << _type << "播放操作" << endl; } else if (_type == 2) { cout << _type << "播放操作" << endl; } else if (_type == 3) { cout << _type << "播放操作" << endl; } } void uninitial() { cout << "释放操作" << endl; } private: int _type; }; int main () { Player *player = new Player(1); player->play(); return 0; }
以上代码没有使用设计模式,同样能实现不同类型的播放代码的模拟功能,但是以上代码有很多问题,代码随着时间,变得越来越难以维护,这里的代码主要不满足开闭原则
通过观察发现,播放器播放视频的初始化和释放操作对所有的视频类型都是一样的,然后不同的播放主流程却是不一致的,因此我们仅仅需要修改播放主流程。我们可以将其修改为以下的代码
class IPlayer { public: IPlayer() {} virtual ~IPlayer() {} public: void play() { if(initial()){ doplay(); uninitial(); } } // 接口隔离 不要让用户去选择它们不需要的接口 protected: virtual bool initial() { cout << "initial" << endl; return true; } virtual void doplay() { cout << "播放操作" << endl; } virtual void uninitial() { cout << "释放操作" << endl; } }; class FilePlayer{ FilePlayer() {} ~FilePlayer(){} protected: virtual void doplay(){ cout << "文件播放操作" << endl; } }; class NetworkPlayer{ NetworkPlayer() {} ~NetworkPlayer(){} protected: virtual void doplay(){ cout << "网络流播放操作" << endl; } }; class CameraPlayer{ CameraPlayer() {} ~CameraPlayer(){} protected: virtual void doplay(){ cout << "摄像头播放操作" << endl; } }; int main () { IPlayer *ip = new CameraPlayer; ip->play(); return 0; }
经过修改,使用了模板与方法编程,并且遵循了设计模式中的开闭原则(能够扩展多个类,但是不需要对已有类进行修改),接口编程(IPlayer作为接口进行调用),单一职责原则(在具体的类里只实现一个具体的操作,那就是播放)
观察者模式
如果有QT开发经验的同学,肯定使用过QT中的信号槽。信号槽是 Qt 框架引以为豪的机制之一。所谓信号槽,实际就是观察者模式。当某个事件发生之后,比如,按钮检测到自己被点击了一下,它就会发出一个信号(signal)。这种发出是没有目的的,类似广播。如果有对象对这个信号感兴趣,它就会使用连接(connect)函数,意思是,将想要处理的信号和自己的一个函数(称为槽(slot))绑定来处理这个信号。也就是说,当信号发出时,被连接的槽函数会自动被回调。
定义
定义对象间的一种一对多(变化)的依赖关系,以便当一个对象(Subject)的状态发生改变时,所有依赖于它的对象都得到通知并自动更新。 ——《 设计模式》 GoF
要点
观察者模式使得我们可以独立地改变目标与观察者,从而使二者之间的关系松耦合;
观察者自己决定是否订阅通知,目标对象并不关注谁订阅了;
观察者不要依赖通知顺序,目标对象也不知道通知顺序;
常用在基于事件的ui框架中,也是 MVC 的组成部分;
常用在分布式系统中、actor框架中;
本质
触发联动
结构图
实现观察者模式时要注意具体目标对象和具体观察者对象之间不能直接调用,否则将使两者之间紧密耦合起来,这违反了面向对象的设计原则。
观察者模式的主要角色如下。
抽象主题(Subject)角色:也叫抽象目标类或目标接口类,它提供了一个用于保存观察者对象的聚集类和增加、删除观察者对象的方法,以及通知所有观察者的抽象方法。
具体主题(Concrete Subject)(被观察目标)角色:也叫具体目标类,它是被观察的目标,它实现抽象目标中的通知方法,当具体主题的内部状态发生改变时,通知所有注册过的观察者对象。
观察者接口(Observer)角色:它是一个抽象类或接口,它包含了一个更新自己的抽象方法,当接到具体主题的更改通知时被调用。
具体观察者(Concrete Observer)角色:实现抽象观察者中定义的抽象方法,以便在得到目标的更改通知时更新自身的状态。
示例代码
示例:气象站发布气象资料给数据中心,数据中心经过处理,将气象信息更新到2个不同的显示终端(A和B)
示例中主要有数据中心,气象资料,气象信息和显示终端。经过分析可以得出显示终端为观察者,数据中心为主题对象。
如果不使用观察者模式,可以用如下代码实现
class DisplayA { public: void Show(float temperature); }; class DisplayB { public: void Show(float temperature); }; class DisplayC { public: void Show(float temperature); } class WeatherData { WeaterData(){ temper = 0;} float getTemper() {return temper;} private: float temper; }; class DataCenter { public: float CalcTemperature() { WeatherData * data = GetWeatherData(); float temper = getTemper(); return temper; } private: WeatherData * GetWeatherData(); // 不同的方式 }; // 订阅发布 int main() { DataCenter *center = new DataCenter; DisplayA *da = new DisplayA; DisplayB *db = new DisplayB; DisplayC *dc = new DisplayC; float temper = center->CalcTemperature(); da->Show(temper); db->Show(temper); dc->Show(temper); return 0; }
以上代码没有使用观察者模式,由数据中心得到气温信息,然后由终端自行去显示气温信息。这样,数据中心和各个终端对象的耦合性非常强,只要数据中心更新了气温信息,显示终端都要自行去显示气温信息,如果增加了一个显示终端,也需要显示终端的显示气温信息。那么当数据中心更新气象信息时,都会主动去通知显示终端(观察者 )。这样显示终端和数据中心的耦合度就减少了.
当我们使用观察者模式,先来找稳定点和变化点
稳定点:气象资料(代码不需要做修改)
变化点:显示终端(不同显示终端有不同的显示和一些定制内容-比如建议等等),数据中心处理气象资料
以下是使用观察者模式使用的代码
#include <vector> // class IDisplay { public: virtual void Show(float temperature) = 0; virtual ~IDisplay() {} }; class DisplayA : public IDisplay { public: virtual void Show(float temperature); private: void jianyi(); }; class DisplayB : public IDisplay{ public: virtual void Show(float temperature); }; class DisplayC : public IDisplay{ public: virtual void Show(float temperature); }; class WeatherData { WeaterData(){ temper = 0;} float getTemper() {return temper;} private: float temper; }; class DataCenter { public: void Attach(IDisplay * ob); void Detach(IDisplay * ob); void Notify() { float temper = CalcTemperature(); for (auto iter = obs.begin(); iter != obs.end(); iter++) { (*iter)->Show(temper); } } // 接口隔离 private: virtual WeatherData * GetWeatherData(); virtual float CalcTemperature() { WeatherData * data = GetWeatherData(); float temper = getTemper(); return temper; } std::vector<IDisplay*> obs; }; int main() { DataCenter *center = new DataCenter; IDisplay *da = new DisplayA(); IDisplay *db = new DisplayB(); IDisplay *dc = new DisplayC(); center->Attach(da); center->Attach(db); center->Attach(dc); center->Notify(); //----- center->Detach(db); center->Notify(); return 0; }
策略模式
定义
定义一系列算法,把它们一个个封装起来,并且使它们可互相替换。该模式使得算法可独立于使用它的客户程序而变化。 ——《设计模式》 GoF
要点
策略模式提供了一系列可重用的算法,从而可以使得类型在运⾏时方便地根据需要在各个算法之间进行切换;
策略模式消除了条件判断语句;也就是在解耦合;
本质
分离算法,选择实现;
结构图
示例代码
示例:某商场节假日有固定促销活动,为了加大促销力度,现提升国庆节促销活动规格;
按照一般的想法,对于商场而言,各种节假日都有各种的促销活动,那么很容易就想到使用if和else来完成各种节假日的促销活动,代码如下:
enum VacationEnum { VAC_Spring, VAC_QiXi, VAC_Wuyi, VAC_GuoQing, VAC_ShengDan, }; class Promotion { VacationEnum vac; public: double CalcPromotion(){ if (vac == VAC_Spring { // 春节 } else if (vac == VAC_QiXi) { // 七夕 } else if (vac == VAC_Wuyi) { // 五一 } else if (vac == VAC_GuoQing) { // 国庆 } else if (vac == VAC_ShengDan) { } } };
如果不使用策略模式,以上代码很容易就知道如果需要添加一种节假日,就会添加一个if代码块,整个文件的代码会越来越大,不符合软件设计种的单一职责和开闭原则等。可以考虑使用策略模式,每种节假日的活动都使用一种策略,代码如下
class Context { }; class ProStategy { public: virtual double CalcPro(const Context &ctx) = 0; virtual ~ProStategy(); }; // cpp class VAC_Spring : public ProStategy { public: virtual double CalcPro(const Context &ctx){} }; // cpp class VAC_QiXi : public ProStategy { public: virtual double CalcPro(const Context &ctx){} }; class VAC_QiXi1 : public VAC_QiXi { public: virtual double CalcPro(const Context &ctx){} }; // cpp class VAC_Wuyi : public ProStategy { public: virtual double CalcPro(const Context &ctx){} }; // cpp class VAC_GuoQing : public ProStategy { public: virtual double CalcPro(const Context &ctx){} }; class VAC_Shengdan : public ProStategy { public: virtual double CalcPro(const Context &ctx){} }; class Promotion { public: Promotion(ProStategy *sss) : s(sss){} ~Promotion(){} double CalcPromotion(const Context &ctx){ return s->CalcPro(ctx); } private: ProStategy *s; }; int main () { Context ctx; ProStategy *s = new VAC_QiXi1(); Promotion *p = new Promotion(s); p->CalcPromotion(ctx); return 0; }
总结
合理的使用设计模式能够使代码更易维护和扩展,但是对于经验不足的同学,不要一味的考虑使用设计模式,那样可能会使你失去对软件开发的乐趣,在没有实现软件功能的前提下,不要考虑使用设计模式,而是优先考虑完成软件的功能。