Interview-C++

本文对C++语言方面的一些基础知识做了一些梳理和总结

C++与C的区别

  • 从语言本身来看:C++是面向对象的语言,C是面向过程的语言

    简单来说,面向过程是以步骤来划分程序的,面向对象是以功能来划分的

    • 面向过程是一种以过程为中心的编程思想,它首先分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,在使用时依次调用,是一种基础的顺序的思维方式。面向过程开发方式是对计算机底层结构的一层抽象,它将程序分为数据和操纵数据的操作两部分,其核心问题是数据结构和算法的开发和优化。
    • 面向对象是按人们认识客观世界的系统思维方式,采用基于对象(实体)的概念建立模型,模拟客观世界分析、设计、实现软件的编程思想,通过面向对象的理念使计算机软件系统能与现实世界中的系统一一对应。
    • 面向对象方法直接把所有事物都当作独立的对象,处理问题过程中所思考的不再主要是怎样用数据结构来描述问题,而是直接考虑重现问题中各个对象之间的关系。
  • 从语言的一些细节来看:

    • C是C++的子集,C++编译器通常能够编译任何C程序。(但由于C++增加了C不具有的关键字,因此程序中不能出现以这些关键字作为函数和变量的标识符)
    • C++多了封装,继承,多态的特性:
      • 封装使用访问说明符来控制权限,另外将类的接口与实现分离
      • 继承使用基类和派生类来定义相似的类型并对其相似关系建模
      • 多态在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象
  • 内存分配方面:C++使用new和delete,取代了C中的malloc和free

C++面向对象的三个特点:

  • 封装:封装可以隐藏实现细节,使得代码模块化
  • 继承:继承可以扩展已存在的代码模块(类),实现代码重用
  • 多态:可以实现接口重用

常用关键字

static

C语言中:(作用域模块内的变量与函数)

  1. 在函数体内,一个被声明为静态的变量在这一函数被调用过程中维持其值不变(该变量存放在静态变量区)。
  2. 在模块内(但在函数体外),一个被声明为静态的变量可以被模块内所有函数访问,但不能被模块外其它函数访问。它是一个本地的全局变量。
    • 注意全局变量:全局变量虽然属于静态存储方式,但并不是静态变量。全局变量的作用域是整个源程序,当一个源程序由多个源文件组成时,全局变量在各个源文件中都是有效的。
  3. 在模块内,一个被声明为静态的函数只可被这一模块内的其它函数调用。那就是,这个函数被限制在声明它的模块的本地范围内使用。

C++中:(除了模块内的变量与函数,多出了在类中使用的static成员变量和static成员函数)

  1. 设置变量的存储域,函数体内static变量的作用范围为该函数体,不同于auto变量,该变量的内存只被分配一次,因此其值在下次调用时仍维持上次的值;(同C)
  2. 限制变量的作用域,在模块内的static全局变量可以被模块内所用函数访问,但不能被模块外其它函数访问;(同C)
  3. 限制函数的作用域,在模块内的static函数只可被这一模块内的其它函数调用,这个函数的使用范围被限制在声明它的模块内;(同C)
  4. 类中的static成员变量意味着它为该类的所有实例所共享,也就是说当某个类的实例修改了该静态成员变量,其修改值为该类的其它所有实例所见;
  5. 类中的static成员函数属于整个类所拥有,这个函数不接收this指针,因而只能访问类的static成员变量

override

  • C++11新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表制后增加一个override关键字

  • 如果添加了override关键字的函数并没有覆盖基类中的对应版本,则编译器会报错。(让错误更早地暴露出来的原则)

final

  • 为了防止继承的发生:有时我们会定义一种类,并且不希望其他类继承它,或者不想考虑它是否适合作为一个基类。为了实现这一目的,C++11新标准提供了一种防止继承发生的方法,即在类名后跟一个关键字final
  • 另外,我们也可以通过将一个虚函数声明为final来禁止一个虚函数被进一步重载。如果一个派生类试图重载一个final函数,编译器就会报错

explicit

抑制构造函数定义的隐式转换:我们可以通过将构造函数声明为explicit来阻止隐式转换,注意关键字explicit只对一个实参的构造函数有效,因为需要多个实参的构造函数不能用于执行隐式转换,所以无须指定为explicit。另外只能在类内声明构造函数时使用explicit关键字,在类外部定义时不应重复。

1
2
3
4
5
6
7
8
9
class Sales_data{
public:
Sales_data() = default; //默认构造函数(C++ 11)
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p*n) {}
explicit Sales_data(const std::string &s):bookNo(s) {}
explicit Sales_data(std::istream&);
//其他成员与之前的版本一致
};

注意**发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=**),此时我们只能使用直接初始化而不能使用explicit构造函数

1
2
Sales_data item1(null_book);    //正确:直接初始化
Sales_data item2 = null_book; //错误:不能将explict构造函数用于拷贝形式的初始化过程

mutable

这个关键字要和const结合起来看。

const修饰变量表示变量内容不可修改,在类中,const可以修饰成员函数,修饰成员函数之后就不可以更改成员变量了。(this指针变成了 const*const类型,不可修改其内容和指向)

有什么办法修改其const属性?

  • 在成员函数中使用const_cast去掉this指针的const属性
  • 使用mutable修饰成员变量。(被mutable修饰的变量,将永远处于可变的状态,即使在一个const函数中)

volatile

参考文章

volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改。比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问

(对于编译器优化的个人理解,例如在循环中,编译器往往会对指令进行优化重排,而不是单纯按照代码中所写的顺序,这与一条指令涉及到的对寄存器的读写等等相关)

当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。例如:

1
2
3
volatile int i=10;
int a = i;
int b = i;

volatile 指出 i 是随时可能发生变化的,每次使用它的时候必须从 i的地址中读取,因而编译器生成的汇编代码会重新从i的地址读取数据放在 b 中。而优化做法是,由于编译器发现两次从 i读数据的代码之间的代码没有对 i 进行过操作,它会自动把上次读的数据放在 b 中。而不是重新从 i 里面读。这样以来,如果 i是一个寄存器变量或者表示一个端口数据就容易出错,所以说 volatile 可以保证对特殊地址的稳定访问。

一般说来,volatile用在如下的几个地方:

  1. 中断服务程序中修改的供其它程序检测的变量需要加volatile;

  2. 多任务环境下各任务间共享的标志应该加volatile;

  3. 存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义;

函数、指针、引用

C中的指针函数与函数指针

指针函数与函数指针表示方法的不同,千万不要混淆。最简单的辨别方式就是看函数名前面的指针*号有没有被括号()包含,如果被包含就是函数指针,反之则是指针函数。

主要的区别是一个是指针变量,一个是函数。在使用时必须要搞清楚才能正确使用。

  • 指针函数:带指针的函数,即本质是一个函数。函数返回类型是某一类型的指针

    声明格式:类型标识符 *函数名(参数表) int *f(x,y);

  • 函数指针:指向函数(首地址)的指针变量,即本质是一个指针变量。

    函数指针说的就是一个指针,但这个指针指向的函数,不是普通的基本数据类型或者类对象。指向函数的指针包含了函数的地址,可以通过它来调用函数(函数的类型由其参数及返回类型共同决定,与函数名无关)。

    声明格式:类型说明符 (*函数名)(参数)

函数指针的使用

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
/* 求最大值,返回值是int类型,返回两个整数中较大的一个*/
int max(int a, int b) {
return a > b ? a : b;
}
/* 求最小值,返回值是int类型,返回两个整数中较小的一个*/
int min(int a, int b) {
return a < b ? a : b;
}

int(*f)(int, int); // 声明函数指针,指向返回值类型为int,有两个参数类型都是int的函数

int main(int argc, _TCHAR* argv[])
{
printf("------------------------------ Start\n");

f = max; // 函数指针f指向求最大值的函数max(将max函数的首地址赋给指针f)
int c = (*f)(1, 2);

printf("The max value is %d \n", c);

f = min; // 函数指针f指向求最小值的函数min(将min函数的首地址赋给指针f)
c = (*f)(1, 2);

printf("The min value is %d \n", c);

printf("------------------------------ End\n");
getchar();
return 0;
}

函数指针的作用

可以在C语言中实现类似面向对象的多态特性:

1
2
3
4
5
6
7
8
struct Bird{
void (*print)(void *p);
};

//fBird是Bird的"子类"
struct fBird{
struct Bird p;
};

而Bird和fBird这两个结构体的print函数实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
void printBird(void *Bird){
if(NULL == Bird)
return ;
struct Bird *p = (struct Bird *)Bird;
printf("run in the Bird!!\n");
}
void printfBird(void *Bird){
if(NULL == Bird)
return ;
struct Bird *p = (struct Bird *)Bird;
printf("run in the fBird!!\n");
}

写一个函数来调用他们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void print(void *Bird){
if(NULL == Bird)
return ;
struct Bird *p = (struct Bird *)Bird;
p->print(Bird);
}
int main(){
struct Bird bird;
struct fBird fbird;
Bird.print = printBird;
fBird.p.print = printfBird;

print(&bird); //实参为Bird的对象
print(&fbird); //实参为fBird的对象

return 0;
}

他们的输出为:

1
2
run in the Bird!!
run in the fBird!!

因为无论是fBird还是Bird,他们在内存中只有一个变量,就是那个函数指针,而void*表示任何类型的指针,当我们将它强制转换成struct Bird*类型时,p->print指向的自然就是传入实参的print地址。

指针数组与数组指针

指针数组:指针数组可以说成是”指针的数组”,首先这个变量是一个数组,其次,”指针”修饰这个数组,意思是说这个数组的所有元素都是指针类型,在32位系统中,指针占四个字节。

