2019-C++ 面向对象程序设计

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++ 的组成

1557455342813

C++ 与 C 的数据和函数区别

1557455426279

在 C 语言中,数据和函数是分开的,构造出的都是一个变量,函数通过变量进行操作,而在 C++中,生成的是对象,数据和函数都包在对象中,数据和函数都是对象的成员,这是说得通,一个对象所具有的属性和数据应该放在一块,而不是分开,并且 C++类通常都是通过暴露接口隐藏数据的形式,让使用者可以调用,更加安全与便捷。

下图为 part1 两个类的数据和函数分布,可以看看:

1557455877512

基于对象与面向对象的区别

基于对象(Object Based):面对的是单一 class 的設計

面向对象(Object Oriented):面对的是多重 classes 的设计,classes 和 classes 之间的关系。

显然,要写好面向对象的程序,先基于对象写出单个 class 是比不可少的。

C++类的两个经典分类

一个是没有指针的类,比如将要写的 complex 类,只有实部和虚部,另一个就是带有指针的类,比如将要写的另一个类 string,数据内部只有一个指针,采用动态分配内存,该指针就指向动态分配的内存。

头文件防卫式声明

1557456561799

从这开始介绍 complex 类,首先是防卫式声明,与 C 语言一样,防止头文件重复包含,上面是经典写法,还有一个# pragma once的写法,两者的区别可以参考这篇博客

头文件的布局

1557457075841

首先是防卫式声明,然后是前置声明(声明要构建的类,这个例子中还有友元函数),类声明中主要写出这个类的成员数据以及成员函数,类定义部分则是将类声明中的成员函数进行实现。

类的声明

1557457377946

这里的 complex 类是侯捷老师从 C++标准库中截取的一段代码,足够说明问题,complex 类主体分为 public 和 private 两部分,public 放置的是类的初始化,以及复数实虚部访问和运算操作等等。private 中主要防止类的数据,目的就是要隐藏数据,只暴露 public 中的接口,private 中有 double 类型的实虚部,以及一个友元函数,这个友元函数实现的是复数的相加,将用于 public 中的+=操作符重载中,在 public 中,有四个函数,第一个是构造函数,目的是初始化复数,实虚部默认值为 0,当传入实虚部时,后面的列表初始化会对 private 中的数据进行初始化,非常推荐使用列表初始化数据。第二个是重载复数的+=操作符,应该系统内部没有定义复数运算操作符,所以需要自己重载定义。第三个和第四个是分别访问复数的实部和虚部,可以看到在第一个大括号前面有一个 const,这个原因将在后面讲述(加粗提醒自己),只要不改变成员数据的函数,都需要加上 const,这是规范写法。

类模板简介

1557458654337

由于我们不光是想创建 double 类型的复数,还想创建 int 类型的复数,愚蠢的想法是在实现一遍 int 类的 complex,这时候类模板派出用场了,模板是一个很大的话题,侯捷老师有一个专门课程讲模板,笔记也会更新到那里。模板可以只写一份模板代码,需要生成不同类型的 class,编译器会自动生成,具体做法是在类定义最上方加入 template ,然后讲所有的 double 都换成 T 即可,在初始化的时候,在类的后面使用尖括号,尖括号中放入你想要生成的类型即可。

内联(inline)函数

1557459096287

内联函数和普通函数的区别在于:当编译器处理调用内联函数的语句时,不会将该语句编译成函数调用的指令,而是直接将整个函数体的代码插人调用语句处,就像整个函数体在调用处被重写了一遍一样。是一种空间换取时间的做法,当函数的行数只有几行的时候,应该将函数设置为内联,提高程序整体的运行效率。更加详细的说明可以参考这篇文章. (补充:在类的内部实现的函数编译器会自动变为 inline,好像现在新的编译器可以自动对函数进行 inline,无需加 inline,即使加了编译器也未必真的会把函数变为 inline,看编译器的判断)

访问级别

1557463294773

这里上面说过,private 内部的函数和成员变量是不能被对象调用的,可以通过 public 提供的接口对数据进行访问。

函数重载

1557463840516

C++中允许“函数名”相同,但函数参数需要不同(参数后面修饰函数的 const 也算是参数的一部分),这样可以满足不同类型参数的应用。上述中就有不同的 real,不必担心它们名字相同而反正调用混乱,相同函数名和不同参数,编译器编译后的实际名称会不一样,实际调用名并不一样,所以在开始的函数名打了引号。另外,写相同函数名还是要注意一下,比如上面有两个构造函数,当使用 complex c1 初始化对象时,编译器不知道调用哪一个构造函数,因为两个构造函数都可以不用参数,这就发生冲突了,第二个构造函数是不需要的。

