C++语言面试的一些问题

本文最后更新于:2022年5月14日 晚上

C++语言面试的一些问题

语言基础

部分摘录于C++岗位面试真题宝典

简述C++语言的特点

  • C++在C语言的基础上引入了面向对象的机制,同时也兼容C语言。
  • C++有三大特性:封装,继承,多态。
  • C++语言编写的代码结构清晰、易于扩充,语言可读性好。
  • C++生成的代码质量高,运行效率高,仅比汇编语言慢10%~20%。
  • C++更加安全,增加了const常量、引用、四类cast转换(static_cast, dynamic_cast, const_cast, reinterpret_cast)、智能指针、try-catch等等。
  • C可复用性高,C引入了模板的概念,后面在此基础上,实现了方便开发的标准模板库STL(Standard Template Library)。
  • 同时,C是不断在发展的语言。C后续版本更是发展了不少新特性,如C++11中引入了nullptr、auto变量、Lambda匿名函数、右值引用、智能指针。

说说C语言和C++的区别

  • C语言是C的子集,C可以很好兼容C语言。但是C++又有很多新特性,如引用、智能指针、auto变量等。
  • C++是面对对象的编程语言;C语言是面对过程的编程语言。
  • C语言有一些不安全的语言特性,如指针使用的潜在危险、强制转换的不确定性、内存泄露等。而C++对此增加了不少新特性来改善安全性,如const常量、引用、cast转换、智能指针、try—catch等等;
  • C可复用性高,C引入了模板的概念,后面在此基础上,实现了方便开发的标准模板库STL。C++的STL库相对于C语言的函数库更灵活、更通用。

说说 C++中 struct 和 class 的区别

  • struct 一般用于描述一个数据结构集合,而 class 是对一个对象数据的封装;
  • struct 中默认的访问控制权限是 public 的,而 class 中默认的访问控制权限是 private 的,例如:
    1
    2
    3
    4
    5
    6
    struct A{
    int iNum; // 默认访问控制权限是 public
    }
    class B{
    int iNum; // 默认访问控制权限是 private
    }
  • 在继承关系中,struct 默认是公有继承,而 class 是私有继承;
  • class 关键字可以用于定义模板参数,就像 typename,而 struct 不能用于定义模板参数,例如:
1
2
3
4
template<typename T, typename Y>    // 可以把typename 换成 class 
int Func(const T& t, const Y& y) {
//TODO
}

备注:

  • C++ 中的 struct 是对 C 中的 struct 进行了扩充,它们在声明时的区别如下:

    C C++
    成员函数 不能有 可以
    静态成员 不能有 可以
    访问控制 默认public,不能修改 public/private/protected
    继承关系 不可以继承 可从类或者其他结构体继承
    初始化 不能直接初始化数据成员 可以
  • 使用时的区别:C 中使用结构体需要加上 struct 关键字,或者对结构体使用 typedef 取别名,而 C++ 中可以省略 struct 关键字直接使用,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    struct Student{
    int iAgeNum;
    string strName;
    }
    typedef struct Student Student2; //C中取别名

    struct Student stu1; // C 中正常使用
    Student2 stu2; // C 中通过取别名的使用
    Student stu3; // C++ 中使用

说说include头文件的顺序以及双引号""和尖括号<>的区别

  • 区别:
    • 尖括号<>的头文件是系统文件,双引号""的头文件是自定义文件。
    • 编译器预处理阶段查找头文件的路径不一样。
  • 查找路径:
    • 使用尖括号<>的头文件的查找路径:编译器设置的头文件路径–>系统变量。
    • 使用双引号""的头文件的查找路径:当前头文件目录–>编译器设置的头文件路径–>系统变量。

说说C++结构体和C结构体的区别

区别:

  • C的结构体内不允许有函数存在,C++允许有内部成员函数,且允许该函数是虚函数。
  • C的结构体对内部成员变量的访问权限只能是public,而C++允许public,protected,private三种。
  • C语言的结构体是不可以继承的,C++的结构体是可以从其他的结构体或者类继承过来的。
  • C语言中使用结构体需要加上 struct 关键字,或者对结构体使用 typedef 取别名,而 C++ 中可以省略 struct 关键字直接使用。

备注:

  • C++ 中的 struct 是对 C 中的 struct 进行了扩充,它们在声明时的区别如下:

    C C++
    成员函数 不能有 可以
    静态成员 不能有 可以
    访问控制 默认public,不能修改 public/private/protected
    继承关系 不可以继承 可从类或者其他结构体继承
    初始化 不能直接初始化数据成员 可以
  • 使用时的区别:C 中使用结构体需要加上 struct 关键字,或者对结构体使用 typedef 取别名,而 C++ 中可以省略 struct 关键字直接使用,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    struct Student{
    int iAgeNum;
    string strName;
    }
    typedef struct Student Student2; //C中取别名

    struct Student stu1; // C 中正常使用
    Student2 stu2; // C 中通过取别名的使用
    Student stu3; // C++ 中使用

导入C函数的关键字是什么,C++编译时和C有什么不同?

  • 关键字:在C中,导入C函数的关键字是extern,表达形式为extern "C"extern "C"的主要作用就是为了能够正确实现C代码调用其他C语言代码。加上extern "C"后,会指示编译器这部分代码按C语言的进行编译,而不是C++的。
  • 编译区别:由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名。

备注:

1
2
3
4
5
6
7
8
9
10
//extern示例
//在C++程序里边声明该函数,会指示编译器这部分代码按C语言的进行编译
extern "C" int strcmp(const char *s1, const char *s2);

//在C++程序里边声明该函数
extern "C"{
#include <string.h>//string.h里边包含了要调用的C函数的声明
}

//两种不同的语言,有着不同的编译规则,比如一个函数fun,可能C语言编译的时候为_fun,而C++则是__fun__

简述C++从代码到可执行二进制文件的过程

C和C语言类似,一个C程序从源码到执行文件,有四个过程,预编译、编译、汇编、链接。

备注:

  • 预编译:这个过程主要的处理操作如下:
    • 将所有的#define删除,并且展开所有的宏定义
    • 处理所有的条件预编译指令,如#if、#ifdef
    • 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。
    • 过滤所有的注释
    • 添加行号和文件名标识。
  • 编译:这个过程主要的处理操作如下:
    • 词法分析:将源代码的字符序列分割成一系列的记号。
    • 语法分析:对记号进行语法分析,产生语法树。
    • 语义分析:判断表达式是否有意义。
    • 代码优化:
    • 目标代码生成:生成汇编代码。
    • 目标代码优化:
  • 汇编:这个过程主要是将汇编代码转变成机器可以执行的指令。
  • 链接:将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。
    链接分为静态链接和动态链接。
    静态链接,是在链接的时候就已经把要调用的函数或者过程链接到了生成的可执行文件中,就算你在去把静态库删除也不会影响可执行程序的执行;生成的静态链接库,Windows下以.lib为后缀,Linux下以.a为后缀。
    而动态链接,是在链接的时候没有把调用的函数代码链接进去,而是在执行的过程中,再去找要链接的函数,生成的可执行文件中没有函数代码,只包含函数的重定位信息,所以当你删除动态库时,可执行程序就不能运行。生成的动态链接库,Windows下以.dll为后缀,Linux下以.so为后缀。

说说 static关键字的作用

  • 定义全局静态变量和局部静态变量:在变量前面加上static关键字。初始化的静态变量会在数据段分配内存,未初始化的静态变量会在BSS段分配内存。直到程序结束,静态变量始终会维持前值。只不过全局静态变量和局部静态变量的作用域不一样;
  • 定义静态函数:在函数返回类型前加上static关键字,函数即被定义为静态函数。静态函数只能在本源文件中使用;
  • 在变量类型前加上static关键字,变量即被定义为静态变量。静态变量只能在本源文件中使用;
    1
    2
    3
    //示例
    static int a;
    static void func();
  • 在c++中,static关键字可以用于定义类中的静态成员变量:使用静态数据成员,它既可以被当成全局变量那样去存储,但又被隐藏在类的内部。类中的static静态数据成员拥有一块单独的存储区,而不管创建了多少个该类的对象。所有这些对象的静态数据成员都共享这一块静态存储空间。
  • 在c++中,static关键字可以用于定义类中的静态成员函数:与静态成员变量类似,类里面同样可以定义静态成员函数。只需要在函数前加上关键字static即可。如静态成员函数也是类的一部分,而不是对象的一部分。所有这些对象的静态数据成员都共享这一块静态存储空间。