数组指针:数组指针可以说成是”数组的指针”,首先这个变量是一个指针,其次,”数组”修饰这个指针,意思是说这个指针存放着一个数组的首地址,或者说这个指针指向一个数组的首地址。(注意定义了数组指针,该指针指向这个数组的首地址,必须给指针指定一个地址

根据上面的解释,可以了解到指针数组和数组指针的区别,因为二者根本就是不同类型的变量。

指针和引用

相同点:都可以实现对其他对象的间接访问

不同点:(核心,一个是对象,一个不是对象(因此不占内存空间,不能没有初始化,不能赋值和拷贝))

  • 引用不是一个对象,因此不能定义引用的引用。引用只能绑定在对象上,而不能与字面值或某个表达式的计算结果绑定在一起。引用必须初始化,并且一旦初始化完成,引用将和初始化值对象一直绑定在一起,无法令引用重新绑定到另一个对象上。

  • 指针本身是一个对象,在内存中占据一定的空间。对于对象来说,可以允许赋值和拷贝,在生命周期内也可以先后指向不同的对象。并且指针无须在定义时就被初始化。

顶层const和底层const

  • 顶层const表示指针本身是个常量(int *const p)(向右向左看原则:p是一个常量指针,指向int)
  • 底层const表示指针所指的对象是一个常量(const int *p)(注意这只是要求不能通过该指针来改变对象的值,而没有规定对象的值不能通过其他途径改变)(p是一个指针,指向const int)。

更一般的:

  • 顶层const可以表示任意的对象是常量(如算数类型,类,指针等)
  • 底层const则与指针和应用等复合类型的基本类型部分有关
1
2
3
4
5
6
int i = 0;
int *const p1 = &i; //不能改变p1的值,是一个顶层const
const int ci = 42; //不能改变ci的值,是一个顶层const
const int *p2 = &ci; //允许改变p2的值,是一个底层const
const int *const p3 = p2;//靠右的是顶层const,靠左的是底层const
const int &r = ci; //用于声明引用的const都是底层const

当执行对象的拷贝操作时,顶层const不受什么影响,而底层const则要求拷入和拷出的对象必须具有相同的底层const资格,或者两个对象的数据类型必须能够转换,一般来说非常量可以转换成常量,反之则不行

左值和右值

C++的表达式要不然是右值(rvalue),要不然就是左值(lvalue)。

这两个名词是从C语言继承过来的,原本是为了帮助记忆:左值可以位于赋值语句的左侧,右值则不能。

在C++语言中,二者的区别就没那么简单了。

**左值(lvalue)**:是指那些求值结果为对象或函数的表达式。一个表示对象的非常量左值可以作为赋值运算符的左侧运算对象。

**右值(rvalue)**:是指一种表达式,其结果是值而非值所在的位置。

  • 一个左值表达式的求值结果是一个对象或者一个函数,然而以常量对象为代表的某些左值实际上不能作为赋值语句的左侧运算对象。(与C中的简单定义不同)
  • 此外,虽然某些表达式的求值结果是对象,但它们是右值而非左值。
  • 可以做一个简单的归纳:当一个对象被用作右值的时候,用的是对象的值(内容);当对象被用作左值的时候,用的是对象的身份(在内存中的位置)。
  • 左值与右值的根本区别在于是否允许取地址&运算符获得对应的内存地址。

左值持久,右值短暂:左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。

不同的运算符对运算对象的要求各不相同,有的需要左值运算对象,有的需要右值运算对象;返回值也有差异,有的得到左值结果,有的得到右值结果。

一个重要的原则(有一种例外的情况),是在需要右值的地方可以用左值来代替,但是不能把右值当成左值(也就是位置)使用。当一个左值被当成右值使用时,实际使用的是它的内容(值)。

运算符用到左值的包括

  1. 赋值运算符需要一个(非常量)左值作为其左侧运算对象,得到的结果也仍然是一个左值。
  2. 取地址符作用于一个左值运算对象,返回一个指向该运算对象的指针,这个指针是一个右值。
  3. 内置解引用运算符、下标运算符、迭代器解引用运算符、string和vector的下标运算符的求值结果都是左值。
  4. 内置类型和迭代器的递增递减运算符作用于左值运算对象,其前置版本所得的结果也是左值。

使用关键字decltype的时候,左值和右值也有所不同。如果表达式的求值结果是左值,decltype作用于该表达式(不是变量)得到一个引用类型。举个例子,假定p的类型是int*,因为解引用运算符生成左值,所以decltype(*p)的结果是int&。另一方面,因为取地址运算符生成右值,所以decltype(&p)的结果是int**,也就是说,结果是一个指向整型指针的指针。

具体来说:

赋值运算符的左侧运算对象必须是一个可修改的左值。赋值运算符的结果是它的左侧运算对象,并且是一个左值。相应的,结果的类型就是左侧运算对象的类型。如果赋值运算符的左右两个运算对象类型不同,则右侧运算对象将转换成左侧运算对象的类型。

  • 递增和递减运算符有两种形式:前置版本和后置版本。这两种运算符必须作用于左值运算对象。前置版本将对象本身作为左值返回,后置版本则将对象原始值的副本作为右值返回(因此后置运算符会多一次拷贝的过程,效率低一点)。
  • 箭头运算符作用于一个指针类型的运算对象,结果是一个左值。点运算符分成两种情况:如果成员所属的对象是左值,那么结果是左值;反之,如果成员所属的对象是右值,那么结果是右值。
  • 条件运算符的两个表达式都是左值或者能转换成同一种左值类型时,运算的结果是左值;否则运算的结果是右值。
  • 对于逗号运算符来说,首先对左侧的表达式求值,然后将求值结果丢弃掉。逗号运算符真正的结果是右侧表达式的值。如果右侧运算对象是左值,那么最终的求值结果也是左值。

函数返回类型

函数的返回类型决定函数调用是否是左值。调用一个返回引用的函数得到左值,其它返回类型得到右值

可以像使用其它左值那样来使用返回引用的函数的调用,特别是,我们能为返回类型是非常量引用的函数的结果赋值。把函数调用放在赋值语句的左侧可能看起来有点奇怪,但其实这没什么特别的。返回值是引用,因此调用是个左值,和其它左值一样它也能出现在赋值运算符的左侧。如果返回类型是常量引用,我们不能给调用的结果赋值,这一点和我们熟悉的情况是一样的。

左值引用和右值引用

严格来说,当我们使用术语”引用(reference)”时,指的其实是”左值引用(lvalue reference)”。C++11中新增了一种引用,右值引用(rvalue reference),这种引用主要用于内置类。

为了支持移动操作,C++11引入了一种新的引用类型—-右值引用(rvalue reference)。

  • 所谓右值引用就是必须绑定到右值的引用。通过&&而不是&来获得右值引用
  • 右值引用有一个重要的性质—-只能绑定到一个将要销毁的对象
  • 因此,可以自由地将一个右值引用的资源”移动”到另一个对象中。

左值和右值是表达式的属性。一些表达式生成或要求左值,而另外一些则生成或要求右值。一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值

左值引用

能指向左值,不能指向右值的就是左值引用:

1
2
3
int a = 5;
int &ref_a = a; // 左值引用指向左值,编译通过
int &ref_a = 5; // 左值引用指向了右值,会编译失败

返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子。可以将一个左值引用绑定到这类表达式的结果上。

引用是变量的别名,由于右值没有地址,没法被修改,所以左值引用无法指向右值。

但是,const左值引用是可以指向右值的:

1
const int &ref_a = 5;  // 编译通过

const左值引用不会修改指向值,因此可以指向右值,这也是为什么要使用const &作为函数参数的原因之一,如std::vectorpush_back

1
void push_back (const value_type& val);

如果没有constvec.push_back(5)这样的代码就无法编译通过了。

右值引用

类似任何引用,一个右值引用也不过是某个对象的另一个名字而已。右值引用的标志是&&,右值引用专门为右值而生,可以指向右值,不能指向左值:

1
2
3
4
5
6
int &&ref_a_right = 5; // ok

int a = 5;
int &&ref_a_left = a; // 编译不过,右值引用不可以指向左值

ref_a_right = 6; // 右值引用的用途:可以修改右值

返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。不能将一个左值引用绑定到这类表达式上,但可以将一个const的左值引用或者一个右值引用绑定到这类表达式上。

由于右值引用只能绑定到临时对象,可知:

  • 所引用的对象将要被销毁
  • 该对象没有其它用户。这两个特性意味着,使用右值引用的代码可以自由地接管所引用的对象的资源。右值引用指向将要销毁的对象。因此,我们可以从绑定到右值引用的对象”窃取”状态。

变量是左值:变量可以看作只有一个运算对象而没有运算符的表达式,虽然我们很少这样看待变量。类似其它任何表达式,变量表达式也有左值/右值属性。变量表达式都是左值。

带来的结果就是,我们不能将一个右值引用绑定到一个右值引用类型的变量上。其实有了右值表示临时对象这一观察结果,变量是左值这一特性并不令人惊讶。毕竟,变量是持久的,直至离开作用域时才被销毁。变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行。

标准库std::move函数:强制将左值转换为右值,让右值引用可以指向左值

虽然不能将一个右值引用直接绑定到一个左值上,但可以显式地将一个左值转换为对应的右值引用类型。我们还可以通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用,此函数定义在头文件utility中。

move调用告诉编译器:有一个左值,但希望像一个右值一样处理它。我们必须认识到,在调用move之后,我们不能对移后源对象的值做任何假设。我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值

1
2
3
4
5
int a = 5; // a是个左值
int &ref_a_left = a; // 左值引用指向左值
int &&ref_a_right = std::move(a); // 通过std::move将左值转化为右值,可以被右值引用指向

cout << a; // 打印结果:5

std::move()的实现等同于一个类型转换:static_cast<T&&>(lvalue)。 所以,单纯的std::move(xxx)不会有性能提升

同样的,右值引用能指向右值,本质上也是把右值提升为一个左值,并定义一个右值引用通过std::move指向该左值:

1
2
3
4
5
6
7
8
int &&ref_a = 5;
ref_a = 6;

//等同于以下代码:

int temp = 5;
int &&ref_a = std::move(temp);
ref_a = 6;

左值引用、右值引用本身是左值还是右值?

被声明出来的左、右值引用都是左值因为被声明出的左右值引用是有地址的,也位于等号左边。仔细看下边代码:

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
// 形参是个右值引用
void change(int&& right_value) {
right_value = 8;
}

int main() {
int a = 5; // a是个左值
int &ref_a_left = a; // ref_a_left是个左值引用
int &&ref_a_right = std::move(a); // ref_a_right是个右值引用

change(a); // 编译不过,a是左值,change参数要求右值
change(ref_a_left); // 编译不过,左值引用ref_a_left本身也是个左值
change(ref_a_right); // 编译不过,右值引用ref_a_right本身也是个左值

change(std::move(a)); // 编译通过
change(std::move(ref_a_right)); // 编译通过
change(std::move(ref_a_left)); // 编译通过

change(5); // 当然可以直接接右值,编译通过

cout << &a << ' ';
cout << &ref_a_left << ' ';
cout << &ref_a_right;
// 打印这三个左值的地址,都是一样的
}

看完后你可能有个问题,std::move会返回一个右值引用int &&,它是左值还是右值呢? 从表达式int &&ref = std::move(a)来看,右值引用ref指向的必须是右值,所以move返回的int &&是个右值。所以右值引用既可能是左值,又可能是右值吗? 确实如此:右值引用既可以是左值也可以是右值,如果有名称则为左值,否则是右值

或者说:作为函数返回值的 && 是右值,直接声明出来的 && 是左值。 这同样对左值,右值的判定方式:其实引用和普通变量是一样的,int &&ref = std::move(a)int a = 5没有什么区别,等号左边就是左值,右边就是右值。

最后,从上述分析中我们得到如下结论:

  1. 从性能上讲,左右值引用没有区别,传参使用左右值引用都可以避免拷贝。
  2. 右值引用可以直接指向右值,也可以通过std::move指向左值;而左值引用只能指向左值(const左值引用也能指向右值)。
  3. 作为函数形参时,右值引用更灵活。虽然const左值引用也可以做到左右值都接受,但它无法修改,有一定局限性。
1
2
3
4
5
6
7
8
9
10
11
12
void f(const int& n) {
n += 1; // 编译失败,const左值引用不能修改指向变量
}

void f2(int && n) {
n += 1; // ok
}

int main() {
f(5);
f2(5);
}

右值引用的意义

右值引用是C++11中最重要的新特性之一,它解决了C++中大量的历史遗留问题,使C++标准库的实现在多种场景下消除了不必要的额外开销(如std::vector, std::string),也使得另外一些标准库(如std::unique_ptr,std::function)成为可能。即使你并不直接使用右值引用,也可以通过标准库,间接从这一新特性中受益。

右值引用的意义通常解释为两大作用:**移动语义(Move Sementics)和完美转发(Perfect Forwarding)**。它的主要目的有两个方面:

  1. 消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率;
  2. 能够更简洁明确地定义泛型函数。

实现移动语义

在实际场景中,右值引用和std::move被广泛用于在STL和自定义类中实现移动语义,避免拷贝,从而提升程序性能。 在没有右值引用之前,一个简单的数组类通常实现如下,有构造函数拷贝构造函数赋值运算符重载析构函数等。

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
class Array {
public:
Array(int size) : size_(size) {
data = new int[size_];
}

// 深拷贝构造
Array(const Array& temp_array) {
size_ = temp_array.size_;
data_ = new int[size_];
for (int i = 0; i < size_; i ++) {
data_[i] = temp_array.data_[i];
}
}

// 深拷贝赋值
Array& operator=(const Array& temp_array) {
delete[] data_;

size_ = temp_array.size_;
data_ = new int[size_];
for (int i = 0; i < size_; i ++) {
data_[i] = temp_array.data_[i];
}
}

~Array() {
delete[] data_;
}

public:
int *data_;
int size_;
};

该类的拷贝构造函数、赋值运算符重载函数已经通过使用左值引用传参来避免一次多余拷贝了,但是内部实现要深拷贝,无法避免。 这时,有人提出一个想法:是不是可以提供一个移动构造函数,把被拷贝者的数据移动过来,被拷贝者后边就不要了,这样就可以避免深拷贝了,如:

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
class Array {
public:
Array(int size) : size_(size) {
data = new int[size_];
}

// 深拷贝构造
Array(const Array& temp_array) {
...
}

// 深拷贝赋值
Array& operator=(const Array& temp_array) {
...
}

// 移动构造函数,可以浅拷贝
Array(const Array& temp_array, bool move) {
data_ = temp_array.data_;
size_ = temp_array.size_;
// 为防止temp_array析构时delete data,提前置空其data_
temp_array.data_ = nullptr;
}


~Array() {
delete [] data_;
}

public:
int *data_;
int size_;
};

这么做有2个问题:

  • 不优雅,表示移动语义还需要一个额外的参数(或者其他方式)。
  • 无法实现!temp_array是个const左值引用,无法被修改,所以temp_array.data_ = nullptr;这行会编译不过。当然函数参数可以改成非const:Array(Array& temp_array, bool move){...},这样也有问题,由于左值引用不能接右值,Array a = Array(Array(), true);这种调用方式就没法用了。

可以发现左值引用真是用的很不爽,右值引用的出现解决了这个问题,在STL的很多容器中,都实现了以右值引用为参数移动构造函数移动赋值重载函数,或者其他函数,最常见的如std::vector的push_backemplace_back。参数为左值引用意味着拷贝,为右值引用意味着移动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Array {
public:
......

// 优雅
Array(Array&& temp_array) {
data_ = temp_array.data_;
size_ = temp_array.size_;
// 为防止temp_array析构时delete data,提前置空其data_
temp_array.data_ = nullptr;
}


public:
int *data_;
int size_;
};

如何使用:

1
2
3
4
5
6
7
8
9
10
// 例1:Array用法
int main(){
Array a;

// 做一些操作
.....

// 左值a,用std::move转化为右值
Array b(std::move(a));
}
实例

vector::push_back使用std::move提高性能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 例2:std::vector和std::string的实际例子
int main() {
std::string str1 = "aacasxs";
std::vector<std::string> vec;

vec.push_back(str1); // 传统方法,copy
vec.push_back(std::move(str1)); // 调用移动语义的push_back方法,避免拷贝,str1会失去原有值,变成空字符串
vec.emplace_back(std::move(str1)); // emplace_back效果相同,str1会失去原有值
vec.emplace_back("axcsddcas"); // 当然可以直接接右值
}

// std::vector方法定义
void push_back (const value_type& val);
void push_back (value_type&& val);

void emplace_back (Args&&... args);

在vector和string这个场景,加个std::move会调用到移动语义函数,避免了深拷贝。

除非设计不允许移动,STL类大都支持移动语义函数,即可移动的。 另外,编译器会默认在用户自定义的classstruct中生成移动语义函数,但前提是用户没有主动定义该类的拷贝构造等函数。 因此,可移动对象在<需要拷贝且被拷贝者之后不再被需要>的场景,建议使用std::move触发移动语义,提升性能。

1
2
3
moveable_objecta = moveable_objectb; 
//改为:
moveable_objecta = std::move(moveable_objectb);

还有些STL类是move-only的,比如unique_ptr,这种类只有移动构造函数,因此只能移动(转移内部对象所有权,或者叫浅拷贝),不能拷贝(深拷贝):

1
2
3
4
5
std::unique_ptr<A> ptr_a = std::make_unique<A>();

std::unique_ptr<A> ptr_b = std::move(ptr_a); // unique_ptr只有‘移动赋值重载函数‘,参数是&& ,只能接右值,因此必须用std::move转换类型

std::unique_ptr<A> ptr_b = ptr_a; // 编译不通过

std::move本身只做类型转换,对性能无影响。 我们可以在自己的类中实现移动语义,避免深拷贝,充分利用右值引用和std::move的语言特性。

1
2
3
moveable_objecta = moveable_objectb; 
//改为:
moveable_objecta = std::move(moveable_objectb);

还有些STL类是move-only的,比如unique_ptr,这种类只有移动构造函数,因此只能移动(转移内部对象所有权,或者叫浅拷贝),不能拷贝(深拷贝):

1
2
3
4
5
std::unique_ptr<A> ptr_a = std::make_unique<A>();

std::unique_ptr<A> ptr_b = std::move(ptr_a); // unique_ptr只有‘移动赋值重载函数‘,参数是&& ,只能接右值,因此必须用std::move转换类型

std::unique_ptr<A> ptr_b = ptr_a; // 编译不通过

std::move本身只做类型转换,对性能无影响。 我们可以在自己的类中实现移动语义,避免深拷贝,充分利用右值引用和std::move的语言特性。

完美转发std::forward

  • 完美转发(perfect forwarding)问题是指函数模板在向其他函数传递参数时该如何保留该参数的左右值属性的问题
    • 也就是说函数模板在向其他函数传递自身形参时,如果相应实参是左值,它就应该被转发为左值;同样如果相应实参是右值,它就应该被转发为右值。
    • 这样做是为了保留在其他函数针对转发而来的参数的左右值属性进行不同处理(比如参数为左值时实施拷贝语义;参数为右值时实施移动语义)的可能性。
    • 如果将自身参数不分左右值一律转发为左值,其他函数就只能将转发而来的参数视为左值,从而失去针对该参数的左右值属性进行不同处理的可能性。

std::move一样,它的兄弟std::forward也充满了迷惑性,虽然名字含义是转发,但他并不会做转发,同样也是做类型转换.

与move相比,forward更强大,move只能转出来右值,forward都可以。

std::forward(u)有两个参数:T与 u。 a. 当T为左值引用类型时,u将被转换为T类型的左值; b. 否则u将被转换为T类型右值。

举个例子,有main,A,B三个函数,调用关系为:main->A->B

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void B(int&& ref_r) {
ref_r = 1;
}

// A、B的入参是右值引用
// 有名字的右值引用是左值,因此ref_r是左值
void A(int&& ref_r) {
B(ref_r); // 错误,B的入参是右值引用,需要接右值,ref_r是左值,编译失败

B(std::move(ref_r)); // ok,std::move把左值转为右值,编译通过
B(std::forward<int>(ref_r)); // ok,std::forward的T是int类型,属于条件b,因此会把ref_r转为右值
}

int main() {
int a = 5;
A(std::move(a));
}

例2:

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
void change2(int&& ref_r) {
ref_r = 1;
}

void change3(int& ref_l) {
ref_l = 1;
}

// change的入参是右值引用
// 有名字的右值引用是 左值,因此ref_r是左值
void change(int&& ref_r) {
change2(ref_r); // 错误,change2的入参是右值引用,需要接右值,ref_r是左值,编译失败

change2(std::move(ref_r)); // ok,std::move把左值转为右值,编译通过
change2(std::forward<int &&>(ref_r)); // ok,std::forward的T是右值引用类型(int &&),符合条件b,因此u(ref_r)会被转换为右值,编译通过

change3(ref_r); // ok,change3的入参是左值引用,需要接左值,ref_r是左值,编译通过
change3(std::forward<int &>(ref_r)); // ok,std::forward的T是左值引用类型(int &),符合条件a,因此u(ref_r)会被转换为左值,编译通过
// 可见,forward可以把值转换为左值或者右值
}

int main() {
int a = 5;
change(std::move(a));
}

类相关

拷贝构造函数和移动构造函数的区别

  • 出现时间的差别

    C++11之前,对象的拷贝控制由三个函数决定:拷贝构造函数拷贝赋值运算符析构函数。C++11之后,新增加了两个函数:移动构造函数移动赋值运算符。

  • 传入的参数类型区别

    移动构造函数传入的参数是一个右值用&&标出。一般来说左值可以通过使用std:move方法强制转换为右值。

  • 效率区别

    对于拷贝构造函数:拷贝构造函数是先将传入的参数对象进行一次深拷贝,再传给新对象。这就会有一次拷贝对象的开销,并且进行了深拷贝,就需要给对象分配地址空间。

    对于移动构造函数:移动构造函数首先将传递参数的内存地址空间接管,然后将内部所有指针设置为nullptr,并且在原地址上进行新对象的构造,最后调用原对象的的析构函数,这样做既不会产生额外的拷贝开销,也不会给新对象分配内存空间。

移动语义的出现使得大对象可以避免频繁拷贝造成的性能下降,特别是对于临时对象,移动语义是传递它们的最佳方式。

深拷贝和浅拷贝的区别:

在未定义显示拷贝构造函数的情况下,系统会调用默认的拷贝函数——即浅拷贝,它能够全部成员一一复制。当数据成员中没有指针时,浅拷贝是可行的;但当数据成员中有指针时,如果采用简单的浅拷贝,则两类中的两个指针将指向同一个地址当对象快结束时,会调用两次析构函数,(堆区内存重复释放)而导致悬垂指针现象,所以,此时,必须采用深拷贝。

深拷贝与浅拷贝的区别就在于深拷贝会在堆内存中另外申请空间来储存数据,从而也就解决了指针悬挂的问题。简而言之,当数据成员中有指针时,必须要用深拷贝

构造函数和析构函数

构造函数:

什么是构造函数?

  • 在类中,函数名和类名相同的函数称为构造函数。
  • 它的作用是在建立一个对象时,作某些初始化的工作(例如对数据赋予初值)。
  • C++允许同名函数,也就允许在一个类中有多个构造函数。
  • 如果一个都没有,编译器将为该类产生一个默认的构造函数。

构造函数上惟一的语法限制是它不能指定返回类型,甚至void 也不行。

不带参数的构造函数:一般形式为 类名 对象名(){函数体}

带参数的构造函数不带参数的构造函数,只能以固定不变的值初始化对象。带参数构造函数的初始化要灵活的多,通过传递给构造函数的参数,可以赋予对象不同的初始值。一般形式为:构造函数名(形参表);

创建对象使用时:类名 对象名(实参表);

构造函数参数的初始值:构造函数的参数可以有缺省值。当定义对象时,如果不给出参数,就自动把相应的缺省参数值赋给对象。一般形式为:构造函数名(参数=缺省值,参数=缺省值,……);

析构函数:

当一个类的对象离开作用域时,析构函数将被调用(系统自动调用)。析构函数的名字和类名一样,不过要在前面加上 ~ 。对一个类来说,只能允许一个析构函数,析构函数不能有参数,并且也没有返回值。析构函数的作用是完成一个清理工作,如释放从堆中分配的内存。

一个类中可以有多个构造函数,但析构函数只能有一个。对象被析构的顺序,与其建立时的顺序相反,即后构造的对象先析构

类中成员的初始化顺序

注意成员的初始化顺序与它们在类定义中的出现顺序一致(而不是构造函数初始值的顺序),因此最好令构造函数初始值的顺序与成员声明的顺序保持一致,并且尽量避免使用某些成员初始化其他成员

在继承体系中的初始化顺序:

  1. 基类的静态变量或全局变量
  2. 派生类的静态变量或全局变量
  3. 基类的成员变量
  4. 派生类的成员变量

对象可以建立在栈上吗

在C++中类的对象建立分为两种:

  1. 静态建立,如A a;

    静态建立一个类对象,是由编译器为对象在栈空间中分配内存,通过直接移动栈顶指针挪出适当的空间,然后在这片内存空间上调用构造函数形成一个栈对象。

  2. 动态建立,如A* p=new A(), Ap=(A)malloc();(注意必须带*)

    动态建立类对象,是使用new运算符将对象建立在堆空间中,在栈中只保留了指向该对象的指针。栈是由编译器自动分配释放 ,存放函数的参数值,局部变量的值,对象的引用地址等。其操作方式类似于数据结构中的栈,通常都是被调用时处于存储空间中,调用完毕立即释放。

堆中通常保存程序运行时动态创建的对象,C++堆中存放的对象需要由程序员分配释放,它存在程序运行的整个生命期,直到程序结束由OS释放。而在java中通常类的对象都分配在堆中,对象的回收由虚拟机的GC垃圾回收机制决定。

this指针

概述:

  • this指针是类的指针,指向对象的首地址
  • this指针只能在成员函数中使用,在全局函数、静态成员函数中都不能用this。实际上,传入参数为当前对象地址,成员函数第一个参数为为T * const this
  • this指针只有在成员函数中才有定义,且存储位置会因编译器不同有不同的存储位置(可能是栈,也可能是寄存器,甚至全局变量。)
  • this在成员函数的开始前构造,在成员函数的结束后清除。这个生命周期同任何一个函数的参数是一样的,没有任何区别。当调用一个类的成员函数时,编译器将类的指针作为函数的this参数传递进去

作用:(本质就是为了让成员函数能够直接使用对象中的成员)

  • 一个对象的this指针并不是对象本身的一部分,不会影响 sizeof(对象) 的结果。

  • this作用域是在类内部,当在类的非静态成员函数中访问类的非静态成员的时候(全局函数,静态函数中不能使用this指针),编译器会自动将对象本身的地址作为一个隐含参数传递给函数。也就是说,即使你没有写上this指针,编译器在编译的时候也是加上this的,它作为非静态成员函数的隐含形参,对各成员的访问均通过this进行

一些常见问题

RAII

什么是RAII

RAII(Resource Acquisition Is Initialization)是由c++之父Bjarne Stroustrup提出的,中文翻译为资源获取即初始化,他说:使用局部对象来管理资源的技术称为资源获取即初始化;这里的资源主要是指操作系统中有限的东西如内存、网络套接字等等,局部对象是指存储在栈的对象,它的生命周期是由操作系统来管理的,无需人工介入;

RAII要求,资源的有效期与持有资源的对象的生命期严格绑定,即由对象的构造函数完成资源的分配(获取),同时由析构函数完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄露(Resource leak)问题。

RAII的原理

资源的使用一般经历三个步骤:

  1. 获取资源
  2. 使用资源
  3. 销毁资源

但是资源的销毁往往是程序员经常忘记的一个环节,所以程序界就想如何在程序员中让资源自动销毁呢?c++之父给出了解决问题的方案:RAII,它充分的利用了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
#include <iostream>
using namespace std;
class person {
public:
person(const std::string name = "", int age = 0) :
name_(name), age_(age) {
std::cout << "Init a person!" << std::endl;
}
~person() {
std::cout << "Destory a person!" << std::endl;
}
const std::string& getname() const {
return this->name_;
}
int getage() const {
return this->age_;
}
private:
const std::string name_;
int age_;
};
int main() {
person p;
return 0;
}
1
2
3
4
5
6
#编译并运行:
g++ person.cpp -o person
./person
#运行结果:
Init a person!
Destory a person!

从person class可以看出,当我们在main函数中声明一个局部对象的时候,会自动调用构造函数进行对象的初始化,当整个main函数执行完成后,自动调用析构函数来销毁对象,整个过程无需人工介入,由操作系统自动完成;于是,很自然联想到,当我们在使用资源的时候,在构造函数中进行初始化,在析构函数中进行销毁。

整个RAII过程总结四个步骤:

  1. 设计一个类封装资源
  2. 在构造函数中初始化
  3. 在析构函数中执行销毁操作
  4. 使用时声明一个该对象的类

RAII的本质内容是用对象代表资源,把管理资源的任务转化为管理对象的任务,将资源的获取和释放与对象的构造和析构对应起来,从而确保在对象的生存期内资源始终有效,对象销毁时资源必被释放。换句话说,拥有对象就等于拥有资源,对象存在则资源必定存在。

虚函数相关

多态

多态的体现

  • (编译时)多态体现在函数的重载运算符的重载,静态多态也体现在模板这一特性上

  • (运行时)多态就是程序运行时,父类指针可以根据具体指向的子类对象(动态绑定),来执行不同的函数,表现为多态。

实现原理:

(1)当类中存在虚函数时,编译器会在类中自动生成一个虚函数表。

(2)虚函数表是一个存储类成员函数指针的数据结构

(3)虚函数表由编译器自动生成和维护

(4)virtual 修饰的成员函数会被编译器放入虚函数表中

(5)存在虚函数时,编译器会为对象自动生成一个指向虚函数表的指针(通常称之为 vptr 指针)

注意定义一个函数为虚函数,不代表函数为不被实现的函数。 定义他为虚函数是为了允许用基类的指针来调用子类的这个函数(实现多态)

证明存在vptr指针

存在虚函数的类的对象,大小大了4个字节,这正好是一个指针对象的大小(指针对象的大小可能会根据运行环境而改变,32位的系统指针的大小是4个字节),这说明编译器确实给我们添加了这么一个指针对象 vptr。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Parent1 {
public:
int a;
virtual void fun() {
cout << "base class" << endl;
}
};
class Parent2 {
public:
int a;
void fun() {
cout << "base class2" << endl;
}
};
int main() {
Parent1 parent1;
Parent2 parent2;
cout << sizeof(parent1) << endl; //大小为8
cout << sizeof(parent2) << endl; //大小为4

system("pause");
return 0;
}

(注意如果类中没有 int a(即为一个空类),类的大小为1个字节,这是为了让对象的实例能够相互区别(如果没有这一个字节的占位,那么空类不能被实例化了,因为实例化的过程就是在内存中分配一块地址))

可以通过类对象的大小来判断是否类中包含虚函数

重载和重写的区别

重载:函数名相同,函数的参数个数、参数类型或参数顺序三者中必须至少有一种不同。函数返回值的类型可以相同,也可以不相同。发生在一个类内部,不能跨作用域。

重定义:也叫做隐藏,子类重新定义父类中有相同名称的非虚函数 ( 参数列表可以不同 ) ,指派生类的函数屏蔽了与其同名的基类函数。可以理解成发生在继承中的重载。

重写:也叫做覆盖,一般发生在子类和父类继承关系之间。子类重新定义父类中有相同名称和参数的虚函数。(override)

构造和析构函数中的virtual

注意不能在构造和析构过程中调用virtual函数:(effective C++ rule 9)

(语法上没问题,但是实际效果和想象的不同,不能达到多态的效果)

  • 当在派生类的构造函数中调用虚函数时,被调用的将不会是派生类中自己定义的版本,而将是基类中的版本,这与我们预想的结果通常不同。
    • 这是因为基类的构造函数的执行更早于派生类部分的构造函数,因此当基类构造函数执行时派生类的成员变量尚未初始化。更加根本的原因是在派生类对象的基类部分构造期间,对象的类型是基类而不是派生类。(对象在派生类构造函数开始执行前不会成为一个派生类对象)
  • 同样的道理也适用于析构函数。一旦派生类析构函数开始执行,对象内的派生类成员就会呈现未定义值,所以C++视它们仿佛不再存在。进入基类的析构函数后对象就成为一个基类对象,而C++的任何部分包括virtual函数、dynamic_cast等也就那么看待它。

一种解决方案是将基类中的虚函数改写成非虚函数的版本,并且要求派生类的构造函数传递必要的信息给基类的构造函数。

另外我们也可以从vptr的角度理解vptr分布初始化):