构造函数的位置

1557469726240

一般情况下,构造函数都放在 public 里面,不然外界无法初始化对象,不过也有例外的,有一种单例设计模式,就将构造函数放入在 private 里面,通过 public 静态(static)函数进行生成对象,这个类只能创建一份对象,所以叫单例设计模式

1557470130720

参数传递

1557470685958

参数传递分为两种:pass-by-value 和 pass-by-reference

一条非常考验你是否受过良好 C++训练就是看你是不是用 pass-by-reference。传值会分配局部变量,然后将传入的值拷贝到变量中,这既要花费时间又要花费内存,传引用就是传指针,4 个字节,要快好多,如果担心传入的值被改变,在引用前加 const,如果函数试图改变,就会报错。

返回值传递

1557471481557

与参数传递一样,返回值传引用速度也会很快,但有一点是不能传引用的,如果你想返回的是函数内的局部变量,传引用后,函数所分配的内存清空,引用所指的局部变量也清空了,空指针出现了,这就很危险了。(引用本质上就是指针,主要用在参数传递和返回值传递)

友元

1557472309252

友元函数是类的朋友,被设定为友元的函数可以访问朋友的私有成员,这个函数(do assignment plus)用来做复数加法的具体实现。第一个参数是复数的指针,这个会在 this 一节中进行说明。

另外还有一种情况很有意思,如下图所示,复数 c2 可以访问 c1 的数据,这个也是可以的,这可能让人感到奇怪,侯捷老师说了原因:相同类的各個对象互為友元。所以可以 c2 可以访问 c1 的数据。

1557473149205

操作符重载(一),this, cout

1557473358698

上面介绍的__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这样的程序。

1557473691604

操作符重载(二)非成员函数,无 this,临时对象

1557474432766

由于使用者可能有多种复数的加法,所以要设计不同的函数满足使用者的要求,由于带有其他类型的参数,所以没有放入 complex 类中,放在外面定义,这里的有一个非常有趣的使用,返回的直接是 complex( xx, xx),没见过呢,这个语法是创建一个临时对象,这个临时对象在下一行就消亡了,不过没关系,我们已经把临时对象的值传到返回值了。由于是临时对象,所以返回值不能是引用,必须是值。

好了,complex 的相关细节写得差不多,有些没写,上面都提到了,还有些操作符重载,与加法类似,不重复写了。具体参考complex.h,下面进入 string 类的实现。

Big Three —string class begin

1557477552802

与 complex 一样,string 类的整个实现分布如上图,右边的是测试的程序。

下面来看看 string 的缩小版实现:

1557477761856

由于字符串不像复数那样固定大小,而是可大可小,所以在实现 string 类的时候,私有数据是一个指针,指向动态分配的 char 数组,这样就可以实现类似动态字符串大小。这个小章节叫 big three,这里的 big three 分别是,拷贝构造(String(const String & str) ),拷贝赋值(String& operator=(const String& str)),以及析构函数( ~String()) 。为什么要有 big three,这个马上就会介绍。

构造函数与析构函数

1557478475362

在构造函数中,如果没有传入字符串,则 string 申请动态分配一个 char[1], 指向的就是'\0',也就是空字符,如果传入的是“hello”, 则动态分配“hello”的长度再加一(一代表结束标识符’\0’),都是用 string 内部的指针指向动态分布的内存的头部。为什么多了一个析构函数呢?在 complex 类为啥没有呢?**这是因为 complex 中没有进行动态分配内存,在复数死亡后,它所占用的内存全部释放,完全 ok,但 string 类动态分配了内存,这份内存在对象的外部,不释放内存的话,在对象死亡后依然存在,这就造成内存泄漏,所以需要构建一个析构函数,在对象死亡释放动态分配的内存。**动态分配使用的时 new 命令,返回的是分配出来的内存的首地址,释放动态分配内存使用 delete 命令,如果分配的是数组对象,则需要在 delete 后加上[],如果是单个,直接 delete 指向的指针即可。上面就有两种情况的实例。

拷贝构造与拷贝赋值

1557479620018

complex 类其实内部存在 C++语言自身提供的拷贝构造和拷贝赋值,不需要自己写,因为没有指针的类的数据赋值无非就是值传递,没有变化。但 string 类不一样,上面的图是很好的例子,因为使用的是动态分配内存,对象 a 和对象 b 都指向外面的一块内存,如果直接使用默认的拷贝构造或者拷贝赋值(例如将 b = a),则是将 b 的指针指向 a 所指的区域,也就是 a 的动态分配内存的首地址,原来 b 所指向的内存就悬空了,于是发生内存泄漏,而且两个指针指向同一块内存,也是一个危险行为。所以带有指针的类是不能使用默认的拷贝构造和拷贝赋值的,需要自己写。下面看看怎么写的。