备注:
当调用一个对象的非静态成员函数时,系统会把该对象的起始地址赋给成员函数的this指针。而静态成员函数不属于任何一个对象,因此C++规定静态成员函数没有this指针(划重点,面试题常考)。既然它没有指向某一对象,也就无法对一个对象中的非静态成员进行访问。

说说数组和指针的区别

  • 概念:
    • 数组:数组是用于储存多个相同类型数据的集合。 数组名是首元素的地址。
    • 指针:指针相当于一个变量,但是它和不同变量不一样,它存放的是其它变量在内存中的地址。 指针名指向了内存的首地址。
  • 区别:
    • 赋值:同类型指针变量可以相互赋值;数组不行,只能一个一个元素的赋值或拷贝
    • 存储方式:
      • 数组:数组在内存中是连续存放的,开辟一块连续的内存空间。数组是根据数组的下进行访问的,数组的存储空间,不是在静态区就是在栈上。
      • ​指针:指针很灵活,它可以指向任意类型的数据。指针的类型说明了它所指向地址空间的内存。由于指针本身就是一个变量,再加上它所存放的也是变量,所以指针的存储空间不能确定。
    • 求sizeof:
      数组所占存储空间的内存大小:sizeof(数组名)/sizeof(数据类型)
      在32位平台下,无论指针的类型是什么,sizeof(指针名)都是4,在64位平台下,无论指针的类型是什么,sizeof(指针名)都是8。
    • 初始化:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      //数组
      int a[5] = {0};
      char b[]={"Hello"};//按字符串初始化,大小为6.
      char c[]={'H','e','l','l','o','\0'};//按字符初始化
      int* arr = new int[n];//创建一维数组

      //指针
      //指向对象的指针
      int p=new int(0) ;
      delete p;
      //指向数组的指针
      int p=new int[n];
      delete[] p;
      //指向类的指针:
      class p=new class;
      delete p;
      //指针的指针(二级指针)
      int **pp=new (int)[1];
      pp[0]=new int[6];
      delete[] pp[0];
    • 指针操作:
      数组名的指针操作
      1
      2
      3
      4
      5
      6
      7
      8
      int a[3][4]; 
      int (*p)[4]; //该语句是定义一个数组指针,指向含4个元素的一维数组
      p = a; //将该二维数组的首地址赋给p,也就是a[0]或&a[0][0]
      p++; //该语句执行过后,也就是p=p+1;p跨过行a[0][]指向了行a[1][]
      //所以数组指针也称指向一维数组的指针,亦称行指针。
      //访问数组中第i行j列的一个元素,有几种操作方式:
      //*(p[i]+j)、*(*(p+i)+j)、(*(p+i))[j]、p[i][j]。其中,优先级:()>[]>*。
      //这几种操作方式都是合法的。
      指针变量的数据操作:
      1
      2
      3
      4
      char *str = "hello,douya!";
      str[2] = 'a';
      *(str+2) = 'b';
      //这两种操作方式都是合法的。

说说什么是函数指针,如何定义函数指针,有什么使用场景

  • 概念:函数指针就是指向函数的指针变量。每一个函数都有一个入口地址,该入口地址就是函数指针所指向的地址。
  • 定义形式如下:
    1
    2
    3
    int func(int a);  
    int (*f)(int a);
    f = &func;
  • 函数指针的应用场景:回调(callback)。我们调用别人提供的 API函数(Application Programming Interface,应用程序编程接口),称为Call;如果别人的库里面调用我们的函数,就叫Callback。

备注:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//以库函数qsort排序函数为例,它的原型如下:
void qsort(void *base,//void*类型,代表原始数组
size_t nmemb, //第二个是size_t类型,代表数据数量
size_t size, //第三个是size_t类型,代表单个数据占用空间大小
int(*compar)(const void *,const void *)//第四个参数是函数指针
);
//第四个参数告诉qsort,应该使用哪个函数来比较元素,即只要我们告诉qsort比较大小的规则,它就可以帮我们对任意数据类型的数组进行排序。在库函数qsort调用我们自定义的比较函数,这就是回调的应用。

//示例
int num[100];
int cmp_int(const void* _a , const void* _b){//参数格式固定
int* a = (int*)_a; //强制类型转换
int* b = (int*)_b;
return *a - *b;
}
qsort(num,100,sizeof(num[0]),cmp_int); //回调

说说静态变量什么时候初始化?

对于C语言的全局和静态变量,初始化发生在任何代码执行之前,属于编译期初始化。
而C++标准规定:全局或静态对象当且仅当对象首次用到时才进行构造。

备注:

  • 作用域:C++里作用域可分为6种:全局,局部,类,语句,命名空间和文件作用域。
    静态全局变量 :全局作用域+文件作用域,所以无法在其他文件中使用。
    静态局部变量 :局部作用域,只被初始化一次,直到程序结束。
    类静态成员变量:类作用域。
  • 所在空间:都在静态存储区。因为静态变量都在静态存储区,所以下次调用函数的时候还是能取到原来的值。
  • 生命周期:静态全局变量、静态局部变量都在静态存储区,直到程序结束才会回收内存。类静态成员变量在静态存储区,当超出类作用域时回收内存。

nullptr调用成员函数可以吗?为什么?

能。
原因:因为在编译时对象就绑定了函数地址,和指针空不空没关系。

备注:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//给出实例
class animal{
public:
void sleep(){ cout << "animal sleep" << endl; }
void breathe(){ cout << "animal breathe haha" << endl; }
};
class fish :public animal{
public:
void breathe(){ cout << "fish bubble" << endl; }
};
int main(){
animal *pAn=nullptr;
pAn->breathe(); // 输出:animal breathe haha
fish *pFish = nullptr;
pFish->breathe(); // 输出:fish bubble
return 0;
}

原因:因为在编译时对象就绑定了函数地址,和指针空不空没关系。pAn->breathe();编译的时候,函数的地址就和指针pAn绑定了;调用breath(*this), this就等于pAn。由于函数中没有需要解引用this的地方,所以函数运行不会出错,但是若用到this,因为this=nullptr,运行出错。

说说什么是野指针,怎么产生的,如何避免?

  • 概念:野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)
  • 产生原因:释放内存后指针不及时置空(野指针),依然指向了该内存,那么可能出现非法访问的错误。这些我们都要注意避免。
  • 避免方法:
    • 初始化置nullptr
    • 申请内存后判空
    • 指针释放后置nullptr
    • 使用智能指针

备注:
产生原因:释放内存后指针不及时置空(野指针),依旧指向了该内存,那么可能出现非法访问的错误,这些我们都要避免。如:

1
2
3
4
5
6
7
char *p = (char *)malloc(sizeof(char) * 100);
strcpy(p, "Douya");
free(p); // p所指向的内存被释放,但是p所指向的地址不变

if(p != NULL){
strcpy(p, "hello, Douya!"); // 出错
}

避免方法:

  • 初始化置NULL
  • 申请内存后判空
  • 指针释放后置NULL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int *p = NULL; //初始化置NULL
p = (int *)malloc(sizeof(int)*n); //申请n个int内存空间
assert(p != NULL); //判空,防错设计
p = (int *) realloc(p, 25);//重新分配内存, p 所指向的内存块会被释放并分配一个新的内存地址
free(p);
p = NULL; //释放后置空

int *p1 = NULL; //初始化置NULL
p1 = (int *)calloc(n, sizeof(int)); //申请n个int内存空间同时初始化为0
assert(p1 != NULL); //判空,防错设计
free(p1);
p1 = NULL; //释放后置空

int *p2 = NULL; //初始化置NULL
p2 = new int[n]; //申请n个int内存空间
assert(p2 != NULL); //判空,防错设计
delete []p2;
p2 = nullptr; //释放后置空

说说静态局部变量,全局变量,局部变量的特点,以及使用场景

  • 首先从作用域考虑:C++里作用域可分为6种:全局,局部,类,语句,命名空间和文件作用域。
    • 全局变量:全局作用域,可以通过extern作用于其他非定义的源文件。
    • 静态全局变量 :全局作用域+文件作用域,所以无法在其他文件中使用。
    • 局部变量:局部作用域,比如函数的参数,函数内的局部变量等等。
    • 静态局部变量 :局部作用域,只被初始化一次,直到程序结束。
  • 从所在空间考虑:除了局部变量在栈上外,其他都在静态存储区。因为静态变量都在静态存储区,所以下次调用函数的时候还是能取到原来的值。
  • 生命周期: 局部变量在栈上,出了作用域就回收内存;而全局变量、静态全局变量、静态局部变量都在静态存储区,直到程序结束才会回收内存。
  • 使用场景:从它们各自特点就可以看出各自的应用场景,不再赘述。

