C++学习(10) —— 面向对象设计
文章目录
面向对象设计
面向对象的基本概念
面向对象的四大基本特征是:抽象、封装、继承、多态。
四大基本特征是我们在进行面向对象编程的准则,但是我们在进行面向对象编程之前还需要进行面向对象分析(Object-Oriented Analysis),通过分析业务,确定需求,找出对象,知道我们要干什么,然后就是进行面向对象的设计(ObjectOriented Design),知道我们如何干,最后才是面向对象的编程(Object-Oriented Program),具体的代码实现阶段。
面向对象的分析(OOA):分析出需求、要做什么。
面向对象的设计(OOD):需要根据需求设计出类,以及类中的数据成员以及成员,设计出类与类之间的关系,以及需要遵循的设计原则。
面向对象的编程(OOP):将上述的需求分析以及设计转化为代码。
以往的练习都比较简单,所以并没有格外强调OOA和OOD的环节,实际在工作中做项目,可能有70%的时间是用在了前两步上,甚至可以说分析与设计才是更难的部分。
为什么要如此强调OOD呢?
因为不论是面向过程的思维方式,还是面向对象的思维方式,最终都是为了能更好的进行软件开发,而软件开发的唯一真理是“软件一定会变化”。一个好的设计是应该能够轻松的应对变化,能被扩展,并且能够被复用。应该最好是能以最小的代价满足变化,不用改变现有代码架构来满足扩展,这样的设计才是一个优秀的设计,所以我们需要进行面向对象的设计。
为了能够更好的进行面向对象的设计,所以需要引用专门的设计语言,也就是我们的UML语言,Unifed Modeling Language, 又称统一建模语言或标准建模语言,是始于1997年一个OMG(Object Management Group)标准,它是一个模型化和软件系统开发的图形化语言。UML为软件开发提供了一些标准的图例(10种),统一开发思想,从而促进团队协作。这10中图例如下:

这里我们主要的来看红色的两种图例,第一种是类图,它是一种静态图,描述的是类与类之间的关系;第二种是序列图,它是一种动态图,可以看到一个动态的过程,后面对序列图我们在进一步了解,现在先来学习类图。
可以使用starUML软件或者某些在线网站练习类图的画法。
类与类之间的关系
—— 类图是用来描述类与类之间的关系,那类与类之间具备哪些关系呢?
继承(泛化) generalization
关联 association
聚合 aggregation
组合 composition
依赖 dependency
继承
继承关系,或者泛化关系,描述的是两个类之间一种很强的关系,基类对象会成为派生类对象的一部分,在语义上是一个A is B(A——派生类,B——基类)的特点。例如经理是个员工、软件开发工程师是个员工、销售也是一个员工,所以可以设计下面的图例:
在类图的画法上:从派生类指向基类的空心三角箭头

继承与泛化的区别:
继承是先有基类,然后从基类产生新的类,也就是派生类;
而泛化是先有派生类,然后多个派生类抽象出基类,也就是先有派生类,然后才有基类。
二者考虑的方向不一样,其实泛化更符合面向对象的思想。
关联
关联关系,是两个类之间最简单、最单纯的关系(仅仅表明两个类之间是有关系的)。
如果两个类之间没有关系,那就不能满足面向对象的特点(对象与对象之间进行交互,使得彼此之间的状态发生变化)。
而关联关系包括两种:双向的关联关系与单向的关联关系。
**但是不论哪种关联关系,两个类之间彼此并不负责对方的生命周期。**注意在语义上是A has B的关系,一种固定的关系。在代码上的表现形式是:使用引用或者指针。两种关联关系的例子:
双向的关联关系
双向的关联关系(使用直线),比如客户与订单之间的关系。客户拥有一个或多个订单,每个订单都会属于一个客户。
彼此知道对方的存在,但是彼此并不负责对方的生命周期。
在代码层面上:使用的是指针或者引用。
在类图的画法上:直接使用实心直线。

当然,还可以画得更准确一些
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
-
0…1
含义:表示关联的这一端可以有 0 个或 1 个实例。也就是说,在这种关联关系中,对应的类的实例数量要么是 0 个(不存在),要么是 1 个。 -
1
含义:表示关联的这一端必须恰好有 1 个实例。即这种关联关系要求对应的类必须有且只有 1 个实例参与。 -
0…*
含义:表示关联的这一端可以有 0 个或多个实例。“ -
1…*
含义:表示关联的这一端至少有 1 个实例,并且可以有多个实例。即实例数量的下限是 1,没有上限。 -
含义:这是 0…* 的简化表示,同样表示关联的这一端可以有 0 个或多个实例。
如果将Order对象作为Customer的成员,或者将Customer作为Order的成员,那么对象销毁时,一定会使得自己的成员子对象也销毁,不符合关联关系的含义(两个类之间彼此并不负责对方的生命周期)
单向的关联关系
A知道B的存在,但是B并不知道A的存在,彼此仍不负责对方的生命周期。
在代码层面上:A类包含B类类型的指针或者引用。
在类图的画法上:从A指向B的直线箭头。

