C++知识点整理第二弹-进阶篇

C++知识点整理第二弹-进阶篇

三月 12, 2021

static的作用和用法

作用

  1. 最重要的作用:隐藏。(static函数、static变量均可)

当同时编译多个文件时,所有未加static修饰的全局变量和函数都具有全局可见性。

  1. 保持变量内容的持久

static修饰的变量具有记忆功能和全局生存期

  1. 默认初始化

存储在静态数据区的变量,内存中所有字节默认都是0x00,,会在程序刚开始运行时就完成初始化(也是唯一的一次初始化)。

  1. 为类成员声明静态属性

用法

  • 非类内使用:

    1. 函数体内的static变量的作用范围为该函数体,该变量只有一次内存分配和初始化,因此其值在下次调用时仍然维持上一次的值
    2. 在模块内的static全局变量可以被同模块内的函数调用,但不能被模块外的函数访问
    3. 模块内的static函数可以被同模块内的其他函数调用,该函数的使用范围被限制在声明该变量的模块内
  • 类内使用:

    1. 在类中的static成员变量属于整个类所拥有,对类的所有对象仅有一份拷贝

    2. 在类中的static成员函数数据整个类所拥有,该函数不接受this指针,因此只能访问类的static成员变量

    3. static修饰的类变量/对象必须在类外进行初始化,static修饰的变量需要先于对象存在

    4. static成员函数不能被virtual关键字修饰,因为static成员函数不属于任何对象,所以加上virtual没有任何实际意义

      因为静态成员函数没有this指针,虚函数的实现时为每一个对象分配一个vptr指针,而vptr是通过this指针调用的。

      补充:虚函数的调用关系:this -> vptr -> vtable -> virtual function

静态变量初始化的时机

  1. 初始化只有一次,但可以重复赋值,在主程序之前,编译器已经为其分配好内存

  2. 静态局部变量和全局变量一样,数据存放在全局数据区,在主程序之前编译器已为其分配空间

  3. C和C++的静态局部变量初始化节点是不一样的

    • C语言中,初始化发生在代码执行之前,编译分配内存之后,所以我们无法通过变量对静态局部变量进行初始化

    • C++中,在执行相关代码时才会进行初始化,所以我们可以通过变量对静态局部变量进行初始化

      因为C++中引入对象,要进行初始化必须执行相应的构造函数,在构造函数经常会需要通过变量进行特定操作,并非简单的分配内存。
      所以C++标准定为全局或静态对象是在首次用到的时候才会构造,并通过atexit()来管理。

const作用

  1. 阻止变量被修改,用const定义变量时需要对其初始化,因为未来便没有机会再去改变它了。

  2. 可以指定指针本身为const,也可以指定指针指向的数据为const,又可以两者都指定为const。

  3. 在函数声明中,const可以修饰形参,表明输入的参数在函数中不能被更改。

  4. 对于类的成员函数,若指定为const,则表示这是一个常函数,不能改变类的成员变量。

  5. 对于类的对象,若指定为const,则表示这是一个常对象,只能访问类的常成员函数。

  6. 对于类的成员函数,有时候必须指定其返回值为const类型,以使得其返回值不为”左值”

    1
    2
    3
    4
    5
    int& min ( int &i, int &j);
    min(a,b) = 4;//返回一个引用,所以可以作为左值使用

    const int & min ( int & i, int &j );
    //min(a,b) = 4; //不能作为左值,会编译报错
  7. const成员函数可以访问非const对象的非const数据成员、const数据成员,也可以访问const对象的所有数据成员

  8. 非const成员可以访问非const对象的非const数据成员、const数据成员,但不可以访问const对象的任意数据或函数

    第7,8点:

    • 没有明确声明为const的成员函数,编译器会认为是修改对象中的数据成员的函数,所以常对象只能调用常函数
    • const成员函数不允许修改数据成员,所以对任何对象的数据成员访问都没有冲突
    • 但是非const成员函数允许修改数据成员,若其可以访问const对象的话,修改其成员数据便造成冲突
  9. const类型变量可以通过类型转换符const_cast将const类型转换为非const类型

  10. const类型变量在定义时进行初始化,所以类的成员变量被const所修饰,那么该变量必须在类的初始化列表中初始化;

  11. 对于函数的值传递,在形参用不用const修饰都没有影响,因为临时变量无法更改实参

  12. 对于函数的指针或引用传递,可以通过形参改变实参,所以const修饰形参能够起到保护实参的作用

指针和const的用法

  1. 当const修饰指针时,由于const的位置不同,它的修饰对象也不一样
  2. int* const p修饰的是p指针,这是一个顶层指针,表示p指针的指向不能改变,但是可以通过这个指针读写地址上的值
  3. int const *p或者const int *p修饰的*p,这是一个底层指针,表示指针指向的地址上的值不能硅钙,但可以改变p的指向。

形参和实参的区别

  1. 形参变量在被调用才会分配内存,在调用结束后,会立即释放掉所分配的内存。所以,形参只有在函数内部有效,函数调用结束返回主函数后便无法再使用该形参变量
  2. 实参可以是常量、变量、表达式、函数等,无论实参是什么类型,在调用函数时,它们都必须具有确定的值以便赋值给形参。
  3. 实参和形参在数量上,类型上,顺序上应该严格一直,否则会发生类型不匹配
  4. 函数调用中发生的数据传递是单向的,只能实参的值传给形参,因此函数调用中,形参的值发生改变,实参中的值并不会发生改变
  5. 但形参和实参不是指针类型时,在该函数运行时,形参和实参实质上是不同的变量,它们在内存中的不同位置,形参将实参的内容复制一份,在函数运行结束后形参会被释放,而实参的内容不会改变

值传递、指针传递、引用传递

值传递

形参向函数所属的栈拷贝数据的过程,如果值传递的对象是类对象或是一个大的结构体对象的话,将耗费一定的时间和空间。

指针传递

同样是形参向函数所属的栈拷贝数据的过程,但拷贝的数据是一个固定的4字节的地址。

引用传递

同样是形参向函数所述栈拷贝数据的过程,但针对的是地址的,相当于为该数据所在地址起别名

效率比较

指针和引用传递都比值传递效率高,但主张使用的还是应用传递,因为代码逻辑更加紧凑、清晰

类的关系

  • has包含关系

表示一个类由另一个类构成,即一个类的成员属性是另一个已定义好的类

  • use使用关系

一个类使用另一个类,通过类之间的成员函数相互联系,即定义友元或者传递参数的方式实现

  • is继承关系

一个类继承了另一个类的属性和方法,这个类包含被继承类的属性和方法,该类我们称作子类或者派生类

而被继承的类我们称为父类或者基类。且子类对象可以当作父类对象使用;

类的继承

  • 特点

子类拥有父类的所有属性和方法(构造和析构除外)

  • 继承方式
    • public
    • protected
    • private
  • 继承中的构造和析构
    • 构造顺序:父类构造函数 -> 子类构造函数
    • 析构顺序:子类构造函数 -> 父类构造函数
  • 继承中的兼容性原则
    • 子类对象可以当作父类对象使用
    • 父类指针可以指向子类对象
    • 父类引用可以直接引用子类对象
    • 子类对象可以直接初始化父类对象
    • 子类对象可以直接赋值给父类对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
#include <iostream>
using namespace std;

class Parent
{
protected:
int m_a;
int m_b;
public:
Parent()
{
cout << "Parent构造函数" << endl;
}
void setab(int a, int b)
{
m_a = a;
m_b = b;
}
void show()
{
cout << "m_a=" << m_a << " " << "m_b=" << m_b << endl;
}
~Parent()
{
cout << "Parent析构函数" << endl;
}
};

class Child : public Parent
{
private:
int m_c;
public:
Child()
{
cout << "Child构造函数" << endl;
}
void setc(int c)
{
m_c = c;
}
void print()
{
cout << "m_c=" << m_c << endl;
}
~Child()
{
cout << "Child析构函数" << endl;
}
};

