一个模式描述了一个在我们周围不断重复发生的问题,以及该问题的解决方案的核心。 这样,你就能一次又一次
地使用该方案而不必做重复劳动。
0. 从面向对象谈起
-
底层思维与抽象思维:
底层思维要求程序员"向下"思考,去把握机器底层,从微观理解对象构造。这就包含了我们之前所学习的C++基本语法,封装,继承,多态这三大机制以及编译原理的相关内容。
抽象思维要求程序员"向上"思考,考虑如何让世界抽象成代码,这就包括面向对象的思想、设计模式等。
-
软件工程的复杂性:
"建筑商从来不会去想给一栋己建好的100层高的楼房底下再新修 个小地下室一一这样做花费极大而且注定要失败。然而令人惊奇的是,软件系统的用户在要求做出类似改变时改变时却不会仔细考虑,而且他们认为这只是需要简单编程的事。"
解决复杂性的方法:
1. 分解:大问题分解成小问题,在面向过程的设计中使用。
2. 抽象:忽略细节只抓重点去处理泛化的、理想化的模型,在面向对象中使用。
-
为什么面向对象
软件设计的重要目标——复用!而变化是复用的天敌,面向对象的作用就在于"抵御变化"——用增加对象(你只需在前边添加新的对象和相应的操作,然后进行使用)代替改变代码(你要在整个源代码的分治的每个步骤中寻找所有涉及到的部分)。
- 从宏观层面来看,面向对象的构建方式更能适应软件的变化,能将变化所带来的影响减为最小。
- 从微观层面来看,更加强调各个类自己的责任,由于需求变化导致的新增类型不应该影响原来类型的实现。
- 从语言实现层面来看,对象封装了代码和数据;从规格层面讲,对象是一系列可被使用的公共接口;从概念层面讲,对象是某种拥有责任的抽象。
-
面向对象设计原则
-
依赖倒置原则(DIP)
高层模块(稳定)不应该依赖于低层模块(变化),二者都应该依赖于抽象(稳定) 。
抽象(稳定)不应该依赖于实现细节(变化) ,实现细节应该依赖于抽象(稳定)。
【举例:在经典的的画图操作中,高层模块为main的画图操作,底层模块为各种形状类,抽象为Shape基类;main和形状都依赖于Shape基类,而Shape基类独立于具体形状类型。软件需求改变时,要改变低层模块,而不是抽象或是高层模块。】
-
开放封闭原则(OCP)
对扩展开放,对更改封闭;类模块应该是可扩展的,但是不可修改。
【设计者可以通过增加内容进行扩展,而不需要在各段代码中修改东西。】
-
单一职责原则(SRP)
一个类应该仅有一个能够让它变化的原因,这个原因包含着它的责任。
【这让我们在扩展功能时有的放矢。】
-
Liskov替换原则(LSP)
子类必须能够替换官们的基类(IS-A)。继承表达类型抽象。
【所有需要父类的地方,作为子类都应该能够使用。】
-
接口隔离原则(ISP)
不应该强迫害户程序依赖它们不用的方法,接口应该小而完备。
【不必要的方法不要public】
-
优先使用组合而不是继承。
继承为白箱复用,组合为黑箱复用。继承比组合的耦合程度更高。
【一定要确保严格的is-a关系才使用继承】
-
封装变化点
使用封装来创建对象之间的分界层,让设计者可以在分界层的一侧进行修改,而不会对另一侧产生不良的影响,从而实现层次间的松耦合。
-
针对接口编程,而不是针对实现编程
不将变量类型声明为某个特定的具体类,而是声明为某个接口。【多态】
客户程序无需获知对象的具体类型,只需要知道对象所具有的接口。
-
1. 设计模式总览
设计模式的要点是"寻找变化点,然后在变化点出应用设计模式,从而来更好的应对需求的变化"。没有一步到位的设计模式,设计模式不应该一开始就应用,而更多的应该通过重构得到。
23种设计模式按功能上可以划分为:
组件协作类:Template Method,Strategy,Observer/Event;
单一职责类:Decorator,Bridge;
对象创建类:Factory Method,Abstract Factory,Prototype Builder;
对象性能类:Singleton,Flyweight;
接口隔离类:Façade,Proxy,Mediator,Adapter;
状态变化类:Memento,State;
数据结构类:Composite,Iterator,Chain of Resposibility;
行为变化类:Command,Visitor;
领域问题类:Interpreter;
五种重构的方式:
静态到动态、早绑定到晚绑定、继承到组合、编译时依赖到运行时依赖、紧耦合到松耦合。
2. 组件协作类设计模式
组件协作类设计模式主要是通过晚期绑定来实现框架与应用的松耦合。
2.1 Template Method(模板方法)
2.1.1 应用场景
常常有稳定的整体操作结构,但各个子步骤却有很多改变的需求。或者由于工程原因无法一起实现(例如框架和应用之间的关系)。
2.1.2 定义与解释
定义一个操作中算法的骨架(稳定),而将一些步骤延迟(变化)到子类中。这种设计模式可以使的子类复用一个算法的结构。
考虑一个库开发的场景,我们希望库能够提供一个方法Run,然而Run的每个步骤对于不同的子类可能是不同的。
//程序库 class Library{ public: //稳定 template method void Run(){ Step1(); if Step3(); } for Step4(); } Step5(); } virtual protected: void Step1() //..... } void Step3() //..... } void Step5() //..... } virtual virtual }; |
这种设计模式的特点是,Run操作(注:Run操作必须是稳定的)涉及到了很多子步骤(不稳定的),程序库在开发的时候,利用虚函数把Run操作写在Library中。如果不采用这种设计模式,那么由于Step2和Step4在设计库的时候还没有办法确定下来,在开发库时Run操作的任务就写不出来,只能在应用开发时在main函数中具体的编写。如果它有多个子类,那么这种重复性的工作量将是巨大的,并且要求应用程序开发者必须知道底层的步骤。采用了Template Method设计模式之后,应用程序开发者只需要提供两个不稳定的Step就可以使用其功能,而不用关注具体步骤之间的关系是什么。
2.1.3 早绑定与晚绑定
传统的早绑定,要求在库的开发的时候就已经确定了将来程序运行哪个方法;而采用了Template Method模式的晚绑定方法,则库依赖于将来才能确定的方法。
2.1.4 核心
- 在变化(子步骤的不同)与稳定(主要流程的相同)之中寻找隔离点(也就是说,完全变化或者完全稳定是不适合这种设计模式的)。
- 它用最简洁的机制(虚函数的多态性)为很多应用程序框架提供了灵活的扩展点,是代码复用方面的基本实现结构。
- 作为库开发人员的角度,"不要调用我,让我来调用你"的反向控制结构。
- 通常采取Protected方法,因为之后的子类就不应该再修改库的任何操作(主流程的稳定性),所以不应采用Public,而使用Protected则是为子类添加新的操作留下了一部分回旋的空间而不需要阅读并修改库的代码。
2.1.5 类图
其中只有ConcreteClass是变化的,另外的两块是稳定的。
2.2 Strategy(策略模式)
2.2.1 应用场景
有些对象使用的算法可能多种多样,经常改变;如果将它们都编码到对象中,会使对象变得非常复杂,并且支持很多不常用的算法会使得对象的性能降低。
2.2.2 定义与解释
定义一系列算法,把官们一个个封装起来,并且使它们可亘相苔换(变化)。 该模式使得算法可独立于使用它的客户程序(稳定)而变化(扩展 ,子类化)。
考虑一个场景,在开发一款财务软件时,想要支持各种国家的税务计算。然而,各个国家的税务计算有着不同的算法,在这种情况下,一种静态的方案是:定义一个枚举类型,然后给类添加一个该枚举类型的标识符,根据标识符的不同来决定怎样计算。然而,这种方法如果想要添加国家,就必须在整个程序中修改,这就违背了开放封闭原则(应该用增加代替更改)。
class TaxStrategy{ public: virtual virtual };
class CNTax : public: virtual //*********** } }; //扩展 //********************************* class FRTax : public: virtual //......... } };
//使用 class SalesOrder{ private: TaxStrategy* strategy;
public:
SalesOrder(StrategyFactory* strategyFactory){ this->strategy = strategyFactory->NewStrategy(); } ~SalesOrder(){ delete }
public //... Context context();
double val = strategy->Calculate(context); //... }
}; |
用strategy模式来代替含有很多if..else…或是switch语句;后两个方式适用于"一定不会更改"的情况,strategy对于更能够容许更改。
2.2.3 核心
- Strategy及子类为组件提供了可重用的算法,从而可以使得类型在运行时方便地根据需要在各个算法之间进行切换。添加算法时,复用性得到了体现(可以不用重新编译)。
- Strategy模式提供了用条件判断语句以外的另一种选择,消除判断语句,就是在解耦合。含许多条件判断语旬的代码通常都需要Strategy模式。
- 如果Strategy对象没有实例变量,那么各个上下文可以共享同一个,节省内存开销。(结合Singleton模式。)
2.2.4 类图
其中只有ConcreteStrategy是变化的,其他两部分是稳定的。注意其中的委托关系和is-a关系。
2.3 Observer/Event(观察者模式)
注:在笔者之前的《C++学习笔记(2)》中有初步的介绍。
2.3.1 应用场景
在软件构建过程中,我们需要为某些对象建立一种 "通知依赖关系 一一一个对象(目标对象)的状态发生改变,所有的依赖对象(观察者对象)都将得到通知。 如果这样的依赖关系过于紧密,将使软件不能很好地抵御变化。使用面向对象技术,可以使这种依赖关系弱化,从而实现松耦合。
2.3.2 定义与解释
定义对象间的一种一对多(变化) 的依赖关系,以便当一对象(Subject)的状态发生改变时,所高依赖于它的对象都得到通知并自动更新。
考虑一个场景,在开发一款文件分割软件时,想要通过一个进度条实时地更新分割文件的状态。所谓分割文件,就是先把文件读到内存中,然后再用比较小的尺寸循环输出。那么要想实现进度条,最直观的想法就是,在文件分割的类里边添加一个进度条对象,在分割的循环过程中每产生一个小文件,就更新一下进度条的值。然而,这种方法违背了"依赖倒置原则"(抽象不应依赖于实现细节)。因为这样如果我们以后不想用进度条了,可能用数值或者其他方式展示进度,或者想要在增加一个显示窗口,就带来了困难(基于开放封闭原则,我们不应该"更改"源代码,而是增加代码。)
观察者模式就是,定义一个接口,在文件分割的类里边对这个基类进行委托。所谓的接口,就是一个抽象基类。从中存储所需要的数值,并将其进行实时的更新。所有的通知控件,比如进度条,都由这个抽象基类派生出来,并使用抽象基类提供的数值进行进度显示。把具体通知控件变成抽象通知机制,使其形成松耦合,符合了依赖倒置原则。
class IProgress{// 抽象通知机制的接口 public: virtual virtual };
class FileSplitter { string m_filePath; int m_fileNumber;
List<IProgress*> m_iprogressList;
public: FileSplitter(const string& filePath, m_filePath(filePath), m_fileNumber(fileNumber){
} void split(){ //1.读取大文件 //2.分批次向小文件中写入 for //... float progressValue = m_fileNumber; progressValue = onProgress(progressValue);//发送通知 }
}
void addIProgress(IProgress* iprogress){ m_iprogressList.push_back(iprogress); }
void removeIProgress(IProgress* iprogress){ m_iprogressList.remove(iprogress); }
protected: virtual List<IProgress*>::iterator itor=m_iprogressList.begin();
while (*itor)->DoProgress(value); //由框架告知控件进行数据更新 itor++; }//利用循环通知所有想要获取进行状态的对象,支持多个观察者。 } }; class MainForm : { TextBox* txtFilePath; TextBox* txtFileNumber;
ProgressBar* progressBar;
public: void Button1_Click(){ string filePath = txtFilePath->getText(); int number = atoi(txtFileNumber->getText().c_str());
ConsoleNotifier cn; FileSplitter splitter(filePath, number); splitter.addIProgress(this); splitter.addIProgress(&cn); splitter.split(); splitter.removeIProgress(this);
} virtual progressBar->setValue(value); } }; //提供另一种显示方式。 class ConsoleNotifier : public: virtual cout << } }; |
注意这里边的主框架程序MainForm使用了多继承。C++不推荐使用多继承,当且仅当在其中一个父类是核心,其他父类都是接口的情况下才推荐使用多继承。在单链继承的语言如JAVA中也引入了类似的机制来包含接口,只不过是以多重继承之外的形式。
2.3.3 依赖的定义
- 编译时依赖:A依赖于B,代表A编译的时候,B必须已经被编译,A才能编译通过。
- 运行时依赖:A运行的时候,B必须已经存在(动态运行库)。
2.3.4 核心
- 使用面向对象的抽象,Observer模式使得我们可以独立地改变目标与观察者,从而使二者之间的依赖关系达到松耦合。
- 目标发送通知肘,无需指定观察者,通知(可以携带通知信息作为参数)会自动传播。
- 观察者自己决定是否需要订阅通知,目标对象对此一无所知。
- Observer模式是基于事件的UI框架中非常常用的设计模式,也是MVC的重要组成部分。
2.3.5 类图
先看功能对象(左侧),其中的Attach为注册过程,Detach为注销过程,功能对象接口中含有对Observer的委托。功能对象派生自功能对象接口,在本例程中功能对象直接是一个完整的具体功能对象了。
再看观察者对象,观察者具体对象派生自观察者接口,观察者具体对象中有指向具体功能对象的引用,这个设定在本例程中也没有采用,它的主要目的是使得观察者对象可以主动进行观察,而不是一定要等待功能对象进行通知。
其中只有两个具体类是变化的,两个抽象类是稳定的。
3. 单一职责类设计模式
在软件组件的设计中,如果责任划分的不清晰,使用继承得到的结果往往是随着需求的变化,子类急剧膨胀,同时充斥着重复代码, 这时候的关键是划清责任。继承转组合是一个非常常用且极其重要的手段。
3.1 Decorator(装饰器模式)
3.1.1 应用场景
在某些情况下我们可能会过度地使用继承来扩展对象的功能。由于继承为类型引入的静态特质,使得这种扩展方式缺乏灵活性;并且随着子类的增多(扩展功能的增多),各种子类的组合(扩展功能的组合)会导致更多子类的膨胀。
3.1.2 定义与解释
动态(组合)地给 一个对象增加些额外的职责。 就增加功能而言, Decorator模式比生成子类(继承)更为灵活(消除重复代码&减少子类个数)。
考虑一个场景,在开发一款需要进行流处理的操作时,有时我们又需要在文件流、网络流、内存流等各种流之上进行一定的加密、缓冲等操作。如下图所示:
然而对于不同的流来讲,其进行的加密操作、缓冲操作等几乎是一样的。如果采用一般的思路,我们会在流的派生类(具体加密流、缓冲流)中各加入相应的代码(注:一般地,我们采取单链继承的方法,正如上图中所示。)。当流的种类越来越多,会发现存在着大量的代码冗余(完全相同的代码)。这就是重构的入手点。
装饰器模式用组合(委托)替代继承,从而达到了统一,实现了对多态的支持。
//共有业务操作 class Stream{
public: virtual virtual virtual
virtual };
//主体类 class FileStream: public: virtual //读文件流 } virtual //定位文件流 } virtual //写文件流 }
};
class NetworkStream :public Stream{ public: virtual //读网络流 } virtual //定位网络流 } virtual //写网络流 }
}; //扩展操作,使用传统方法,存在大量的重复代码。。 class CryptoFileStream :public FileStream{ public: virtual //额外的加密操作... FileStream::Read(number);//读文件流 } virtual //额外的加密操作... FileStream::Seek(position);//定位文件流 //额外的加密操作... } virtual //额外的加密操作... FileStream::Write(data);//写文件流 //额外的加密操作... } };
class CryptoNetworkStream : public: virtual
//额外的加密操作... NetworkStream::Read(number);//读网络流 } virtual //额外的加密操作... NetworkStream::Seek(position);//定位网络流 //额外的加密操作... } virtual //额外的加密操作... NetworkStream::Write(data);//写网络流 //额外的加密操作... } };//每种流的同样处理却要用很多段不同的代码。 ====================================================================== //扩展操作,应用装饰器。 //使用继承的目的在于,只有通过继承才能改写虚函数Read,Seek等;如果是再重新定义一个Read,Seek操作,则有装饰流和无装饰流不从属于个基类,无法放在一个容器里实现多态。 //实用组合的目的在于,可以把具体的被装饰类延迟到运行时决定,而不用静态的决定好。(因为继承一定是静态的,组合可以是动态的。)
DecoratorStream: //作为基类的第一派生类(中间类),为其赋予一个委托。 //stream对象也可以直接扔在下面的加密流中,不过考虑到可能还有内存流,缓冲流等其他的//流,他们都要有这样一个委托。根据马丁福勒的重构理论,同一基类的不同子类中的相同元//素应该"往上提"。 protected: Stream* stream;//... DecoratorStream(Stream * stm):stream(stm){ }
};
class CryptoStream:
public: CryptoStream(Stream* stm):DecoratorStream(stm){ } virtual
//额外的加密操作... stream->Read(number);//读文件流 } virtual //额外的加密操作... stream::Seek(position);//定位文件流 //额外的加密操作... } virtual //额外的加密操作... stream::Write(data);//写文件流 //额外的加密操作... } };//所有的流都可以用这个类进行装饰,只需要把流指定为基类的成员。 void Process(){
//运行时装配 FileStream* s1=new FileStream();
CryptoStream* s2=new CryptoStream(s1); //使用时最外层应该是装饰器。
} |
注意这里即继承了基类,又组合了一个基类。这是Decorator类的一个重要的特点,几乎是所有设计模式中Decorator独有的一个特点。继承是为了完善接口规范(否则将无法使用虚函数机制),组合是为了支持将来的具体实现。
3.1.3 静态与动态
- 静态:没有变化的可能性,比如传统方法中FileStream::Read(number)就是读文件流,NetworkStream::Read(number)就是读网络流,他是没有办法变化的。
- 动态:可以进行延迟决定,比如装饰器模式中使用组合实现,装饰流之中的stream->Read(number)在将来运行时可以应用于任何功能流。
3.1.4 核心
- 通过采用组合而非继承的手法,Decorator模式实现了在运行时动态扩展对象功能的能力,而且可以根据需要扩展多个功能。避免"了使用继承带来的灵活性差和多子类衍生问题 。
- Decorator类在接口上表现为is-a Component的继承关系,即Decorator类继承了Component类所具有的接口。但在实现上又表现为has-a Component 的组合关系,即Decorator类又使用了另一个Component类。
- Decorator模式的目的并非解决多子类衍生的多继承问题,而是"主体类在多个方向上的扩展功能"——这是装饰的含义。
3.1.5 类图
3.2 Bridge(桥模式)
3.1.1 应用场景
由于某些类型固有的实现逻辑,使得他们具有两个或多个变化的维度。
3.1.2 定义与解释
将抽象部分(功能)与实现部分(平台)分离,使得它们可以独立的变化。
考虑一个场景,开发一个通讯软体,他需要能够登陆、发信息、发图片、发声音、连接网络、画图等各种各样的功能。而它还需要支持PC端,移动端等等实现。那么直接的想法就是,先用一个Messager作为抽象类,包含登陆等纯虚函数。随后,在此之上派生出PCMessager,MobileMessager。随后我们还有可能需要精简版,完整版,豪华版等等,就需要在PCMessager,MobileMessager中再进行派生。
这种直观方法的缺点在于,如果有n个平台,m个版本,最后的类的数目为1(抽象基类)+n(平台接口)+m*n(具体版本)个类。这种重复我们在装饰器设计模式中似曾相识,出现了大量的冗余代码。
那么为什么这种情况下不能够直接应用装饰器模式,而要引入一个新的"桥模式"呢?参看下面的代码:
class Messager{ public: virtual virtual virtual
virtual virtual virtual virtual
virtual };
class VersionDecorate : Messager* PlatformMessanger;//指向平台,然而根本做不到。 public: virtual messanger->Connect(); //........ } virtual messanger->WriteText(); //........ } virtual messanger->DrawShape(); //........ } }; |
这是一个包含了我们所有需要的功能的抽象基类,对于不同平台,PlaySound等函数的实现不一致;对于不同版本,Login等函数的实现不一致。那么如果采用装饰器模式,我们可能想到的是,建立平台装饰器和版本装饰器两种类型的装饰器,然后将他们套到一起。然而在"套到一起"的这一步实际上是做不到的,其原因在于,如果想要实现"套在一起"那么根据装饰器的构成,一定要有一个能够被委托的,平台装饰器的对象。可是,平台装饰器只重写了一部分虚函数,仍然是虚基类。我们是无法创建虚基类对象的。
为了避免这种情况的发生,我们就有必要让功能进行一定的划分,也就是所谓的解耦合,让版本和平台不再互相依赖。如下面的桥模式的代码,两个模块的抽象基类之间含有了委托关系:
class Messager{ protected: MessagerImp* messagerImp;//... public: virtual virtual virtual
virtual };
class MessagerImp{ public: virtual virtual virtual virtual
virtual MessagerImp(){} }; //平台实现 n class PCMessagerImp : public:
virtual //********** } virtual //********** } virtual //********** } virtual //********** } }; //业务抽象 m //类的数目:1+n+m class MessagerLite :public Messager { public: virtual
messagerImp->Connect(); //........ } virtual
messagerImp->WriteText(); //........ } virtual
messagerImp->DrawShape(); //........ } };
class MessagerPerfect :public Messager { public: virtual
messagerImp->PlaySound(); //******** messagerImp->Connect(); //........ } virtual
messagerImp->PlaySound(); //******** messagerImp->WriteText(); //........ } virtual
messagerImp->PlaySound(); //******** messagerImp->DrawShape(); //........ } };
void Process(){ //运行时装配 MessagerImp* mImp=new PCMessagerImp(); Messager *m =new Messager(mImp); } |
3.2.3 核心
- Bridqe模式使用对象间的组合关系解耦了抽象和实现之间固有的绑定关系,使得抽象和实现可以沿着各自的维度来变化。所谓抽象和实现沿着各自纬度的变化,即子类化它们。
- Bridge模式萄时候类似于多继承方案,但是多继承方案往往违背单一职责原则(即一个类只有一个变化的原因),复用性比较差。Bridge模式是比多继承方案更好的解决方法。
- Bridge模式一般应用在有两个或多个非常强的变化维度情况。
3.2.4 类图
左侧就是版本的具体,右侧是平台的具体,这样使得两个维度可以分别变化。
3.2.5 多继承与桥模式
仍然考虑这个问题,我们还有一种直接的方式——多重继承,继承关系如下图,这是一个典型的"菱形继承"。
我们完全可以在Platform处重写平台相关代码,在Version处重写版本相关代码。但在具体实现时,多继承机制存在一些缺陷。在这种情况下,当我们创建一个ConcreteMessager时,先要创建Platform基类,这时就创建了一个Messager,然后创建Version基类,这时又创建了一个Messager。所以实际上的类的结构是这样的:
这种情况下,当你想通过ConcreteMessager调用Messager的某个Func()时,编译器会认为这里边有两个Func()而无法调用成功,除非显式指定ConcreteMessager.Platform::Func().不过需要提出的是,在我们这个项目目前的代码中还不涉及这一问题,因为Messager和Platform,Version都是纯虚基类(在JAVA中,接口的本质就是纯虚基类,纯虚基类的功能由接口实现),所以不会发生找不到具体实例的情况。但如果在Messenger有一个非虚函数Func()或者是数据成员a,那么诸如ConcreteMessenger.a的语句,编译将无法通过。这种由于多继承产生的向上的二义性,是多重继承不被推荐使用的重要原因。
C++所提供了的一种解决方法是虚继承机制。即在定义ConcreteMessager的时候,采用例如
Class ConcreteMessager:virtual public Platform,virtual public Version{…};
的定义方式,这样编译器就会检查超类Messager是否已经存在,从而实现"菱形继承"。
即使通过虚继承机制解决了菱形继承的问题,我们还是推荐用单继承+组合的桥模式来代替多重继承。这是由于多重继承情况下最后的子类会有两张乃至多张虚函数表,这经常会带来难以定位的,及其微小但致命的Bug。
注:此Bug例子搬运自 cnblogs—bourneli(李伯韬)的技术博客《C++多重继承要慎用!》一文。
#include <iostream> using
class Base1{ public: virtual };
class Base2{ public: virtual };
class MI : public: virtual virtual };
int main(){ MI oMI;
Base1* pB1 = pB1->foo1();
Base2* pB2 = pB2->foo2();
pB2 = pB2->foo2();
return } |
这里边尚且不涉及复杂的菱形继承,但是涉及了类型转换,从中就带来了虚函数表的问题。直观地看我们可能认为,输出结果会是:
MI::foo1,MI::foo2,MI::foo2.
然而实际结果却是:MI::foo1,MI::foo1,MI::foo2!
其原因在于强制类型转换是没有改变各个函数的偏移量,而dynamic_cast 方法编译器会对虚函数表进行检查。第一次使用的pB2指针按照Base2的内存模型进行偏移,实际操作的时候,由于先继承自Base1,直接就调用到了foo1()!