1557480221092

首先是拷贝构造,由于是构造函数一种,跟之前的构造函数一样,需要分配一块内存,大小为要拷贝的 string 的长度+1,然后使用 C 语言自带的 strcpy 进行逐个赋值。

1557481099332

上面这个拷贝赋值,首先检查是不是自我赋值,只要有这种情况发生,就要考虑,自我赋值则直接返回 this 所指的对象就可以了,如果不是自我赋值,则删除分配的内存,重新分配内存,长度为传入字符串的长度+1,同理使用 strcpy 函数进行逐个赋值。

1557481941108

自我赋值的检查很重要,没有自我检查,就会发生上面的情况,一运行程序的第一句话,内存就释放了,指针就又悬空了,不确定行为产生。

1557482417743

string 剩余一点放到这里面,打印直接调用 get_c_str 成员函数就可以,返回指针,os 会遍历它所指向的内存,打印出字符串,遇到'\0'终止。

生命期——堆,栈,静态,全局

1557487075666

c1 便是所谓 stack object,其生命在作用域(scope) 结束之际结束。这种作用域內的 object,又称为 auto object,因为它会被「自动」清理。p 所指的便是 heap object,其生命在它被 deleted 之际结束,所以要在指针生命结束之前对堆内存进行释放。

1557487606126

1557487638928

上面的 c2 和 c3 分别是静态对象和全局对象,作用域为整个程序。以下是它们四个的内存分布,更具体的细节可以参考这篇文章

1557487724501

重探 new 与 delete

1557492888491

1557492926893

可以到使用 new 命令动态分配内存,主要有以下三步,首先分配要构建对象的内存,返回的是一个空指针,然后对空指针进行转型,转成要生成对象类型初始化给指针,然后指针调用构造函数初始化对象。

1557493242994

1557493258187

可以看到 delete 操作可以分为两步,首先调用析构函数释放对象,然后通过操作符 delete(内部调用 free 函数)释放分配的内存。

探究动态分配过程的内存块

1557493966655

上图中就是 vc 创建 complex 类以及 string 类的内存块图,左边两个是 complex 类,长的那个是调试(debug)模式下的内存块分布,短的那个是执行(release)模式下的内存块分布,复数有两个 double,所以内存占用 8 个字节,vc 调试模式下,调试的信息部分内存占用是上面灰色块的 32 个字节以及下面灰色块的 4 个字节,红色的代表内存块的头和尾(叫 cookie),占用八个字节,合在一起是 52 个字节,vc 会以 16 个字节对齐,所以会填充 12 字节,对应的是 pad 的部分,另外,为了凸显这是分配出去的内存,所以在头尾部分,用最后一位为 1 代表该内存分配出去了,为 0 就是收回来了。执行模式下没有调试信息。string 类类似分析。

动态分配 array 需要注意的问题

1557494889736

上面是动态分配内存,生成 complex 类的数组以及 string 类的数组的内存块图,与上面类似,不过这里多了一个长度的字节,都为 3,标记对象的个数。

1557495133404

上面说明的是,如果分配的是动态对象数组,就一定要在 delete 后面加上[]符号,不然就无法完全释放动态分配的内存。array new 一定要搭配 array delete

C++ 面向对象程序设计 Part2

part1 讲述了基于对象,part2 则是在基于对象的基础上,建立类与类之间的联系,即面向对象编程以及面向对象设计。

主要讲述以下三点:

  • Inheritance (继承)
  • Composition(复合)
  • Delegation (委托)

另外,我把补充内容中的对象模型放入到 Part2,我觉得放入这里更加合适。

Composition(复合)

1558453874580

Composition(复合)就是 has a, 上面的事例就是队列(queue)类中有一个双端队列(deque)类,队列中的成员函数通过双端队列的操作函数完成,这是类与类之间的第一个关系。(黑色菱形代表内部拥有)

deque 中可能拥有很多方法,但 queue 中只通过 deque 提供了非常少的方法,这是一个设计模式 Adapter,将一个拥有许多功能的类改造一下,得到另一个类。

内存视角下的 composition(复合)

1558459151904

可以看到有两个复合关系,最后 queue 的内存是 40.

composition(复合)关系下的构造与析构

1558457528106