聚合
是一种稍微强一点的关联关系,表现为整体与局部的关系,整体并不负责局部对象的销毁。
在语义层面:A has B
在代码层面上:使用指针或引用。
在类图的画法上:从局部指向整体的空心菱形箭头。

组合
是一种最强的关联关系,表现为整体与局部的关系,整体部分会负责局部的销毁。
在语义层面上:A has B
在代码层面上:
使用的是成员子对象;或者使用指针作为数据成员,但在析构函数中有回收的逻辑;或者使用管理局部的智能指针作为整体的成员
在类图的画法上:从局部指向整体的实心菱形箭头。

在上图案例中,HRDepartment/SaleDepartment/DevelopDepartment类的成员如果发生改变,会直接影响Company;
但是对于聚合关系则不会有这个效果,比如改变Mainboard/CPU/memory类的成员,对于Computer自身而言并没有什么影响。
而且Company对象的销毁会直接导致HRDepartment/SaleDepartment/DevelopDepartment对象的销毁。
依赖
是两个类之间的一种不确定的关系,语义上是一种A use B的关系,这种关系是偶然的,临时的,并非固定的 。
在类图的画法上:使用虚线的箭头,从A指向B
代码表现形式:
- B作为A的成员函数参数;(或者B类引用、B类指针作为A的成员函数参数)
- B作为A的成员函数的局部变量;
- B作为A的成员函数的返回值;
- A的成员函数调用B的静态方法。
(总之,只与A的成员函数相关,与A的数据成员无关)

主人散步时可以带着宠物一起散步,但是主人工作时就与宠物没有关系,这就是一种依赖关系(体现出不确定性、偶然性)。
总结:
1、继承关系是一个纵向关系,但是其他的四种是横向关系
2、在语义层面上:
继承 is;
关联、聚合、组合 has;
依赖:use
3、耦合程度:依赖 < 关联 < 聚合 < 组合 < 继承
4、关联、聚合、组合:三者强调的是数据成员之间的关系;
依赖:体现在类之间的成员函数上;
继承:不仅表现在数据成员上,还表现在成员函数上。
面向对象的设计原则
在进行面向对象设计的时候,需要考虑类与类之间的关系,这样可以让类之间的关系更加明确。但是,除此之外,在进行面向对象设计的时候,还需要注意满足一定的设计要求,也就是面向对象的设计原则,只有遵循一定的原则,才能更好的满足软件的设计需求,更好的满足变化。
一个优良的系统设计
要求:低耦合与高内聚。
耦合:强调的是类与类之间、或者模块与模块之间的关系。
内聚:强调的是类内部、或者模块内部的关系。
简单来说就是类之间的关联性降低一些,类内部关联性提高一些(比如一个类只干一件事情)。
可以这样理解,如果一个函数的功能太多,那么出现问题的时候一定不方便排查,对于类而言也是同样的道理。
单一职责原则
定义:一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中。单一职责原则的另一种定义方式:就一个类而言,应该仅有一个引起它变化的原因。单一职责原则是最简单的面向对象设计原则,它用于控制类的粒度大小。
核心思想:一个类,最好只做一件事,只有一个引起它变化的原因。
核心是:解耦与增加内聚性。
在软件系统中,一个类(大到模块,小到方法)承担的职责越多,它被复用的可能性就越小,而且一个类承担的职责过多,相当于将这些职责耦合在一起,当其中一个职责变化时可能会影响其他职责的运作,因此要将这些职责进行分离,将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中,如果多个职责总是同时发生改变则可将它们封装在同一类中。
单一职责原则是实现高内聚、低耦合的指导方针,它是最简单但又最难运用的原则,需要设计人员发现类的不同职责并将其分离,而发现类的多重职责需要设计人员具有较强的分析设计能力和相关实践经验。
例子:
Rectangle类有两个成员函数draw和area
GeometryApp类负责计算几何图形面积,GraphicalApp类负责绘制几何图形,GUI表示图形用户界面(Graphics User Interface)