说说内联函数和宏函数的区别

  • 宏定义不是函数,但是使用起来像函数。预处理器用复制宏代码的方式代替函数的调用,省去了函数压栈退栈过程,提高了效率;而内联函数本质上是一个函数,内联函数一般用于函数体的代码比较简单的函数,不能包含复杂的控制语句,while、switch,并且内联函数本身不能直接调用自身。
  • 宏函数是在预编译的时候把所有的宏名用宏体来替换,简单的说就是字符串替换;而内联函数则是在编译的时候进行代码插入,编译器会在每处调用内联函数的地方直接把内联函数的内容展开,这样可以省去函数的调用的开销,提高效率
  • 宏定义是没有类型检查的,无论对还是错都是直接替换;而内联函数在编译的时候会进行类型的检查,内联函数满足函数的性质,比如有返回值、参数列表等

备注:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//宏定义示例
#define MAX(a,b) ((a)>(b)?(a):(b))
MAX(a,"Hello"); //错误地比较int和字符串,没有参数类型检查

//内联函数示例
#include <stdio.h>
inline int add(int a, int b){
return (a + b);
}
int main(void){
int a;
a = add(1, 2);
printf("a+b=%d\n", a);
return 0;
}
//以上a = add(1, 2);处在编译时将被展开为:a = (a + b);
  • 使用时的一些注意事项:
    • 使用宏定义一定要注意错误情况的出现,比如宏定义函数没有类型检查,可能传进来任意类型,从而带来错误,如举例。还有就是括号的使用,宏在定义时要小心处理宏参数,一般用括号括起来,否则容易出现二义性
    • inline函数一般用于比较小的,频繁调用的函数,这样可以减少函数调用带来的开销。只需要在函数返回类型前加上关键字inline,即可将函数指定为inline函数。
    • 同其它函数不同的是,最好将inline函数定义在头文件,而不仅仅是声明,因为编译器在处理inline函数时,需要在调用点内联展开该函数,所以仅需要函数声明是不够的。
  • 内联函数使用的条件:
    • 内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率 的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。以下情况不宜使用内联:
      • 如果函数体内的代码比较长,使用内联将导致内存消耗代价较高。
      • 如果函数体内出现循环,那么执行函数体内代码的时间要比函数调用的开销大。
    • 内联不是什么时候都能展开的,一个好的编译器将会根据函数的定义体,自动地取消不符合要求的内联。

说说运算符ii的区别

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
int main(){
int i = 2;
int j = 2;
j += i++; //先赋值后加
printf("i= %d, j= %d\n",i, j); //i= 3, j= 4
i = 2;
j = 2;
j += ++i; //先加后赋值
printf("i= %d, j= %d",i, j); //i= 3, j= 5
}
  • 赋值顺序不同:i 是先加后赋值;i 是先赋值后加;i和i都是分两步完成的。
  • 效率不同:后置++执行速度比前置的慢。
  • i++ 不能作为左值,而++i 可以:
    1
    2
    3
    4
    5
    int i = 0;
    int *p1 = &(++i);//正确
    int *p2 = &(i++);//错误
    ++i = 1//正确
    i++ = 1//错误
  • 两者都不是原子操作。

说说new和malloc的区别,各自底层实现原理。

  • new是操作符,而malloc是函数。
  • new在调用的时候先分配内存,在调用构造函数,释放的时候调用析构函数;而malloc没有构造函数和析构函数。
  • malloc需要给定申请内存的大小,返回的指针需要强转;new会调用构造函数,不用指定内存的大小,返回指针不用强转。
  • new可以被重载;malloc不行
  • new分配内存更直接和安全
  • new发生错误抛出异常,malloc返回null

备注:
malloc底层实现:当开辟的空间小于 128K 时,调用 brk()函数;当开辟的空间大于 128K 时,调用mmap()。malloc采用的是内存池的管理方式,以减少内存碎片。先申请大块内存作为堆区,然后将堆区分为多个内存块。当用户申请内存时,直接从堆区分配一块合适的空闲快。采用隐式链表将所有空闲块,每一个空闲块记录了一个未分配的、连续的内存地址。

new底层实现:关键字new在调用构造函数的时候实际上进行了如下的几个步骤:

  • 创建一个新的对象
  • 将构造函数的作用域赋值给这个新的对象(因此this指向了这个新的对象)
  • 执行构造函数中的代码(为这个新对象添加属性)
  • 返回新对象

说说const和define的区别。

const用于定义常量;而define用于定义宏,而宏也可以用于定义常量。都用于常量定义时,它们的区别有:

  • const生效于编译的阶段;define生效于预处理阶段。
  • const定义的常量,在C语言中是存储在内存中、需要额外的内存空间的;define定义的常量,运行时是直接的操作数,并不会存放在内存中。
  • const定义的常量是带类型的;define定义的常量不带类型。因此define定义的常量不利于类型检查。