(1)对象在创建时,由编译器对 vptr 进行初始化

(2)子类的构造会先调用父类的构造函数,这个时候 vptr 会先指向父类的虚函数表

(3)子类构造的时候,vptr 会再指向子类的虚函数表

(4)对象的创建完成后,vptr 最终的指向才确定

虚析构函数

继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数,这样就能动态分配继承体系中的对象了。

  • 当我们delete一个动态分配的对象的指针时将执行析构函数。
  • 如果该指针指向继承体系中的某个类型,则可能出现指针的静态类型与被删除对象的动态类型不符的情况
  • 例如,如果我们delete一个Quote*类型的指针,该指针有可能实际指向一个Bulk_quote类型的对象,这样编译器就必须清楚它执行的是Bulk_quote的析构函数。
  • 通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本
1
2
3
4
5
class Quote {
public:
//如果我们删除的是一个指向派生类对象的基类指针,则需要虚析构函数
virtual ~Quote() = default; //对析构函数进行动态绑定
};

和其他虚函数一样,析构函数的虚属性也会被继承。因此,无论Quote的派生类使用合成的析构函数还是定义自己的析构函数,都将是虚析构函数。只要基类的析构函数是虚函数,就能确保当我们delete基类指针时将运行正确的析构函数版本:

1
2
3
4
Quote *itemP = new Quote;   //静态类型与动态类型一致
delete itemP; //调用Quote的析构函数
itemP = new Bulk_quote; //静态类型与动态类型不一致
delete itemP; //调用Bulk_quote的析构函数