int main()
{
/*模块一:子类对象可当成父类对象使用*/
/*Child c; //检验 子类对象能否当成父类对象使用 结果可以
c.setc(3);
c.setab(1,2);
c.show();
c.print();*/

/*模块二:父类指针指向子类对象*/
/*Child c;
c.setc(3);
c.setab(1,2);
Child *pc = &c; //子类指针指向子类对象
pc->show();
pc->print();

Parent *p = &c; //父类指针指向子类对象
p->setab(3,4);
p->show();
//p->setc(5); //经检验 父类指针确实可以指向子类对象 但父类指针是Parent类型 它只能作用Parent成员对象和函数对子类的其他成员变量和函数不起作用
//p->print();*/

/*模块三:基类的引用可直接引用派生类对象*/
/*Child c;
c.setc(6);
c.setab(7,8);
Parent &p = c;

p.show(); //父类引用只能引用父类 子类不可以 (另外:引用是常指针 情况和模块二一样)
//p.print();*/

/*模块四:子类对象可以直接初始化基类对象*/
/*Child c;
c.setc(10);
c.setab(11,12);

Parent p = c; //c是子类对象,用子类对象直接对父类对象p进行初始化,这里会调用p的拷贝构造函数:Parent(const Parent &obj) 拷贝构造函数形参是基类的引用 这就回到模块三 基类的引用可以直接引用派生类的对象
p.show();
//p.print();*/

/*模块五:子类对象可直接赋值给父类对象*/
Child c;
c.setc(13);
c.setab(14,15);

Parent p;
p = c; //c是派生类对象,用派生类对象直接赋值给基类对象p,赋值操作不会调用对象的构造函数,但是会调用对象赋值运算符重载函数 Parent operator=(const Parent &obj) 赋值运算符重载函数的参数也是一个基类的引用
p.show();
//p.print();


return 0;
}

内存池概念和实现

概念

  • 内存池是一种内存分配方式,通常我们习惯直接使用newmalloc申请内存
  • 但这样的缺点在于:我们申请的内存块大小不定,当频繁使用时会造成大量的内存碎片而降低性能。
  • 内存池则是在真正使用内存之前,先申请分配一定数量的、大小相等的内存块留作备份。
  • 当有新的内存需求时,就从内存池分出一部分内存块,
  • 若内存块不够再继续申请新的内存
  • 这样的作法可以显著减少了内存碎片,使得内存分配效率得到了很大的提升

实现

  • 使用allocate向内存池请求size大小的内存空间,如果需要请求的大小大于128bytes,直接malloc获得内存
  • 如果需要的内存小于128bytes,allocate会根据size找到最合适的自由链表
    • 如果链表不为空,返回第一个node,链表头改为第二个node。
    • 如果链表为空,使用blockAlloc请求分配node
      • 如果内存池中有大于一个node的空间,分配会尽可能多的node(最多20个),并将一个node返回,其他的node添加到链表中
      • 如果内存池只有一个node的空间,直接返回给用户
      • 如果连一个node都没有,再次向操作系统请求分配内存
        • 分配成功后再使用blockAlloc请求分配node
        • 分配失败后,循环各个自由链表寻找空间
          • 找到空间,再使用blockAlloc请求分配node
          • 找不到空间,抛出异常
  • 用户调用deallocate释放内存空间,如果要求释放的内存空间大于128byte,直接调用free
  • 否则按照其大小找到合适的自由链表并将其插入
  • STL的allocator比较好的实现规则
    • 分配器维护着一条0-15号的链表,其中第0号链表存储1*8byte大小的数据,第1号链表存储2*8byte大小的数据…直至第15号16*8byte大小的数据
    • 如果申请的不是8的整数倍,那么就找到刚好满足内存大小的位置。(例如:申请12byte,我们就找到16byte的链表)

汇编层解释引用

1
2
3
4
5
6
7
8
9:      int x = 1; //x的地址为ebp-4
10: int &b = x; //b的地址为ebp-8
//因为栈的变量是从内存的高往低进行分配的,所以b的地址比x的低

00401048 mov dword ptr [ebp-4],1

0040104F lea eax,[ebp-4] //将x的地址放去eax寄存器
00401052 mov dword ptr [ebp-8],eax //将eax的值放入b的地址

可以看出,这和将某个变量的地址存入指针变量是一样的,所以从汇编层次来看,引用的本质其实就是int* const b

深拷贝和浅拷贝

  • 浅拷贝

仅拷贝了基本类型的数据,而引用类型数据在拷贝后会发生引用。换句话说,引用类型的浅拷贝只是指向被拷贝的内存地址,若原地址数据被改变了,那么浅拷贝出来的对象也会发生改变

  • 深拷贝

在计算机中开辟了一块新的内存地址用于存放拷贝的对象

在某些情况下,类内对象变量需要动态开辟堆内存,若实行的是浅拷贝,对象B的指针指向了对象A已经申请好的内存,那么当对象B把内存释放后,对象A的指针就是野指针,运行的时候会发生错误。

C++模板的底层实现

  • 编译器并不是把函数模板处理成能够处理任意类的函数
  • 编译器从函数模板通过具体类型产生不同的函数
  • 编译器会对函数模板进行两次编译
  • 在声明的地方对模板代码本身进行编译
  • 在调用的地方对参数替换后的代码在进行一遍编译

函数模板要被实例化后才能成为真正的函数,在使用函数模板的源文件中包含函数模板的头文件,如果该头文件只有声明没有定义,那编译器将无法实例化该模板,最终导致链接错误

new和malloc区别

  • new/delete是C++关键字,需要编译器支持;malloc/free是库函数,需要头文件支持
  • 使用new操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算;而malloc则需要显式地指出所需内存的尺寸
  • new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符;而malloc内存分配成功则是返回void * ,需要通过强制类型转换将void*指针转换成我们需要的类型
  • new内存分配失败时,会抛出bac_alloc异常;malloc分配内存失败时返回NULL
  • new会先调用operator new函数,申请足够的内存(通常底层使用malloc实现)。然后调用类型的构造函数,初始化成员变量,最后返回自定义类型指针。delete先调用析构函数,然后调用operator delete函数释放内存(通常底层使用free实现);malloc/free是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构工作

new[]/delete[]、allocator作用

  • 动态数组new[]一个数组时,[]中必须是一个整数,但是不一定是常量整数,普通数组必须是一个常量整数;
  • new[]动态数组返回的并不是数组类型,而是一个元素类型的指针;
  • delete[]时,数组中的元素按逆序的顺序进行销毁;
  • new在内存分配上面有一些局限性,new的机制是将内存分配和对象构造组合在一起,同样的,delete也是将对象析构和内存释放组合在一起的。
  • allocator将这两部分分开进行,allocator申请一部分内存,不进行初始化对象,只有当需要的时候才进行初始化操作。

new和delete的实现

new的实现

  • new简单类型,直接调用operator new()分配内存;
  • new复杂类型,先调用operator new()分配内存,然后在分配的内存上调用构造函数;
  • new[]简单类型,计算好大小后调用operator new()分配内存;
  • new[]复杂类型,先调用operator new[]分配内存,然后在p的前四个字节写入数组大小n,然后调用n次构造函数,针对复杂类型,new[]会额外存储数组大小
    1. new表达式调用一个名为operator new(operator new[])函数,分配一块足够大的、原始的、未命名的内存空间
    2. 编译器运行相应的构造函数以构造这些对象,并为其传入初始值
    3. 对象被分配了空间并构造完成,返回一个指向该对象的指针

delete的实现

  • delete简单数据类型默认只是调用free函数
  • 复杂数据类型先调用析构函数再调用operator delete
  • 假设指针p指向new[]分配的内存。因为要4字节存储数组大小,实际分配的内存地址为[p-4],系统记录的也是这个地址。delete[]实际释放的就是p-4指向的内存。而delete会直接释放p指向的内存,这个内存根本没有被系统记录,所以会崩溃。

delete是如何知道释放内存大小呢?

答:需要在 new [] 一个对象数组时,需要保存数组的维度,C++ 的做法是在分配数组空间时多分配了 4 个字节的大小,专门保存数组的大小,在 delete [] 时就可以取出这个保存的数,就知道了需要调用析构函数多少次了,析构完后再调用operator delete函数释放空间

malloc申请的存储空间可以用delete释放吗

  • 不能,错配使用容易导致内存泄露。

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A
{
void * t;
public A(){
t=malloc(100);
}
public ~A() {
free(t);
}
};

void main() {
A *n=(A *)malloc(sizeof(A)); //导致构造函数没执行
delete n; //因为没有执行构造函数,所以t没有分配到空间,但delete会执行析构函数,释放t的内存空间
}

从理论上说使用malloc申请的内存是可以通过delete释放的。不过一般不这样写的。而且也不能保证每个C++的运行时都能正常。

malloc与free的实现

  • 在标准C库中,提供了malloc/free函数分配释放内存,这两个函数底层是由brk、mmap、,munmap这些系统调用实现的
  • brk是将数据段(.data)的最高地址指针_edata往高地址推
  • mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存
  • 这两种方式分配的都是虚拟内存,没有分配物理内存
  • 在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系
  • malloc小于128k的内存,使用brk分配内存,将_edata往高地址推
  • malloc大于128k的内存,使用mmap分配内存,在堆和栈之间找一块空闲内存分配
  • brk分配的内存需要等到高地址内存释放以后才能释放
  • 当最高地址空间的空闲内存超过128K(可由M_TRIM_THRESHOLD选项调节)时,执行内存紧缩操作(trim)。在上一个步骤free的时候,发现最高地址空闲内存超过128K,于是进行一遍内存紧缩
  • 而mmap分配的内存可以单独释放。
  • malloc是从堆里面申请内存,也就是说函数返回的指针是指向堆里面的一块内存
  • 操作系统中有一个记录空闲内存地址的链表。当操作系统收到程序的申请内存时,就会遍历该链表,然后就寻找第一个空间大于所申请空间的堆结点,然后就将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。