如果将类的结构设计成上图这样,存在一个问题,GeometryApp与Rectangle之间的关系,按道理来说只有area函数才应该对GeometryApp产生影响,draw函数并不该影响GeometryApp(图形怎么画和图形的面积没有必然的关系)。
但实际上draw方法和area方法都有可能影响到GeometryApp的使用,违反了单一职责原则。
可以改成:
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
既然Rectangle类中影响GeometryApp(计算面积)的因素有两个,无法满足单一职责原则,可以将不相关的draw函数分离出来。
对于GraphicalApp(绘制图形),如何绘制既跟画法有关,又跟图形面积有关,可使其与Rectangle的派生类关联。
开闭原则(重要)
定义:软件实体应当对扩展开放,对修改关闭。
开闭原则是面向对象的可复用性设计的第一块基石,它是最重要的面向对象设计原则。
核心思想:对抽象编程,而不对具体编程,因为抽象相对稳定。
在开闭原则的定义中,软件实体可以指一个软件模块、一个由多个类组成的局部结构或一个独立的类。
**开闭原则就是指软件实体应尽量在不修改原有代码的情况下进行扩展。**任何软件都需要面临一个很重要的问题,即它们的需求会随时间的推移而发生变化。当软件系统需要面对新的需求时,应该尽量保证系统的设计框架是稳定的。如果一个软件设计符合开闭原则,那么可以非常方便地对系统进行扩展,而且在扩展时无须修改现有代码,使得软件系统在拥有适应性和灵活性的同时具备较好的稳定性和延续性。随着软件规模越来越大, 软件寿命越来越长,软件维护成本越来越高,设计满足开闭原则的软件系统也变得越来越重要。
为了满足开闭原则,需要对系统进行抽象化设计,抽象化是开闭原则的关键。在C++中,可以为系统定义一个相对稳定的抽象层,而将不同的实现行为移至具体的实现层中完成。在C++语言中提供了抽象类的机制,可以通过它定义系统的抽象层,再通过具体类来进行扩展。如果需要修改系统的行为,无须对抽象层进行任何改动,只需要增加新的具体类来实现新的业务功能即可,实现在不修改已有代码的基础上扩展系统的功能,达到开闭原则的要求。
例如,对计算器的实现
最基本的设计思路如下,但是如果需要给计算机器类添加新的功能就不是很方便,需要改变原有的代码(改变了Calculator的成员)

可以修改成如下结构

里氏替换原则(重要)
定义:所有引用基类(父类)的地方必须能透明地使用其派生类(子类)。
里氏代换原则表明:
如果一个软件实体使用的是一个基类对象,那么把它替换成派生类对象,程序将不会产生任何错误和异常;(反过来则不成立)
如果一个软件实体使用的是一个派生类对象,那么它不一定能够替换成基类对象直接使用。
核心思想:派生类必须能够替换其基类。派生类可以扩展基类的功能,但不能改变基类原有的功能。
表现为:派生类可以实现基类的抽象方法(纯虚函数),也可以覆盖基类的普通虚函数——表现多态,但不能覆盖基类的普通成员函数(此处的覆盖是站在面向对象设计的角度来进行的通俗描述,并非C++中特指的override,可以理解为C++中的隐藏)
class Base
{
public:
virtual void func1() = 0;
virtual void func2()
{
//...
}
void func3()
{
//...
}
};
class Derived
: public Base
{
public:
//必要的
virtual void func1() override
{
//...
}
//可以覆盖
virtual void func2() override
{
//...
}
//不建议隐藏
void func3()
{
//...
}
//添加新的成员
void func4()
{
//...
}
};
Base b1;
Derived d1;
b1.func3();
d1.func3(); //如果使用派生类对象替换基类对象,调用函数的效果可能不同(进行了隐藏),不符合里氏替换原则
//对于虚函数,设置virtual的目的就是实现动态多态,用基类指针或引用调用虚函数时调用到派生类实现的虚函数,是ok的
Base * pb = &b1;
pb->func2();
pb = &d1;
pb->func2();
//对于派生类新增的成员,原本基类就不会去访问
b1.func4(); //error
d1.func4();
例如我喜欢动物,那我一定喜欢狗,因为狗是动物的派生类;但是我喜欢狗,不能据此断定我喜欢所有的动物。
里氏代换原则是实现开闭原则的重要方式之一,由于在使用基类对象的地方都可以使用派生类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其派生类类型,用派生类对象来替换基类对象。
在运用里氏代换原则时应该将基类设计为抽象类,让派生类继承基类,并实现在基类中声明的方法,在运行时派生类实例替换基类实例,可以很方便地扩展系统的功能,无须修改原有派生类的代码,增加新的功能可以通过增加一个新的派生类来实现。
表现形式:
- 派生类可以实现基类的抽象方法(纯虚函数),表现多态 (就是C++中的动态多态)
- 派生类可以覆盖基类的普通虚函数,同样也是表现多态
- 派生类不能 “覆盖”(oversee)基类的普通成员函数 (不要使用C++中的隐藏)
- 派生类可以有自己的个性(可以添加新的成员)
接口分离原则
定义:客户端不应该依赖那些它不需要的接口。
核心思想:使用多个小的专门的接口,而不要使用一个大的总接口。
根据接口隔离原则,当一个接口太大时需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。每一个接口应该承担一种相对独立的角色,不干不该干的事,该干的事都要干。
C++中并没有单独的语法表示"接口",所以这里的“接口”实际上是逻辑层面的概念,往往有两种不同的含义:
一种是指一组方法的集合,这组方法由实现接口的类提供;
另外一种是指C++语言中声明纯虚函数的抽象类。
对于这两种不同的含义,接口隔离原则的表达方式以及含义也有所不同。
(1)当把“接口”理解成一个类型所提供的所有方法特征的集合的时候,接口的划分将直接带来类型的划分。可以把接口理解成角色,一个接口只能代表一个角色,每个角色都有它特定的一个接口,此时这个原则可以叫“角色隔离原则”。
(2)如果把“接口”理解成狭义的C++语言的抽象类,那么接口隔离原则表达的意思是指接口仅仅提供客户端需要的行为,客户端不需要的行为则隐藏起来,应当为客户端提供尽可能小的单独的接口,而不要提供大的总接口。在C++语言中,实现一个接口(抽象类)需要实现该接口(抽象类)中定义的所有方法,因此大的总接口使用起来不一定很方便。为了使接口的职责单一,需要将大接口中的方法根据其职责不同分别放在不同的小接口中,以确保每个接口使用起来都较为方便,并都承担某一单一角色。
接口(抽象类)应该尽量细化,同时接口中的方法应该尽量少,每个接口只包含一个客户端(如子模块或业务逻辑类)所需的方法即可,这种机制也称为“定制服务”,即为不同的客户端提供宽窄不同的接口。
例子:
如果将鸟类视为一个接口,它拥有吃、走、叫、飞四个动作。乌鸦类继承了鸟类,确实也都合理拥有这些方法;但是鸵鸟类继承鸟类,但是鸵鸟并不会飞。