总结:

  • 由于类的多态性,基类指针可以指向派生类的对象,如果删除该基类的指针,就会调用该指针指向的派生类析构函数,而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象完全被释放。
  • 析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,造成内存泄漏
  • 所以将析构函数声明为虚函数是十分必要的。在实现多态时,当用基类操作派生类,在析构时防止只析构基类而不析构派生类的状况发生,要将基类的析构函数声明为虚函数

构造函数不能是虚函数

  • vptr角度

    • 虚函数的调用是通过虚函数表来查找的,而虚函数表由类的实例化对象的vptr指针指向,该指针存放在对象的内部空间中,需要调用构造函数完成初始化。如果构造函数是虚函数,那么调用构造函数就需要去找vptr,但此时vptr还没有初始化!
  • 从多态角度

    • 虚函数主要是实现多态,在运行时才可以明确调用对象,根据传入的对象类型来调用函数,例如通过父类的指针或者引用来调用它的时候可以变成调用子类的那个成员函数。而构造函数是在创建对象时自己主动调用的,不可能通过父类的指针或者引用去调用。那使用虚函数也没有实际意义。

模板类中的虚函数

注意模板类中的普通成员函数也可以是虚函数,但是模板成员函数不可以是虚函数

注意模板成员函数的特点:在用到这个成员函数的时候才会被实例化

  • 解释1:

    • 编译器都期望在处理类的定义的时候就能确定这个类的虚函数表的大小(必须确定虚函数表的大小,也就是虚函数的个数),如果允许有类的虚成员模板函数,那么就必须要求编译器提前知道程序中所有对该类的该虚成员模板函数的调用,而这是不可行的。
  • 解释2:

    • 在实例化模板类时,需要创建virtual table。
    • 模板类被实例化完成之前不能确定函数模板(包括虚函数模板,加入支持的话)会被实例化多少个
    • 普通成员函数模板无所谓,什么时候需要什么时候就给你实例化,编译器不用知道到底需要实例化多少个,虚函数的个数必须知道,否则这个类就无法被实例化(因为要创建virtual table)。因此,目前不支持虚函数模板。

虚函数表

虚函数表是指在每个包含虚函数的类中都存在着一个函数地址的数组

  • 当我们用父类的指针来操作一个子类的时候,这张虚函数表指明了实际所应该调用的函数。

  • C++的编译器保证虚函数表的指针存在于对象实例中最前面的位置

    • 在C++的标准规格说明书中说到,编译器必须要保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证正确取到虚函数的偏移量)。 这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。
  • 注意C++内存模型一般分为五个区域:栈区、堆区、函数区(存放函数体等二进制代码)、全局静态区、常量区

    • C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。
    • 被放在这里也还是比较容易理解的,虚函数表由于一旦产生就具有不变性,所以编译器就会尽量把它放到稳定(或者说是只读)的内存区。
    • 另外通过这个性质我们可以推断得出虚函数表是属于类的,而不属于类对象。在编译的时候确定,存放在只读数据段,每一个实例化对象都有一个虚函数表的指针,虚函数表指针属于类对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

