C++类

  • 《面向对象C++程序设计》皮德常 编著 清华大学出版社 2017
  • 《C++ Primer Plus (第6版)中文版》Stephen Prata 著 张海龙 袁国忠 译 人民邮电出版社 2012

1 构造函数与析构函数

1.1 构造函数定义或作用

初始化类对象的数据成员。

即类的对象被创建的时候,编译系统对该对象分配内存空间,并自动调用构造函数,完成类成员的初始化。

构造函数的特点:以类名作为函数名,无返回类型。

常见的构造函数有三种写法:

  • 无参构造函数

    • 如果创建一个类,没有写任何构造函数,则系统会自动生成默认的无参构造函数,且此函数为空。

      默认构造函数(default constructor)就是在没有显式提供初始化式时调用的构造函数。如果定义某个类的变量时没有提供初始化时就会使用默认构造函数。

  • 一般构造函数

  • 复制构造函数

    • 复制构造函数,也称为拷贝构造函数。复制构造函数参数为类对象本身的引用,根据一个已存在的对象复制出一个新的对象,一般在函数中会将已存在对象的数据成员的值复制一份到新创建的对象中。

    • 注意:若没有显示定义复制构造函数,则系统会默认创建一个复制构造函数,当类中有指针成员时,由系统默认创建的复制构造函数会存在“浅拷贝”的风险,因此必须显示定义复制构造函数。

      • 浅拷贝指的是在对对象复制时,只对对象中的数据成员进行简单的赋值,若存在动态成员,就是增加一个指针,指向原来已经存在的内存。这样就造成两个指针指向了堆里的同一个空间。当这两个对象生命周期结束时,析构函数会被调用两次,同一个空间被两次free,造成野指针。
      • 深拷贝就是对于对象中的动态成员,不是简单的赋值,而是重新分配空间。

C++的构造函数可以有多个,创建对象时编译器会根据传入的参数不同调用不同的构造函数。

1.2 析构函数定义或作用

C++析构函数概述

C++析构函数是一个特殊的成员函数,它的名字是类名的前面加一个~符号,作用与构造函数相反,当对象的生命期结束时,会自动执行析构函数。

C++执行析构函数的情况

如果在一个函数中定义了一个对象,当这个函数被调用结束时,对象应该释放,在对象释放前自动执行析构函数。

static局部对象在函数调用结束时对象并不释放,因此也不调用析构函数,只在main函数结束调用exit函数结束程序时,才调用static局部对象的析构函数。

如果定义了一个全局对象,则在程序的流程离开其作用域时,调用该全局对象的析构函数。

如果用new运算符动态地建立了一个对象,当用delete运算符释放该对象时,先调用该对象的析构函数。

C++析构函数详解

析构函数的作用并不是删除对象,而是在撤销对象占用的内存之前完成一些清理工作,使这部分内存可以被程序分配给新对象使用。

析构函数不返回任何值,没有函数类型,也没有函数参数,因此不能重载,一个类可以有多个构造函数,但只能有一个析构函数。

析构函数的作用并不仅限于释放资源方面,它还可以被用来执行程序员希望在最后一次使用对象之后所执行的任何操作。

如果没有定义析构函数,C++编译系统会自动生成一个析构函数,但它只是徒有析构函数的名称和形式,实际上什么都不执行,要想让析构函数执行,必须在定义的析构函数中指定。

1.3 C++类内静态成员的内存释放问题

C/C++中静态成员变量存放在全局内存的静态区域,因此,我们虽然delete掉了这个类,但是并不代表我们真的释放掉了类内静态成员的内存,这些静态成员的内存会在整个进程退出的时候由系统回收。

1.4 构造析构顺序

  1. 存在继承关系时,先执行父类的构造函数,再执行子类的构造函数;析构的时候恰好相反:先调用派生类的析构函数、然后调用基类的析构函数。
  2. 当一个类中含有对象成员时,在启动本类的构造函数之前,先分配对象空间,按对象成员的声明顺序执行他们各自的构造函数,再执行本类的构造函数。
  3. 对于非静态的局部对象,他们的析构函数的执行顺序与构造函数相反。
  4. 构造:父类->对象成员>子类