由于 Container 类是拥有 Component 类,所以在构造方面,先调用 Component 类的默认构造函数,然后再调用 Container 的构造函数,由内而外的构造,里面做好了,再做外面。析构则相反,先对 Container 进行析构,然后再对 Component 进行析构,过程是由外而内,将外面的去掉,才能看到里面去掉里面,符合常识。

Delegation (委託) —— Composition by reference

1558457888072

如果一个类(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 (继承)

1558491481526

继承的语法就是在类名后加上:public(还可以是 protected,private)你想要继承的类,如果想继承多个类,用逗号隔开就可以了。什么时候用继承,确定一个关键点,子类 is a 父类(例如,狗 is a 动物)。上述的 List_nodes 是继承了 List_node_base 所有的数据,另外还有自己的数据。

Inheritance (继承)关系下的构造与析构

1558716277153

继承的类(derived object)的一部分是基类(base part),对于要被继承的基类,它的析构函数必须是 virtual,不然会出现问题,这个问题将在后面说。继承的构造函数会首先调用基类的构造函数,然后调用自己的构造函数(由内而外)。析构则相反,先析构自己,然后再调用基类的析构函数。

Inheritance (继承)with virtual functions(虚函数)

1558716850059

子类继承了父类的两样东西,一种是父类的数据,一种是父类函数的调用权。对于一个类而言,它的子类都可以访问所以的 public 方法,而子类要不要重新定义父类的函数呢?这时候就需要虚函数了,当 public 里面的函数不是虚函数时,则希望子类不重新定义该函数。当函数是虚函数时(在返回类型前加入关键字 virtual),则希望子类重新定义它,并且父类已经有了默认定义。当函数是纯虚函数时(在结束符;前面加上=0),则希望子类一定要重新定义它,父类没有默认定义(但可以有默认定义)。该事例是定义了一个基类 shape,然后矩形 Rectangle 和椭圆 Ellipse 对 shape 进行继承,基类的 objectID 是无需继承的,可以提前定好,在父类调用即可,而 error 函数,父类有默认的错误信息,如果子类有更精细的错误信息,父类允许子类可以重新定义的,打印出子类调用时的错误,而 draw 函数则必须重新定义,父类没有定义(draw shape 没有意义),子类不同,所画出的形状自然不同。

Inheritance (继承)with virtual ——经典实例

1558718409179

对于在 powerpoint 打开 ppt 文件而言,有以下几步,先点打开命令的菜单栏,然后出现文件界面,选择我们要打开的文件名,然后程序会检查文件名是否符合规范,符合规范则在磁盘上搜索文件,搜索到了打开文件即可。而遇到注意的是,所以打开文件的过程都是这样,只有最后打开文件可能会不同(可能会打开不同格式的文件),于是有团队就将除文件打开函数以外的函数进行打包,子类直接继承,只要子类重新定义父类打开文件的函数即可。如下图所示:

1558718827030

团队开发了 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 函数。

1558752779674

上图就是 CDocument 和 CMyDoc 的实例,用 cout 来模拟步骤,呼应上面两张图片。

Inheritance + Composition 关系下的构造与析构

1558753009197

当同时存在继承和复合,类是如何进行构造和析构呢?这一节要讨论的问题:

  1. 子类有父类的成分还有包含着另一个类;
  2. 子类继承父类,父类拥有另外一个类。

情况 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 类,再初始化自己,析构与构造相反。

下图也给出了结论。

1558760439215

至此,面向对象的概念说完了,下面进入实例环节。

Delegation (委托) + Inheritance (继承) (一)

1558873574510

上述代码解决的是下图所示的问题,对同一份文件使用四个不同窗口去查看,或者右下角所示的,同一个数据,三种不同的 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 真的感觉好强大呀。

1558873750010

下图是一个更详细的 Observer 解法构建

1558875339692

Delegation (委托) + Inheritance (继承) (二)

1558887391028

第二个实例,构建一个文件系统。可以把 Primitive 类当作文件类,而 Composite 类当作目录类,与日常使用的文件系统一样,目录里面可以包含目录,也可以包含文件,所以目录里面存放的不止是目录本身还可以是文件,但是需要放入到同一个容器中,想法是使用指针,但文件和目录是不太一样的,所以解决方案是将文件和目录共同继承 Component 类,然后 Composite 类中的容器存放的是 Component 的指针,这样就可以既存放文件又可以存放目录了,这是一种经典的解决方案,也是 23 种设计模式之一,Composite。另外,Component 类中还有一个虚函数 add,这是给目录进行继承的,因为目录可以新建目录和文件,这里不能设置为纯虚函数,因为文件不能继承这个函数,文件是不能在进行添加的。

Prototype

1558923111947

这又是 23 种设计模式之一的 Prototype,Prototype 要解决的是要设计一个类,这个类是为未来的子类所设计的,他可以访问和操作未来的子类,这就很有意思了,都不知道未来的子类是啥,要去访问这个子类,这是怎么去做呢?原来在子类内部会申明一个关于子类的静态变量,就是上图中的LSAT : LandSatImage,另外这个子类会定义一个私有的构造方法(前面有一个负号,可以通过定义静态变量调用私有的构造方法),构造方法里面会调用父类的 addPrototype 函数,将静态变量的指针传到父类,父类就把传入的指针(通过父类的指针形式)加入到自己的容器当中,这样父类就知道子类的类型,就可以操作子类了,上述操作是这样的,父类有一个 findAndClone 函数,根据输入参数 i 选择父类容器中的子类进行 clone,返回子类的指针,而 clone 父类定义的是一个纯虚函数,子类必须重新定义,上图中子类重新定义的是返回新建一个子类,返回它的指针,不过这个新建是调用的另外一个构造函数(有一个#号,代表 protect),使用 private 里面的构造函数是不行的,它是为父类打通桥梁的,为了与 private 里面的构造函数区分开,形参有一个 int 类型,这个 int 类型不会进行使用。

下面的图片是相关代码,解释上面的文字已经说的很清楚了。

父类 Image

1558925338958

两个子类代码:

1558925377433

主函数:

1558925446700

Prototype 例子来自于《Design Patterns Explained Simply》这本书,经典致敬!

1558925624955

面向对象的例子讲完了,下面介绍更加深层次的内容,理解面向对象更底层的东西。

对象模型

1558939321858

这张图所蕴含的信息量很大,现在一步步来看,首先最右边有三个类,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++通过类指针找到虚指针,根据虚指针找到虚表,从虚表中取出虚函数地址进行调用,这是一种动态的调用,不同类的指针所调用的虚函数是不一样的,这种方式叫做动态绑定,也叫虚机制。

下图就是一个实例,展示了这种动态绑定的强大威力。

1558943366112

我要设计一个 powerpoint,要画出各种不同的形状,我们可以用一个 List,承载 A 类的指针(放指针是因为容器只能放内存大小一致的东西,不同的形状内存不一致),这样它的所有子类都可以放入 List 中了,因为都是 A 类,放入 List 后,循环遍历直接调用各自形状的 draw 函数就可以完成要求,这里走的依然是动态绑定,这是很好的,为了更加突出动态绑定的好处,这里要写出 C 语言中是如何做的,C 语言只有静态绑定,上述过程需要条件判断当前的指针是指向哪一个类,各种形状要使用多重判断,另外,当需要添加新的形状类时,又要修改条件判断代码,这是很不好的,不符合直观理解,应该像 C++的虚函数一样,指针指向什么形状,就应该调用那种类型的 draw。由此可见,C++动态绑定很棒,很强大。C++支持动态绑定和静态绑定,符合下面三个条件,C++采用动态绑定,条件如下:

  • 必须使用指针调用函数
  • 该指针必须是向上转型 (List 中定义的类型是 A 的指针,但可以存储 C 的指针,通过继承向上转型)
  • 调用的函数必须是虚函数

这里还有一个概念——多态,List 申明的时候,是通过 pointer-to-A 进行申明的,但实际是 List 可以存储各种不同的形状,都属于 A,确是不同形态,所以叫多态。

所以,所谓的多态,虚函数,动态绑定,虚指针以及虚表,所有的故事都是同一件事情,真的了然于胸啊!:smile:

1558945922875

可以再来看看之前讲的 Template method,这就是动态绑定的一个应用,子类 CMyDoc 调用父类的 OnFileOpen 函数,调用的时候会将子类的地址传入到父类的函数中,也就是 this pointer(成员函数都是一个默认参数,代表的就是 this pointer,谁调用成员函数,谁的地址就是 this pointer),在 OnFileOpen 中,所有的调用前面都会加上一个 this pointer,而对于 Serialize 函数来说,它是符合上述我们说的三个条件的,首先调用者是 this,是指针,然后指向的是子类,向上转型,调用的 Serialize 函数是虚函数,所以会使用动态绑定,调用 CMyDoc 的 Serialize 函数。这是很好的!

1558948644501

现在从汇编代码的角度来看函数调用,初始化 B 类,讲 B 类强制转型为 A 类,得到 a,调用 a.vfunc1()函数,这里是静态绑定,因为是通过类调用函数,不是指针调用,汇编代码也说明了这个问题,使用的是 call xxx 形式编译函数。

1558949507638

而新创建了一个指针 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!

上一页