这种情况可以认为Bird接口是一个“胖接口”,应该进行拆分,比如:

为了满足接口分离原则,将Bird中fly函数抽出来,将鸟分为不会飞的鸟和会飞的鸟,然后让乌鸦继承会飞的鸟,让鸵鸟继承不会飞的鸟。
当然,我们默认了鸟都会吃、走路和叫,所以把这三个函数都放在了Bird类中,如果彻底贯彻接口分离原则,那么一个类中不会有多个成员函数。
所以在贯彻此原则时,还需要结合实际情况进行权衡,不能死板套用。
——试想,如果有一种鸟一生只能飞翔,不能走路,那应该如何改造Bird接口呢?
依赖倒置原则(重要)
定义:高层模块不应该依赖低层模块,它们都应该依赖抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
简单来说,依赖倒置原则要求针对接口编程,不要针对实现编程。
如果说开闭原则是面向对象设计的目标,那么依赖倒置原则就是面向对象设计的主要实现机制之一。
依赖倒置原则要求在程序代码中传递参数时或在关联关系中尽量引用层次高的抽象层类。(基类往派生类方向代表从上往下,从高到低)
也就是使用抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体的派生类来做这些事情。
如果为了严格确保该原则的应用,建议 —— 一个具体类应当只实现接口或抽象类中声明过的方法,而不要给出多余的方法
因为基类指针或引用无法调用到在派生类中增加的新方法。
在引入抽象层后,系统将具有很好的灵活性,在程序中尽量使用抽象层进行编程,而将具体类写在配置文件中,这样如果系统行为发生变化,只需要对抽象层进行扩展,并修改配置文件,而无须修改原有系统的源代码,在不修改的情况下来扩展系统的功能,从而满足开闭原则的要求。
**核心思想:面向接口编程,依赖于抽象 **
在大多数情况下,开闭原则、里氏代换原则和依赖倒置原则会同时出现,开闭原则是目标,里氏代换原则是基础,依赖倒置原则是手段。
如图,银行职员需要做这些业务

class BankWorker
{
public:
void saveService()
{
cout << "办理存款业务" << endl;
}
void payService()
{
cout << "办理支付业务" << endl;
}
void transferService()
{
cout << "办理转账业务" << endl;
}
};
void doSaveBusiness(BankWorker *worker)
{
worker->saveService();
}
void doPayBusiness(BankWorker *worker)
{
worker->payService();
}
void doTransferBusiness(BankWorker *worker)
{
worker->transferService();
}
void test0()
{
unique_ptr<BankWorker> worker(new BankWorker());
doSaveBusiness(worker.get());
doPayBusiness(worker.get());
doTransferBusiness(worker.get());
}