1.5 析构函数能抛出异常吗?

C++标准指明析构函数不能、也不应该抛出异常。

  1. 如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。
  2. 通常异常发生时,c++的机制会调用已经构造对象的析构函数来释放资源,此时若析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃的问题。

1.6 调用拷贝构造函数的情况

  • 用对象初始化同类的另一个对象
  • 函数形参是对象,当进行参数传递时将调用copyconstruct
  • 函数返回值是对象,函数执行结束时,将调用拷贝构造函数对无名临时对象初始化

2 多态与虚函数

2.1 多态性

多态性指"一个接口,多种方法"。C++支持两种多态性:编译时多态性,运行时多态性。

  1. 编译时多态性(静态多态):通过重载函数和泛型编程实现
  2. 运行时多态性(动态多态):通过虚函数实现

静态与动态的实质区别就是函数地址是早绑定还是晚绑定。

如果函数的调用,在编译器编译期间就可以确定函数的调用地址,并生产代码,是静态的,就是说地址是早绑定的。

而如果函数调用的地址不能在编译器期间确定,需要在运行时才确定,这就属于晚绑定。

2.2 动态绑定的概念

动态绑定是指在运行时(非编译期)将过程调用与相应代码链接起来的过程称为动态绑定。

C中,通过基类的引用或指针调用虚函数时,发生动态绑定。C中动态绑定是通过虚函数实现的,而虚函数是通过一张虚函数表实现的。这个表中记录了虚函数的地址,保证动态绑定时能够根据对象的实际类型调用正确的函数。编译器必须保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证正确取到虚函数的偏移量)。这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。

2.3 虚函数和纯虚函数

用 virtual 关键字修饰的成员函数就是虚函数,虚函数的作用就是实现多态性。纯虚函数是一种特殊的虚函数,在基类中不能对虚函数给出有意义的实现,而把它的实现留给派生类去做。只含有虚函数的类可以被实例化,含有纯虚函数的类不能被实例化。

注意

  1. 只有类的成员函数才能声明为虚函数,虚函数仅适用于有继承关系的类对象。普通函数不能声明为虚函数。
  2. 静态成员函数不能是虚函数,因为静态成员函数不受限于某个对象。
  3. 内联函数(inline)不能是虚函数,因为内联函数不能在运行中动态确定位置。
  4. 构造函数不能是虚函数。
  5. 析构函数可以是虚函数,而且建议声明为虚函数。

2.4 C++类中定义虚函数和不定义在初始化的时候大小是不是一样?

定义了虚函数的类要大一些,因为要建立虚函数表

2.5 构造函数能不能声明成虚函数?

不能。

  1. 当创建一个派生类对象时,会先调用基类的构造函数,但是派生类已经覆盖了基类的构造函数,所以也就无法进一步执行,导致程序出错。
  2. 在创建对象时,首先要调用构造函数,然后构造函数是虚函数,就需要用虚函数指针去调用,但是,对象都还没构造,也就没有虚函数指针,造成了一个循环调用的问题。

2.6 基类的析构函数不是虚函数,会带来什么问题?

delete 指向派生类对象的基类指针时,只有基类的内存被释放,派生类的没有。这样就内存泄漏了。

2.7 构造函数和析构函数中调用虚函数吗?

从语法上讲,调用完全没有问题。但是从效果上看,往往不能达到多态的效果。
Effective C的解释是:派生类对象的基类成分会在派生类自身成分被构造之前先构造妥当,派生类对象构造期间会首先进入基类的构造函数,在基类构造函数执行时继承类的成员变量尚未初始化,对象类型是基类类型,而不是派生类类型,虚函数会被编译器解析为基类,若使用运行时类型信息,也会把对象视为基类类型,构造期间调用虚函数,会调用自己的虚函数,此时虚函数和普通函数没有区别了,达不到多态的效果。
同样,进入基类析构函数时,对象也是基类类型。C
中派生类在构造时会先调用基类的构造函数再调用派生类的构造函数,析构时则相反,先调用派生类的析构函数再调用基类的析构函数。一旦派生类析构函数运行,这个对象的派生类数据成员就被视为未定义的值,所以 C++ 就将它们视为不再存在。假设一个派生类的对象进行析构,首先调用了派生类的析构,然后再调用基类的析构时,遇到了一个虚函数,这个时候有两种选择:

  1. 编译器调用这个虚函数的基类版本,那么虚函数则失去了运行时调用正确版本的意义;
  2. 编译器调用这个虚函数的派生类版本,但是此时对象的派生类部分已经完成析构,“数据成员就被视为未定义的值”,这个函数调用会导致未知行为。