说说C++中函数指针和指针函数的区别。

  • 定义不同
    指针函数本质是一个函数,其返回值为指针。
    函数指针本质是一个指针,其指向一个函数。
  • 写法不同
    1
    2
    指针函数:int *fun(int x,int y);
    函数指针:int (*fun)(int x,int 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
    30
    //指针函数示例
    typedef struct _Data{
    int a;
    int b;
    }Data;
    //指针函数
    Data* f(int a,int b){
    Data * data = new Data;
    //...
    return data;
    }
    int main(){
    //调用指针函数
    Data * myData = f(4,5);
    //Data * myData = static_cast<Data*>(f(4,5));
    //...
    }

    //函数指针示例
    int add(int x,int y){
    return x+y;
    }
    //函数指针
    int (*fun)(int x,int y);
    //赋值
    fun = add;
    //调用
    cout << "(*fun)(1,2) = " << (*fun)(1,2) ;
    //输出结果
    //(*fun)(1,2) = 3

说说const int *a, int const *a, const int a, int *const a, const int *const a分别是什么,有什么特点。

1
2
3
4
5
const int a;     //指的是a是一个常量,不允许修改。
const int *a; //a指针所指向的内存里的值不变,即(*a)不变
int const *a; //同const int *a;
int *const a; //a指针所指向的内存地址不变,即a不变
const int *const a; //都不变,即(*a)不变,a也不变

说说使用指针需要注意什么?

  • 定义指针时,先初始化为NULL。
  • 用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。
  • 不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。
  • 避免数字或指针的下标越界,特别要当心发生“多1”或者“少1”操作
  • 动态内存的申请与释放必须配对,防止内存泄漏
  • 用free或delete释放了内存之后,立即将指针设置为NULL,防止“野指针”

备注:

  • 初始化置NULL
  • 申请内存后判空
  • 指针释放后置NULL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int *p = NULL; //初始化置NULL
p = (int *)malloc(sizeof(int)*n); //申请n个int内存空间
assert(p != NULL); //判空,防错设计
p = (int *) realloc(p, 25);//重新分配内存, p 所指向的内存块会被释放并分配一个新的内存地址
free(p);
p = NULL; //释放后置空

int *p1 = NULL; //初始化置NULL
p1 = (int *)calloc(n, sizeof(int)); //申请n个int内存空间同时初始化为0
assert(p1 != NULL); //判空,防错设计
free(p1);
p1 = NULL; //释放后置空

int *p2 = NULL; //初始化置NULL
p2 = new int[n]; //申请n个int内存空间
assert(p2 != NULL); //判空,防错设计
delete []p2;
p2 = nullptr; //释放后置空

说说内联函数和函数的区别,内联函数的作用。

  • 内联函数比普通函数多了关键字inline
  • 内联函数避免了函数调用的开销;普通函数有调用的开销
  • 普通函数在被调用的时候,需要寻址(函数入口地址);内联函数不需要寻址。
  • 内联函数有一定的限制,内联函数体要求代码简单,不能包含复杂的结构控制语句;普通函数没有这个要求。

内联函数的作用: 内联函数在调用时,是将调用表达式用内联函数体来替换。避免函数调用的开销。

备注:
在使用内联函数时,应注意如下几点:

  • 在内联函数内不允许用循环语句和开关语句。
    如果内联函数有这些语句,则编译将该函数视同普通函数那样产生函数调用代码,递归函数是不能被用来做内联函数的。内联函数只适合于只有1~5行的小函数。对一个含有许多语句的大函数,函数调用和返回的开销相对来说微不足道,所以也没有必要用内联函数实现。
  • 内联函数的定义必须出现在内联函数第一次被调用之前。

简述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;

void testfunc(int a, int *b, int &c){//形参a值发生了改变,但是没有影响实参i的值;但形参*b、c的值发生了改变,影响到了实参*j、k的值
a += 1;
(*b) += 1;
c += 1;
printf("a= %d, b= %d, c= %d\n",a,*b,c);//a= 2, b= 2, c= 2
}
int main(){
int i = 1;
int a = 1;
int *j = &a;
int k = 1;
testfunc(i, j, k);
printf("i= %d, j= %d, k= %d\n",i,*j,k);//i= 1, j= 2, k= 2
return 0;
}

简述const(星号)和(星号)const的区别

1
2
3
4
//const*是常量指针,*const是指针常量

int const *a; //a指针所指向的内存里的值不变,即(*a)不变
int *const a; //a指针所指向的内存地址不变,即a不变

C++内存

简述一下堆和栈的区别

区别:

  • 堆栈空间分配不同。栈由操作系统自动分配释放,存放函数的参数值,局部变量的值等;堆一般由程序员分配释放。
  • 堆栈缓存方式不同。栈使用的是一级缓存,它们通常都是被调用时处于存储空间中,调用完毕立即释放;堆则是存放在二级缓存中,速度要慢些。
  • 堆栈数据结构不同。堆类似数组结构;栈类似栈结构,先进后出。

简述C++的内存管理

  • 内存分配方式:
    在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

    • 栈,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。
    • 堆,就是那些由new分配的内存块,一般一个new就要对应一个delete。
    • 自由存储区,就是那些由malloc等分配的内存块,和堆是十分相似的,不过是用free来结束自己的生命。
    • 全局/静态存储区,全局变量和静态变量被分配到同一块内存中
    • 常量存储区,这是一块比较特殊的存储区,里面存放的是常量,不允许修改。
  • 常见的内存错误及其对策:

    • 内存分配未成功,却使用了它
    • 内存分配虽然成功了,但是尚未初始化就引用了它
    • 内存分配成功并且已经初始化,但是操作越过了内存的边界
    • 忘记释放内存,造成了内存的泄露
    • 释放了内存,却继续使用它

    对策:

    • 定义指针的时候,先初始化为NULL
    • 用malloc或者new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。
    • 不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。
    • 避免数字或指针的下标越界,特别要当心发生“多1”或者“少1”操作
    • 动态内存的申请与释放必须配对,防止内存泄漏
    • 用free或delete释放了内存之后,立即将指针设置为NULL,防止“野指针”
    • 使用智能指针。
  • 内存泄露及解决办法:
    什么是内存泄露?
    简单地说就是申请了一块内存空间,使用完毕后没有释放掉。

    • new和malloc申请资源使用后,没有用delete和free释放;
    • 子类继承父类时,父类析构函数不是虚函数。
    • Windows句柄资源使用后没有释放。

    怎么检测?

    • 良好的编码习惯,使用了内存分配的函数,一旦使用完毕,要记得使用其相应的函数释放掉。
    • 将分配的内存的指针以链表的形式自行管理,使用完毕之后从链表中删除,程序结束时可检查改链表。
    • 使用智能指针。
    • 一些常见的工具插件,如ccmalloc、Dmalloc、Leaky、Valgrind等等。

malloc和局部变量分配在堆上还是栈上

malloc是在堆上分配内存,需要程序员自己回收内存;局部变量实在栈上分配内存,超过作用域就自动回收。

程序有哪些section,分别的作用?程序启动的过程?怎么判断数据分配在栈上还是堆上?

linux memory

一个程序有哪些section:
如上图,从低地址到高地址,一个程序由代码段、数据段、 BSS 段组成。

  • 数据段:存放程序中已初始化的全局变量和静态变量的一块内存区域。
  • 代码段:存放程序执行代码的一块内存区域。只读,代码段的头部还会包含一些只读的常数变量。
  • BSS 段:存放程序中未初始化的全局变量和静态变量的一块内存区域。
  • 可执行程序在运行时又会多出两个区域:堆区和栈区。
    • 堆区:动态申请内存用。堆从低地址向高地址增长。
    • 栈区:存储局部变量、函数参数值。栈从高地址向低地址增长。是一块连续的空间。
  • 最后还有一个文件映射区,位于堆和栈之间。

程序启动的过程:

  • 操作系统首先创建相应的进程并分配私有的进程空间,然后操作系统的加载器负责把可执行文件的数据段和代码段映射到进程的虚拟内存空间中。
  • 加载器读入可执行程序的导入符号表,根据这些符号表可以查找出该可执行程序的所有依赖的动态链接库。
  • 加载器针对该程序的每一个动态链接库调用LoadLibrary
    • 查找对应的动态库文件,加载器为该动态链接库确定一个合适的基地址。
    • 加载器读取该动态链接库的导入符号表和导出符号表,比较应用程序要求的导入符号是否匹配该库的导出符号。
    • 针对该库的导入符号表,查找对应的依赖的动态链接库,如有跳转,则跳到3
    • 调用该动态链接库的初始化函数
  • 初始化应用程序的全局变量,对于全局对象自动调用构造函数。
  • 进入应用程序入口点函数开始执行。

怎么判断数据分配在栈上还是堆上: 首先局部变量分配在栈上;而通过malloc和new申请的空间是在堆上。

初始化为0的全局变量在BSS还是DATA

BSS段通常是指用来存放程序中未初始化的或者初始化为0的全局变量和静态变量的一块内存区域。特点是可读写的,在程序执行之前BSS段会自动清0。

什么是内存泄露,内存泄露怎么检测?

什么是内存泄露?
简单地说就是申请了一块内存空间,使用完毕后没有释放掉。

  • new和malloc申请资源使用后,没有用delete和free释放
  • 子类继承父类时,父类析构函数不是虚函数
  • Windows句柄资源使用后没有释放

怎么检测?

  • 良好的编码习惯,使用了内存分配的函数,一旦使用完毕,要记得使用其相应的函数释放掉。
  • 将分配的内存的指针以链表的形式自行管理,使用完毕之后从链表中删除,程序结束时可检查改链表。
  • 使用智能指针。
  • 一些常见的工具插件,如ccmalloc、Dmalloc、Leaky、Valgrind等等。

请简述一下atomoic内存顺序。

有六个内存顺序选项可应用于对原子类型的操作:

  • memory_order_relaxed:在原子类型上的操作以自由序列执行,没有任何同步关系,仅对此操作要求原子性。
  • memory_order_consume:memory_order_consume只会对其标识的对象保证该对象存储先行于那些需要加载该对象的操作。
  • memory_order_acquire:使用memory_order_acquire的原子操作,当前线程的读写操作都不能重排到此操作之前。
  • memory_order_release:使用memory_order_release的原子操作,当前线程的读写操作都不能重排到此操作之后。
  • memory_order_acq_rel:memory_order_acq_rel在此内存顺序的读-改-写操作既是获得加载又是释放操作。没有操作能够从此操作之后被重排到此操作之前,也没有操作能够从此操作之前被重排到此操作之后。
  • memory_order_seq_cst:memory_order_seq_cst比std::memory_order_acq_rel更为严格。memory_order_seq_cst不仅是一个"获取释放"内存顺序,它还会对所有拥有此标签的内存操作建立一个单独全序。

除非你为特定的操作指定一个顺序选项,否则内存顺序选项对于所有原子类型默认都是memory_order_seq_cst。

内存模型,堆栈,常量区。

linux memory

内存模型(内存布局):

如上图,从低地址到高地址,一个程序由代码段、数据段、 BSS 段组成

  • 数据段:存放程序中已初始化的全局变量和静态变量的一块内存区域
  • 代码段:存放程序执行代码的一块内存区域。只读,代码段的头部还会包含一些只读的常数变量
  • BSS 段:存放程序中未初始化的全局变量和静态变量的一块内存区域
  • 可执行程序在运行时又会多出两个区域:堆区和栈区
    • 堆区:动态申请内存用。堆从低地址向高地址增长
    • 栈区:存储局部变量、函数参数值。栈从高地址向低地址增长。是一块连续的空间
  • 最后还有一个文件映射区,位于堆和栈之间

堆heap: 由new分配的内存块,其释放由程序员控制(一个new对应一个delete)
栈stack: 是那些编译器在需要时分配,在不需要时自动清除的存储区。存放局部变量、函数参数。
常量存储区: 存放常量,不允许修改。

简述C++中内存对齐的使用场景

内存对齐应用于三种数据类型中:struct/class/union
struct/class/union内存对齐原则有四个:

  • 数据成员对齐规则:结构(struct)或联合(union)的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小的整数倍开始。
  • 结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部"最宽基本类型成员"的整数倍地址开始存储。(struct a里存有struct b,b里有char,int ,double等元素,那b应该从8的整数倍开始存储)。
  • 收尾工作:结构体的总大小,也就是sizeof的结果,必须是其内部最大成员的"最宽基本类型成员"的整数倍。不足的要补齐。(基本类型不包括struct/class/uinon)。
  • sizeof(union),以结构里面size最大元素为union的size,因为在某一时刻,union只有一个成员真正存储于该地址。

备注:

  • 什么是内存对齐?
    那么什么是字节对齐?在C语言中,结构体是一种复合数据类型,其构成元素既可以是基本数据类型(如int、long、float等)的变量,也可以是一些复合数据类型(如数组、结构体、联合体等)的数据单元。在结构体中,编译器为结构体的每个成员按其自然边界(alignment)分配空间。各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构体的地址相同。
    为了使CPU能够对变量进行快速的访问,变量的起始地址应该具有某些特性,即所谓的“对齐”,比如4字节的int型,其起始地址应该位于4字节的边界上,即起始地址能够被4整除,也即“对齐”跟数据在内存中的位置有关。如果一个变量的内存地址正好位于它长度的整数倍,他就被称做自然对齐。
    比如在32位cpu下,假设一个整型变量的地址为0x00000004(为4的倍数),那它就是自然对齐的,而如果其地址为0x00000002(非4的倍数)则是非对齐的。现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。

  • 为什么要字节对齐?
    需要字节对齐的根本原因在于CPU访问数据的效率问题。假设上面整型变量的地址不是自然对齐,比如为0x00000002,则CPU如果取它的值的话需要访问两次内存,第一次取从0x00000002-0x00000003的一个short,第二次取从0x00000004-0x00000005的一个short然后组合得到所要的数据,如果变量在0x00000003地址上的话则要访问三次内存,第一次为char,第二次为short,第三次为char,然后组合得到整型数据。
    而如果变量在自然对齐位置上,则只要一次就可以取出数据。一些系统对对齐要求非常严格,比如sparc系统,如果取未对齐的数据会发生错误,而在x86上就不会出现错误,只是效率下降。
    各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据。显然在读取效率上下降很多。

  • 字节对齐实例

    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
    union example {  
    int a[5];
    char b;
    double c;
    };
    int result = sizeof(example);
    /*
    如果以最长20字节为准,内部double占8字节,这段内存的地址0x00000020并不是double的整数倍,只有当最小为0x00000024时可以满足整除double(8Byte)同时又可以容纳int a[5]的大小,所以正确的结果应该是result=24
    */

    struct example {
    int a[5];
    char b;
    double c;
    }test_struct;
    int result = sizeof(test_struct);
    /*
    如果我们不考虑字节对齐,那么内存地址0x0021不是double(8Byte)的整数倍,所以需要字节对齐,那么此时满足是double(8Byte)的整数倍的最小整数是0x0024,说明此时char b对齐int扩充了三个字节。所以最后的结果是result=32
    */

    struct example {
    char b;
    double c;
    int a;
    }test_struct;
    int result = sizeof(test_struct);
    /*
    字节对齐除了内存起始地址要是数据类型的整数倍以外,还要满足一个条件,那就是占用的内存空间大小需要是结构体中占用最大内存空间的类型的整数倍,所以20不是double(8Byte)的整数倍,我们还要扩充四个字节,最后的结果是result=24
    */

C++面向对象

简述一下什么是面向对象

  • 面向对象是一种编程思想,把一切东西看成是一个个对象,比如人、耳机、鼠标、水杯等,他们各自都有属性,比如:耳机是白色的,鼠标是黑色的,水杯是圆柱形的等等,把这些对象拥有的属性变量和操作这些属性变量的函数打包成一个类来表示
  • 面向过程和面向对象的区别
    面向过程:根据业务逻辑从上到下写代码
    面向对象:将数据与函数绑定到一起,进行封装,这样能够更快速的开发程序,减少了重复代码的重写过程

简述一下面向对象的三大特征

面向对象的三大特征是封装、继承、多态。

  • 封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行 交互。封装本质上是一种管理:我们如何管理兵马俑呢?比如如果什么都不管,兵马俑就被随意破坏了。那么我们首先建了一座房子把兵马俑给封装起来。但是我们目的全封装起来,不让别人看。所以我们开放了售票通 道,可以买票突破封装在合理的监管机制下进去参观。类也是一样,不想给别人看到的,我们使用protected/private把成员封装起来。开放一些共有的成员函数对成员合理的访问。所以封装本质是一种管理。

  • 继承:可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。

    继承方式 private继承 protected继承 public继承
    基类的private成员 不可见 不可见 不可见
    基类的protected成员 变为private成员 仍为protected成员 仍为protected成员
    基类的public成员 变为private成员 变为protected成员 仍为public成员仍为public成员
  • 多态:用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。实现多态,有二种方式,重写,重载。

简述一下 C++ 的重载和重写,以及它们的区别

  • 重写
    是指派生类中存在重新定义的函数。其函数名,参数列表,返回值类型,所有都必须同基类中被重写的函数一致。只有函数体不同(花括号内),派生类对象调用时会调用派生类的重写函数,不会调用被重写函数。重写的基类中被重写的函数必须有virtual修饰。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    #include<bits/stdc++.h>
    using namespace std;
    class A{
    public:
    virtual void fun(){
    cout << "A";
    }
    };
    class B :public A{
    public:
    virtual void fun(){
    cout << "B";
    }
    };
    int main(void){
    A* a = new B();
    a->fun();//输出B,A类中的fun在B类中重写
    }
  • 重载
    我们在平时写代码中会用到几个函数但是他们的实现功能相同,但是有些细节却不同。例如:交换两个数的值其中包括(int, float,char,double)这些个类型。在C语言中我们是利用不同的函数名来加以区分。这样的代码不美观而且给程序猿也带来了很多的不便。于是在C++中人们提出了用一个函数名定义多个函数,也就是所谓的函数重载。函数重载是指同一可访问区内被声明的几个具有不同参数列(参数的类型,个数,顺序不同)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型。

说说 C++ 的重载和重写是如何实现的

  • C利用命名倾轧(name mangling)技术,来改名函数名,区分参数不同的同名函数。命名倾轧是在编译阶段完成的。
    C
    定义同名重载函数:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #include<iostream>
    using namespace std;
    int func(int a,double b){
    return ((a)+(b));
    }
    int func(double a,float b){
    return ((a)+(b));
    }
    int func(float a,int b){
    return ((a)+(b));
    }
    int main(){
    return 0;
    }
    编译g++ -c test.cpp -o test.o之后,用objdump -t test.o查看符号表
    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
    test.o:     file format elf64-x86-64

    SYMBOL TABLE:
    0000000000000000 l df *ABS* 0000000000000000 test.cpp
    0000000000000000 l d .text 0000000000000000 .text
    0000000000000000 l d .data 0000000000000000 .data
    0000000000000000 l d .bss 0000000000000000 .bss
    0000000000000000 l d .rodata 0000000000000000 .rodata
    0000000000000000 l O .rodata 0000000000000001 _ZStL19piecewise_construct
    0000000000000000 l O .bss 0000000000000001 _ZStL8__ioinit
    0000000000000071 l F .text 000000000000004d _Z41__static_initialization_and_destruction_0ii
    00000000000000be l F .text 0000000000000019 _GLOBAL__sub_I__Z4funcid
    0000000000000000 l d .init_array 0000000000000000 .init_array
    0000000000000000 l d .note.GNU-stack 0000000000000000 .note.GNU-stack
    0000000000000000 l d .note.gnu.property 0000000000000000 .note.gnu.property
    0000000000000000 l d .eh_frame 0000000000000000 .eh_frame
    0000000000000000 l d .comment 0000000000000000 .comment
    0000000000000000 g F .text 0000000000000020 _Z4funcid
    0000000000000020 g F .text 0000000000000022 _Z4funcdf
    0000000000000042 g F .text 0000000000000020 _Z4funcfi
    0000000000000062 g F .text 000000000000000f main
    0000000000000000 *UND* 0000000000000000 _GLOBAL_OFFSET_TABLE_
    0000000000000000 *UND* 0000000000000000 _ZNSt8ios_base4InitC1Ev
    0000000000000000 *UND* 0000000000000000 .hidden __dso_handle
    0000000000000000 *UND* 0000000000000000 _ZNSt8ios_base4InitD1Ev
    0000000000000000 *UND* 0000000000000000 __cxa_atexit
    由此可得,d代表double,f代表float,i代表int,加上参数首字母以区分同名函数。
  • 在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。
    • 用virtual关键字申明的函数叫做虚函数,虚函数肯定是类的成员函数。
    • 存在虚函数的类都有一个一维的虚函数表叫做虚表,类的对象有一个指向虚表开始的虚指针。虚表是和类对应的,虚表指针是和对象对应的。
    • 多态性是一个接口多种实现,是面向对象的核心,分为类的多态性和函数的多态性。
    • 重写用虚函数来实现,结合动态绑定。
    • 纯虚函数是虚函数再加上 = 0
    • 抽象类是指包括至少一个纯虚函数的类。
      纯虚函数:virtual void fun()=0。即抽象类必须在子类实现这个函数,即先有名称,没有内容,在派生类实现内容。

说说 C 语言如何实现 C++ 语言中的重载

C语言中不允许有同名函数,因为编译时函数命名是一样的,不像C++会添加参数类型和返回类型作为函数编译后的名称,进而实现重载。如果要用c语言显现函数重载,可通过以下方式来实现:

  • 使用函数指针来实现,重载的函数不能使用同名称,只是类似的实现了函数重载功能
  • 重载函数使用可变参数,方式如打开文件open函数
  • gcc有内置函数,程序使用编译函数可以实现函数重载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include<stdio.h>

void func_int(void * a){
printf("%d\n",*(int*)a); //输出int类型,注意 void * 转化为int
}

void func_double(void * b){
printf("%.2f\n",*(double*)b);
}

typedef void (*ptr)(void *); //typedef申明一个函数指针

void c_func(ptr p,void *param){
p(param); //调用对应函数
}

int main(){
int a = 23;
double b = 23.23;
c_func(func_int,&a);
c_func(func_double,&b);
return 0;
}

说说构造函数有几种,分别什么作用

C++中的构造函数可以分为4类:默认构造函数、初始化构造函数、拷贝构造函数、移动构造函数。

  • 默认构造函数和初始化构造函数。 在定义类的对象的时候,完成对象的初始化工作。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class Student{
    public:
    //默认构造函数
    Student(){
    num=1001;
    age=18;
    }
    //初始化构造函数
    Student(int n,int a):num(n),age(a){}
    private:
    int num;
    int age;
    };
    int main(){
    //用默认构造函数初始化对象S1
    Student s1;
    //用初始化构造函数初始化对象S2
    Student s2(1002,18);
    return 0;
    }

    有了有参的构造了,编译器就不提供默认的构造函数。
  • 拷贝构造函数
    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 "stdafx.h"
    #include "iostream.h"

    class Test{
    int i;
    int *p;
    public:
    Test(int ai,int value){
    i = ai;
    p = new int(value);
    }
    ~Test(){
    delete p;
    }
    Test(const Test& t){
    this->i = t.i;
    this->p = new int(*t.p);
    }
    };
    //复制构造函数用于复制本类的对象
    int main(int argc, char* argv[]){
    Test t1(1,2);
    Test t2(t1);//将对象t1复制给t2。注意复制和赋值的概念不同
    return 0;
    }
    赋值构造函数默认实现的是值拷贝(浅拷贝)。
  • 移动构造函数。用于将其他类型的变量,隐式转换为本类对象。下面的转换构造函数,将int类型的r转换为Student类型的对象,对象的age为r,num为1004。
    1
    2
    3
    4
    Student(int r){
    int num=1004
    int age= r;
    }

只定义析构函数,会自动生成哪些构造函数

只定义了析构函数,编译器将自动为我们生成拷贝构造函数和默认构造函数。
默认构造函数和初始化构造函数。 在定义类的对象的时候,完成对象的初始化工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Student{
public:
//默认构造函数
Student(){
num=1001;
age=18;
}
//初始化构造函数
Student(int n,int a):num(n),age(a){}
private:
int num;
int age;
};
int main(){
//用默认构造函数初始化对象S1
Student s1;
//用初始化构造函数初始化对象S2
Student s2(1002,18);
return 0;
}

有了有参的构造了,编译器就不提供默认的构造函数。
拷贝构造函数

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

class Test{
int i;
int *p;
public:
Test(int ai,int value){
i = ai;
p = new int(value);
}
~Test(){
delete p;
}
Test(const Test& t){
this->i = t.i;
this->p = new int(*t.p);
}
};
//复制构造函数用于复制本类的对象
int main(int argc, char* argv[]){
Test t1(1,2);
Test t2(t1);//将对象t1复制给t2。注意复制和赋值的概念不同。
return 0;
}

赋值构造函数默认实现的是值拷贝(浅拷贝)。

备注:
示例如下:

1
2
3
4
5
6
7
8
class HasPtr{
public:
HasPtr(const string& s = string()) :ps(new string(s)), i(0) {}
~HasPtr() { delete ps; }
private:
string * ps;
int i;
};

如果类外面有这样一个函数:

1
2
3
4
5
HasPtr f(HasPtr hp){
HasPtr ret = hp;
///... 其他操作
return ret;
}

当函数执行完了之后,将会调用hp和ret的析构函数,将hp和ret的成员ps给delete掉,但是由于ret和hp指向了同一个对象,因此该对象的ps成员被delete了两次,这样产生一个未定义的错误,所以说,如果一个类定义了析构函数,那么它要定义自己的拷贝构造函数和默认构造函数。

说说一个类,默认会生成哪些函数

定义一个空类

1
class Empty { };

默认会生成以下几个函数

  • 无参的构造函数
    在定义类的对象的时候,完成对象的初始化工作。
    1
    Empty() { }
  • 拷贝构造函数
    拷贝构造函数用于复制本类的对象
    1
    Empty(const Empty& copy) { }
  • 赋值运算符
    1
    Empty& operator = (const Empty& copy) { }
  • 析构函数(非虚)
    1
    ~Empty() { }

说说 C++ 类对象的初始化顺序,有多重继承情况下的顺序

  • 创建派生类的对象,基类的构造函数优先被调用(也优先于派生类里的成员类);
  • 如果类里面有成员类,成员类的构造函数优先被调用;(也优先于该类本身的构造函数)
  • 基类构造函数如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序而不是它们在成员初始化表中的顺序;
  • 成员类对象构造函数如果有多个成员类对象,则构造函数的调用顺序是对象在类中被声明的顺序而不是它们出现在成员初始化表中的顺序;
  • 派生类构造函数,作为一般规则派生类构造函数应该不能直接向一个基类数据成员赋值而是把值传递给适当的基类构造函数,否则两个类的实现变成紧耦合的(tightly coupled)将更加难于正确地修改或扩展基类的实现。(基类设计者的责任是提供一组适当的基类构造函数)
  • 综上可以得出,初始化顺序:
    父类构造函数–>成员类对象构造函数–>自身构造函数
    其中成员变量的初始化与声明顺序有关,构造函数的调用顺序是类派生列表中的顺序。
    析构顺序和构造顺序相反。

简述下向上转型和向下转型

  • 子类转换为父类:向上转型,使用dynamic_cast(expression),这种转换相对来说比较安全不会有数据的丢失;
  • 父类转换为子类:向下转型,可以使用强制转换,这种转换时不安全的,会导致数据的丢失,原因是父类的指针或者引用的内存中可能不包含子类的成员的内存。

简述下深拷贝和浅拷贝,如何实现深拷贝

  • 浅拷贝:又称值拷贝,将源对象的值拷贝到目标对象中去,本质上来说源对象和目标对象共用一份实体,只是所引用的变量名不同,地址其实还是相同的。举个简单的例子,你的小名叫西西,大名叫冬冬,当别人叫你西西或者冬冬的时候你都会答应,这两个名字虽然不相同,但是都指的是你。
  • 深拷贝,拷贝的时候先开辟出和源对象大小一样的空间,然后将源对象里的内容拷贝到目标对象中去,这样两个指针就指向了不同的内存位置。并且里面的内容是一样的,这样不但达到了我们想要的目的,还不会出现问题,两个指针先后去调用析构函数,分别释放自己所指向的位置。即为每次增加一个指针,便申请一块新的内存,并让这个指针指向新的内存,深拷贝情况下,不会出现重复释放同一块内存的错误。
  • 深拷贝的实现:深拷贝的拷贝构造函数和赋值运算符的重载传统实现:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    STRING( const STRING& s ){
    //_str = s._str;
    _str = new char[strlen(s._str) + 1];
    strcpy_s( _str, strlen(s._str) + 1, s._str );
    }
    STRING& operator=(const STRING& s){
    if (this != &s){
    //this->_str = s._str;
    delete[] _str;
    this->_str = new char[strlen(s._str) + 1];
    strcpy_s(this->_str, strlen(s._str) + 1, s._str);
    }
    return *this;
    }
    这里的拷贝构造函数我们很容易理解,先开辟出和源对象一样大的内存区域,然后将需要拷贝的数据复制到目标拷贝对象 , 那么这里的赋值运算符的重载是怎么样做的呢?
    这种方法解决了我们的指针悬挂问题,通过不断的开空间让不同的指针指向不同的内存,以防止同一块内存被释放两次的问题。

简述一下 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;

    int Add(int a,int b)//1
    {
    return a+b;
    }

    char Add(char a,char b)//2
    {
    return a+b;
    }

    int main()
    {
    cout<<Add(666,888)<<endl;//1
    cout<<Add('1','2');//2
    return 0;
    }
    显然,第一条语句会调用函数1,而第二条语句会调用函数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
      26
      27
      28
      29
      30
      31
      32
      33
      34
      //协变测试函数
      #include<iostream>
      using namespace std;

      class Base
      {
      public:
      virtual Base* FunTest()
      {
      cout << "victory" << endl;
      return this;
      }
      };

      class Derived :public Base
      {
      public:
      virtual Derived* FunTest()
      {
      cout << "yeah" << endl;
      return this;
      }
      };

      int main()
      {
      Base b;
      Derived d;

      b.FunTest();
      d.FunTest();

      return 0;
      }

说说为什么要虚析构,为什么不能虚构造

  • 虚析构:将可能会被继承的父类的析构函数设置为虚函数,可以保证当我们new一个子类,然后使用基类指针指向该子类对象,释放基类指针时可以释放掉子类的空间,防止内存泄漏。如果基类的析构函数不是虚函数,在特定情况下会导致派生来无法被析构。
    • 用派生类类型指针绑定派生类实例,析构的时候,不管基类析构函数是不是虚函数,都会正常析构
    • 用基类类型指针绑定派生类实例,析构的时候,如果基类析构函数不是虚函数,则只会析构基类,不会析构派生类对象,从而造成内存泄漏。为什么会出现这种现象呢,个人认为析构的时候如果没有虚函数的动态绑定功能,就只根据指针的类型来进行的,而不是根据指针绑定的对象来进行,所以只是调用了基类的析构函数;如果基类的析构函数是虚函数,则析构的时候就要根据指针绑定的对象来调用对应的析构函数了。
      C默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针,占用额外的内存。而对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。因此C默认的析构函数不是虚函数,而是只有当需要当作父类时,设置为虚函数。
  • 不能虚构造:
    • 从存储空间角度:虚函数对应一个vtable,这个表的地址是存储在对象的内存空间的。如果将构造函数设置为虚函数,就需要到vtable中调用,可是对象还没有实例化,没有内存空间分配,如何调用。(悖论)
    • 从使用角度:虚函数主要用于在信息不全的情况下,能使重载的函数得到对应的调用。构造函数本身就是要初始化实例,那使用虚函数也没有实际意义呀。所以构造函数没有必要是虚函数。虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
    • 从实现上看,vtable在构造函数调用后才建立,因而构造函数不可能成为虚函数。从实际含义上看,在调用构造函数时还不能确定对象的真实类型(因为子类会调父类的构造函数);而且构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有太大的必要成为虚函数。

说说模板类是在什么时候实现的

  • 模板实例化:模板的实例化分为显示实例化和隐式实例化,前者是研发人员明确的告诉模板应该使用什么样的类型去生成具体的类或函数,后者是在编译的过程中由编译器来决定使用什么类型来实例化一个模板不管是显示实例化或隐式实例化,最终生成的类或函数完全是按照模板的定义来实现的
  • 模板具体化:当模板使用某种类型类型实例化后生成的类或函数不能满足需要时,可以考虑对模板进行具体化。具体化时可以修改原模板的定义,当使用该类型时,按照具体化后的定义实现,具体化相当于对某种类型进行特殊处理。
  • 代码示例:
    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 <iostream>
    using namespace std;

    // #1 模板定义
    template<class T>
    struct TemplateStruct{
    TemplateStruct(){
    cout << sizeof(T) << endl;
    }
    };

    // #2 模板显示实例化
    template struct TemplateStruct<int>;

    // #3 模板具体化
    template<> struct TemplateStruct<double>{
    TemplateStruct() {
    cout << "--8--" << endl;
    }
    };

    int main(){
    TemplateStruct<int> intStruct;
    TemplateStruct<double> doubleStruct;

    // #4 模板隐式实例化
    TemplateStruct<char> llStruct;
    }
    运行结果:
    1
    2
    3
    4
    --8--
    1

说说类继承时,派生类对不同关键字修饰的基类方法的访问权限

类中的成员可以分为三种类型,分别为public成员、protected成员、public成员。类中可以直接访问自己类的public、protected、private成员,但类对象只能访问自己类的public成员。

  • public继承:派生类可以访问基类的public、protected成员,不可以访问基类的private成员;派生类对象可以访问基类的public成员,不可以访问基类的protected、private成员。
  • protected继承:派生类可以访问基类的public、protected成员,不可以访问基类的private成员;派生类对象不可以访问基类的public、protected、private成员。
  • private继承:派生类可以访问基类的public、protected成员,不可以访问基类的private成员;派生类对象不可以访问基类的public、protected、private成员。

简述一下移动构造函数,什么库用到了这个函数?

C++11中新增了移动构造函数。与拷贝类似,移动也使用一个对象的值设置另一个对象的值。但是,又与拷贝不同的是,移动实现的是对象值真实的转移(源对象到目的对象):源对象将丢失其内容,其内容将被目的对象占有。移动操作的发生的时候,是当移动值的对象是未命名的对象的时候。这里未命名的对象就是那些临时变量,甚至都不会有名称。典型的未命名对象就是函数的返回值或者类型转换的对象。使用临时对象的值初始化另一个对象值,不会要求对对象的复制:因为临时对象不会有其它使用,因而,它的值可以被移动到目的对象。做到这些,就要使用移动构造函数和移动赋值:当使用一个临时变量对对象进行构造初始化的时候,调用移动构造函数。类似的,使用未命名的变量的值赋给一个对象时,调用移动赋值操作。
移动操作的概念对对象管理它们使用的存储空间很有用的,诸如对象使用new和delete分配内存的时候。在这类对象中,拷贝和移动是不同的操作:从A拷贝到B意味着,B分配了新内存,A的整个内容被拷贝到为B分配的新内存上。
而从A移动到B意味着分配给A的内存转移给了B,没有分配新的内存,它仅仅包含简单地拷贝指针。
看下面的例子:

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

class Example6 {
string* ptr;
public:
Example6 (const string& str) : ptr(new string(str)) {}
~Example6 () {delete ptr;}
// 移动构造函数,参数x不能是const Pointer&& x,
// 因为要改变x的成员数据的值;
// C++98不支持,C++0x(C++11)支持
Example6 (Example6&& x) : ptr(x.ptr) {
x.ptr = nullptr;
}
// move assignment
Example6& operator= (Example6&& x) {
delete ptr;
ptr = x.ptr;
x.ptr=nullptr;
return *this;
}
// access content:
const string& content() const {return *ptr;}
// addition:
Example6 operator+(const Example6& rhs) {
return Example6(content()+rhs.content());
}
};
int main () {
Example6 foo("Exam"); // 构造函数
// Example6 bar = Example6("ple"); // 拷贝构造函数
Example6 bar(move(foo)); // 移动构造函数
// 调用move之后,foo变为一个右值引用变量,
// 此时,foo所指向的字符串已经被"掏空",
// 所以此时不能再调用foo
bar = bar + bar; // 移动赋值,在这儿"="号右边的加法操作,
// 产生一个临时值,即一个右值
// 所以此时调用移动赋值语句
cout << "foo's content: " << foo.content() << '\n';
return 0;
}

执行结果:

1
foo's content: ExamExam

请你回答一下 C++ 类内可以定义引用数据成员吗?

C++类内可以定义引用成员变量,但要遵循以下三个规则:

  • 不能用默认构造函数初始化,必须提供构造函数来初始化引用成员变量。否则会造成引用未初始化错误。
  • 构造函数的形参也必须是引用类型。
  • 不能在构造函数里初始化,必须在初始化列表中进行初始化。

构造函数为什么不能被声明为虚函数?

  • 从存储空间角度:虚函数对应一个vtable,这个表的地址是存储在对象的内存空间的。如果将构造函数设置为虚函数,就需要到vtable中调用,可是对象还没有实例化,没有内存空间分配,如何调用。(悖论)
  • 从使用角度:虚函数主要用于在信息不全的情况下,能使重载的函数得到对应的调用。构造函数本身就是要初始化实例,那使用虚函数也没有实际意义呀。所以构造函数没有必要是虚函数。虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
  • 从实现上看,vtable在构造函数调用后才建立,因而构造函数不可能成为虚函数。从实际含义上看,在调用构造函数时还不能确定对象的真实类型(因为子类会调父类的构造函数);而且构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有太大的必要成为虚函数。

简述一下什么是常函数,有什么作用

类的成员函数后面加 const,表明这个函数不会对这个类对象的数据成员(准确地说是非静态数据成员)作任何改变。在设计类的时候,一个原则就是对于不改变数据成员的成员函数都要在后面加const,而对于改变数据成员的成员函数不能加 const。所以 const 关键字对成员函数的行为作了更明确的限定:有 const 修饰的成员函数(指 const 放在函数参数表的后面,而不是在函数前面或者参数表内),只能读取数据成员,不能改变数据成员;没有 const 修饰的成员函数,对数据成员则是可读可写的。除此之外,在类的成员函数后面加 const 还有什么好处呢?那就是常量(即 const)对象可以调用 const 成员函数,而不能调用非const修饰的函数。正如非const类型的数据可以给const类型的变量赋值一样,反之则不成立。

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

class CStu
{
public:
int a;
CStu()
{
a = 12;
}

void Show() const
{
//a = 13; //常函数不能修改数据成员
cout <<a << "I am show()" << endl;
}
};

int main()
{
CStu st;
st.Show();
system("pause");
return 0;
}

说说什么是虚继承,解决什么问题,如何实现?

虚继承是解决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 A{
public:
int _a;
};
class B :virtual public A
{
public:
int _b;
};
class C :virtual public A
{
public:
int _c;
};
class D :public B, public C
{
public:
int _d;
};
//菱形继承和菱形虚继承的对象模型
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
cout << sizeof(D) << endl;
return 0;
}