—— 尝试根据类图实现出代码
#include <iostream>
#include <memory>
using std::cout;
using std::endl;
using std::unique_ptr;
class BankWorker
{
public:
virtual void doBusiness() = 0;
virtual ~BankWorker() {}
};
class SaveBankWorker
: public BankWorker
{
public:
void doBusiness() override
{
cout << "办理存款业务" << endl;
}
};
class PayBankWorker
: public BankWorker
{
public:
void doBusiness() override
{
cout << "办理支付业务" << endl;
}
};
class TransferBankWorker
: public BankWorker
{
public:
void doBusiness() override
{
cout << "办理转账业务" << endl;
}
};
void doBusiness(BankWorker *worker)
{
worker->doBusiness();
}
void test()
{
unique_ptr<BankWorker> saveWorker(new SaveBankWorker());
unique_ptr<BankWorker> payWorker(new PayBankWorker());
unique_ptr<BankWorker> transferWorker(new TransferBankWorker());
doBusiness(saveWorker.get());
doBusiness(payWorker.get());
doBusiness(transferWorker.get());
}
int main(int argc, char **argv)
{
test();
return 0;
}
迪米特原则
又称为最少知识原则
定义:每一个软件单位对其他单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。
迪米特法则要求一个软件实体应当尽可能少地与其他实体发生相互作用。如果一个系统符合迪米特法则,那么当其中的某一个模块发生修改时就会尽量少地影响其他模块,扩展会相对容易。
应用迪米特法则可降低系统的耦合度,使类与类之间保持松散的耦合关系。当两个类之间直接通信的时候,会造成高度依赖的后果(高耦合)。解决此问题的办法,尽量避免两个类直接接触(低耦合),通过一个第三者做转发。
在迪米特法则中,对于一个对象,其朋友包括以下几种:
(1)当前对象本身(this)
(2)以参数形式传入到当前对象方法中的对象
(3)当前对象的成员子对象
(4)如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友
(5)当前对象的成员函数中创建的对象
任何一个对象如果满足上面的条件之一,就是当前对象的“朋友”,否则就是“陌生人”。在应用迪米特法则时,一个对象只能与直接朋友发生交互,不要与“陌生人”发生直接交互,这样做可以降低系统的耦合度,一个对象的改变不会给太多其他对象带来影响。
迪米特法则要求在设计系统时应该尽量减少对象之间的交互,如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用,如果其中一个对象需要调用另一个对象的方法,可以通过“第三者”转发这个调用。简而言之,就是通过引入一个合理的“第三者”来降低现有对象之间的耦合度。
在将迪米特法则运用到系统设计时要注意下面几点:
在类的划分上应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改不会对关联的类造成太大影响;
在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限;
在类的设计上,只要有可能,一个类型应当设计成不变类;
在对其他类的引用上,一个对象对其他对象的引用应当降到最低。
迪米特原则又称为最少知道原则。核心思想:降低耦合程度。
例如:
A方案的参考代码
class Building
{
public:
virtual void sale() = 0;
virtual string getQuality() = 0;
virtual ~Building(){}
};
class WankeBuilding
: public Building
{
public:
WankeBuilding()
: _quality("高品质")
{}
void sale() override
{
cout << "万科楼盘" << _quality
<< "的房子被出售" << endl;
}
string getQuality() override
{
return _quality;
}
private:
string _quality;
};
class HengdaBuilding
: public Building
{
public:
HengdaBuilding()
: _quality("低品质")
{
}
void sale() override
{
cout << "恒大楼盘" << _quality
<< "的房子被出售" << endl;
}
string getQuality() override
{
return _quality;
}
private:
string _quality;
};
void test()
{
Building *pbuidingA = new WankeBuilding();
Building *pbuidingB = new HengdaBuilding();
string demand = "低品质";
if(pbuidingA->getQuality() == demand)
{
pbuidingA->sale();
}
if(pbuidingB->getQuality() == demand)
{
pbuidingB->sale();
}
}
试着实现一下,如果按照方案B,代码应该改成什么样子呢?
#include <iostream>
#include <string>
#include <vector>
using std::cout;
using std::endl;
using std::string;
using std::vector;
class Building
{
public:
virtual void sale() = 0;
virtual string getQuality() = 0;
virtual ~Building(){}
};
class WankeBuilding
: public Building
{
public:
WankeBuilding()
: _quality("高品质")
{
}
void sale() override
{
cout << "万科楼盘" << _quality << "的房子被出售" << endl;
}
string getQuality() override
{
return _quality;
}
private:
string _quality;
};
class HengdaBuilding
: public Building
{
public:
HengdaBuilding()
: _quality("低品质")
{
}
void sale() override
{
cout << "恒大楼盘" << _quality << "的房子被出售" << endl;
}
string getQuality() override
{
return _quality;
}
private:
string _quality;
};
class Mediator
{
public:
Mediator()
{
Building *pbuildingA = new WankeBuilding();
Building *pbuildingB = new HengdaBuilding();
_buildings.push_back(pbuildingA);
_buildings.push_back(pbuildingB);
}
Building *findBuilding(const string &quality)
{
for(auto &building : _buildings)
{
if(building->getQuality() == quality)
{
return building;
}
}
return nullptr;
}
~Mediator()
{
for(auto &building : _buildings)
{
if(building)
{
delete building;
}
}
}
private:
vector<Building *> _buildings;
};
void test()
{
string demand = "低品质";
Mediator mediator;
Building *pbuilding = mediator.findBuilding(demand);
if(pbuilding)
{
pbuilding->sale();
}
else
{
cout << "没有符合要求的楼盘" << endl;
}
}
int main(int argc, char **argv)
{
test();
return 0;
}
pimpl模式也可以体现出迪米特原则
原本的结构是
class Line { Point _pt1; Point _pt2; };这样Point类有任何的改变,Line也会随之改变(组合关系)
但pimpl模式把代码写成了下面这样,实际上也就是引入了中间层。Point类的改变并不会直接造成Line的改变。
class Line { LineImpl * _pimpl; }; class LineImpl { Point _pt1; Point _pt2; };
组合复用原则
定义:优先使用对象组合,而不是通过继承来达到复用的目的。
组合复用原则就是在一个新的对象里通过关联关系(包括组合关系和聚合关系)来使用一些已有的对象,使之成为新对象的一部分,新对象通过委派调用已有对象的方法达到复用功能的目的。简而言之,在复用时要尽量使用组合/聚合关系(关联关系),少用继承。
在面向对象设计中可以通过两种方法在不同的环境中复用已有的设计和实现:即通过组合/聚合关系或通过继承。
首先应该考虑使用组合/聚合,组合/聚合可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较少;其次才考虑继承,在使用继承时需要严格遵循里氏替换原则,有效使用继承会有助于对问题的理解,降低复杂度,而滥用继承反而会增加系统构建和维护的难度以及系统的复杂度。
通过继承来进行复用的主要问题在于继承复用会破坏系统的封装性,因为继承会将基类的实现细节暴露给子类,由于基类的某些内部细节对子类来说是可见的,所以这种复用又称“白箱”复用,如果基类发生改变,那么子类的实现也不得不发生改变;从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性;而且继承只能在有限的环境中使用(如类没有声明为不能被继承 —— final语法)。
class A final {}; class B : public A //error {};
由于组合/聚合关系可以将已有的对象纳入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,这样做可以使成员对象的内部实现细节对于新对象不可见,所以这种复用又称为“黑箱”复用。相对继承关系而言,其耦合度相对较低,成员对象的变化对新对象的影响不大,可以在新对象中根据实际需要有选择性地调用成员对象的操作。
组合复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的其他对象。
核心思想:在复用时要尽量使用组合/聚合关系(关联关系),少用继承。
例子:
有多个人去开不同品牌的车,可以这样实现:
class Vehicle
{
public:
virtual void run() = 0;
virtual ~Vehicle() {}
};
class Tesla
: public Vehicle
{
public:
void run() override
{
cout << "Model Y start..." << endl;
}
};
class BYD
: public Vehicle
{
public:
void run() override
{
cout << "汉EV start..." << endl;
}
};
class Geely
: public Vehicle
{
public:
void run() override
{
cout << "Geely LYNK03 start..." << endl;
}
};
class PersonA
: public Tesla
{};
class PersonB
: public BYD
{};
class PersonC
: public Geely
{};
void test()
{
PersonA pa;
pa.run();
PersonB pb;
pb.run();
PersonC pc;
pc.run();
}
全都设计成继承结构确实能够达到人驾驶车的目的,但是这样写耦合度太高了。仅仅为了让人对象去使用车的run方法,就让车作为人的基类,有些没必要。
试想如果车坏了,难道人也坏了吗?显然不合理。那么可以根据组合复用原则,将人和车的继承关系改成关联关系,降低了人与车之间的耦合度。
—— 根据类图写出相应的代码试一试
#include <iostream>
#include <memory>
using std::cout;
using std::endl;
using std::unique_ptr;
class Vehicle
{
public:
virtual void run() = 0;
virtual ~Vehicle() {}
};
class Tesla
: public Vehicle
{
public:
void run() override
{
cout << "Model Y start..." << endl;
}
};
class BYD
: public Vehicle
{
public:
void run() override
{
cout << "汉EV start..." << endl;
}
};
class Geely
: public Vehicle
{
public:
void run() override
{
cout << "Geely LYNK03 start..." << endl;
}
};
class Person
{
public:
void getVehicle(Vehicle *vehicle)
{
_vehicle = vehicle;
}
void drive()
{
_vehicle->run();
}
private:
Vehicle *_vehicle;
};
void test()
{
Person person;
unique_ptr<Vehicle> tesla(new Tesla());
unique_ptr<Vehicle> han(new BYD());
unique_ptr<Vehicle> geely(new Geely());
person.getVehicle(tesla.get());
person.drive();
person.getVehicle(han.get());
person.drive();
person.getVehicle(geely.get());
person.drive();
}
int main(int argc, char **argv)
{
test();
return 0;
}
设计模式
它是解决特定问题的一系列套路,有一定的普遍性。它是一套被反复使用、多数人知晓的、代码设计经验的总结。它可以提高代码的可重用性、代码的可读性和代码的可靠性以及可扩展性。
分类
创建型模式(Creational patterns)5种
提供对象创建机制,增加现有代码的灵活性和重用