using namespace std;

class Base {
public:
virtual void f() { cout << "f()" << endl; }
virtual void g() { cout << "g()" << endl; }
virtual void h() { cout << "h()" << endl; }
};

int main()
{
Base t;
( ((void(*)())*((int*)(*((int*)&t)) + 0)) ) ();
( ((void(*)())*((int*)(*((int*)&t)) + 1)) ) ();
( ((void(*)())*((int*)(*((int*)&t)) + 2)) ) ();
return 0;
}

经过VS2017,x86测试:

1558844330501

(注意linux centos下会报访问内存区域错误:)

image-20220908025810377

Linux下,直接用下面的命令即可将xxx.cpp的内存布局导出到一个生成的

xxx.cpp.002t.class文件中:

1
g++ -fdump-class-hierarchy xxx.cpp

:/xxx搜索关键字找到相应类的位置,可以查看虚函数表结构

1
2
3
4
5
6
7
8
9
10
11
12
13
Vtable for Parent1
Parent1::_ZTV7Parent1: 5u entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI7Parent1)
16 (int (*)(...))Parent1::func1
24 (int (*)(...))Parent1::func2
32 (int (*)(...))Parent1::func3

Class Parent1
size=12 align=4
base size=12 base align=4
Parent1 (0x0x7fd4f8b4dd80) 0
vptr=((& Parent1::_ZTV7Parent1) + 16u)

我们即成功地通过实例对象的地址,得到了对象所有的类函数。

main定义Base类对象t,把&t转成int *,取得虚函数表的地址vtptr就是:(int*)(&t),然后再解引用并强转成int *得到第一个虚函数的地址,也就是Base::f()即(int*)(*((int*)&t)),那么,第二个虚函数g()的地址就是(int*)(*((int*)&t)) + 1,依次类推。

单继承体系下:

  • 派生类未覆盖基类虚函数的情况下:
    • 虚函数表中依照声明顺序先放基类的虚函数地址,再放派生类的虚函数地址。(基类虚函数在派生类之前)
  • 派生类函数覆盖基类虚函数的情况下:
    • 虚表中派生类覆盖的虚函数的地址被放在了基类相应的函数原来的位置 (显然的,不然虚函数失去意义)
    • 派生类没有覆盖的虚函数延用基类的

多继承体系下:(每个基类都有其虚函数表)

注意在多继承情况下,有多少个基类就有多少个虚函数表指针,前提是基类要有虚函数才算上

  • 派生类未覆盖基类虚函数情况下:

    • 派生类的虚函数地址存依照声明顺序放在第一个基类的虚表最后(这点和单继承无虚函数覆盖相同)

  • 派生类覆盖基类虚函数情况下:

    • 虚表中派生类覆盖的虚函数的地址被放在了基类相应的函数原来的位置
    • 派生类没有覆盖的虚函数延用基类的

虚函数表的安全性问题

  • 通过父类型的指针可以访问子类自己的虚函数
    • 在运行时,我们可以通过指针的方式访问虚函数表来达到违反C++语义的行为。(和妄图使用父类指针想调用子类中的未覆盖父类的成员函数的行为都会被编译器视为非法)
  • 访问non-public的虚函数
    • 如果父类的虚函数是private或是protected的,但这些非public的虚函数同样会存在于虚函数表中,所以,我们同样可以使用访问虚函数表的方式来访问这些non-public的虚函数,这是很容易做到的。

虚函数指针的调用过程

(1)首先识别到func()是一个虚函数

(2)其次程序使用vptr获得了虚函数表

(3)在虚函数表中寻找可以调用的func()版本进行调用

调用虚函数和调用普通函数的区别

(1)普通函数调用,直接call

(2)虚函数调用需要首先获得vptr,间接调用vptr指向的虚表的内容(虚成员函数地址)

虚函数表指针和虚函数表的创建时间

  • 虚函数表指针(vptr)创建时机(运行时)
    • vptr跟着对象走,所以对象什么时候创建出来,vptr就什么时候创建出来,也就是运行的时候
    • 当程序在编译期间,编译器会为构造函数中增加为vptr赋值的代码(这是编译器的行为),当程序在运行时,遇到创建对象的代码,执行对象的构造函数,那么这个构造函数里有为这个对象的vptr赋值的语句。
  • 虚函数表创建时机(编译时)
    • 虚函数表创建时机是在编译期间。编译期间编译器就为每个类确定好了对应的虚函数表里的内容。
    • 所以在程序运行时,编译器会把虚函数表的首地址赋值给虚函数表指针,所以,这个虚函数表指针就有值了

抽象基类和纯虚函数

主要目的是为了实现一种接口的效果。

抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的,它处于继承层次结构的较上层。

  • 抽象类的定义带有纯虚函数的类为抽象类。(子类必须实现所有的纯虚函数,否则也为抽象类,无法被实例化)

  • 抽象类的作用

    • 主要作用是将有关的操作作为结果接口组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作。
    • 所以派生类实际上刻画了一组子类的操作接口的通用语义,这些语义也传给子类,子类可以具体实现这些语义,也可以再将这些语义传给自己的子类。
  • 使用场景:

    一句话,在既需要统一的接口,又需要实例变量或缺省的方法的情况下,就可以使用它。最常见的有:

    • 定义了一组接口,但又不想强迫每个实现类都必须实现所有的接口。可以用abstract class定义一组共用的方法体,甚至可以是空方法体,然后由子类选择自己所感兴趣的方法来覆盖。
    • 某些场合下,只靠纯粹的接口不能满足类与类之间的协调,还必需类中表示状态的变量来区别不同的关系。abstract的中介作用可以很好地满足这一点。
    • 规范了一组相互协调的方法,其中一些方法是共同的,与状态无关的,可以共享的,无需子类分别实现;而另一些方法却需要各个子类根据自己特定的状态来实现特定的功能

不能定义为虚函数的函数

本质上就是指哪些类型的函数不能被动态绑定

  • 普通函数(非成员函数):都是在编译期绑定的
  • 构造函数
  • 内联函数:因为内联函数要求在编译期就在调用点展开,与虚函数要求的动态绑定不符
  • 静态成员函数:静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,他不归某个具体对象所有,所以他也没有要动态绑定的必要性。
  • 友元函数:因为C++不支持友元函数的继承,对于没有继承特性的函数没有虚函数的说法

多重继承

二义性问题

在C++中,派生类继承基类,对基类成员的访问应该是确定的、唯一的,但是常常会有以下情况导致访问不一致,产生二义性。

  1. 在继承时,基类之间、或基类与派生类之间发生成员同名时,将出现对成员访问的不确定性——同名二义性。(倒三角继承)

    解决方法:

    1. 利用作用域限定符(::),用来限定子类调用的是哪个父类的成员
    2. 在类中定义同名成员,覆盖掉父类中的相关成员
  2. 当派生类从多个基类派生,而这些基类又从同一个基类派生,则在访问此共同基类(祖父类)中的成员时,将产生另一种不确定性——路径二义性。(菱形继承)(同时还会导致内存浪费,祖父类的成员被拷贝两份)

菱形继承二义性的解决方法 — 虚继承

解决方法:

  1. 使用作用域限定符,指明访问的是哪一个基类的成员。注意:不能是Grandpa作用域下限定,因为Son直接基类的基类才是Grandpa,纵使指明了访问Grandpa的成员的话,对于Son来说,还是模糊的,还是具有二义性。

  2. 在类中定义同名成员,覆盖掉父类中的相关成员。

  3. 虚继承、使用虚基类:(作用是在间接继承共同基类时只保留一份基类成员)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class A//A基类
    {...};

    //类B是类A的公用派生类,类A是类B的虚基类
    class B: virtual public A
    {...};

    //类C是类A的公用派生类,类A是类C的虚基类
    class C: virtual public A
    {...};

    虚基类不是在声明基类时声明的,而是在声明派生类时,指定继承方式时声明的

    虚继承是声明类时的一种继承方式,在继承属性前面添加virtual关键字

虚基类的初始化

虚基类的初始化是由最后的派生类中负责初始化的,在最后的派生类中不仅要对直接基类进行初始化,还要负责对虚基类初始化

虚基类的构造次数

由于C++编译系统只执行最后的派生类对基类的构造函数调用,而忽略其他派生类对虚基类的调用。从而避免对基类数据成员的重复初始化,因此,虚基类只会构造一次。

实现原理

虚继承底层实现原理与编译器相关,一般通过虚基类指针和虚基类表实现:

  • 每个虚继承的子类都有一个虚基类指针(占用一个指针的存储空间,4字节)和虚基类表(不占用类对象的存储空间)(需要强调的是,虚基类依旧会在子类里面存在拷贝,只是仅仅最多存在一份而已,并不是不在子类里面了)
  • 当虚继承的子类被当做父类继承时,虚基类指针也会被继承。

实际上,vbptr指的是虚基类表指针(virtual base table pointer):

  • 该指针指向了一个虚基类表(virtual table),虚表中记录了虚基类与本类的偏移地址

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    class Base{
    public:
    int a=1;
    virtual void func1(){
    cout<<"func1"<<endl;
    }
    void func2(){
    cout<<"func2"<<endl;
    }
    };
    class Son:virtual public Base{
    public:
    int b=2;
    void func1()override{
    cout<<"son's func1"<<endl;
    }

    };
    1
    2
    3
    4
    VTT for Son
    Son::_ZTT3Son: 2u entries
    0 ((& Son::_ZTV3Son) + 24u)
    8 ((& Son::_ZTV3Son) + 56u)
  • 通过偏移地址,这样就找到了虚基类成员,而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。

在这里我们可以对比虚函数的实现原理:

  • 他们有相似之处,都利用了虚指针(均占用类的存储空间)和虚表(均不占用类的存储空间)。
  • 虚基类依旧存在继承类中,只占用存储空间;虚函数不占用存储空间。
  • 虚基类表存储的是虚基类相对直接继承类的偏移;而虚函数表存储的是虚函数地址。

动态内存