分别从菱形继承和虚继承来分析:
菱形继承中A在B,C,D,中各有一份,虚继承中,A共享。
上面的虚继承表实际上是一个指针数组。B、C实际上是虚基表指针,指向虚基表。
虚基表:存放相对偏移量,用来找虚基类

简述一下虚函数和纯虚函数,以及实现原理

  • C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。如果调用非虚函数,则无论实际对象是什么类型,都执行基类类型所定义的函数。非虚函数总是在编译时根据调用该函数的对象,引用或指针的类型而确定。如果调用虚函数,则直到运行时才能确定调用哪个函数,运行的虚函数是引用所绑定或指针所指向的对象所属类型定义的版本。虚函数必须是基类的非静态成员函数。虚函数的作用是实现动态联编,也就是在程序的运行阶段动态地选择合适的成员函数,在定义了虚函数后,可以在基类的派生类中对虚函数重新定义,在派生类中重新定义的函数应与虚函数具有相同的形参个数和形参类型。以实现统一的接口,不同定义过程。如果在派生类中没有对虚函数重新定义,则它继承其基类的虚函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class Person{
    public:
    //虚函数
    virtual void GetName(){
    cout<<"PersonName:xiaosi"<<endl;
    };
    };
    class Student:public Person{
    public:
    void GetName(){
    cout<<"StudentName:xiaosi"<<endl;
    };
    };
    int main(){
    //指针
    Person *person = new Student();
    //基类调用子类的函数
    person->GetName();//StudentName:xiaosi
    }

    虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。

  • 纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加=0virtual void GetName() = 0)。在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。为了解决上述问题,将函数定义为纯虚函数,则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。将函数定义为纯虚函数能够说明,该函数为后代类型提供了可以覆盖的接口,但是这个类中的函数绝不会调用。声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。必须在继承类中重新声明函数(不要后面的=0)否则该派生类也不能实例化,而且它们在抽象类中往往没有定义。定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。纯虚函数的意义,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    //抽象类
    class Person{
    public:
    //纯虚函数
    virtual void GetName()=0;
    };
    class Student:public Person{
    public:
    Student(){
    };
    void GetName(){
    cout<<"StudentName:xiaosi"<<endl;
    };
    };
    int main(){
    Student student;
    }