结构型模式(Structural patterns)7种
解释如何将对象和类组装成更大的结构,同时保持结构的灵活性和高效性

行为型模式(Behavioral patterns)11种
负责有效的沟通和对象之间的责任分配
简单工厂模式
以之前多态阶段学习过的Figure类作为案例

—— 如果想让程序更具备可拓展性,则可以使用上读取配置文件的方式来创建具体的图形对象(txt、xml、conf、yang等格式)
这样一来把所有对象直接创建的具体细节都写到main函数中就不合适了,可以封装一些函数。也就是把具体的不同类型对象的创建打包到接口中,要使用这些对象时调用接口即可。
当然,创建的这一系列create函数,本质上功能和写法都很相似,那么可以将它们统一起来。
根据继承的知识,进行改写
根据面向对象的思想,可以把一系列的create函数统一为成员函数
——可以定义一个新的类Factory
进一步地,把这一系列的成员函数合并成一个
——注意到一个问题,三个图形对象并没有被销毁。
那么请思考一下,怎样可以完成回收工作呢?
智能指针 + 虚析构函数

描述:
简单工厂的写法,也被称为静态工厂
其做法是提供一个专门的工厂类,在工厂类中做判断,根据传入的类型创造相应的产品,这样做能够将对象的创建和对象的使用分离开。当增加新的产品时,就需要修改工厂类。它作为一种最简单的工厂模式在软件开发中得到了较为广泛的应用。
类图:
优点:
1、工厂类包含必要的判断逻辑,可以决定在什么时候创建哪一个产品类的实例,客户端可以免除直接创建产品对象的职责,而仅仅“消费”产品,简单工厂模式实现了对象创建和使用的分离
2、客户端无须知道所创建的具体产品类的类名,只需要知道具体产品类所对应的参数即可,对于一些复杂的类名,通过简单工厂模式可以在一定程度减少使用者的记忆量
3、通过引入配置文件,可以在不修改任何客户端代码的情况下更换和增加新的具体产品类,在一定程度上提高了系统的灵活性。
总结:可以根据产品的名字生产出对应的产品
缺点:
1、由于工厂类集中了所有产品的创建逻辑,职责过重,一旦不能正常工作,整个系统都要受到影响;
2、使用简单工厂模式势必会增加系统中类的个数(引入新的工程类),增加了系统的复杂度和理解难度;(比如,如果想要创建梯形、椭圆形之类的图形,还需要改动Factory的代码)
3、系统拓展困难,一旦添加了新的产品就不得不修改工厂逻辑,在产品类型较多时,有可能造成工厂逻辑过于复杂,不利于系统的拓展和维护
4、简单工厂模式由于使用了静态工厂方法,造成工厂角色无法形成基于继承的等级结构
总结:违反了单一职责原则、违反了开放闭合原则
适用场景:
1、工厂类负责创建的对象比较少,由于创建的对象较少,不会造成工厂方法中的业务逻辑太过复杂。
2、客户端只知道传入工厂类的参数,对于如何创建对象并不关心。
工厂模式
事实上开放闭合原则和依赖倒置原则都是要求程序员面向抽象编程,那么我们可以把Factory写成抽象类