malloc、realloc、calloc区别

  • malloc函数
1
2
3
void* malloc(unsigned int num_size);

int *p = malloc(20*sizeof(int)); //申请20个int类型的空间;
  • realloc函数
1
void realloc(void *p, size_t new_size);

给动态分配的空间分配额外的空间,用于扩充容量。

  • calloc函数
1
2
3
void* calloc(size_t n,size_t size);

int *p = calloc(20, sizeof(int));

省去了人为空间计算;malloc申请的空间的值是随机初始化的,calloc申请的空间的值是初始化为0的;

类成员的初始化方式

  • 赋值初始化(通过在函数体内进行赋值初始化)

在函数体中初始化,是在所有的数据成员被分配内存空间后才进行的。

  • 列表初始化(在冒号后使用初始化列表进行初始化)

给数据成员分配内存空间时就进行初始化,换句话说分配一个数据成员只要冒号后有此数据成员的赋值表达式(此表达式必须是括号赋值表达式),那么分配了内存空间后在进入函数体之前给数据成员赋值,就是说初始化这个数据成员此时函数体还未执行。

为什么用成员初始化列表会快一些?

答:方法一是在构造函数当中做赋值的操作,而方法二是做纯粹的初始化操作。我们都知道,C++的赋值操作是会产生临时对象的。临时对象的出现会降低程序的效率。

构造函数的执行顺序

  1. 虚拟基类的构造函数(多个虚拟基类则按照继承表的出现顺序执行构造函数)。
  2. 基类的构造函数(多个普通基类也按照继承的顺序执行构造函数)。
  3. 类类型的成员对象的构造函数(按照初始化顺序)
  4. 派生类自己的构造函数。

成员列表初始化

  • 必须使用成员初始化的四种情况
    • 当初始化一个引用成员时
    • 当初始化一个常量成员时
    • 当调用一个基类的构造函数,而构造函数拥有一组参数时
    • 当调用一个成员类的构造函数,而构造函数拥有一组参数时
  • 成员初始化列表做了什么
    • 编译器会一一操作初始化列表,以适当的顺序在构造函数之内安插初始化操作,在构造函数中的用户代码之前
    • 列表中的项目顺序是由类中的成员声明顺序决定的,不是由初始化列表的顺序决定的
  • 效率的提升
    • 对于类类型,它少了一次调用构造函数,而在函数体中的赋值则会多一次调用
    • 而对于基本数据类型则没有区别
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <iostream>
using namespace std;
class A
{
public:
A()
{
cout << "默认构造函数A()" << endl;
}
A(int a)
{
value = a;
cout << "A(int "<<value<<")" << endl;
}
A(const A& a)
{
value = a.value;
cout << "拷贝构造函数A(A& a): "<<value << endl;
}
int value;
};

class B
{
public:
B() : a(1)
{
b = A(2);
}
A a;
A b;
};
int main()
{
B b;
}

//输出结果:
//A(int 1)
//默认构造函数A()
//A(int 2)

从代码运行结果可以看出,初始化列表会比构造函数赋值多了一次构造函数的调用,但这是为什么呢?

答:这是因为对象成员变量的初始化动作发生在进入构造函数之前,对于内置类型没什么影响,但如果有些成员是类,那么在进入构造函数之前,会先调用一次默认构造函数,进入构造函数后所做的事其实是一次赋值操作(对象已存在),所以如果是在构造函数体内进行赋值的话,等于是一次默认构造加一次赋值,而初始化列表只做一次赋值操作。

内存泄露

一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定)内存块,使用完后必须显式释放的内存。应用程序般使用malloc,、realloc、 new等函数从堆中分配到块内存,使用完后,程序必须负责相应的调用free或delete释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了

  • 避免内存泄露的方式
    • 使用new或者malloc时,让该数+1,delete或free时,该数-1,程序执行完打印这个计数,如果不为0则表示存在内存泄露
    • 一定要将基类的析构函数声明为虚函数
    • 对象数组的释放一定要用delete []
    • 有new就有delete,有malloc就有free,保证它们一定成对出现

对象复用和零拷贝

  • 对象复用

通过将对象存储到“对象池”中实现对象的重复利用,这样可以避免多次创建重复对象的开销,节约系统资源。

  • 零拷贝

零拷贝就是一种避免 CPU 将数据从一块存储拷贝到另外一块存储的技术,减少数据拷贝和共享总线操作的次数。

在C++中,vector的一个成员函数emplace_back()**很好地体现了零拷贝技术,它跟push_back()函数一样可以将一个元素插入容器尾部,区别在于:使用push_back()函数需要调用拷贝构造函数和转移构造函数,而使用emplace_back()插入的元素原地构造,不需要触发拷贝构造和转移构造**,效率更高。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <vector>
#include <string>
#include <iostream>
using namespace std;

struct Person
{
string name;
int age;
//初始构造函数
Person(string p_name, int p_age): name(std::move(p_name)), age(p_age)
{
cout << "I have been constructed" <<endl;
}
//拷贝构造函数
Person(const Person& other): name(std::move(other.name)), age(other.age)
{
cout << "I have been copy constructed" <<endl;
}
//转移构造函数
Person(Person&& other): name(std::move(other.name)), age(other.age)
{
cout << "I have been moved"<<endl;
}
};

int main()
{
vector<Person> e;
cout << "emplace_back:" <<endl;
e.emplace_back("Jane", 23); //不用构造类对象

vector<Person> p;
cout << "push_back:"<<endl;
p.push_back(Person("Mike",36));
return 0;
}
//输出结果:
//emplace_back:
//I have been constructed
//push_back:
//I have been constructed
//I am being moved.

trivial destructor

  • “trivial destructor”一般是指用户没有自定义析构函数,而由系统生成的,这种析构函数在《STL源码解析》中成为“无关痛痒”的析构函数。
  • 反之,用户自定义了析构函数,则称之为“non-trivial destructor”,这种析构函数如果申请了新的空间一定要显式的释放,否则会造成内存泄露
  • 对于trivial destructor,如果每次都调用,显然会对效率是一种伤害,那么如何判断呢?

《STL源码解析》中给出了说明:

首先利用value_type()获取所指对象的型别,再利用__type_traits判断该型别的析构函数是否trivial,若是__true_type,则什么也不做,若为__false_type,则去调用destory()函数

面向对象的三大特性

  • 继承
  • 封装
  • 特性

继承

以一个类为基础,获得其属性和方法,并在其基础上进行扩展和补充

  • 常见的继承方式
    • 实现继承:指使用基类的属性和方法而无需额外编码的能力
    • 接口继承:指仅使用属性和方法的名称、但是子类必须提供实现的能力
    • 可视继承:指子窗体(类)使用基窗体(类)的外观和实现代码的能力

封装

对不可信的外界进行信息隐藏,只允许外界通过接口修改内部数据。避免外界干扰和不确定性访问

多态

向不同对象发送同一消息,不同对象在接受时产生不同的行为

  • 函数重载实现了编译时多态。实现方式:重载
  • 虚函数实现了运行时多态。实现方式:重写

类的内存分配

  • C++的类是从结构体发展而来的,所以两者的内存分配机制时一样的。
  • 一个类对象的地址就是类包含的这片内存空间的首地址
  • 这个首地址就对应着具体某一个成员变量的地址
  • 在定义类对象的同时这些成员变量也被定义了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
using namespace std;

class Person
{
public:
Person()
{
this->age = 23;
}
void printAge()
{
cout << this->age <<endl;
}
~Person(){}
public:
int age;
};

int main()
{
Person p;
cout << "对象地址:"<< &p <<endl;
cout << "age地址:"<< &(p.age) <<endl;
cout << "对象大小:"<< sizeof(p) <<endl;
cout << "age大小:"<< sizeof(p.age) <<endl;
return 0;
}
//输出结果
//对象地址:0x7fffec0f15a8
//age地址:0x7fffec0f15a8
//对象大小:4
//age大小:4
  • 类对象的大小和对象中数据成员的大小是一致的,换句话说,成员函数不占用对象内存
  • 因为所有的函数都是存放在代码区的,不管是全局函数还是成员函数