类的对象存储空间?

  • 非静态成员的数据类型大小之和。
  • 编译器加入的额外成员变量(如指向虚函数表、虚基类表的指针)。
  • 为了边缘对齐优化加入的padding(如按4字节、8字节等进行对其,C/C++可用 #pragma pack(n)进行设置,其中n为对齐字节)。
  • 成员函数不占用对象的内存。这是因为所有的函数都是存放在代码区的,不管是全局函数,还是成员函数。

空类(无非静态数据成员)的对象的size为1, 当作为基类时, size为0

内存对齐为何有效

内存对齐是按2^n为单位进行对齐

CPU访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问。比如32位的CPU,字长为4字节,那么CPU访问内存的单位也是4字节。

这么设计的目的,是减少CPU访问内存的次数,加大CPU访问内存的吞吐量。比如同样读取8个字节的数据,一次读取4个字节那么只需要读取2次。

能否用memset初始化一个类

语法上可行!实际不要这样用!

memset 某个结构(或其它的数据类型)在C语言中是很常见的代码,其目的是对结构(或其它的数据类型)进行初始化,通常都是将变量置为NULL或者0。

在C++ 中,针对类对象除了用构造函数初始化对象外,也可以使用memset来进行初始化操作(确实有这种情况,不得已而为之)。但是一定要注意以下所说的这种情况:如果类包含虚函数,则不能用 memset 来初始化类对象。

因为每个包含虚函数的类对象都有一个指针指向虚函数表(vtbl)。这个指针被用于解决运行时以及动态类型强制转换时虚函数的调用问题。该指针是被隐藏的,对程序员来说,这个指针也是不可存取的。当进行memset操作时,这个指针的值也要被overwrite,这样一来,只要一调用虚函数,程序便崩溃。这在很多由C转向C++的程序员来说,很容易犯这个错误,而且这个错误很难查。
为了避免这种情况,记住对于有虚拟函数的类对象,决不能使用 memset 来进行初始化操作。而是要用缺省的构造函数或其它的 init 例程来初始化成员变量。

C++的内存分区

C++中的内存分区,分别是堆、栈、自由存储区、全局/静态存储区、常量存储区和代码区。如下图所示

:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限

:就是那些由 new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个 delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收

自由存储区:如果说堆是操作系统维护的一块内存,那么自由存储区就是C++中通过new和delete动态分配和释放对象的抽象概念。需要注意的是,自由存储区和堆比较像,但不等价。

全局/静态存储区:全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量和静态变量又分为初始化的和未初始化的,在C++里面没有这个区分了,它们共同占用同一块内存区,在该区定义的变量若没有初始化,则会被自动初始化,例如int型变量自动初始为0

常量存储区:这是一块比较特殊的存储区,这里面存放的是常量,不允许修改。虚函数表也放在这个区域

代码区:存放函数体的二进制代码

堆和栈的区别

主要区别有以下几点

  • 管理方式

    • 对于栈,是由编译器自动管理,无需我们手动控制
    • 对于堆,释放工作由程序员控制,因此管理不当容易出现内存泄漏
  • 空间大小:一般来讲在32位系统下

    • 堆内存可以达到4G的空间

    • 栈内存空间大小一般是几M(我的系统上是8M):

      1
      2
      [zhangqi@localhost testCpp]$ ulimit -s
      8192
  • 碎片问题

    • 对于堆来说,频繁的new/delete会造成内存空间的不连续,从而造成大量碎片使程序效率降低
    • 而对于栈来说,由于始终是先进后出,不可能有一个内存块从栈中间弹出,因此不存在碎片问题。
  • 生长方向

    • 对于堆来说,生长方向是向上的,也就是从低地址开始向着内存地址增加的方向

    • 对于栈来讲,生长方向是向下的,从高地址向着内存地址减小的方向增长(先进入的对象放在栈底,地址最大)。

    • 注意经过实验,windows X86系统进程中堆栈都向下增长的(没有完全搞懂,可以看一下这篇文章

    • linux下证实是如此的:

      image-20220907094513848
  • 分配方式:堆都是动态分配的,没有静态分配的堆。栈可以静态分配也可以动态分配,静态分配是由编译器完成的,如局部变量的分配,动态分配由alloca()函数进行分配。

  • 分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。

malloc底层实现及原理

首先看内存空间分布

image-20220922075452731
  • 32位系统,寻址空间是4G,linux系统下0-3G是用户模式,3-4G是内核模式。而在用户模式下又分为代码段、数据段、.bss段、堆、栈。各个segment所含内容在图中有具体说明。

  • bss段:存放未初始化的全局变量和局部静态变量。

  • 数据段:存放已经初始化的全局变量和局部静态变量。

  • 栈段:存放局部变量。

可以看到heap段位于bss下方,而其中有个重要的标志:program break。Linux维护一个break指针,这个指针指向堆空间的某个地址。从堆起始地址到break之间的地址空间为映射好的,可以供进程访问;而从break往上,是未映射的地址空间,如果访问这段空间则程序会报错。我们用malloc进行内存分配就是从break往上进行的。

进程所面对的虚拟内存地址空间,只有按页映射到物理内存地址,才能真正使用。受物理存储容量限制,整个堆虚拟内存空间不可能全部映射到实际的物理内存。

Linux对堆的管理示意如下:

获取了break地址,也就是内存申请的初始地址,下面是malloc的整体实现方案:

  • malloc 函数的实质是它有一个将可用的内存块连接为一个长长的列表的所谓空闲链表。
  • 调用 malloc() 函数时,它沿着链表寻找一个大到足以满足用户请求所需要的内存块。
  • 然后,将该内存块一分为二(一块的大小与用户申请的大小相等,另一块的大小就是剩下来的字节)。
  • 接下来,将分配给用户的那块内存存储区域传给用户,并将剩下的那块(如果有的话)返回到连接表上。
  • 调用 free 函数时,首先搜索空闲链表,找到可以插入被释放块的合适位置。如果与被释放块相邻的任一边是一个空闲块,则将这两个块合为一个更大的块,以减少内存碎片
  • 到最后,空闲链会被切成很多的小内存片段,如果这时用户申请一个大的内存片段, 那么空闲链表上可能没有可以满足用户要求的片段了。于是,malloc() 函数请求延时,并开始在空闲链表上检查各内存片段,对它们进行内存整理,将相邻的小空闲块合并成较大的内存块。

因为brk、sbrk、mmap都属于系统调用,若每次申请内存,都调用这三个,那么每次都会产生系统调用,影响性能;其次,这样申请的内存容易产生碎片,因为堆是从低地址到高地址,如果高地址的内存没有被释放,低地址的内存就不能被回收。
所以malloc采用的是内存池的管理方式(ptmalloc),Ptmalloc 采用边界标记法将内存划分成很多,从而对内存的分配与回收进行管理。为了内存分配函数malloc的高效性,ptmalloc会预先向操作系统申请一块内存供用户使用,当我们申请和释放内存的时候,ptmalloc会将这些内存管理起来,并通过一些策略来判断是否将其回收给操作系统。这样做的最大好处就是,使用户申请和释放内存的时候更加高效,避免产生过多的内存碎片

内存池

内存池(Memory Pool) 是一种内存分配方式。

通常我们习惯直接使用new、malloc 等申请内存,这样做的缺点在于:

  • 由于所申请内存块的大小不定,当频繁使用时会造成大量的内存碎片并进而降低性能。

内存池则是在真正使用内存之前,先申请分配一定数量的、大小相等(一般情况下)的内存块留作备用。

  • 当有新的内存需求时,就从内存池中分出一部分内存块, 若内存块不够再继续申请新的内存。
  • 这样做的一个显著优点是尽量避免了内存碎片,使得内存分配效率得到提升。

《STL源码剖析》中的内存池实现机制

每次配置一大块内存,并维护对应的自由链表free-list,内存分配和回收都方法哦free-lists中。

allocate 包装 malloc,deallocate包装free

一般是一次20*2个的申请,先用一半,留着一半,为什么也没个说法,侯捷在STL那边书里说好像是C++委员会成员认为20是个比较好的数字,既不大也不小。

  1. 首先客户端会调用malloc()配置一定数量的区块(固定大小的内存块,通常为8的倍数),假设40个32bytes的区块,其中20个区块(一半)给程序实际使用,1个区块交出,另外19个处于维护状态。剩余20个(一半)留给内存池,此时一共有(20*32byte)
  2. 客户端之后有有内存需求,想申请(20*64bytes)的空间,这时内存池只有(20*32bytes),就先将(10*64bytes)个区块返回,1个区块交出,另外9个处于维护状态,此时内存池空空如也.
  3. 接下来如果客户端还有内存需求,就必须再调用malloc()配置空间,此时新申请的区块数量会增加一个随着配置次数越来越大的附加量,同样一半提供程序使用,另一半留给内存池。申请内存的时候用永远是先看内存池有无剩余,有的话就用上,然后挂在0-15号某一条链表上,要不然就重新申请。
  4. 如果整个堆的空间都不够了,就会在原先已经分配区块中寻找能满足当前需求的区块数量,能满足就返回,不能满足就向客户端报bad_alloc异常

allocator就是用来分配内存的,最重要的两个函数是allocate和deallocate,就是用来申请内存和回收内存的,外部(一般指容器)调用的时候只需要知道这些就够了。

内部实现,目前的所有编译器都是直接调用的::operator new()和::operator delete(),说白了就是和直接使用new运算符的效果是一样的,所以老师说它们都没做任何特殊处理。

malloc和new,free和delete

区别:

  • malloc和free是C语言中的库函数,需要头文件支持,而new和delete是C++中的关键字

  • 使用new操作符时编译器会根据类型信息自行计算所需要的内存,而malloc需要显式指出所需内存的尺寸。

    1
    2
    int *p = new int[2];
    int *q = (int*)malloc(2*sizeof(int));
  • malloc内存分配成功时,返回的是void*,需要转换成所需要的类型。而new内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符。

  • free释放内存的时候需要的是void类型的参数,而delete释放内存的时候需要使用具体类型的指针

  • new操作符在分配失败的时候会抛出bad_alloc异常,malloc在分配内存失败时返回NULL

  • new操作符从自由存储区(默认为堆,和C相同,可以通过重载操作符来改用其他内存来实现自由存储)(free store)上位对象动态分配内存空间,允许重载new/delete操作符。malloc函数从堆上动态分配内存,malloc不允许被重载。

  • new会先调用operator new函数,申请足够的内存(通常底层由malloc实现)。然后调用类型的构造函数,初始化成员变量,最后返回自定义类型的指针。

  • delete先调用析构函数,然后调用operator delete函数释放内存(通常底层使用free实现)。

  • malloc/free是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构工作。即,new和delete能触发构造和析构函数的调用,malloc和free只能申请和归还内存空间

为什么说new是低效的

  • 一般来说,操作越简单,意味着封装了更多的实现细节。new作为一个通用接口,需要处理任意时间、任意位置申请任意大小内存的请求,在设计上就无法兼顾一些特殊场景的优化,在管理上也会带来一定开销
  • 系统调用带来的开销。多数操作系统上,申请内存会从用户模式切换到内核模式,当前线程会被block,上下文切换会消耗一定时间
    • 上下文切换就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
    • 而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。

另外注意:

  • delete和free被调用后,内存不会立即回收,指针也不会指向空,delete或free仅仅只是告诉操作系统,这一块内存被释放了,可以用作其他用途。
  • 但是由于没有重新对这块内存进行写操作,所以内存中的变量数值并没有发生变化。这时候就会出现野指针的情况。因此,释放完内存后,应该把指针指针nullptr。

另外:

  • 使用malloc分配内存时候根据参数指定的大小,分配一块内存,然后返回这块内存的起始位置给调用者,这就是调用者拿到的所谓的指针。
  • 其实这个指针并不是真正的起始位置,真正的指针在malloc返回指针 p 的前面,内存分配器在 p 的前面用两个字节的空间来存放分配的内存大小信息

是否可以混用

  • 当申请的空间是内置类型时,delete和free可以混用
  • 当申请的空间是自定义类型时:
    • 若没有析构函数,delete和malloc可以混用,有[]和没有[]都相同
    • 若申请的空间有析构函数时,malloc申请的空间可以用delete和free释放,但是用delete释放时不能加[]
    • 若申请的空间有析构函数时,new申请的空间不能用free释放,可以用delete释放,但是释放时必须加上[]

new []/delete []

用new分配一个数组之后,之后用delete[]释放掉,那这个delete怎么知道应该释放多大一片内存呢?

  • new int[10]时,malloc本应该申请10个A类型大小的空间,也就是40个字节,但是此时malloc实际上申请了44个字节,new返回的指针是malloc返回的指针向后偏移4个字节的地址。
  • 这4个字节存放着所申请自定义元素的个数。

delete[]的原理::

  • delete[]会把new[]所返回的指针向前偏移4个字节的地址返回给free,因此free就能正确的释放掉整片空间。
  • 多出来4个字节保存着自定义元素的个数的作用:
    • 一个对象在释放空间前需要调用析构函数来完成一些资源的清理工作。那么问题来了,delete[] 没有像new A[10]传参进去,delete[]怎么知道调用多少次析构。
    • 其实多出来的4个字节存的元素个数,就是用来让delete[]知道调用多少次析构函数的
1
2
【注】对于没有自定义析构器的class或者struct,在delete时不需要调用其析构器,
所以可以将整块数组当成一个整体来处理,也就可以使用delete直接释放这一整块内存空间。
1
2
【注】而对于自定义了析构器的class或者struct,在delete时需要逐个调用其析构器,
所以需要有标识位记录数组大小,然后通过数组大小来逐个调用其析构器,再释放内存。

重载new/delete

因为new是关键字,我们本应该无法修改new分配内存的方式。由于new在分配内存时,调用operator new。所以重载operator new就可以修改分配内存的方式了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Foo {
public:
void* operator new(std::size_t size)
{
std::cout << "operator new" << std::endl;
return std::malloc(size);
}
}

int main()
{
Foo* m = new Foo;
std::cout << sizeof(m) << std::endl;
delete m;
return 0;
}

operator new返回值必须是void*。第一个参数必须是size_t,还可加其它参数,下文讨论。

operator new重载可以放在全局中,也可以放到类内部。当编译器发现有new关键字,就会现在类和其基类中寻找operator new,找不到就在全局中找,再找不到就用默认的。

在类中的operator new默认就是static。所以加static可以,不加也是全局,可以正常使用。

operator new 加入其它形参

上文说到operator new第一个参数必须是size_t并且可以重载加入其它形参。

这就引出placement new。原型是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Foo {
public:
void* operator new(std::size_t size, void* ptr)
{
std::cout << "placement new" << std::endl;
return ptr;
}
}

int main()
{
Foo* m = new Foo;
Foo* m2 = new(m) Foo;
std::cout << sizeof(m) << std::endl;
// delete m2;
delete m;
return 0;
}

placement new就是operator new重载的一种形式。上文说到,operator new的主要作用就是分配空间,初始化对象的工作是new关键字的。

经过这样重载,placement new完全没有分配新的空间,而是把ptr的地址传了出去。由于调用构造函数的工作是new关键字,所以placement new不影响对象初始化。

placement new由于不需要申请新内存和释放旧内存。所以在内存池中,意外的便捷。

在上面代码中,我将delete m2注释掉了。在看到这里的时候,应该明白若不注释将会发生什么。

operator new还可以有其它的形参,并且有很大的自由度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Foo {
public:
void* operator new(std::size_t size, int num)
{
std::cout << "operator new" << std::endl;
std::cout << "num is " << num << std::endl;
return std::malloc(size);
}
}

int main()
{
Foo* m = new(100) Foo;
std::cout << sizeof(m) << std::endl;
delete m;
return 0;
}

如这个例子。参数的个数没有限制的,往后面加即可。

内存问题

C++ 里可能出现的内存问题大致有这么几个方面:

  • 缓冲区溢出(buffer overrun)。
    • 用 std::vector<char> , std::string 或自己编写 Buffer class 来管理缓冲区,自动记住用缓冲区的长度,并通过成员函数而不是裸指针来修改缓冲区。
  • 空悬指针/野指针。
    • 用 shared_ptr/weak_ptr
  • 重复释放(double delete)。
    • 用 scoped_ptr,只在对象析构的时候释放一次。
  • 内存泄漏(memory leak)。
    • 用 scoped_ptr,对象析构的时候自动释放内存。
  • 不配对的 new[]/delete。
    • 把 new[] 统统替换为 std::vector/scoped_array
  • 内存碎片(memory fragmentation)。

重载operator delete

1
2
3
4
5
void operator delete(void* ptr)
{
std::cout << "operator delete" << std::endl;
std::free(ptr);
}

deletenew类似,只不过返回值必须为void

值得多提一嘴的是,虽然operator delete重载和operator new相同,但一般不会重载operator delete

根本原因是重载后的delete不可手动调用。例如:

1
2
3
4
5
6
7
void operator delete(void* ptr, int num)
{
std::cout << "operator delete" << std::endl;
std::free(ptr);
}

delete(10) p; // 不合法的

这样调用是不合法的。

这种重载的意义是和重载operator new配套。只有operator new报异常了,就会调用对应的operator delete。若没有对应的operator delete,则无法释放内存。

内存泄漏相关

概念

  • 内存泄漏是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况。
  • 内存泄漏并非指内存在物理上消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制

后果

  • 只发生一次小的内存泄漏可能不被注意,但泄漏大量内存的程序将会出现各种征兆:性能下降到内存逐渐用完,导致另一个程序失败;

如何排除

  • 使用工具软件BoundsChecker,BoundsChecker是一个运行时错误检测工具,它主要定位程序运行时期发生的各种错误;
  • 调试运行DEBUG版程序,运用以下技术:CRT(C run-time libraries)、运行时函数调用堆栈、内存泄漏时提示的内存分配序号(集成开发环境OUTPUT窗口),综合分析内存泄漏的原因,排除内存泄漏。

解决方法

  • 智能指针(std::shared_ptr和std::unique_ptr)(RAII最具代表性的实现)可以实现自动的内存管理,再也不需要担心忘记delete造成的内存泄漏。

检查、定位内存泄漏

  • 检查方法:在main函数最后面一行,加上一句_CrtDumpMemoryLeaks()。
  • 调试程序,自然关闭程序让其退出,查看输出:
    • 输出这样的格式{453}normal block at 0x02432CA8,868 bytes long
    • 被{}包围的453就是我们需要的内存泄漏定位值,868 bytes long就是说这个地方有868比特内存没有释放。
  • 定位代码位置
    • 在main函数第一行加上_CrtSetBreakAlloc(453);意思就是在申请453这块内存的位置中断。然后调试程序,程序中断了,查看调用堆栈。加上头文件#include <crtdbg.h>

野指针

“野指针”不是NULL指针,是指向“垃圾”内存的指针。人们一般不会错用NULL指针,因为用if语句很容易判断。但是“野指针”是很危险的,if语句对它不起作用。野指针的成因主要有两种:

  • 指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。
  • 指针p被free或者delete之后,没有置为NULL,让人误以为p是个合法的指针。(此时又叫空悬指针)free和delete只是把指针所指的内存给释放掉,但并没有把指针本身干掉。通常会用语句if (p != NULL)进行防错处理。很遗憾,此时if语句起不到防错作用,因为即便p不是NULL指针,它也不指向合法的内存块。

STL相关

clear()是否调用析构函数

vector和list的clear()函数是否会调用析构函数?

  • 如果vector中存储了对象的指针,调用clear后,并不会调用这些指针所指对象析构函数,因此要在clear之前调用delete(否则会有内存泄漏的风险);
  • 如果vector存储的是对象,调用clear后,自建类型的对象(int之类的)直接删除,若是外部类型,则调用析构函数(destroy)。

STL迭代器如何实现

  • 迭代器是一种抽象的设计理念,通过迭代器可以在不了解容器内部原理的情况下遍历容器,除此之外,STL中迭代器一个最重要的作用就是作为容器与STL算法的粘合剂。
  • 迭代器的作用就是提供一个遍历容器内部所有元素的接口,因此迭代器内部必须保存一个与容器相关联的指针,然后重载各种运算操作来遍历,其中最重要的是*运算符与->运算符,以及++、–等可能需要重载的运算符重载。这和C++中的智能指针很像,智能指针也是将一个指针封装,然后通过引用计数或是其他方法完成自动释放内存的功能。
  • 最常用的迭代器的相应型别有五种:value type、difference type、pointer、reference、iterator category;

迭代器:++it、it++哪个好,为什么

前置返回一个引用,后置返回一个对象

1
2
3
4
5
// ++i实现代码为:
int& operator++(){
*this += 1;
return *this;
}

前置不会产生临时对象,后置会产生临时对象,临时对象会导致效率降低

1
2
3
4
5
6
7
8
//i++实现代码为:                 
int operator++(int) {
int temp = *this;

++*this;

return temp;
}

STL中的allocator、deallocator

当我们初始化容器时不传入allocator时,那就自动使用STL默认的allocator

  1. 第一级配置器直接使用malloc()、free()和relloc(),第二级配置器视情况采用不同的策略:
    • 当配置区块超过128bytes时,视之为足够大,便调用第一级配置器;
    • 当配置器区块小于128bytes时,为了降低额外负担,使用复杂的内存池整理方式,而不再用一级配置器;
  2. 第二级配置器主动将任何小额区块的内存需求量上调至8的倍数,并维护16个free-list,各自管理大小为8~128bytes的小额区块;
  3. 空间配置函数allocate(),首先判断区块大小,大于128就直接调用第一级配置器,小于128时就检查对应的free-list。如果free-list之内有可用区块,就直接拿来用,如果没有可用区块,就将区块大小调整至8的倍数,然后调用refill(),为free-list重新分配空间;
  4. 空间释放函数deallocate(),该函数首先判断区块大小,大于128bytes时,直接调用一级配置器,小于128bytes就找到对应的free-list然后释放内存。
  5. 内存池中一共有16个free-lists,各自管理8,16,24,32,40……128bytes的小额区块

C++11新特性

总结几个关键的:

  • 列表初始化
  • auto关键字
  • range for
  • default来显式地说明默认构造函数
  • lambda表达式
  • unordered_map、unordered_set
  • 移动语义:右值引用,移动构造函数
  • tuple类型
  • 可变参数模板
  • decltype
  • constexpr

auto、decltype和decltype(auto)的用法

auto

C++11新标准引入了auto类型说明符,用它就能让编译器替我们去分析表达式所属的类型。和原来那些只对应某种特定的类型说明符(例如 int)不同,

auto 让编译器通过初始值来进行类型推演。从而获得定义变量的类型,所以说 auto 定义的变量必须有初始值。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//普通;类型
int a = 1, b = 3;
auto c = a + b;// c为int型

//const类型
const int i = 5;
auto j = i; // 变量i是顶层const, 会被忽略, 所以j的类型是int
auto k = &i; // 变量i是一个常量, 对常量取地址是一种底层const, 所以b的类型是const int*
const auto l = i; //如果希望推断出的类型是顶层const的, 那么就需要在auto前面加上const

//引用和指针类型
int x = 2;
int& y = x;
auto z = y; //z是int型不是int& 型
auto& p1 = y; //p1是int&型
auto p2 = &x; //p2是指针类型int*

decltype

有的时候我们还会遇到这种情况,我们希望从表达式中推断出要定义变量的类型,但却不想用表达式的值去初始化变量。还有可能是函数的返回类型为某表达式的值类型。在这些时候auto显得就无力了,所以C++11又引入了第二种类型说明符decltype,它的作用是选择并返回操作数的数据类型。

在此过程中,编译器只是分析表达式并得到它的类型,却不进行实际的计算表达式的值。

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
int func() {return 0};

//普通类型
decltype(func()) sum = 5; // sum的类型是函数func()的返回值的类型int, 但是这时不会实际调用函数func()
int a = 0;
decltype(a) b = 4; // a的类型是int, 所以b的类型也是int

//不论是顶层const还是底层const, decltype都会保留
const int c = 3;
decltype(c) d = c; // d的类型和c是一样的, 都是顶层const
int e = 4;
const int* f = &e; // f是底层const
decltype(f) g = f; // g也是底层const

//引用与指针类型
//1. 如果表达式是引用类型, 那么decltype的类型也是引用
const int i = 3, &j = i;
decltype(j) k = 5; // k的类型是 const int&

//2. 如果表达式是引用类型, 但是想要得到这个引用所指向的类型, 需要修改表达式:
int i = 3, &r = i;
decltype(r + 0) t = 5; // 此时是int类型

//3. 对指针的解引用操作返回的是引用类型
int i = 3, j = 6, *p = &i;
decltype(*p) c = j; // c是int&类型, c和j绑定在一起

//4. 如果一个表达式的类型不是引用, 但是我们需要推断出引用, 那么可以加上一对括号, 就变成了引用类型了
int i = 3;
decltype((i)) j = i; // 此时j的类型是int&类型, j和i绑定在了一起

(3)decltype(auto)

decltype(auto)是C++14新增的类型指示符,可以用来声明变量以及指示函数返回类型。在使用时,会将“=”号左边的表达式替换掉auto,再根据decltype的语法规则来确定类型。举个例子:

1
2
3
4
int e = 4;
const int* f = &e; // f是底层const
decltype(auto) j = f;//j的类型是const int* 并且指向的是e

NULL和nullptr

NULL来自C语言,一般由宏定义实现,而 nullptr 则是C++11的新增关键字。在C语言中,NULL被定义为(void*)0,而在C++语言中,NULL则被定义为整数0。编译器一般对其实际定义如下:

1
2
3
4
5
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif

在C++中指针必须有明确的类型定义。但是将NULL定义为0带来的另一个问题是无法与整数的0区分。因为C++中允许有函数重载,所以可以试想如下函数定义情况:

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

void fun(char* p) {
cout << "char*" << endl;
}

void fun(int p) {
cout << "int" << endl;
}

int main()
{
fun(NULL);
return 0;
}
//输出结果:int

那么在传入NULL参数时,会把NULL当做整数0来看,如果我们想调用参数是指针的函数,该怎么办呢?。nullptr在C++11被引入用于解决这一问题,nullptr可以明确区分整型和指针类型,能够根据环境自动转换成相应的指针类型,但不会被转换为任何整型,所以不会造成参数传递错误。

nullptr的一种实现方式如下:

1
2
3
4
5
6
7
const class nullptr_t{
public:
template<class T> inline operator T*() const{ return 0; }
template<class C, class T> inline operator T C::*() const { return 0; }
private:
void operator&() const;
} nullptr = {};

以上通过模板类和运算符重载的方式来对不同类型的指针进行实例化从而解决了(void*)指针带来参数类型不明的问题,另外由于nullptr是明确的指针类型,所以不会与整形变量相混淆。但nullptr仍然存在一定问题,例如:

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
#include <iostream>
using namespace std;

void fun(char* p)
{
cout<< "char* p" <<endl;
}
void fun(int* p)
{
cout<< "int* p" <<endl;
}

void fun(int p)
{
cout<< "int p" <<endl;
}
int main()
{
fun((char*)nullptr);//语句1
fun(nullptr);//语句2
fun(NULL);//语句3
return 0;
}
//运行结果:
//语句1:char* p
//语句2:报错,有多个匹配
//3:int p

在这种情况下存在对不同指针类型的函数重载,此时如果传入nullptr指针则仍然存在无法区分应实际调用哪个函数,这种情况下必须显示的指明参数类型。

智能指针的原理、常用的智能指针及实现

原理

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

常用的智能指针

(1) shared_ptr

实现原理:采用引用计数器的方法,允许多个智能指针指向同一个对象,每当多一个指针指向该对象时,指向该对象的所有智能指针内部的引用计数加1,每当减少一个智能指针指向对象时,引用计数会减1,当计数为0的时候会自动的释放动态分配的资源。

  • 智能指针将一个计数器与类指向的对象相关联,引用计数器跟踪共有多少个类对象共享同一指针
  • 每次创建类的新对象时,初始化指针并将引用计数置为1
  • 当对象作为另一对象的副本而创建时,拷贝构造函数拷贝指针并增加与之相应的引用计数
  • 对一个对象进行赋值时,赋值操作符减少左操作数所指对象的引用计数(如果引用计数为减至0,则删除对象),并增加右操作数所指对象的引用计数
  • 调用析构函数时,构造函数减少引用计数(如果引用计数减至0,则删除基础对象)

(2) unique_ptr

unique_ptr采用的是独享所有权语义,一个非空的unique_ptr总是拥有它所指向的资源。转移一个unique_ptr将会把所有权全部从源指针转移给目标指针,源指针被置空;

所以unique_ptr不支持普通的拷贝和赋值操作,不能用在STL标准容器中;局部变量的返回值除外(因为编译器知道要返回的对象将要被销毁);如果你拷贝一个unique_ptr,那么拷贝结束后,这两个unique_ptr都会指向相同的资源,造成在结束时对同一内存指针多次释放而导致程序崩溃。

(3) weak_ptr

weak_ptr:弱引用。

  • 引用计数有一个问题就是互相引用形成环(环形引用),这样两个指针指向的内存都无法释放。需要使用weak_ptr打破环形引用。
    • 循环引用就是:两个对象互相使用一个shared_ptr成员变量指向对方。弱引用并不对对象的内存进行管理,在功能上类似于普通指针,然而一个比较大的区别是,弱引用能检测到所管理的对象是否已经被释放,从而避免访问非法内存。
  • weak_ptr是一个弱引用,它是为了配合shared_ptr而引入的一种智能指针,它指向一个由shared_ptr管理的对象而不影响所指对象的生命周期,也就是说,它只引用,不计数。
  • 如果一块内存被shared_ptr和weak_ptr同时引用,当所有shared_ptr析构了之后,不管还有没有weak_ptr引用该内存,内存也会被释放。所以weak_ptr不保证它指向的内存一定是有效的,在使用之前使用函数lock()检查weak_ptr是否为空指针。

(4) 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
49
50
51
52
53
54
55
56
57
template <typename T>
class sharedPtr{
private:
T * ptr;
int * count;
public:
sharedPtr():ptr(nullptr),count(nullptr){}
sharedPtr(T* p):ptr(p),count(new int(1)){}
sharedPtr(const sharedPtr& s): ptr(s.ptr),count(s.count){
++(*count);
}
~SharedPtr(){
if(count == nullptr) return;
--(*count);
if (*count == 0){
delete ptr;
ptr = nullptr;
delete count;
count = nullptr;
}
}
SharedPtr<T>& operator=(const SharedPtr& s){
if (this != &s){
if (--(*count) == 0){
delete ptr;
delete pcount;
}
ptr = s.ptr;
count = s.count;
++(*count);
}
return *this;
}
sharedPtr(const sharedPtr&& dyingS): ptr(nullptr),count(nullptr){
dyingS.swap(*this);
}
SharedPtr<T>& operator=(const SharedPtr&& dyingS){
//用移动构造函数创建出一个新的shared_ptr(此时dying_obj的内容被清除了)
//再和this交换指针和引用计数
//因为this的内容被交换到了当前的临时创建的my_shared_ptr里,原this指向的引用计数-1
sharedPtr(std::move(dyingS).swap(*this));
return *this;
}
void swap(sharedPtr& other){
std::swap(ptr,other.ptr);
std::swap(count,other.count);
}
T& operator*(){
return *ptr;
}
T* operator->(){
return ptr;
}
int get_count(){
return *count;
}
}

shared_ptr的基本用法

通过 shared_ptr 的构造函数,可以让 shared_ptr 对象托管一个 new 运算符返回的指针,写法如下:

1
shared_ptr<T> ptr(new T); 

此后,ptr 就可以像 T* 类型的指针一样使用,即 *ptr 就是用 new 动态分配的那个对象。

多个 shared_ptr 对象可以共同托管一个指针 p,当所有曾经托管 p 的 shared_ptr 对象都解除了对其的托管时,就会执行delete p

例子:

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
#include "stdafx.h"
#include <iostream>
#include <future>
#include <thread>

using namespace std;
class Person{
public:
Person(int v) {
value = v;
std::cout << "Cons" <<value<< std::endl;
}
~Person() {
std::cout << "Des" <<value<< std::endl;
}

int value;

};

int main(){
std::shared_ptr<Person> p1(new Person(1));// Person(1)的引用计数为1

std::shared_ptr<Person> p2 = std::make_shared<Person>(2);

p1.reset(new Person(3));// 首先生成新对象,然后引用计数减1,引用计数为0,故析构Person(1)
// 最后将新对象的指针交给智能指针

std::shared_ptr<Person> p3 = p1;//现在p1和p3同时指向Person(3),Person(3)的引用计数为2

p1.reset();//Person(3)的引用计数为1
p3.reset();//Person(3)的引用计数为0,析构Person(3)
return 0;
}

其他

单例模式实现

懒汉式(第一次获取实例才创建),非线程安全实现(在执行过程中,可能会有多个线程同时进行 instance == nullptr 的判断,这种情况就也可能会创建出多个实例,违背单例模式初衷。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Singleton
{
public:
static Singleton* getInstance()
{
if(instance == nullptr)
instance = new Singleton();
return instance;
}

private:
static Singleton* instance = nullptr;

Singleton() {} = default;
~Singleton() {} = default;

//防止拷贝和赋值
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};

懒汉式,线程安全实现

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
#include <mutex>

class Singleton
{
public:
static Singleton* getInstance()
{
if(instance == nullptr) {
m.lock();
if(instance == nullptr){
instance = new Singleton();
}
m.unlock();
}
return instance;
}

private:
static Singleton* instance = nullptr;
static std::mutex m;

Singleton() {} = default;
~Singleton() {} = default;

//防止拷贝和赋值
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};

饿汉式(第一次获取实例前就已创建好)

故在性能需求较高时,应使用这种模式,避免频繁的锁争夺

1
2
3
4
5
6
7
8
class Singleton {
public:
static Singleton* getInstance(){
return instance;
};
private:
static Singleton* instance = new Singleton();
};

C++程序编译过程

一步到位
使用gcc命令可以一步将main.c源文件编译生成最终的可执行文件main_direct

gcc main.c –o main_direct

分步执行
gcc的编译流程通常认为包含以下四个步骤,实际上就是将上面的命令分成4步执行,这也是gcc命令实际的操作流程,生成的可执行文件main与上面单条命令生成的可执行文件main_direct是一模一样的

  • 预处理,生成预编译文件(.i文件):gcc –E main.c –o main.i
    • 根据以字符#开头的命令,修改原始的程序
  • 编译,生成汇编代码(.s文件):gcc –S main.i –o main.s
  • 汇编,生成目标文件(.o文件):gcc –c main.s –o main.o
  • 链接,生成可执行文件(executable文件):gcc main.o –o main

预处理阶段主要做以下几个方面的工作:

  1. 文件包含:#include 是 C 程序设计中最常用的预处理指令,格式有尖括号 #include <xxx.h> 和双引号 #include “xxx.h” 之分,分别表示从系统目录下查找和优先在当前目录查找,例如常用的 #include <stdio.h> 指令,就表示使用 stdio.h 文件中的全部内容,替换该行指令。
  2. 添加行号和文件名标识: 比如在文件main.i中就有类似 # 2 “main.c” 2 的内容,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
  3. 宏定义展开及处理: 预处理阶段会将使用 #define A 100 定义的常量符号进行等价替换,文中所有的宏定义符号A都会被替换成100,还会将一些内置的宏展开,比如用于显示文件全路径的__FILE__,另外还可以使用 #undef 删除已经存在的宏,比如 #undef A 就是删除之前定义的宏符号A。
  4. 条件编译处理:如 #ifdef,#ifndef,#else,#elif,#endif等,这些条件编译指令的引入使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理,将那些不必要的代码过滤掉,防止文件重复包含等。
  5. 清理注释内容: // xxx 和 /*xxx*/ 所产生的的注释内容在预处理阶段都会被删除,因为这些注释对于编写程序的人来说是用来记录和梳理逻辑代码的,但是对编译程序来说几乎没有任何用处,所以会被删除,观察 main.i 文件也会发现之前的注释都被删掉了。
  6. 特殊控制处理:保留编译器需要使用 #pragma 编译器指令,另外还有用于输出指定的错误信息,通常来调试程序的 #error 指令。

编译

编译过程是整个程序构建的核心部分,也是最复杂的部分之一,其工作就是把预处理完生成的 .i 文件进行一系列的词法分析、语法分析、语义分析以及优化后产生相应的汇编代码文件,也就是 .s 文件,这个过程调用的处理程序一般是 cc 或者 ccl。汇编语言是非常有用的,因为它给不同高级语言的不同编译器提供了可选择的通用的输出语言,比如 C 和 Fortran 编译产生的输出文件都是汇编语言。

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2022 ZHU
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信