一类产品一个工厂,这就是工厂模式(工厂方法)
使用方式
用统一的方式实现了产品的生产,如果想要生产新类型的产品,无需改变原有的代码,只需添加新的工厂即可。
上面这样写需要手动回收空间,可以优化为智能指针

如果对RectangleFactory等类的构造与析构进行打印,会发现三个实际的工厂类对象并没有析构,原因与上述简单工厂一样,需要将Factory的析构函数设为虚函数。
类图
描述:
在软件开发及运行过程中,经常需要创建对象,但常出现由于需求的变更,需要创建的对象的具体类型也要经常变化。工厂方法通过采取虚函数的方法,实现了使用者和具体类型之间的解耦,可以用来解决这个问题。工厂方法模式对简单工厂模式中的工厂类进一步抽象。核心工厂类不再负责产品的创建,而是演变为一个抽象工厂角色,仅负责定义具体工厂子类必须实现的接口。同时,针对不同的产品提供不同的工厂。即每个产品都有一个与之对应的工厂。这样,系统在增加新产品时就不会修改工厂类逻辑而是添加新的工厂子类,从而弥补简单工厂模式对修改开放的缺陷。定义一个创建对象的接口,让子类决定实例化哪个类。该模式使类对象的创建延迟到子类。
优点:
1、用户只需要知道具体工厂的名称就可得到所要的产品,无须知道产品的具体创建过程。
2、灵活性增强,对于新产品的创建,只需多写一个相应的工厂类。
3、典型的解耦框架。高层模块只需要知道产品的抽象类,无须关心其他实现类。
4、对扩展开放对修改关闭;解决了简单工厂的缺点问题。
总结:遵循了单一职责原则、开放闭合原则、遵循了依赖倒置原则。
缺点:
1、类的个数容易过多,增加复杂度
2、增加了系统的抽象性和理解难度
3、接口的传入参数类型需要一致,且只能对单一变化量接口使用
4、抽象产品只能生产一种产品,此弊端可使用抽象工厂模式解决。
总结:工厂的数量会随着产品的增加而急剧上升。
适用场景:
1、客户只知道创建产品的工厂名,而不知道具体的产品名。
2、创建对象的任务由多个具体子工厂中的某一个完成,而抽象工厂只提供创建产品的接口。
3、客户不关心创建产品的细节,只关心产品的品牌
抽象工厂模式
工厂方法最大的弊端就是工厂太多之后难以管理,此时可以使用抽象工厂模式。
描述:
在软件开发及运行过程中,经常面临着“一系列相互依赖的对象”的创建工作;而由于需求的变化,常常存在更多系列对象的创建问题。
定义:提供一个接口,该接口负责创建一系列“相关或者相互依赖的对象”,无需指定它们具体的类。
前面的两种模式都是先写出代码,再画类图。那不妨尝试一下反其道而行之,阅读下面的类图,可以体会到设计的思想,并实现出相应的代码吗?