构造函数不能为虚函数

  • 内存空间角度:
    • 虚函数对应着一个指向vtable虚函数表的指针,这个指针指向vtable事实上是存放在对象的内存空间上
    • 若我们假设构造函数是虚的,就需要通过vtable来调用,那么对象都还没有实例化,也就是内存空间都没有,那指向vtable的指针去哪里找呢
  • 使用角度:
    • 虚函数主要是用于父类的指针指向不同子类对象,能使重写的函数得到相应的调用
    • 虚函数用于通过父类的指针或者引用来调用它的时候可以变成调用子类的那个成员函数。而构造函数是在创建对象时自己主动调用的,不可能通过父类的指针或者引用去调用
    • 虚函数是在不同类型的子类对象产生不同的动作,但构造函数调用时,其子类都还没有产生,虚函数就没有存在的意义了
  • 实现角度:
    • vtable在构造函数调用后才建立,因而构造函数不可能成为虚函数
  • 实际含义角度:
    • 在调用构造函数时还不能确定对象的真实类型(由于子类会调父类的构造函数)
    • 并且构造函数的作用是提供初始化,在对象生命期仅仅运行一次,不是对象的动态行为,也没有必要成为虚函数。

析构函数要为虚函数

  • 我们往往通过基类的指针来销毁对象。这时候假设析构函数不是虚函数,就不能正确识别对象类型从而不能正确调用析构函数。
  • 基类采用virtual虚析构函数是为了防止内存泄漏。
  • 具体地说,如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。
  • 假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数。
  • 那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏。
  • 为了防止这种情况的发生,C++中基类的析构函数应采用virtual虚析构函数。
  • 纯虚析构函数必须定义,因为每一个派生类的析构函数都会被编译器加以扩张,以静态调用的方式调用其每一个虚基类以及上层基类的析构函数
  • 所以,缺乏任何一个基类析构函数的定义都会导致链接失败,最好还是不要把虚析构函数定义为纯虚析构函数。

析构函数作用

  • 用于释放对象内的动态申请内存的数据成员,
  • 析构函数与构造函数同名,但该函数前面加~。
  • 析构函数没有参数,也没有返回值,而且不能重载,在一个类中只能有一个析构函数。
  • 当释放对象时,编译器也会自动调用析构函数。
  • 每一个类必须有一个析构函数,用户可以自定义析构函数,也可以是编译器自动生成默认的析构函数。
  • 一般析构函数定义为类的公有成员。

构造函数和析构函数能否调用虚函数

  • 在C++中,提倡不在构造函数和析构函数中调用虚函数
  • 从语法上讲,调用完全没有问题
  • 但是从效果上看,往往不能达到需要的目的
  • 构造函数和析构函数调用虚函数时都不使用动态联编,如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本
  • 因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数时不安全的,故而C++不会进行动态联编
  • 析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经销毁,这个时候再调用子类的虚函数没有任何意义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include<iostream>
using namespace std;

class Base
{
public:
Base()
{
Function();
}

virtual void Function()
{
cout << "Base::Fuction" << endl;
}
~Base()
{
Function();
}
};

class A : public Base
{
public:
A()
{
Function();
}

virtual void Function()
{
cout << "A::Function" << endl;
}
~A()
{
Function();
}
};

int main()
{
Base* a = new Base;
delete a;
cout << "-------------------------" <<endl;
Base* b = new A;//语句1
delete b;
}
//输出结果
//Base::Fuction
//Base::Fuction
//-------------------------
//Base::Fuction
//A::Function
//Base::Fuction

析构函数的执行顺序

  1. 调用派生类的析构函数
  2. 调用成员类对象的析构函数
  3. 调用基类的析构函数

构造函数析构函数可否抛出异常

  • C++只会析构已经完成的对象,对象只有在其构造函数执行完毕才算是完全构造妥当。在构造函数中发生异常,控制权转出构造函数之外。所以在对象的构造函数中发生异常,对象的析构函数不会被调用。因此会造成内存泄漏。
  • 用auto_ptr对象来取代指针类成员,便对构造函数做了强化,免除了抛出异常时发生资源泄漏的危机,不再需要在析构函数中手动释放资源。
  • 如果控制权基于异常的因素离开析构函数,而此时正有另一个异常处于作用状态,C++会调用terminate函数让程序结束。
  • 如果异常从析构函数抛出,而且没有在当地进行捕捉,那个析构函数便是执行不全的。如果析构函数执行不全,就是没有完成他应该执行的每一件事情。

析构函数调用的时机

  • 对象生命周期结束,被销毁时
  • delete指向对象的指针时,或delete指向对象的基类类型指针,而其基类析构函数是虚函数时
  • 对象a是对象b的成员,b的析构函数被调用时,对象a的析构函数也被调用

智能指针

原理

智能指针是一个类,用来存储指向动态分配对象的指针,负责自动释放动态分配的对象,防止堆内存泄漏。动态分配的资源,交给一个类对象去管理,当类对象声明周期结束时,自动调用析构函数释放资源。

作用

  • C++11中引入了智能指针的概念,方便管理堆内存。使用普通指针,容易造成堆内存泄露(忘记释放),二次释放,程序发生异常时内存泄露等问题等,使用智能指针能更好的管理堆内存。
  • 智能指针在C++11版本之后提供,包含在头文件中,shared_ptr、unique_ptr、weak_ptr。
  • 初始化。智能指针是个模板类,可以指定类型,传入指针通过构造函数初始化。也可以使用make_shared函数初始化。不能将指针直接赋值给一个智能指针,一个是类,一个是指针。例如std::shared_ptr p4 = new int(1);写法是错误的

常用的智能指针

shared_ptr

  • 采用引用计数器的方法,允许多个智能指针指向同一个对象
  • 每当多一个指针指向该对象时,指向该对象的所有智能指针内部的引用计数加1
  • 每当减少一个智能指针指向对象时,引用计数会减1
    • 对一个对象进行赋值时,赋值操作符减少左操作数所指对象的引用计数
    • 并增加右操作数所指对象的引用计数
  • 当计数为0的时候会自动的释放动态分配的资源
  • shared_ptr内部的引用计数是线程安全的,但是对象的读取需要加锁。

unique_ptr

  • unique_ptr采用的是独享所有权语义
  • 一个非空的unique_ptr总是拥有它所指向的资源
  • 转移一个unique_ptr将会把所有权全部从源指针转移给目标指针,源指针被置空
  • 所以unique_ptr不支持普通的拷贝和赋值操作,不能用在STL标准容器中
  • 局部变量的返回值除外(因为编译器知道要返回的对象将要被销毁)
  • 如果你拷贝一个unique_ptr,那么拷贝结束后,这两个unique_ptr都会指向相同的资源,造成在结束时对同一内存指针多次释放而导致程序崩溃
  • 相比于原始指针,unique_ptr使得在出现异常的情况下,动态资源能得到释放
  • unique_ptr指针本身的生命周期:从unique_ptr指针创建时开始,直到离开作用域。
  • 离开作用域时,若其指向对象,则将其所指对象销毁(默认使用delete操作符,用户可指定其他操作)。
  • 在智能指针生命周期内,可以改变智能指针所指对象,如:
    • 创建智能指针时通过构造函数指定
    • 通过reset方法重新指定
    • 通过release方法释放所有权
    • 通过移动语义转移所有权

weak_ptr

  • 弱引用,引用计数有一个问题就是互相引用形成环(环形引用)
  • 这样两个指针指向的内存都无法释放。需要使用weak_ptr打破环形引用
  • weak_ptr是为了配合shared_ptr而引入的一种智能指针
  • 它只是提供了对管理对象的一个访问手段
  • 它指向一个由shared_ptr管理的对象而不影响所指对象的生命周期,也就是说,它只引用,不计数
  • 如果一块内存被shared_ptr和weak_ptr同时引用,当所有shared_ptr析构了之后,不管还有没有weak_ptr引用该内存,内存也会被释放
  • 所以weak_ptr不保证它指向的内存一定是有效的,在使用之前使用函数lock()检查weak_ptr是否为空指针

auto_ptr

  • 主要是为了解决“有异常抛出时发生内存泄漏”的问题 ,因为发生异常而无法正常释放内存
  • auto_ptr有拷贝语义,拷贝后源对象变得无效,这可能引发很严重的问题
  • 而unique_ptr则无拷贝语义,但提供了移动语义,这样的错误不再可能发生,因为很明显必须使用std::move()进行转移
  • auto_ptr不支持拷贝和赋值操作,不能用在STL标准容器中
  • 因为STL容器中的元素经常要支持拷贝、赋值操作,在这过程中auto_ptr会传递所有权,所以不能在STL中使用。

实现