说说纯虚函数能实例化吗,为什么?派生类要实现吗,为什么?

  • 纯虚函数不可以实例化,但是可以用其派生类实例化,示例如下:

    1
    2
    3
    4
    5
    6
    class Base
    {
    public:
    virtual void func() = 0;
    };

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

    using namespace std;

    class Base
    {
    public:
    virtual void func() = 0;
    };

    class Derived :public Base
    {
    public:
    void func() override
    {
    cout << "哈哈" << endl;
    }
    };

    int main()
    {
    Base *b = new Derived();
    b->func();

    return 0;
    }
  • 虚函数的原理采用 vtable。类中含有纯虚函数时,其vtable不完全,有个空位。
    即“纯虚函数在类的vtable表中对应的表项被赋值为0。也就是指向一个不存在的函数。由于编译器绝对不允许有调用一个不存在的函数的可能,所以该类不能生成对象。在它的派生类中,除非重写此函数,否则也不能生成对象。”
    所以纯虚函数不能实例化。

  • 纯虚函数是在基类中声明的虚函数,它要求任何派生类都要定义自己的实现方法,以实现多态性。

  • 定义纯虚函数是为了实现一个接口,用来规范派生类的行为,也即规范继承这个类的程序员必须实现这个函数。派生类仅仅只是继承函数的接口。纯虚函数的意义在于,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但基类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。