2.8 重载(overload)和重写(overried,有的书也叫做“覆盖”)的区别?

  • 重载:是指允许存在多个同名函数,而这些函数的参数列表不同
  • 重写:是指派生类重新定义基类虚函数的方法。

2.9 虚函数表存放的内容以及在内存中的位置

  • 每个对象里有虚(函数表)指针,指向虚函数表,虚函数表里存放了虚函数的地址。虚函数表是顺序存放虚函数地址的,不需要用到链表。
  • 还有类的类型信息,在《深度探索C++对象模型》中有提到
  • C中**虚函数表位于只读数据段(.rodata),也就是C内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。**

2.10 如果虚函数非常有效,我们是否可以把每个函数都声明为虚函数?

不行,这是因为虚函数是有代价的:

  • 由于每个虚函数的对象都必须维护一个虚函数表,因此在使用虚函数的时候会产生一个系统开销。如果仅是一个很小的类,且不想派生其他类,那么根本没必要使用虚函数。
  • 虚函数效率低。因为虚函数需要一次间接的寻址,而一般的函数可以在编译时定位到函数的地址。虚函数(动态类型调用)是要根据某个指针定位到函数的地址。多增加了一个过程,效率肯定会低一些,但带来了运行时的多态。

3 继承

3.1 继承的概念

派生类可以具有基类的特性,共享基类的成员函数,使用基类的数据成员,还可以定义自己的数据成员和函数成员。从一个基类派生的称为单继承,从多个基类派生的称为多继承。

C++中,继承方式有 3 种

  1. 公有继承:public、protected、private 权限均不变
  2. 私有继承:public、protected 权限变为 private
  3. 保护继承:public 变为 protected

实现的访问控制如下

  1. public:自己、友元、派生类、外界均可访问
  2. protected:自己、友元、派生类可以访问
  3. private:自己、友元可以访问

3.2 多继承的优缺点

优点

  1. **简单,清晰,更有利于复用。**不会因为基类一个小小的改变而大张旗鼓去改代码。

缺点

  1. **二义性。**两个基类中有同名方法的时候,需要在子类的调用中指明此方法出自那个基类。
  2. 使用父类指针指向子类对象变得复杂。你不得不用到C++中提供的dynamic_cast来执行强制转换。至于dynamic_cast,也是个麻烦的家伙,它是在运行期间而非编译期间进行转换的(因为编译期间它不能确定到底要转向一个什么类型),因此除了会带来一些轻微的性能损失,它要求编译器允许RTTI(Runtime Type Information,运行时类型信息),也就是要求编译器保存所有类在运行时的信息。
  3. 使得子类的vtable变得不同寻常。单继承的vtable只是在父类vtable的表尾加上新的虚函数,子类对象的vtable中包含了有序的父类vtable。而对于多重继承,两个父类可能有完全不同的vtable,因此,子类的vtable中绝对不可能包含完整的有序的两个父类的vtable。子类的vtable中可能包含了两块不相连的父类vtable,因此每个父类都被迫追加了一个vtable,也就是,每个父类的对象都添加了一个指针。

孰优孰劣,自己把握。没有永远最好的,只有当前适合的。Java中摒弃了多重继承可能也是出于太过复杂,可能有不可料知的结果的原因。

不要随意使用多重继承。大多数的情况,用容器(也就是类的组合法)会更好些。

https://blog.csdn.net/woodforestbao/article/details/4500406