我们以shared_ptr作为栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
template<typename T>
class SharedPtr
{
public:
SharedPtr(T* ptr = NULL):_ptr(ptr), _pcount(new int(1))
{}

SharedPtr(const SharedPtr& s):_ptr(s._ptr), _pcount(s._pcount){
*(_pcount)++;
}

SharedPtr<T>& operator=(const SharedPtr& s){
if (this != &s)
{
if (--(*(this->_pcount)) == 0)
{
delete this->_ptr;
delete this->_pcount;
}
_ptr = s._ptr;
_pcount = s._pcount;
*(_pcount)++;
}
return *this;
}
T& operator*()
{
return *(this->_ptr);
}
T* operator->()
{
return this->_ptr;
}
~SharedPtr()
{
--(*(this->_pcount));
if (this->_pcount == 0)
{
delete _ptr;
_ptr = NULL;
delete _pcount;
_pcount = NULL;
}
}
private:
T* _ptr;
int* _pcount;//指向引用计数的指针
};

智能指针的循环引用

循环引用是指使用多个智能指针share_ptr时,出现了指针之间相互指向,从而形成环的情况,有点类似于死锁的情况,这种情况下,智能指针往往不能正常调用对象的析构函数,从而造成内存泄漏。

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <iostream>
using namespace std;

template <typename T>
class Node
{
public:
Node(const T& value)
:_pPre(NULL)
, _pNext(NULL)
, _value(value)
{
cout << "Node()" << endl;
}
~Node()
{
cout << "~Node()" << endl;
cout << "this:" << this << endl;
}

shared_ptr<Node<T>> _pPre;
shared_ptr<Node<T>> _pNext;
T _value;
};

void Funtest()
{
shared_ptr<Node<int>> sp1(new Node<int>(1));
shared_ptr<Node<int>> sp2(new Node<int>(2));

cout << "sp1.use_count:" << sp1.use_count() << endl;
cout << "sp2.use_count:" << sp2.use_count() << endl;

sp1->_pNext = sp2; //sp1的引用+1
sp2->_pPre = sp1; //sp2的引用+1

cout << "sp1.use_count:" << sp1.use_count() << endl;
cout << "sp2.use_count:" << sp2.use_count() << endl;
}
int main()
{
Funtest();
system("pause");
return 0;
}
//输出结果
//Node()
//Node()
//sp1.use_count:1
//sp2.use_count:1
//sp1.use_count:2
//sp2.use_count:2
  • 只有当引用计数等于0,析构时才会释放对象,而上述情况造成了一个僵局。
  • 那就是析构对象时先析构sp2,可是由于sp2的空间sp1还在使用中,所以sp2.use_count--之后为1,不释放。
  • sp1也是相同的道理,由于sp1的空间sp2还在使用中,所以sp1.use_count--之后为1,也不释放。
  • sp1等着sp2先释放,sp2等着sp1先释放,二者互不相让,导致最终都没能释放,内存泄漏。
  • 所以在实际编程过程中,应该尽量避免出现智能指针之前相互指向的情况.
  • 如果不可避免,可以使用使用弱指针——weak_ptr,它不增加引用计数,只要出了作用域就会自动析构。

构造函数的几种关键字

default

可以显式要求编译器生成合成构造函数,防止在调用时相关构造函数类型没有定义而报错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
using namespace std;

class CString
{
public:
CString() = default; //语句1
//构造函数
CString(const char* pstr) : _str(pstr){}
void* operator new() = delete;//这样不允许使用new关键字
//析构函数
~CString(){}
public:
string _str;
};


int main()
{
auto a = new CString(); //语句2
cout << "Hello World" <<endl;
return 0;
}
//运行结果
//Hello World

如果没有语句1,那么语句2会报错,因为找不到参数为空的构造函数,将其设置为default可以解决这个问题

delete

可以删除构造函数、赋值运算符函数等,这样在使用的时候会得到友善的提示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
using namespace std;

class CString
{
public:
void* operator new() = delete;//这样不允许使用new关键字
//析构函数
~CString(){}
};


int main()
{
auto a = new CString(); //语句1
cout << "Hello World" <<endl;
return 0;
}

在执行语句1会提醒new方法已被删除,如果将new设置为私有方法,则会报惨不忍睹的错误,因此使用delete关键字可以更加人性化的删除一些默认方法

0

将虚函数定义为纯虚函数(纯虚函数无需定义,= 0只能出现在类内部虚函数的声明语句处;当然,也可以为纯虚函数提供定义,不过函数体必须定义在类的外部)

四种强制转换

reinterpret_cast

const_cast

该运算符用来修改类型的const或volatile属性。除了const 或volatile修饰之外, type_id和expression的类型是一样的。用法如下:

  • 常量指针被转化成非常量的指针,并且仍然指向原来的对象
  • 常量引用被转换成非常量的引用,并且仍然指向原来的对象
  • const_cast一般用于修改底指针。如const char *p形式

static_cast

该运算符把expression转换为type-id类型,但没有运行时类型检查来保证转换的安全性。它主要有如下几种用法:

  • 用于类层次结构中基类(父类)和派生类(子类)之间指针或引用引用的转换
    • 进行上行转换(把派生类的指针或引用转换成基类表示)是安全的
    • 进行下行转换(把基类指针或引用转换成派生类表示)时,由于没有动态类型检查,所以是不安全的
  • 用于基本数据类型之间的转换,如把int转换成char,把int转换成enum。这种转换的安全性也要开发人员来保证
  • 把空指针转换成目标类型的空指针
  • 把任何类型的表达式转换成void类型

注意:static_cast不能转换掉expression的const、volatile、或者__unaligned属性。

dynamic_cast

  • 有类型检查,基类向派生类转换比较安全,但是派生类向基类转换则不太安全
  • 该运算符把expression转换成type-id类型的对象。type-id 必须是类的指针、类的引用或者void*
  • 如果 type-id 是类指针类型,那么expression也必须是一个指针,如果 type-id 是一个引用,那么 expression 也必须是一个引用
  • 可以在执行期决定真正的类型,也就是说expression必须是多态类型。如果下行转换是安全的(也就说,如果基类指针或者引用确实指向一个派生类对象)这个运算符会传回适当转型过的指针。如果 如果下行转换不安全,这个运算符会传回空指针(也就是说,基类指针或者引用没有指向一个派生类对象)
  • 可以用于类层次间的上行转换和下行转换,还可以用于类之间的交叉转换
  • 在类层次间进行上行转换时,dynamic_cast和static_cast的效果是一样的
  • 在进行下行转换时,dynamic_cast具有类型检查的功能,比static_cast更安全
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <bits/stdc++.h>
using namespace std;

class Base
{
public:
Base() :b(1) {}
virtual void fun() {};
int b;
};

class Son : public Base
{
public:
Son() :d(2) {}
int d;
};

int main()
{
int n = 97;

//reinterpret_cast
int *p = &n;
//以下两者效果相同
char *c = reinterpret_cast<char*> (p);
char *c2 = (char*)(p);
cout << "reinterpret_cast输出:"<< *c2 << endl;

//const_cast
const int *p2 = &n;
int *p3 = const_cast<int*>(p2);
*p3 = 100;
cout << "const_cast输出:" << *p3 << endl;

Base* b1 = new Son;
Base* b2 = new Base;

//static_cast
Son* s1 = static_cast<Son*>(b1); //同类型转换
Son* s2 = static_cast<Son*>(b2); //下行转换,不安全
cout << "static_cast输出:"<< endl;
cout << s1->d << endl;
cout << s2->d << endl; //下行转换,原先父对象没有d成员,输出垃圾值

//dynamic_cast
Son* s3 = dynamic_cast<Son*>(b1); //同类型转换
Son* s4 = dynamic_cast<Son*>(b2); //下行转换,安全
cout << "dynamic_cast输出:" << endl;
cout << s3->d << endl;
if(s4 == nullptr)
cout << "s4指针为nullptr" << endl;
else
cout << s4->d << endl;


return 0;
}
//输出结果:
//reinterpret_cast输出:a

//const_cast输出:100

//static_cast输出:
//2
//-33686019

//dynamic_cast输出:
//2
//s4指针为nullptr

从输出结果可以看出,在进行下行转换时,dynamic_cast安全的,如果下行转换不安全的话其会返回空指针,这样在进行操作的时候可以预先判断。而使用static_cast下行转换存在不安全的情况也可以转换成功,但是直接使用转换后的对象进行操作容易造成错误。

函数调用的压栈过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
using namespace std;

int f(int n)
{
cout << n << endl;
return n;
}

void func(int param1, int param2)
{
int var1 = param1;
int var2 = param2;
printf("var1=%d,var2=%d", f(var1), f(var2));
}