说说C++中虚函数与纯虚函数的区别

  • 虚函数和纯虚函数可以定义在同一个类中,含有纯虚函数的类被称为抽象类,而只含有虚函数的类不能被称为抽象类。
  • 虚函数可以被直接使用,也可以被子类重载以后,以多态的形式调用,而纯虚函数必须在子类中实现该函数才可以使用,因为纯虚函数在基类有声明而没有定义。
  • 虚函数和纯虚函数都可以在子类中被重载,以多态的形式被调用。
  • 虚函数和纯虚函数通常存在于抽象基类之中,被继承的子类重载,目的是提供一个统一的接口。
  • 虚函数的定义形式:virtual{};纯虚函数的定义形式:virtual { } = 0;在虚函数和纯虚函数的定义中不能有static标识符,原因很简单,被static修饰的函数在编译时要求前期绑定,然而虚函数却是动态绑定,而且被两者修饰的函数生命周期也不一样。

备注:

  • 我们举个虚函数的例子:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    class A
    {
    public:
    virtual void foo()
    {
    cout<<"A::foo() is called"<<endl;
    }
    };
    class B:public A
    {
    public:
    void foo()
    {
    cout<<"B::foo() is called"<<endl;
    }
    };
    int main(void)
    {
    A *a = new B();
    a->foo(); // 在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B的!
    return 0;
    }
    这个例子是虚函数的一个典型应用,通过这个例子,也许你就对虚函数有了一些概念。它虚就虚在所谓“推迟联编”或者“动态联编”上,一个类函数的调用并不是在编译时刻被确定的,而是在运行时刻被确定的。由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以被成为“虚”函数。
    虚函数只能借助于指针或者引用来达到多态的效果。
  • 纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加=0
    virtual void function1() = 0
    为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。
    在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。
    为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:virtual ReturnType Function()= 0;),则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。
    声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。
    纯虚函数最显著的特征是:它们必须在继承类中重新声明函数(不要后面的=0,否则该派生类也不能实例化),而且它们在抽象类中往往没有定义。
    定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。
    纯虚函数的意义,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。

C++语言面试的一些问题
https://dianhsu.top/2022/05/10/cpp/
作者
Dian Hsu
发布于
2022年5月10日
许可协议