C++ 面向对象程序设计
C++ 笔记主要参考侯捷老师的课程,这是一份是C++ 面向对象程序设计(Object Oriented Programming)的part1 部分,这一部分讲述的是以良好的习惯构造C++ 类,基于对象(object based)讲述了两个C++ 类的经典实例——complex 类和string 类。看这份笔记需要有C++ 和C 语言的基础,有一些很基础的不会解释。
C++ 历史
谈到C++ ,课程首先过了一遍历史,C++ 是建立在C 语言之上,最早期叫C++ with class ,后来在1983 年正式命名为C++ ,在1998 年,C++ 98 标志C++ 1.0 诞生,C++03 是C++ 的一次科技报告,加了一些新东西,C++ 11 加入了更多新的东西,标志着C++ 2.0 的诞生,然后后面接着出现C++14 ,C++ 17,到现在的C++ 20 。
C++ 的组成
C++ 与C 的数据和函数区别
在C 语言中,数据和函数是分开的,构造出的都是一个变量,函数通过变量进行操作,而在C++ 中,生成的是对象,数据和函数都包在对象中,数据和函数都是对象的成员,这是说得通,一个对象所具有的属性和数据应该放在一块,而不是分开,并且C++ 类通常都是通过暴露接口隐藏数据的形式,让使用者可以调用,更加安全与便捷。
下图为part1 两个类的数据和函数分布,可以看看:
基于对象与面向对象的区别
基于对象(Object Based) :面对的是单一class 的設計
面向对象(Object Oriented) :面对的是多重classes 的设计,classes 和classes 之间的关系。
显然,要写好面向对象的程序,先基于对象写出单个class 是比不可少的。
C++ 类的两个经典分类
一个是没有指针的类,比如将要写的complex 类,只有实部和虚部,另一个就是带有指针的类,比如将要写的另一个类string ,数据内部只有一个指针,采用动态分配内存,该指针就指向动态分配的内存。
头文件防卫式声明
从这开始介绍complex 类,首先是防卫式声明,与C 语言一样,防止头文件重复包含,上面是经典写法,还有一个# pragma once
的写法,两者的区别可以参考这篇博客 。
头文件的布局
首先是防卫式声明,然后是前置声明(声明要构建的类,这个例子中还有友元函数) ,类声明中主要写出这个类的成员数据以及成员函数,类定义部分则是将类声明中的成员函数进行实现。
类的声明
这里的complex 类是侯捷老师从C++ 标准库中截取的一段代码,足够说明问题,complex 类主体分为public 和private 两部分,public 放置的是类的初始化,以及复数实虚部访问和运算操作等等。private 中主要防止类的数据,目的就是要隐藏数据,只暴露public 中的接口,private 中有double 类型的实虚部,以及一个友元函数,这个友元函数实现的是复数的相加,将用于public 中的+= 操作符重载中,在public 中,有四个函数,第一个是构造函数,目的是初始化复数,实虚部默认值为0 ,当传入实虚部时,后面的列表初始化会对private 中的数据进行初始化,非常推荐使用列表初始化数据。第二个是重载复数的+= 操作符,应该系统内部没有定义复数运算操作符,所以需要自己重载定义。第三个和第四个是分别访问复数的实部和虚部,可以看到在第一个大括号前面有一个const ,这个原因将在后面讲述 (加粗提醒自己) ,只要不改变成员数据的函数,都需要加上const ,这是规范写法。
类模板简介
由于我们不光是想创建double 类型的复数,还想创建int 类型的复数,愚蠢的想法是在实现一遍int 类的complex ,这时候类模板派出用场了,模板是一个很大的话题,侯捷老师有一个专门课程讲模板,笔记也会更新到那里。模板可以只写一份模板代码,需要生成不同类型的class ,编译器会自动生成,具体做法是在类定义最上方加入template ,然后讲所有的double 都换成T 即可,在初始化的时候,在类的后面使用尖括号,尖括号中放入你想要生成的类型即可。
内联(inline)函数
内联函数和普通函数的区别在于:当编译器处理调用内联函数的语句时,不会将该语句编译成函数调用的指令,而是直接将整个函数体的代码插人调用语句处,就像整个函数体在调用处被重写了一遍一样。是一种空间换取时间的做法,当函数的行数只有几行的时候,应该将函数设置为内联,提高程序整体的运行效率。更加详细的说明可以参考这篇文章 . (补充:在类的内部实现的函数编译器会自动变为inline ,好像现在新的编译器可以自动对函数进行inline ,无需加inline ,即使加了编译器也未必真的会把函数变为inline ,看编译器的判断)
访问级别
这里上面说过,private 内部的函数和成员变量是不能被对象调用的,可以通过public 提供的接口对数据进行访问。
函数重载
C++ 中允许“函数名”相同,但函数参数需要不同(参数后面修饰函数的const 也算是参数的一部分) ,这样可以满足不同类型参数的应用。上述中就有不同的real ,不必担心它们名字相同而反正调用混乱,相同函数名和不同参数,编译器编译后的实际名称会不一样,实际调用名并不一样,所以在开始的函数名打了引号。另外,写相同函数名还是要注意一下,比如上面有两个构造函数,当使用complex c1 初始化对象时,编译器不知道调用哪一个构造函数,因为两个构造函数都可以不用参数,这就发生冲突了,第二个构造函数是不需要的。
构造函数的位置
一般情况下,构造函数都放在public 里面,不然外界无法初始化对象,不过也有例外的,有一种单例设计模式,就将构造函数放入在private 里面,通过public 静态(static)函数进行生成对象,这个类只能创建一份对象,所以叫单例设计模式
参数传递
参数传递分为两种:pass-by-value 和pass-by-reference
一条非常考验你是否受过良好C++ 训练就是看你是不是用pass-by-reference 。传值会分配局部变量,然后将传入的值拷贝到变量中,这既要花费时间又要花费内存,传引用就是传指针,4 个字节,要快好多,如果担心传入的值被改变,在引用前加const ,如果函数试图改变,就会报错。
返回值传递
与参数传递一样,返回值传引用速度也会很快,但有一点是不能传引用的,如果你想返回的是函数内的局部变量,传引用后,函数所分配的内存清空,引用所指的局部变量也清空了,空指针出现了,这就很危险了。 (引用本质上就是指针,主要用在参数传递和返回值传递)
友元
友元函数是类的朋友,被设定为友元的函数可以访问朋友的私有成员,这个函数(do assignment plus)用来做复数加法的具体实现。第一个参数是复数的指针,这个会在this 一节中进行说明。
另外还有一种情况很有意思,如下图所示,复数c2 可以访问c1 的数据,这个也是可以的,这可能让人感到奇怪,侯捷老师说了原因:相同类的各個对象互為友元。所以可以c2 可以访问c1 的数据。
操作符重载(一) ,this, cout
上面介绍的__doapl
函数将在操作符重载中进行调用,可以看到第一个参数是this ,对于成员函数来说,都有一个隐藏参数,那就是this ,this 是一个指针,指向调用这个函数的对象 ,而操作符重载一定是作用在左边的对象 ,所以+= 的操作符作用在c2 上,所以this 指向的是c2 这个对象,然后在__doapl
函数中修改this 指向c2 的值。
另外,还记得上面说过<<
运算符重载嘛,它作用的不是复数,而是ostream ,这是处于使用者习惯的考量,作用复数的话将形成complex<<cout
的用法,这样很不习惯,用于ostream 就跟平常使用的cout 一样,另外,下面这个函数返回的引用,那么就可以构成cout << c2 << c1
这种连串打印的程序(与平常的习惯,cout << c2
返回的依然是cout 的引用,又可以调用<<
重载函数,如果不是引用,则会报错,侯捷老师讲到这,真感觉标准库的设计真是厉害。另外,每次向os 传入值打印时,os 的状态会发生改变,所以os 不能加const 。上面复数的加法由于返回的是引用,也可以构成c3 += c2 += c1
这样的程序。
操作符重载(二)非成员函数,无this ,临时对象
由于使用者可能有多种复数的加法,所以要设计不同的函数满足使用者的要求,由于带有其他类型的参数,所以没有放入complex 类中,放在外面定义,这里的有一个非常有趣的使用,返回的直接是complex ( xx, xx) ,没见过呢,这个语法是创建一个临时对象 ,这个临时对象在下一行就消亡了,不过没关系,我们已经把临时对象的值传到返回值了。由于是临时对象,所以返回值不能是引用,必须是值。
好了,complex 的相关细节写得差不多,有些没写,上面都提到了,还有些操作符重载,与加法类似,不重复写了。具体参考complex.h
,下面进入string 类的实现。
Big Three —string class begin
与complex 一样,string 类的整个实现分布如上图,右边的是测试的程序。
下面来看看string 的缩小版实现:
由于字符串不像复数那样固定大小,而是可大可小,所以在实现string 类的时候,私有数据是一个指针,指向动态分配的char 数组,这样就可以实现类似动态字符串大小。这个小章节叫big three ,这里的big three 分别是,拷贝构造(String(const String & str) ),拷贝赋值(String& operator=(const String& str)),以及析构函数( ~String()) 。为什么要有big three ,这个马上就会介绍。
构造函数与析构函数
在构造函数中,如果没有传入字符串,则string 申请动态分配一个char[1], 指向的就是'\0'
,也就是空字符,如果传入的是“hello”
, 则动态分配“hello”
的长度再加一( 一代表结束标识符’\0’),都是用string 内部的指针指向动态分布的内存的头部。为什么多了一个析构函数呢?在complex 类为啥没有呢?** 这是因为complex 中没有进行动态分配内存,在复数死亡后,它所占用的内存全部释放,完全ok ,但string 类动态分配了内存,这份内存在对象的外部,不释放内存的话,在对象死亡后依然存在,这就造成内存泄漏,所以需要构建一个析构函数,在对象死亡释放动态分配的内存。** 动态分配使用的时new 命令,返回的是分配出来的内存的首地址,释放动态分配内存使用delete 命令,如果分配的是数组对象,则需要在delete 后加上[] ,如果是单个,直接delete 指向的指针即可。上面就有两种情况的实例。
拷贝构造与拷贝赋值
complex 类其实内部存在C++ 语言自身提供的拷贝构造和拷贝赋值,不需要自己写,因为没有指针的类的数据赋值无非就是值传递,没有变化。但string 类不一样,上面的图是很好的例子,因为使用的是动态分配内存,对象a 和对象b 都指向外面的一块内存,如果直接使用默认的拷贝构造或者拷贝赋值(例如将b = a ) ,则是将b 的指针指向a 所指的区域,也就是a 的动态分配内存的首地址,原来b 所指向的内存就悬空了,于是发生内存泄漏,而且两个指针指向同一块内存,也是一个危险行为。所以带有指针的类是不能使用默认的拷贝构造和拷贝赋值的,需要自己写。下面看看怎么写的。
首先是拷贝构造,由于是构造函数一种,跟之前的构造函数一样,需要分配一块内存,大小为要拷贝的string 的长度+1 ,然后使用C 语言自带的strcpy 进行逐个赋值。
上面这个拷贝赋值,首先检查是不是自我赋值,只要有这种情况发生,就要考虑,自我赋值则直接返回this 所指的对象就可以了,如果不是自我赋值,则删除分配的内存,重新分配内存,长度为传入字符串的长度+1 ,同理使用strcpy 函数进行逐个赋值。
自我赋值的检查很重要,没有自我检查,就会发生上面的情况,一运行程序的第一句话,内存就释放了,指针就又悬空了,不确定行为产生。
string 剩余一点放到这里面,打印直接调用get_c_str 成员函数就可以,返回指针,os 会遍历它所指向的内存,打印出字符串,遇到'\0'
终止。
生命期——堆,栈,静态,全局
c1 便是所谓stack object ,其生命在作用域(scope) 结束之际结束。这种作用域內的object ,又称为auto object ,因为它会被「自动」清理。p 所指的便是heap object ,其生命在它被deleted 之际结束,所以要在指针生命结束之前对堆内存进行释放。
上面的c2 和c3 分别是静态对象和全局对象,作用域为整个程序。以下是它们四个的内存分布,更具体的细节可以参考这篇文章 。
重探new 与delete
可以到使用new 命令动态分配内存,主要有以下三步,首先分配要构建对象的内存,返回的是一个空指针,然后对空指针进行转型,转成要生成对象类型初始化给指针,然后指针调用构造函数初始化对象。
可以看到delete 操作可以分为两步,首先调用析构函数释放对象,然后通过操作符delete (内部调用free 函数)释放分配的内存。
探究动态分配过程的内存块
上图中就是vc 创建complex 类以及string 类的内存块图,左边两个是complex 类,长的那个是调试(debug)模式下的内存块分布,短的那个是执行(release)模式下的内存块分布,复数有两个double ,所以内存占用8 个字节,vc 调试模式下,调试的信息部分内存占用是上面灰色块的32 个字节以及下面灰色块的4 个字节,红色的代表内存块的头和尾(叫cookie ) ,占用八个字节,合在一起是52 个字节,vc 会以16 个字节对齐,所以会填充12 字节,对应的是pad 的部分,另外,为了凸显这是分配出去的内存,所以在头尾部分,用最后一位为1 代表该内存分配出去了,为0 就是收回来了。执行模式下没有调试信息。string 类类似分析。
动态分配array 需要注意的问题
上面是动态分配内存,生成complex 类的数组以及string 类的数组的内存块图,与上面类似,不过这里多了一个长度的字节,都为3 ,标记对象的个数。
上面说明的是,如果分配的是动态对象数组,就一定要在delete 后面加上[] 符号,不然就无法完全释放动态分配的内存。array new 一定要搭配array delete 。
C++ 面向对象程序设计Part2
part1 讲述了基于对象,part2 则是在基于对象的基础上,建立类与类之间的联系,即面向对象编程以及面向对象设计。
主要讲述以下三点:
Inheritance (继承)
Composition(复合)
Delegation (委托)
另外,我把补充内容中的对象模型放入到Part2 ,我觉得放入这里更加合适。
Composition(复合)
Composition(复合)就是has a , 上面的事例就是队列(queue)类中有一个双端队列(deque)类,队列中的成员函数通过双端队列的操作函数完成,这是类与类之间的第一个关系。 (黑色菱形代表内部拥有)
deque 中可能拥有很多方法,但queue 中只通过deque 提供了非常少的方法,这是一个设计模式Adapter ,将一个拥有许多功能的类改造一下,得到另一个类。
内存视角下的composition (复合)
可以看到有两个复合关系,最后queue 的内存是40.
composition(复合)关系下的构造与析构
由于Container 类是拥有Component 类,所以在构造方面,先调用Component 类的默认构造函数,然后再调用Container 的构造函数,由内而外的构造,里面做好了,再做外面。析构则相反,先对Container 进行析构,然后再对Component 进行析构,过程是由外而内,将外面的去掉,才能看到里面去掉里面,符合常识。
Delegation ( 委託) —— Composition by reference
如果一个类(string)中拥有一个指针(StringRep*) ,该指针指向的是另一个类(StringRep) ,这种关系是Delegation (委托) ,更好的说法就是Composition by reference (学术界不说by pointer ) ,两种类的生命周期不一样,与复合两种类会同时初始化不同,委托当需要用的时候再进行初始化。上图中的实例是一种非常有名的设计,叫handle/body ,指针指向的类负责具体实现,可以看到有一个//pimpl ,意思是pointer to implement ,而拥有那个指针的类只提供外界接口,就是基于委托这种方式,Handle(string)是提供给外界的接口,body(StringRep)就是实现部分,为什么这么有名,这是因为String 类设计好了就不需修改了,只需要修改实现的那一个类,具有很好的弹性,另外,还有可以进行共享机制(减小内存) ,下图的a ,b,c 共享一个StringRep ,这种方式叫做reference counting ,当需要修改其中一个时,需要把内容copy 出来一份进行修改,另外两个依然指向原StringRep 。 (白色菱形代表指针指向)
Inheritance (继承)
继承的语法就是在类名后加上:public(还可以是protected ,private)你想要继承的类,如果想继承多个类,用逗号隔开就可以了。什么时候用继承,确定一个关键点,子类is a 父类(例如,狗is a 动物) 。上述的List_nodes 是继承了List_node_base 所有的数据,另外还有自己的数据。
Inheritance (继承)关系下的构造与析构
继承的类(derived object)的一部分是基类(base part) ,对于要被继承的基类,它的析构函数必须是virtual ,不然会出现问题,这个问题将在后面说。继承的构造函数会首先调用基类的构造函数,然后调用自己的构造函数(由内而外) 。析构则相反,先析构自己,然后再调用基类的析构函数。
Inheritance (继承)with virtual functions(虚函数)
子类继承了父类的两样东西,一种是父类的数据,一种是父类函数的调用权。对于一个类而言,它的子类都可以访问所以的public 方法,而子类要不要重新定义父类的函数呢?这时候就需要虚函数了,当public 里面的函数不是虚函数时,则希望子类不重新定义该函数。当函数是虚函数时(在返回类型前加入关键字virtual ) ,则希望子类重新定义它,并且父类已经有了默认定义。当函数是纯虚函数时(在结束符;前面加上=0 ) ,则希望子类一定要重新定义它,父类没有默认定义(但可以有默认定义) 。该事例是定义了一个基类shape ,然后矩形Rectangle 和椭圆Ellipse 对shape 进行继承,基类的objectID 是无需继承的,可以提前定好,在父类调用即可,而error 函数,父类有默认的错误信息,如果子类有更精细的错误信息,父类允许子类可以重新定义的,打印出子类调用时的错误,而draw 函数则必须重新定义,父类没有定义(draw shape 没有意义) ,子类不同,所画出的形状自然不同。
Inheritance (继承)with virtual ——经典实例
对于在powerpoint 打开ppt 文件而言,有以下几步,先点打开命令的菜单栏,然后出现文件界面,选择我们要打开的文件名,然后程序会检查文件名是否符合规范,符合规范则在磁盘上搜索文件,搜索到了打开文件即可。而遇到注意的是,所以打开文件的过程都是这样,只有最后打开文件可能会不同(可能会打开不同格式的文件) ,于是有团队就将除文件打开函数以外的函数进行打包,子类直接继承,只要子类重新定义父类打开文件的函数即可。如下图所示:
团队开发了CDocument 类,定义Serialize 函数需要重新定义,在OnFileOpen 函数中的省略号即为打包好的过程。用CDocument 类的人只需重新定义Serialize 函数即可,则在main 函数中,先创建一个CMyDoc 实例myDoc ,调用myDoc.OnFileOpen 函数,子类没有定义这个函数,实则调用的是父类的函数,即CDocument::OnFileOpen(&myDoc), 进入父类函数中,运行打包好的过程,当运行到Serialize 函数时,发现子类重新定义了它,则调用子类重新定义的Serialize 函数,最后再返回到CDocument::OnFileOpen ,继续下面的过程。再也不用写一般的步骤了,完美!这是一种非常有名的设计模式Template method (不是说C++ template ) ,提供了一种应用框架,它将重复一样的操作写好,不确定的步骤留给实际应用设计者重新实现。十年前最有名的产品MFC 就是这样一种应用框架。
深层次的理解,谁调用函数,this 就是谁,当调用Serialize 函数是,编译器是通过this->Serialize() 调用,于是就调用到了this 重新定义的Serialize 函数。
上图就是CDocument 和CMyDoc 的实例,用cout 来模拟步骤,呼应上面两张图片。
Inheritance + Composition 关系下的构造与析构
当同时存在继承和复合,类是如何进行构造和析构呢?这一节要讨论的问题:
子类有父类的成分还有包含着另一个类;
子类继承父类,父类拥有另外一个类。
情况2 就很明显了,构造依然是自内而外,析构是由外而内。
对于情况1 ,这是侯捷老师留的作业,自己写代码判断,我写了一个:
#include <iostream>
using namespace std ;
namespace fy1 {
class Base ;
class Derived ;
class Component ;
class Base {
public :
Base () { cout << "Base Ctor" << endl ;}
virtual ~ Base () { cout << "Base Dtor" << endl ;}
};
class Component {
public :
Component () { cout << "Component Ctor" << endl ;};
~ Component () { cout << "Component Dtor" << endl ;};
};
class Derived : Base {
public :
Derived () { cout << "Derived Ctor" << endl ;}
~ Derived () { cout << "Derived Dtor" << endl ;}
protected :
Component c ;
};
void fy1_test () {
Derived d ;
}
}
int main () {
cout << "Ctor and Dtor test:" << endl ;
fy1 :: fy1_test ();
return 0 ;
}
运行结果为:
Ctor and Dtor test :
Base Ctor
Component Ctor
Derived Ctor
Derived Dtor
Component Dtor
Base Dtor
可以看到先初始化父类(Base) ,然后再初始化Component 类,再初始化自己,析构与构造相反。
下图也给出了结论。
至此,面向对象的概念说完了,下面进入实例环节。
Delegation ( 委托) + Inheritance ( 继承) (一)
上述代码解决的是下图所示的问题,对同一份文件使用四个不同窗口去查看,或者右下角所示的,同一个数据,三种不同的viewer 查看。数据只有一份,表现多种形式,数据变化,表现形式也会发生变化,要表现这样的特性,这就对表现文件的class 和存储数据的class 之间关系要有要求,上图就是下图的一种解法,23 种设计模式之一。Subject 类是存储数据的类,不过类中有delegation ,使用了一个vector 类用来存放Observer 类的指针,这样Observer 类以及它的所有子类都可以导入这个vector 中,Observer 类相当于表现形式类的父类,可以有多种表现形式,这些都是子类。update 则是子类需要重新定义的方法,不同表现形式可以有不同的更新方法。对于Subject 类来说,当我们想创建新的窗口(新的observer 类)去查看它的时候,需要对将新的Observer 类进行注册,函数attach 就是实现这样的功能,可以将新的observer 子类的指针加到vector 中,注销的函数没有写出来,另外,当数据发生变化时,使用set_val 函数,需要使用一个函数去更新所有的observer 子类,这就是notify 函数干的事,遍历vector 每一个observer 指针,调用指针指向的update 方法,对表现形式进行更新。Delegation + Inheritance 真的感觉好强大呀。
下图是一个更详细的Observer 解法构建
Delegation ( 委托) + Inheritance ( 继承) (二)
第二个实例,构建一个文件系统。可以把Primitive 类当作文件类,而Composite 类当作目录类,与日常使用的文件系统一样,目录里面可以包含目录,也可以包含文件,所以目录里面存放的不止是目录本身还可以是文件,但是需要放入到同一个容器中,想法是使用指针,但文件和目录是不太一样的,所以解决方案是将文件和目录共同继承Component 类,然后Composite 类中的容器存放的是Component 的指针,这样就可以既存放文件又可以存放目录了,这是一种经典的解决方案,也是23 种设计模式之一,Composite。另外,Component 类中还有一个虚函数add ,这是给目录进行继承的,因为目录可以新建目录和文件,这里不能设置为纯虚函数,因为文件不能继承这个函数,文件是不能在进行添加的。
Prototype
这又是23 种设计模式之一的Prototype ,Prototype 要解决的是要设计一个类,这个类是为未来的子类所设计的,他可以访问和操作未来的子类,这就很有意思了,都不知道未来的子类是啥,要去访问这个子类,这是怎么去做呢?原来在子类内部会申明一个关于子类的静态变量,就是上图中的LSAT : LandSatImage ,另外这个子类会定义一个私有的构造方法(前面有一个负号,可以通过定义静态变量调用私有的构造方法) ,构造方法里面会调用父类的addPrototype 函数,将静态变量的指针传到父类,父类就把传入的指针(通过父类的指针形式)加入到自己的容器当中,这样父类就知道子类的类型,就可以操作子类了,上述操作是这样的,父类有一个findAndClone 函数,根据输入参数i 选择父类容器中的子类进行clone ,返回子类的指针,而clone 父类定义的是一个纯虚函数,子类必须重新定义,上图中子类重新定义的是返回新建一个子类,返回它的指针,不过这个新建是调用的另外一个构造函数(有一个#号,代表protect ) ,使用private 里面的构造函数是不行的,它是为父类打通桥梁的,为了与private 里面的构造函数区分开,形参有一个int 类型,这个int 类型不会进行使用。
下面的图片是相关代码,解释上面的文字已经说的很清楚了。
父类Image
两个子类代码:
主函数:
Prototype 例子来自于《Design Patterns Explained Simply》这本书,经典致敬!
面向对象的例子讲完了,下面介绍更加深层次的内容,理解面向对象更底层的东西。
对象模型
这张图所蕴含的信息量很大,现在一步步来看,首先最右边有三个类,A,B,C,A 是爷爷,B 是父亲,C 是孙子,向上继承的关系,首先我们从内存的角度来看类的布局,对于有虚函数(不管有多少个虚函数)的类来说,它在内存中不仅有自己定义的数据,还会多出一个指针,这个指针学名叫做虚指针(vptr) ,虚指针会指向一个虚表(vtbl) ,虚表里面定义的是各个虚函数所在的地址。A 类内存中第一个就是虚指针,指向虚表,虚标里面有两个指针指向A 的两个虚函数,下面两个是A 类的数据,B 类的前三个是继承了A 类的数据以及虚指针,不同的是B 类重新定义了vfunc1 函数,这将更新虚表,会将原来指向A::vfunc1 函数的指针改为指向B::vfunc1 函数的指针,如图就是将黄色的0x401ED0 变为了浅蓝的0x401F80 ,C 类继承B 类的数据和虚指针,另外还有自己的数据,同时又重载了vfunc1 ,所以对应虚表中的vfunc1 指针也要发生变化。在调用的时候,根据类中虚指针定义的虚表所指向的函数进行调用虚函数,这就是继承的根本原理,虚函数则是面向对象最强大的武器。非虚函数就是普通的函数,不过前面加上了类作用域。
静态绑定与动态绑定:当new 一个C 类时,得到一个指针p (上图所示) ,当通过p 调用vfunc1 的时候,实际上是最下面中间语句进行调用的(其中n 为编译器给虚函数在虚表中的索引) ,这是函数的调用方式与c 很不一样,在c 的时代,当编译器看到函数调用,编译器会直接调用call XXX(XXX 代表地址) ,地址是静态的,不会发生变化,这种方式叫做静态绑定,而C++ 通过类指针找到虚指针,根据虚指针找到虚表,从虚表中取出虚函数地址进行调用,这是一种动态的调用,不同类的指针所调用的虚函数是不一样的,这种方式叫做动态绑定,也叫虚机制。
下图就是一个实例,展示了这种动态绑定的强大威力。
我要设计一个powerpoint ,要画出各种不同的形状,我们可以用一个List ,承载A 类的指针(放指针是因为容器只能放内存大小一致的东西,不同的形状内存不一致) ,这样它的所有子类都可以放入List 中了,因为都是A 类,放入List 后,循环遍历直接调用各自形状的draw 函数就可以完成要求,这里走的依然是动态绑定,这是很好的,为了更加突出动态绑定的好处,这里要写出C 语言中是如何做的,C 语言只有静态绑定,上述过程需要条件判断当前的指针是指向哪一个类,各种形状要使用多重判断,另外,当需要添加新的形状类时,又要修改条件判断代码,这是很不好的,不符合直观理解,应该像C++ 的虚函数一样,指针指向什么形状,就应该调用那种类型的draw 。由此可见,C++ 动态绑定很棒,很强大。C++ 支持动态绑定和静态绑定,符合下面三个条件,C++ 采用动态绑定,条件如下:
必须使用指针调用函数
该指针必须是向上转型(List 中定义的类型是A 的指针,但可以存储C 的指针,通过继承向上转型)
调用的函数必须是虚函数
这里还有一个概念——多态,List 申明的时候,是通过pointer-to-A 进行申明的,但实际是List 可以存储各种不同的形状,都属于A ,确是不同形态,所以叫多态。
所以,所谓的多态,虚函数,动态绑定,虚指针以及虚表,所有的故事都是同一件事情,真的了然于胸啊!:smile:
可以再来看看之前讲的Template method ,这就是动态绑定的一个应用,子类CMyDoc 调用父类的OnFileOpen 函数,调用的时候会将子类的地址传入到父类的函数中,也就是this pointer (成员函数都是一个默认参数,代表的就是this pointer ,谁调用成员函数,谁的地址就是this pointer ) ,在OnFileOpen 中,所有的调用前面都会加上一个this pointer ,而对于Serialize 函数来说,它是符合上述我们说的三个条件的,首先调用者是this ,是指针,然后指向的是子类,向上转型,调用的Serialize 函数是虚函数,所以会使用动态绑定,调用CMyDoc 的Serialize 函数。这是很好的!
现在从汇编代码的角度来看函数调用,初始化B 类,讲B 类强制转型为A 类,得到a ,调用a.vfunc1() 函数,这里是静态绑定,因为是通过类调用函数,不是指针调用,汇编代码也说明了这个问题,使用的是call xxx 形式编译函数。
而新创建了一个指针B ,给的类型是指针A 的类型,通过然后调用vfunc1 函数,符合三个条件,是动态绑定,汇编代码的形式也不一样了,汇编表示看不懂,不过call 一行连同上面几行最后的表示在C 语言中的描述确实是动态绑定的描述。另外,将b 的地址赋给pa ,重新调用vfunc1 ,一样是动态绑定,与new B 是一样的。
#include <iostream>
using namespace std ;
namespace fy2 {
class A {
public :
virtual void vfunc1 () { cout << "A::vfunc1()" << endl ;}
private :
int data1 ;
};
class B : public A {
public :
virtual void vfunc1 () { cout << "B::vfunc1()" << endl ;}
private :
int data2 ;
};
void fy2_test () {
B b ;
A a = ( A ) b ;
a . vfunc1 ();
A * pa = new B ;
pa -> vfunc1 ();
pa = & b ;
pa -> vfunc1 ();
}
}
int main () {
cout << "object model test:" << endl ;
fy2 :: fy2_test ();
return 0 ;
}
输出结果:
object model test :
A::vfunc1()
B::vfunc1()
B::vfunc1()
面向对象的笔记到此结束,深深感受到了C++ 面向对象程序的power ,fighting!