int main()
{
func(1, 2);
return 0;
}
//输出结果
//2
//1
//var1=1,var2=2
  • 入口函数 main()开始执行,编译器会将操作系统的运行状态、main()函数的返回地址,main()的参数、main()函数中的变量、进行依次压栈;
  • main()函数开始调用func()函数时候,编译器会将main()函数的运行状态进行压栈,再将func()函数的返回地址、func()函数参数从右到左、func()定义的变量依次压栈;
  • func()函数调用f()函数的时候,编译器此时会将func()函数的运行状态进行压栈,f()的返回地址、f()函数的参数从右到左、f()定义的变量从右到左依次压栈。
  • 从代码输出结果看,函数f(var1)f(var2)依次入栈,而后先执行``f(var2),再执行f(var1)`,最后打印整个字符串,将栈中的变量依次弹出,最后主函数返回。

移动构造函数

我们用对象a初始化对象b,后对象a我们就不在使用了,但是对象a的空间还在呀(在析构之前),既然拷贝构造函数,实际上就是把a对象的内容复制一份到b中,那么为什么我们不能直接使用a的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷;

  • 拷贝构造函数中,对于指针,我们一定要采用深层复制
  • 而移动构造函数中,对于指针,我们采用浅层复制。
  • 浅层复制之所以危险,是因为两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了。
  • 所以我们只要避免第一个指针释放空间就可以了。
  • 避免的方法就是将第一个指针(比如a->value)置为NULL,这样在调用析构函数的时候,由于有判断是否为NULL的语句,所以析构a的时候并不会回收a->value指向的空间
  • 移动构造函数的参数和拷贝构造函数不同,拷贝构造函数的参数是一个左值引用,但是移动构造函数的初值是一个右值引用。
  • 意味着,移动构造函数的参数是一个右值或者将亡值的引用。
  • 也就是说,只要用一个右值或者将亡值初始化另一个对象的时候,才会调用移动构造函数。而那个move语句,就是将一个左值变成一个将亡值。

临时变量作为返回值的过程

  • 临时变量,在函数调用过程中是被压到程序进程的栈中的
  • 当函数退出时,临时变量出栈,即临时变量已经被销毁
  • 临时变量占用的内存空间可以没有被清空,但是可以被分配给其他变量
  • 所以有可能在函数退出时,该内存已经被修改了,对于临时变量来说已经是没有意义的值了
  • 函数调用结束后,返回值被临时存储到寄存器中,并没有放到堆或栈中,也就是说与内存没有关系
  • 当退出函数的时候,临时变量可能被销毁,但是返回值却被放到寄存器中与临时变量的生命周期没有关系
  • 如果我们需要返回值,一般使用赋值语句就可以了

C语言里规定:16bit程序中,返回值保存在ax寄存器中,32bit程序中,返回值保持在eax寄存器中,如果是64bit返回值,edx寄存器保存高32bit,eax寄存器保存低32bit

this指针

  • his指针是类的指针,指向对象的首地址
  • this指针只能在成员函数中使用,在全局函数、静态成员函数中都不能用this
  • this指针只有在成员函数中才有定义,且存储位置会因编译器不同有不同存储位置

this指针的使用

  • 在类的非静态成员函数中返回类对象本身的时候,直接使用 return *this
  • 当形参数与成员变量名相同时用于区分,如this->n = n (不能写成n = n)

this指针的特点

  • this只能在成员函数中使用,全局函数、静态函数都不能使用this。实际上,成员函数默认第一个参数为T * const this
1
2
3
4
5
class A{
public:
//func的原型在编译器看来应该是:int func(A * const this,int p);
int func(int p){}
};
  • this在成员函数的开始前构造,在成员函数的结束后清除。这个生命周期同任何一个函数的参数是一样的,没有任何区别。当调用一个类的成员函数时,编译器将类的指针作为函数的this参数传递进去。如:
1
2
3
A a;
a.func(10);
//此处,编译器将会编译成:A::func(&a,10);

看起来和静态函数没差别,对吗?不过,区别还是有的。编译器通常会对this指针做一些优化,因此,this指针的传递效率比较高,例如VC通常是通过ecx(计数寄存器)传递this参数的。

this指针的易混问题

  • this指针是什么时候创建的?
    • this在成员函数的开始执行前构造,在成员的执行结束后清除。
    • 但是如果class或者struct里面没有方法的话,它们是没有构造函数的,只能当做C语言的struct使用。
    • 采用TYPE xx的方式定义的话,在栈里分配内存,这时候this指针的值就是这块内存的地址。
    • 采用new的方式创建对象的话,在堆里分配内存,new操作符通过eax(累加寄存器)返回分配的地址,然后设置给指针变量。之后去调用构造函数(如果有构造函数的话),这时将这个内存块的地址传给ecx
  • this指针存放在何处?堆、栈、全局变量,还是其他?
    • this指针会因编译器不同而有不同的放置位置。
    • 可能是栈,也可能是寄存器,甚至全局变量。
    • 在汇编级别里面,一个值只会以3种形式出现:立即数、寄存器值和内存变量值。
    • 不是存放在寄存器就是存放在内存中,它们并不是和高级语言变量对应的。
  • this指针是如何传递类中的函数的?绑定?还是在函数参数的首参数就是this指针?那么,this指针又是如何找到“类实例后函数的”?
    • 大多数编译器通过ecx(寄数寄存器)寄存器传递this指针。
    • 事实上,这也是一个潜规则。一般来说,不同编译器都会遵从一致的传参规则,否则不同编译器产生的obj就无法匹配了。
    • 在调用之前,编译器会把对应的对象地址放到eax中。this是通过函数参数的首参来传递的。
    • this指针在调用之前生成,至于“类实例后函数”,没有这个说法。类在实例化时,只分配类中的变量空间,并没有为函数分配空间。自从类的函数定义完成后,它就在那儿,不会跑的
  • 我们只有获得一个对象后,才能通过对象使用this指针。如果我们知道一个对象this指针的位置,可以直接使用吗?
    • this指针只有在成员函数中才有定义。
    • 因此,你获得一个对象后,也不能通过对象使用this指针。
    • 所以,我们无法知道一个对象的this指针的位置(只有在成员函数里才有this指针的位置)。
    • 当然,在成员函数里,你是可以知道this指针的位置的(可以通过&this获得),也可以直接使用它。
  • 每个类编译后,是否创建一个类中函数表保存函数指针,以便用来调用函数?
    • 普通的类函数(不论是成员函数,还是静态函数)都不会创建一个函数表来保存函数指针。
    • 只有虚函数才会被放到函数表中。
    • 但是,即使是虚函数,如果编译期就能明确知道调用的是哪个函数,编译器就不会通过函数表中的指针来间接调用,而是会直接调用该函数。
    • 正是由于this指针的存在,用来指向不同的对象,从而确保不同对象之间调用相同的函数可以互不干扰

构造函数、拷贝构造函数、赋值操作符区别

  • 构造函数

对象不存在,没用别的对象初始化,在创建一个新的对象时调用构造函数

  • 拷贝构造函数

对象不存在,但是使用别的已经存在的对象来进行初始化

  • 赋值运算符

对象存在,用别的对象给它赋值,这属于重载“=”号运算符的范畴,“=”号两侧的对象都是已存在的

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
using namespace std;

class A
{
public:
A()
{
cout << "我是构造函数" << endl;
}
A(const A& a)
{
cout << "我是拷贝构造函数" << endl;
}
A& operator = (A& a)
{
cout << "我是赋值操作符" << endl;
return *this;
}
~A() {};
};

int main()
{
A a1; //调用构造函数
A a2 = a1; //调用拷贝构造函数
a2 = a1; //调用赋值操作符
return 0;
}
//输出结果
//我是构造函数
//我是拷贝构造函数
//我是赋值操作符

拷贝构造函数和赋值运算符重载的区别

  • 拷贝构造函数是函数,赋值运算符是运算符重载。

  • 拷贝构造函数会生成新的类对象,赋值运算符不能。

  • 拷贝构造函数是直接构造一个新的类对象,所以在初始化对象前不需要检查源对象和新建对象是否相同;
    赋值运算符需要上述操作并提供两套不同的复制策略,另外赋值运算符中如果原来的对象有内存分配则需要先把内存释放掉。

  • 形参传递是调用拷贝构造函数(调用的被赋值对象的拷贝构造函数),但并不是所有出现”=”的地方都是使用赋值运算符,举个栗子:

    1
    2
    3
    4
    Student s;
    Student s1 = s; // 调用拷贝构造函数
    Student s2;
    s2 = s; // 赋值运算符操作

    类中有指针变量时要重写析构函数、拷贝构造函数和赋值运算符

什么是虚拟继承

C++支持多继承,除了public、protected和private三种继承方式外,还支持虚拟(virtual)继承,举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

#include<iostream>
using namespace std;

class A //大小为4
{
public:
int a;
};
class B :virtual public A //大小为12,变量a,b共8字节,虚基类表指针4
{
public:
int b;
};
class C :virtual public A //与B一样12
{
public:
int c;
};
class D :public B, public C //24,变量a,b,c,d共16,B的虚基类指针4,C的虚基类指针
{
public:
int d;
};

int main()
{
A a;
B b;
C c;
D d;
cout << sizeof(a) << endl;
cout << sizeof(b) << endl;
cout << sizeof(c) << endl;
cout << sizeof(d) << endl;
system("pause");
return 0;

  • 虚拟继承的情况下,无论基类被继承多少次,只会存在一个实体。
  • 通过虚基类指针和虚基类表实现,每个虚继承的子类都有一个虚基类指针(占用一个指针的存储空间,4字节)和虚基类表(不占用类对象的存储空间)。
  • 当虚继承的子类被当做父类继承时,虚基类指针也会被继承。
  • vbptr指的是虚基类表指针(virtual base table pointer),该指针指向了一个虚基类表(virtual table)
  • 虚表中记录了虚基类与本类的偏移地址;通过偏移地址,这样就找到了虚基类成员,
  • 而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。

获得结构成员相对于结构开头的字节偏移量

使用<stddef.h>头文件中的,offsetof宏。

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>
#include <stddef.h>
using namespace std;

struct S
{
int x;
char y;
int z;
double a;
};
int main()
{
cout << offsetof(S, x) << endl; // 0
cout << offsetof(S, y) << endl; // 4
cout << offsetof(S, z) << endl; // 8
cout << offsetof(S, a) << endl; // 12
return 0;
}
/*
在VS2019 + win下 并不是这样的

cout << offsetof(S, x) << endl; // 0
cout << offsetof(S, y) << endl; // 4
cout << offsetof(S, z) << endl; // 8
cout << offsetof(S, a) << endl; // 16 这里是 16的位置,因为 double是8字节,需要找一个8的倍数对齐,
当然了,如果加上 #pragma pack(4)指定 4字节对齐就可以了

#pragma pack(4)
struct S
{
int x;
char y;
int z;
double a;
};
void test02()
{

cout << offsetof(S, x) << endl; // 0
cout << offsetof(S, y) << endl; // 4
cout << offsetof(S, z) << endl; // 8
cout << offsetof(S, a) << endl; // 12
}
*/

静态/动态类型,静态/动态绑定

概念

  • 静态类型:对象在声明时采用的类型,在编译期既已确定;
  • 动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的;
  • 静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;
  • 动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;

非虚函数一般都是静态绑定,而虚函数都是动态绑定(如此才可实现多态性)。

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
using namespace std;

class A
{
public:
/*virtual*/ void func() { std::cout << "A::func()\n"; }
};
class B : public A
{
public:
void func() { std::cout << "B::func()\n"; }
};
class C : public A
{
public:
void func() { std::cout << "C::func()\n"; }
};
int main()
{
C* pc = new C(); //pc的静态类型是它声明的类型C*,动态类型也是C*;
B* pb = new B(); //pb的静态类型和动态类型也都是B*;
A* pa = pc; //pa的静态类型是它声明的类型A*,动态类型是pa所指向的对象pc的类型C*;
pa = pb; //pa的动态类型可以更改,现在它的动态类型是B*,但其静态类型仍是声明时候的A*;
C *pnull = NULL; //pnull的静态类型是它声明的类型C*,没有动态类型,因为它指向了NULL;

pa->func(); //A::func() pa的静态类型永远都是A*,不管其指向的是哪个子类,都是直接调用A::func();
pc->func(); //C::func() pc的动、静态类型都是C*,因此调用C::func();
pnull->func(); //C::func() 不用奇怪为什么空指针也可以调用函数,因为这在编译期就确定了,和指针空不空没关系;
/*
pa->func(); //B::func() 因为有了virtual虚函数特性,pa的动态类型指向B*,因此先在B中查找,找到后直接调用;
pc->func(); //C::func() pc的动、静态类型都是C*,因此也是先在C中查找;
pnull->func(); //空指针异常,因为是func是virtual函数,因此对func的调用只能等到运行期才能确定,然后才发现pnull是空指针;
*/
return 0;
}
  • 如果基类A中的func不是virtual函数,那么不论pa、pb、pc指向哪个子类对象,对func的调用都是在定义pa、pb、pc时的静态类型决定,早已在编译期确定了。
  • 同样的空指针也能够直接调用no-virtual函数而不报错(这也说明一定要做空指针检查啊!),因此静态绑定不能实现多态;
  • 如果func是虚函数,那所有的调用都要等到运行时根据其指向对象的类型才能确定,比起静态绑定自然是要有性能损失的,但是却能实现多态特性;

总结

  • 静态绑定发生在编译期,动态绑定发生在运行期;
  • 对象的动态类型可以更改,但是静态类型无法更改;
  • 要想实现多态,必须使用动态绑定;
  • 在继承体系中只有虚函数使用的是动态绑定,其他的全部是静态绑定;

建议

  • 绝对不要重新定义继承而来的非虚(non-virtual)函数(《Effective C++ 第三版》条款36)。
  • 因为这样导致函数的调用由对象声明时的静态类型确定,而和对象本身脱离了关系,没有多态,也这将给程序留下不可预知的隐患和莫名其妙的BUG;
  • 另外,在动态绑定也即在virtual函数中,要注意默认参数的使用。当缺省参数和virtual函数一起使用的时候一定要谨慎,不然出了问题怕是很难排查。

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
using namespace std;

class E
{
public:
virtual void func(int i = 0)
{
std::cout << "E::func()\t" << i << "\n";
}
};
class F : public E
{
public:
virtual void func(int i = 1)
{
std::cout << "F::func()\t" << i << "\n";
}
};

void test2()
{
F* pf = new F();
E* pe = pf;
pf->func(); //F::func() 1 正常,就该如此;
pe->func(); //F::func() 0 哇哦,这是什么情况,调用了子类的函数,却使用了基类中参数的默认值!
}
int main()
{
test2();
return 0;
}

C++11新特性

  • nullptr替代 NULL
  • 引入了 auto 和 decltype 这两个关键字实现了类型推导
  • 基于范围的 for 循环for(auto& i : res){}
  • 类和结构体的中初始化列表
  • Lambda 表达式(匿名函数)
  • std::forward_list(单向链表)
  • 右值引用和move语义

引用实现动态绑定

引用在创建的时候必须初始化,在访问虚函数时,编译器会根据其所绑定的对象类型决定要调用哪个函数。注意只能调用虚函数。

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
using namespace std;

class Base
{
public:
virtual void fun()
{
cout << "base :: fun()" << endl;
}
};

class Son : public Base
{
public:
virtual void fun()
{
cout << "son :: fun()" << endl;
}
void func()
{
cout << "son :: not virtual function" <<endl;
}
};

int main()
{
Son s;
Base& b = s; // 基类类型引用绑定已经存在的Son对象,引用必须初始化
s.fun(); //son::fun()
b.fun(); //son :: fun()
return 0;
}

需要说明的是:

虚函数才具有动态绑定,上面代码中,Son类中还有一个非虚函数func(),这在b对象中是无法调用的,如果使用基类指针来指向子类也是一样的。

全局变量和局部变量的区别

  • 生命周期不同

全局变量随主程序创建和创建,随主程序销毁而销毁;局部变量在局部函数内部,甚至局部循环体等内部存在,退出就不存在;

  • 使用方式不同

通过声明后全局变量在程序的各个部分都可以用到;局部变量分配在堆栈区,只能在局部使用。

  • 内存分配的位置不同

全局变量分配在全局数据段并且在程序开始运行的时候被加载。局部变量则分配在堆栈里面 。

指针加减计算

指针加减本质是对其所指地址的移动,移动的步长跟指针的类型是有关系的,因此在涉及到指针加减运算需要十分小心,加多或者减多都会导致指针指向一块未知的内存地址,如果再进行操作就会很危险。

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;

int main()
{
int *a, *b, c;
a = (int*)0x500;
b = (int*)0x520;
c = b - a;
printf("%d\n", c); // 8
a += 0x020;
c = b - a;
printf("%d\n", c); // -24
return 0;
}
  • 变量a和b都是以16进制的形式初始化
  • 将它们转成10进制分别是1280(5*16^2=1280)和1312(5*16^2+2*16=1312)
  • 那么它们的差值为32,也就是说a和b所指向的地址之间间隔32个位,
  • 但是考虑到是int类型占4位,所以c的值为32/4=8
  • a自增16进制0x20之后,其实际地址变为1280 + 2*16*4 = 1408,(因为一个int占4位,所以要乘4),
  • 这样它们的差值就变成了1312 - 1280 = -96,所以c的值就变成了-96/4 = -24
  • 遇到指针的计算,需要明确的是指针每移动一位,它实际跨越的内存间隔是指针类型的长度
  • 建议都转成10进制计算,计算结果除以类型长度取得结果

判断浮点数是否相等

  • 对两个浮点数判断大小和是否相等不能直接用==来判断,会出错!
  • 明明相等的两个数比较反而是不相等!
  • 对于两个浮点数比较只能通过相减并与预先设定的精度比较,记得要取绝对值!
  • 浮点数与0的比较也应该注意。与浮点数的表示方式有关。

方法的调用

原理

  • 程序用栈来传递过程参数、存储返回信息,保存寄存器用于以后恢复以及本地存储。
  • 而为单个过程分配的那部分栈称为帧栈;
  • 帧栈可以认为是程序栈的一段,它有两个端点,一个标识起始地址,一个标识着结束地址,结束地址指针esp,开始地址指针ebp;
  • 一个程序由一系列栈帧构成,这些栈帧对应一个过程,而且每一个栈指针+4的位置存储函数返回地址;
  • 每一个栈帧都建立在调用者的下方,当被调用者执行完毕时,这一段栈帧会被释放。
  • 由于栈帧是向地址递减的方向延伸,因此如果我们将栈指针减去一定的值,就相当于给栈帧分配了一定空间的内存。
  • 如果将栈指针加上一定的值,也就是向上移动,那么就相当于压缩了栈帧的长度,也就是说内存被释放了。

过程实现

  • 备份原来的帧指针,调整当前的栈帧指针到栈指针位置
  • 建立起来的栈帧就是为被调用者准备的,当被调用者使用栈帧时,需要给临时变量分配预留内存
  • 使用建立好的栈帧,比如读取和写入,一般使用mov,push以及pop指令等等。
  • 恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了
  • 释放被调用者的栈帧,释放就意味着将栈指针加大,而具体的做法一般是直接将栈指针指向帧指针
  • 恢复调用者的栈帧,恢复其实就是调整栈帧两端,使得当前栈帧的区域又回到了原始的位置。
  • 弹出返回地址,跳出当前过程,继续执行调用者的代码。

指针参数传递和引用参数传递

指针参数传递

  • 指针参数传递本质上是值传递,它所传递的是一个地址值。
  • 值传递过程中,被调函数的形式参数作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本(替身)。
  • 值传递的特点是,被调函数对形式参数的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值(形参指针变了,实参指针不会变)。

引用参数传递

  • 引用参数传递过程中,被调函数的形式参数也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。
  • 被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量(根据别名找到主调函数中的本体)。
  • 因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。

区别

  • 虽然他们都是在被调函数栈空间上的一个局部变量。
  • 对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。
  • 对于指针传递的参数,如果改变被调函数中的指针地址,它将应用不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引用。
  • 从编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。
  • 指针变量在符号表上对应的地址值为指针变量的地址值。
  • 引用变量在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)。
  • 符号表生成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改。

类如何实现只能静态分配和只能动态分配

  • 静态分配是把new、delete运算符重载为private属性;动态分配是把构造、析构函数设为protected属性,再用子类来动态创建
  • 建立类的对象有两种方式:
    • 静态建立,静态建立一个类对象,就是由编译器为对象在栈空间中分配内存;
    • 动态建立,A *p = new A();动态建立一个类对象,就是使用new运算符为对象在堆空间中分配内存。这个过程分为两步,第一步执行operator new()函数,在堆中搜索一块内存并进行分配;第二步调用类构造函数构造对象;
  • 只有使用new运算符,对象才会被建立在堆上,因此只要限制new运算符就可以实现类对象只能建立在栈上,可以将new运算符设为私有。

自动生成默认构造函数的情况

    1. 带有默认构造函数的类成员对象,如果一个类没有任何构造函数,但它含有一个成员对象,而后者有默认构造函数,那么编译器就为该类合成出一个默认构造函数。
    2. 不过这个合成操作只有在构造函数真正被需要的时候才会发生。
    3. 如果一个类A含有多个成员类对象的话,那么类A的每一个构造函数必须调用每一个成员对象的默认构造函数而且必须按照类对象在类A中的声明顺序进行。
  • 带有默认构造函数的基类,如果一个没有任何构造函数的派生类派生自一个带有默认构造函数基类,那么该派生类会合成一个构造函数调用上一层基类的默认构造函数。
  • 带有一个虚函数的类
  • 带有一个虚基类的类
  • 合成的默认构造函数中,只有基类子对象和成员类对象会被初始化。所有其他的非静态数据成员都不会被初始化。

感谢

转载自https://github.com/forthespada/InterviewGuide,感激大佬的整理和分享!

  1. 1. static的作用和用法
    1. 1.1. 作用
    2. 1.2. 用法
  2. 2. 静态变量初始化的时机
  3. 3. const作用
  4. 4. 指针和const的用法
  5. 5. 形参和实参的区别
  6. 6. 值传递、指针传递、引用传递
    1. 6.1. 值传递
    2. 6.2. 指针传递
    3. 6.3. 引用传递
    4. 6.4. 效率比较
  7. 7. 类的关系
  8. 8. 类的继承
  9. 9. 内存池概念和实现
    1. 9.1. 概念
    2. 9.2. 实现
  10. 10. 汇编层解释引用
  11. 11. 深拷贝和浅拷贝
  12. 12. C++模板的底层实现
  13. 13. new和malloc区别
  14. 14. new[]/delete[]、allocator作用
  15. 15. new和delete的实现
    1. 15.1. new的实现
    2. 15.2. delete的实现
  16. 16. malloc申请的存储空间可以用delete释放吗
  17. 17. malloc与free的实现
  18. 18. malloc、realloc、calloc区别
  19. 19. 类成员的初始化方式
  20. 20. 构造函数的执行顺序
  21. 21. 成员列表初始化
  22. 22. 内存泄露
  23. 23. 对象复用和零拷贝
  24. 24. trivial destructor
  25. 25. 面向对象的三大特性
    1. 25.1. 继承
    2. 25.2. 封装
    3. 25.3. 多态
  26. 26. 类的内存分配
  27. 27. 构造函数不能为虚函数
  28. 28. 析构函数要为虚函数
  29. 29. 析构函数作用
  30. 30. 构造函数和析构函数能否调用虚函数
  31. 31. 析构函数的执行顺序
  32. 32. 构造函数析构函数可否抛出异常
  33. 33. 析构函数调用的时机
  34. 34. 智能指针
    1. 34.1. 原理
    2. 34.2. 作用
    3. 34.3. 常用的智能指针
      1. 34.3.1. shared_ptr
      2. 34.3.2. unique_ptr
      3. 34.3.3. weak_ptr
      4. 34.3.4. auto_ptr
    4. 34.4. 实现
  35. 35. 智能指针的循环引用
  36. 36. 构造函数的几种关键字
    1. 36.1. default
    2. 36.2. delete
    3. 36.3. 0
  37. 37. 四种强制转换
    1. 37.1. reinterpret_cast
    2. 37.2. const_cast
    3. 37.3. static_cast
    4. 37.4. dynamic_cast
  38. 38. 函数调用的压栈过程
  39. 39. 移动构造函数
  40. 40. 临时变量作为返回值的过程
  41. 41. this指针
    1. 41.1. this指针的使用
    2. 41.2. this指针的特点
    3. 41.3. this指针的易混问题
  42. 42. 构造函数、拷贝构造函数、赋值操作符区别
  43. 43. 拷贝构造函数和赋值运算符重载的区别
  44. 44. 什么是虚拟继承
  45. 45. 获得结构成员相对于结构开头的字节偏移量
  46. 46. 静态/动态类型,静态/动态绑定
    1. 46.1. 概念
    2. 46.2. 总结
    3. 46.3. 建议
  47. 47. C++11新特性
  48. 48. 引用实现动态绑定
  49. 49. 全局变量和局部变量的区别
  50. 50. 指针加减计算
  51. 51. 判断浮点数是否相等
  52. 52. 方法的调用
    1. 52.1. 原理
    2. 52.2. 过程实现
  53. 53. 指针参数传递和引用参数传递
    1. 53.1. 指针参数传递
    2. 53.2. 引用参数传递
    3. 53.3. 区别
  54. 54. 类如何实现只能静态分配和只能动态分配
  55. 55. 自动生成默认构造函数的情况
  56. 56. 感谢