如果仍然不够直观,那么我们可以将这些类名具体化
试想,如果再出现一家工厂也生产CPU和主板,在当前的类结构上进行拓展是很方便的。
即增加新的产品族是很简单的。
代码:
部件
工厂
在不考虑回收的情况下
全部换成智能指针的使用就不会有泄露情况(要熟悉智能指针的使用方式)
优点
1、抽象工厂模式隔离了具体类的生成,使得客户端并不需要知道什么被创建。
2、当一个产品族中的多个对象被设计成一起工作时,它能够保证客户端始终只使用同一产品族中的对象;
3、增加新的产品族很方便(生成新的具体工厂),无需修改已有系统代码,符合开闭原则;缺点
增加新的产品等级结构很复杂,需要修改抽象工厂和所有的具体工厂类,对“开闭原则”的支持呈现倾斜性。应用场景
1、用户无需关心对象的创建过程,将对象的创建和使用解耦;
2、产品等级结构稳定,在设计完成之后不会向系统中增加新的产品等级结构或者删除已有的产品等级结构;
3、系统中有多于一个的产品族,而每次只使用其中某一产品族。可以通过配置文件等方式来使用户能够动态改变产品族,也可以很方便的增加新的产品族。
总结:
- 想要一个工厂生产有限种产品 —— 简单工厂模式
- 产品种类的总量不太多,但可能会有较多的新产品需求 —— 工厂模式
- 生产产品时想要对产品的层级进行更细化的划分 —— 抽象工厂
观察者模式
描述:
定义对象的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。所以这种交互也称为发布-订阅(publish-subscribe)模式
当应用中一些对象必须观察其他对象时,可使用这个模式,但只能在有限时间内或特定情况下使用。
被观察的对象状态发生改变,就会改变观察者对象的内容。
类图:
代码

测试:

优点:
1、观察者和被观察者是抽象耦合的
2、建立一套触发机制。
缺点:
1、如果一个被观察者对象有很多的直接和间接的观察者的话,将所有的观察者都通知到会花费很多时间。
2、如果在观察者和观察目标之间有循环依赖的话,观察目标会触发它们之间进行循环调用,可能导致系统崩溃。
3、观察者模式没有相应的机制让观察者知道所观察的目标对象是怎么发生变化的,而仅仅只是知道观察目标发生了变化。
应用场景:
1、一个抽象模型有两个方面,其中一个方面发依赖于另外一个方面。将这些方面封装在独立的对象中使它们可以各自独立地改变和复用。
2、一个对象的改变将导致其它一个或多个对象发生改变,而不知道具体有多少对象将发生改变,可以降低对象之间的耦合度。
3、一个对象必须通知其他对象,而并不知道这些对象是谁。
4、需要在系统中创建一个触发链,A对象的行为将影响B对象,B对象的行为将影响C对象……,可以使用观察者模式创建一种链式触发机制。
文章大纲

更多推荐




所有评论(0)