title: C面试准备
date: 2022-03-02 21:40:12
tags: C
一.C++基础
1.语言基础
1.1 野指针是什么?
- 概念:野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)
- 产生原因:释放内存后指针不及时置空(野指针),依然指向了该内存,那么可能出现非法访问的错误。这些我们都要注意避免。
- 避免办法:
(1)初始化置NULL
(2)申请内存后判空
(3)指针释放后置NULL
(4)使用智能指针 ```c 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; //释放后置空
<a name="f6461ff8"></a>
### 1.2 内联函数和宏函数的区别
区别:
1. **宏定义不是函数**,但是使用起来像函数。预处理器用复制宏代码的方式代替函数的调用,省去了函数压栈退栈过程,提高了效率;**而内联函数本质上是一个函数**,内联函数一般用于函数体的代码比较简单的函数,不能包含复杂的控制语句,while、switch,并且内联函数本身不能直接调用自身。
2. **宏函数**是在预编译的时候把所有的宏名用宏体来替换,简单的说就是字符串替换 ;**而内联函数**则是在编译的时候进行代码插入,编译器会在每处调用内联函数的地方直接把内联函数的内容展开,这样可以省去函数的调用的开销,提高效率
3. **宏定义**是没有类型检查的,无论对还是错都是直接替换;**而内联函数**在编译的时候会进行类型的检查,内联函数满足函数的性质,比如有返回值、参数列表等
**答案解析**
```c
//宏定义示例
#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);
1、使用时的一些注意事项:
- 使用宏定义一定要注意错误情况的出现,比如宏定义函数没有类型检查,可能传进来任意类型,从而带来错误,如举例。还有就是括号的使用,宏在定义时要小心处理宏参数,一般用括号括起来,否则容易出现二义性
- inline函数一般用于比较小的,频繁调用的函数,这样可以减少函数调用带来的开销。只需要在函数返回类型前加上关键字inline,即可将函数指定为inline函数。
- 同其它函数不同的是,最好将inline函数定义在头文件,而不仅仅是声明,因为编译器在处理inline函数时,需要在调用点内联展开该函数,所以仅需要函数声明是不够的。
2、内联函数使用的条件:
- 内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率 的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。以下情况不宜使用内联:
- (1)如果函数体内的代码比较长,使用内联将导致内存消耗代价较高。
- (2)如果函数体内出现循环,那么执行函数体内代码的时间要比函数调用的开销大。
- (3)内联不是什么时候都能展开的,一个好的编译器将会根据函数的定义体,自动地取消不符合要求的内联。
在使用内联函数时,应注意如下几点:- 在内联函数内不允许用循环语句和开关语句。
如果内联函数有这些语句,则编译将该函数视同普通函数那样产生函数调用代码,递归函数是不能被用来做内联函数的。内联函数只适合于只有1~5行的小函数。对一个含有许多语句的大函数,函数调用和返回的开销相对来说微不足道,所以也没有必要用内联函数实现。 - 内联函数的定义必须出现在内联函数第一次被调用之前。
- 在内联函数内不允许用循环语句和开关语句。
1.3 运算符i和i的区别
参考回答
先看到实现代码:
#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都是分两步完成的。
- 效率不同:后置++执行速度比前置的慢6。
- i++ 不能作为左值(这是因为可以把i看做是右值引用的),而i 可以:
int i = 0;
int *p1 = &(++i);//正确
int *p2 = &(i++);//错误
++i = 1;//正确
i++ = 1;//错误
- 两者都不是原子操作。
1.4 new和malloc的区别,各自底层实现原理。
参考回答
- new是操作符,而malloc是函数。
- new在调用的时候先分配内存,在调用构造函数,释放的时候调用析构函数;而malloc没有构造函数和析构函数。
- malloc需要给定申请内存的大小,返回的指针需要强转;new会调用构造函数,不用指定内存的大小,返回指针不用强转。
- new可以被重载;malloc不行
- new分配内存更直接和安全。
- new发生错误抛出异常bac_alloc,malloc返回null
- new会先调用operator new函数,申请足够的内存(通常底层使用malloc实现)。然后调用类型的构造函数,初始化成员变量,最后返回自定义类型指针。delete先调用析构函数,然后调用operator delete函数释放内存(通常底层使用free实现)。malloc/free是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构工作。
答案解析
malloc底层实现:当开辟的空间小于 128K 时,调用 brk()函数;当开辟的空间大于 128K 时,调用mmap()。malloc采用的是内存池的管理方式,以减少内存碎片。先申请大块内存作为堆区,然后将堆区分为多个内存块。当用户申请内存时,直接从堆区分配一块合适的空闲快。采用隐式链表将所有空闲块,每一个空闲块记录了一个未分配的、连续的内存地址。
new底层实现:关键字new在调用构造函数的时候实际上进行了如下的几个步骤:
- 创建一个新的对象
- 将构造函数的作用域赋值给这个新的对象(因此this指向了这个新的对象)
- 执行构造函数中的代码(为这个新对象添加属性)
- 返回新对象
1.5 C++中函数指针和指针函数的区别
参考回答
- 定义不同
指针函数本质是一个函数,其返回值为指针。
函数指针本质是一个指针,其指向一个函数。 - 写法不同
指针函数:int *fun(int x,int y);
函数指针:int (*fun)(int x,int y);
- 用法不同
//指针函数示例
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分别是什么,有什么特点。
参考回答
const是常量指针,const是指针常量
1. const int a; //指的是a是一个常量,不允许修改。
2. const int *a; //a指针所指向的内存里的值不变,即(*a)不变
3. int const *a; //同const int *a;
4. int *const a; //a指针所指向的内存地址不变,即a不变
5. const int *const a; //都不变,即(*a)不变,a也不变
1.6 函数异常
(1)try throw catech语句的使用
(2)可以在定义函数时使用 int func() throw(int,double,A,B,C){} 可能会将int double 以及ABC类型的异常抛出,但是如果throw中为空,那么不会抛出任何异常出来。
(3)异常类型
- bad_typeid:使用typeid运算符,如果其操作数是一个多态类的指针,而该指针的值为 NULL,则会拋出此异常。
- bad_cast:在用 dynamic_cast 进行从多态基类对象(或引用)到派生类的引用的强制类型转换时,如果转换是不安全的,则会拋出此异常
- bad_alloc:在用 new 运算符进行动态内存分配时,如果没有足够的内存,则会引发此异常
- out_of_range:用 vector 或 string的at 成员函数根据下标访问元素时,如果下标越界,则会拋出此异常
1.7 static的用法和作用
1.先来介绍它的第一条也是最重要的一条:隐藏。(static函数,static变量均可)当同时编译多个文件时,所有未加static前缀的全局变量和函数都具有全局可见性。
2.static的第二个作用是保持变量内容的持久。(static变量中的记忆功能和全局生存期)存储在静态数据区的变量会在程序刚开始运行时就完成初始化,也是唯一的一次初始化。共有两种变量存储在静态存储区:全局变量和static变量,只不过和全局变量比起来,static可以控制变量的可见范围,说到底static还是用来隐藏的。
3.static的第三个作用是默认初始化为0(static变量)。其实全局变量也具备这一属性,因为全局变量也存储在静态数据区。在静态数据区,内存中所有的字节默认值都是0x00,某些时候这一特点可以减少程序员的工作量。
4.static的第四个作用:C++中的类成员声明static
1) 函数体内static变量的作用范围为该函数体,不同于auto变量,该变量的内存只被分配一次,因此其值在下次调用时仍维持上次的值;
2) 在模块内的static全局变量可以被模块内所用函数访问,但不能被模块外其它函数访问;
3) 在模块内的static函数只可被这一模块内的其它函数调用,这个函数的使用范围被限制在声明它的模块内;
4) 在类中的static成员变量属于整个类所拥有,对类的所有对象只有一份拷贝;
5) 在类中的static成员函数属于整个类所拥有,这个函数不接收this指针,因而只能访问类的static成员变量。
类内:
6) static类对象必须要在类外进行初始化,static修饰的变量先于对象存在,所以static修饰的变量要在类外初始化;
7) 由于static修饰的类成员属于类,不属于对象,因此static类成员函数是没有this指针的,this指针是指向本对象的指针。正因为没有this指针,所以static类成员函数不能访问非static的类成员,只能访问 static修饰的类成员;
8) static成员函数不能被virtual修饰,static成员不属于任何对象或实例,所以加上virtual没有任何实际意义;静态成员函数没有this指针,虚函数的实现是为每一个对象分配一个vptr指针,而vptr是通过this指针调用的,所以不能为virtual;虚函数的调用关系,this->vptr->ctable>virtual function1.8
1.9
1.10
1.11
1.12
1.13
1.14
2.内存
2.1 简述C++的内存管理
- 内存分配方式:
在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。
栈,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。
堆,就是那些由new分配的内存块,一般一个new就要对应一个delete。
自由存储区,就是那些由malloc等分配的内存块,和堆是十分相似的,不过是用free来结束自己的生命。
全局/静态存储区,全局变量和静态变量被分配到同一块内存中
常量存储区,这是一块比较特殊的存储区,里面存放的是常量,不允许修改。 - 常见的内存错误及其对策:
(1)内存分配未成功,却使用了它。
(2)内存分配虽然成功,但是尚未初始化就引用它。
(3)内存分配成功并且已经初始化,但操作越过了内存的边界。
(4)忘记了释放内存,造成内存泄露。
(5)释放了内存却继续使用它。
对策:
(1)定义指针时,先初始化为NULL。
(2)用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。
(3)不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。
(4)避免数字或指针的下标越界,特别要当心发生“多1”或者“少1”操作
(5)动态内存的申请与释放必须配对,防止内存泄漏
(6)用free或delete释放了内存之后,立即将指针设置为NULL,防止“野指针”
(7)使用智能指针。
2.2 内存泄露及解决办法:
什么是内存泄露?
简单地说就是申请了一块内存空间,使用完毕后没有释放掉。(1)new和malloc申请资源使用后,没有用delete和free释放;(2)子类继承父类时,父类析构函数不是虚函数。(3)Windows句柄资源使用后没有释放。
怎么检测?
第一:良好的编码习惯,使用了内存分配的函数,一旦使用完毕,要记得使用其相应的函数释放掉。
第二:将分配的内存的指针以链表的形式自行管理,使用完毕之后从链表中删除,程序结束时可检查改链表。
第三:使用智能指针。
第四:一些常见的工具插件,如ccmalloc、Dmalloc、Leaky、Valgrind等等。
2.3 初始化为0的全局变量在bss还是data
BSS段通常是指用来存放程序中未初始化的或者初始化为0的全局变量和静态变量的一块内存区域。特点是可读写的,在程序执行之前BSS段会自动清0。
2.4 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数据。显然在读取效率上下降很多。 ``` 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
/
<a name="b06ca76e"></a>
## 3.C++11新特性
> C++新特性主要包括包含语法改进和标准库扩充两个方面,主要包括以下11点:
>
> 1. 语法的改进<br />(1)统一的初始化方法<br />(2)成员变量默认初始化<br />(3)auto关键字 用于定义变量,编译器可以自动判断的类型(前提:定义一个变量时对其进行初始化)<br />(4)decltype 求表达式的类型<br />(5)智能指针 shared_ptr<br />(6)空指针 nullptr(原来NULL)<br />(7)基于范围的for循环<br />(8)右值引用和move语义 让程序员有意识减少进行深拷贝操作
> 2. 标准库扩充(往STL里新加进一些模板类,比较好用)<br />(9)无序容器(哈希表) 用法和功能同map一模一样,区别在于哈希表的效率更高<br />(10)正则表达式 可以认为正则表达式实质上是一个字符串,该字符串描述了一种特定模式的字符串<br />(11)Lambda表达式
<a name="350bdf1d"></a>
### 3.1 decltype
decltype(e)是复制 e 的类型,auto 是自动识别返回信息的具体内容大小。
<a name="133462cc"></a>
### 3.2 C++11 右值引用
概念 1:
左值: 可以放到等号左边的东西叫左值。
右值: 不可以放到等号左边的东西就叫右值。
概念 2:
左值: 可以取地址并且有名字的东西就是左值。
右值: 不能取地址的没有名字的东西就是右值
| 左值 | l 函数名和变量名 l 返回左值引用的函数调用 l 前置自增自减表达式++i--i l 由赋值表达式或赋值运算符连接的表达式(a=b, a += b 等) l 解引用表达式*p l 字符串字面值"abcd |
| --- | --- |
| 纯右值 | l 除字符串字面值外的字面值 l 返回非引用类型的函数调用 l 后置自增自减表达式 i++、 i-- l 算术表达式(a+b, a*b, a&&b, a==b 等) l 取地址表达式等(&a) |
| 将亡值 | 通常指将要被移动的对象、 T&&函数的返回值、 std::move 函数的返回值、转换为 T&&类型转换函数的返回值, 将亡值可以理解为即将要销毁的值, 通过“盗取”其它变量内存空间方式获取的值。在确保其它变量不再被使用或者即将被销毁时, 可以避免内存空间的释放和分配, 延长变量值的生命周期, 常用来完成移动构造或者移动赋值的特殊任务。 |
**左值引用**就是对左值进行引用的类型, **右值引用**就是对右值进行引用的类型, 他们都是引用,都是对象的一个别名, 并不拥有所绑定对象的堆存, 所以都必须立即初始化
对于左值引用, 等号右边的值必须可以取地址, 如果不能取地址, 则会编译失败, 或者可以使用 const 引用形式, 但这样就只能通过引用来读取输出, 不能修改数组, 因为是常量引用。
如果使用右值引用, **那表达式等号右边的值需要时右值, 可以使用** **std::move** **函数强制把左值转换为右值。**
<a name="a42c6f08"></a>
### 3.3 深拷贝和浅拷贝
**深拷贝**就是再拷贝对象时, 如果**被拷贝对象内部还有指针引用指向其它资源**, 自己需要**重新开辟一块新的内存存储资源**, 而不只是简单的赋值。
**浅拷贝就是直接在拷贝对象时候,拷贝所有对象**(包括目标的指针)
Class A{
A();
~A();
A(const A& a){ A.size = a.size; //浅拷贝的过程
A.size = new int[a.size] ; //深拷贝的过程
}
};
//但是这样的深拷贝复制需要存储空间,更好的想法是直接进行move操作。
class A { public: A(int size) : size_(size)
{
data_ = new int[size];
}
A() {}
A(const A &a) //拷贝构造函数,深拷贝。
{
size_ = *a*.size_;
data_ = new int[size_];
cout << "copy " << endl;
}
A(A &&a) //移动构造函数,拷贝之后将原有的指针指向nullptr。
{
this->data = a.data;
a.data_ = nullptr;
cout << “move “ << endl;
}
~A() //析构函数,对象撤销时使用。
{
if (data_ != nullptr)
{
delete[] data_;
}
}
int *data_;
int size_;
};
int main()
{
A a(10);
A b = a;
A c = std::move(a); // 调用移动构造函数
return 0;
}
如果不使用 std::move(), 会有很大的拷贝代价, 使用移动语义可以避免很多无用的拷贝。
提供程序性能, C++所有的 STL 都实现了移动语义, 方便我们使用。
**移动语义**仅针对于那些实现了移动构造函数的类的对象,对于那种基本类型 int、 float 等没有任何优化作用, 还是会拷贝, 因为它们实现没有对应的移动构造函数。
**完美转发**指可以写一个接受任意实参的函数模板, 并转发到其它函数, 目标函数会收到与转发函数完全相同的实参,
转发函数实参是左值那目标函数实参也是左值, 转发函数实参是右值那目标函数实参也是右值。
<a name="e1e751bf"></a>
### 3.4 新特性之模板的改进
**C++11** **之前是不允许两个右尖括号出现的, 会被认为是右移操作符, 所以需要中间加个空格进行分割, 避免发生编译错误。**
**直接使用using来代替typedef,可以轻松的来定义别名。**
<a name="afc9e4a4"></a>
### 3.5 列表初始化
列表的初始化具体的好处大小是: 它是能够防止 类型窄化的。
<a name="f2948472"></a>
### 3.6 std::function 和 lambda 表达式
<a name="166d8b9a"></a>
### 3.7 C++11多并发
<a name="5f9fe9f1"></a>
### 3.8 智能指针
四种指针各自特性
**(1)auto_ptr**
auto指针存在的问题是,两个智能指针同时指向一块内存,就会两次释放同一块资源,自然报错。
**(2)unique_ptr**
unique指针规定一个智能指针独占一块内存资源。当两个智能指针同时指向一块内存,编译报错。
**实现原理:**将拷贝构造函数和赋值拷贝构造函数申明为private或delete。不允许拷贝构造函数和赋值操作符,但是支持移动构造函数,通过std:move把一个对象指针变成右值之后可以移动给另一个unique_ptr。
**(3)shared_ptr**
共享指针可以实现多个智能指针指向相同对象,该对象和其相关资源会在引用为0时被销毁释放。
**实现原理:**有一个引用计数的指针类型变量,专门用于引用计数,使用拷贝构造函数和赋值拷贝构造函数时,引用计数加1,当引用计数为0时,释放资源。
> 智能指针发生内存泄露的情况
>
> **当两个对象同时使用一个shared_ptr成员变量指向对方,会造成循环引用,使引用计数失效,从而导致内存泄露。**
**注意:**weak_ptr、shared_ptr存在一个问题,当两个shared_ptr指针相互引用时,那么这两个指针的引用计数不会下降为0,资源得不到释放。因此引入weak_ptr,weak_ptr是弱引用,weak_ptr的构造和析构不会引起引用计数的增加或减少。
| 指针 | 定义 | 使用方法 |
| --- | --- | --- |
| shared_ptr | shared_ptr 使用了引用计数, 每一个 shared_ptr 的拷贝都指向相同的内存, 每次拷贝都会触发引用计数+1, 每次生命周期结束析构的时候引用计数-1, 在最后一个 shared_ptr 析构的时候, 内存才会释放 | |
| weak_ptr | weak_ptr 是用来监视 shared_ptr 的生命周期, 它不管理 shared_ptr 内部的指针, 它的拷贝的析构都不会影响引用计数, 纯粹 是作为一个旁观者监视 shared_ptr 中管理的资源是否存在, 可以用来返回 this 指针和解决循环引用问题。 | |
| unique_ptr | 是一个独占型的智能指针, 它不允许其它智能指针共享其内部指针, 也不允许 unique_ptr 的拷贝和赋值。 使用 方法和 shared_ptr 类似, 区别是不可以拷贝 | |
<a name="7b98a297"></a>
### 3.9 类型转换
四种类型转换
C++中四种类型转换分别为**const_cast、static_cast、dynamic_cast、reinterpret_cast**,四种转换功能分别如下:
1. **const_cast**
将const变量转为非const
2. **static_cast**
最常用,可以用于各种隐式转换,比如非const转const,static_cast可以用于类向上转换,但向下转换能成功但是不安全。
3. **dynamic_cast**
**只能用于含有虚函数的类转换**,用于类向上和向下转换
**向上转换:**指子类向基类转换。
**向下转换:**指基类向子类转换。
这两种转换,子类包含父类,当父类转换成子类时可能出现非法内存访问的问题。
> dynamic_cast通过判断变量运行时类型和要转换的类型是否相同来判断是否能够进行向下转换。dynamic_cast可以做类之间上下转换,转换的时候会进行类型检查,类型相等成功转换,类型不等转换失败。运用RTTI技术,RTTI是”Runtime Type Information”的缩写,意思是运行时类型信息,它提供了运行时确定对象类型的方法。在c++层面主要体现在dynamic_cast和typeid。
4. **reinterpret_cast**
reinterpret_cast可以做任何类型的转换,不过不对转换结果保证,容易出问题。
**注意:**为什么不用C的强制转换:C的强制转换表面上看起来功能强大什么都能转,但是转换不够明确,不能进行错误检查,容易出错。
<a name="fa7278ec"></a>
## 4.面向对象
<a name="f131fe94"></a>
### 4.1 面向对象的三大特征
**参考回答**
面向对象的三大特征是**封装、继承、多态**。
1. 封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行 交互。封装本质上是一种管理:我们如何管理兵马俑呢?比如如果什么都不管,兵马俑就被随意破坏了。那么我们首先建了一座房子把兵马俑给封装起来。但是我们目的全封装起来,不让别人看。所以我们开放了售票通 道,可以买票突破封装在合理的监管机制下进去参观。类也是一样,不想给别人看到的,我们使用protected/private把成员封装起来。开放一些共有的成员函数对成员合理的访问。所以封装本质是一种管理。
2. 继承:可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。<br />三种继承方式
| 继承方式 | private继承 | protected继承 | public继承 |
| --- | --- | --- | --- |
| 基类的private成员 | 不可见 | 不可见 | 不可见 |
| 基类的protected成员 | 变为private成员 | 仍为protected成员 | 仍为protected成员 |
| 基类的public成员 | 变为private成员 | 变为protected成员 | 仍为public成员 |
3. 多态:**用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数**。实现多态,有二种方式,重写,重载。
<a name="eb620340"></a>
### 4.2 C++ 的重载和重写,以及它们的区别
**参考回答**
1. 重写<br />**是指派生类中存在重新定义的函数。其函数名,参数列表,返回值类型,所有都必须同基类中被重写的函数一致。**只有函数体不同(花括号内),派生类对象调用时会调用派生类的重写函数,不会调用被重写函数。**重写的基类中被重写的函数必须有virtual修饰。**<br />示例如下:
include
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类中重写,使用父类的指针指向子类的实例,并可以用父类指针来调取子类的成员函数。 }
重写——也就是基类中有一个虚函数,而在派生类中也要重写一个原型(返回值、名字、参数)都相同的虚函数。不过协变例外。**协变是重写的特例,基类中返回值是基类类型的引用或指针,在派生类中,返回值为派生类类型的引用或指针(特例在于它的函数返回类型不一样)**
//协变测试函数
include
using namespace std;
class Base { public: virtual Base* FunTest() { cout << “victory” << endl; //返回Base类型 return this; } };
class Derived :public Base { public: virtual Derived* FunTest() //返回Derived类型 { cout << “yeah” << endl; return this; } };
int main() { Base b; Derived d;
b.FunTest();
d.FunTest();
return 0;
}
2.重载
**我们在平时写代码中会用到几个函数但是他们的实现功能相同,但是有些细节却不同。**例如:交换两个数的值其中包括(int, float,char,double)这些个类型。在C语言中我们是利用不同的函数名来加以区分。这样的代码不美观而且给程序猿也带来了很多的不便。于是在C++中人们提出了用一个函数名定义多个函数,也就是所谓的函数重载。**函数重载是指同一可访问区内被声明的几个具有不同参数列(参数的类型,个数,顺序不同)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型**。
include
using namespace std;
class A { void fun() {}; void fun(int i) {}; void fun(int i, int j) {}; void fun1(int i,int j){}; };
**C++ 的重载和重写是如何实现的**
1. C++利用**命名倾轧**(name mangling)技术,来改名函数名,区分参数不同的同名函数。命名倾轧是在编译阶段完成的。<br />C++定义同名重载函数:
include
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; }
1. 由上图可得,d代表double,f代表float,i代表int,加上参数首字母以区分同名函数。
2. 在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。
1. 用virtual关键字申明的函数叫做虚函数,虚函数肯定是类的成员函数。
1. **存在虚函数的类都有一个一维的虚函数表叫做虚表,类的对象有一个指向虚表开始的虚指针**。虚表是和类对应的,虚表指针是和对象对应的。
1. 多态性是一个接口多种实现,是面向对象的核心,分为类的多态性和函数的多态性。
1. 重写用虚函数来实现,结合动态绑定。
1. 纯虚函数是虚函数再加上 = 0。
1. 抽象类是指包括至少一个纯虚函数的类。
**纯虚函数:virtual void fun()=0。即抽象类必须在子类实现这个函数,即先有名称,没有内容,在派生类实现内容**。
<a name="22e5ded1"></a>
#### C 语言如何实现 C++ 语言中的重载
**参考答案**c语言中不允许有同名函数,因为编译时函数命名是一样的,不像c++会添加参数类型和返回类型作为函数编译后的名称,进而实现重载。如果要用c语言显现函数重载,可通过以下方式来实现:
1. 使用函数指针来实现,重载的函数不能使用同名称,只是类似的实现了函数重载功能
1. 重载函数使用可变参数,方式如打开文件open函数
1. gcc有内置函数,程序使用编译函数可以实现函数重载
include
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; }
<a name="dae129f5"></a>
### 4.3 说说构造函数有几种,分别什么作用
C++中的构造函数可以分为4类:**默认构造函数、初始化构造函数、拷贝构造函数、移动构造函数**。
- 默认构造函数和初始化构造函数。 在定义类的对象的时候,完成对象的初始化工作。 ```
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;
}
有了有参的构造了,编译器就不提供默认的构造函数。
2.拷贝构造函数
#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;
}
C++ 类对象的初始化顺序
初始化顺序:
父类构造函数–>成员类对象构造函数–>自身构造函数
其中成员变量的初始化与声明顺序有关,构造函数的调用顺序是类派生列表中的顺序。析构顺序和构造顺序相反。
4.4 深拷贝和浅拷贝,如何实现深拷贝
- 浅拷贝:又称值拷贝,将源对象的值拷贝到目标对象中去,本质上来说源对象和目标对象共用一份实体,只是所引用的变量名不同,地址其实还是相同的。举个简单的例子,你的小名叫西西,大名叫冬冬,当别人叫你西西或者冬冬的时候你都会答应,这两个名字虽然不相同,但是都指的是你。
- 深拷贝,拷贝的时候先开辟出和源对象大小一样的空间,然后将源对象里的内容拷贝到目标对象中去,这样两个指针就指向了不同的内存位置。并且里面的内容是一样的,这样不但达到了我们想要的目的,还不会出现问题,两个指针先后去调用析构函数,分别释放自己所指向的位置。即为每次增加一个指针,便申请一块新的内存,并让这个指针指向新的内存,深拷贝情况下,不会出现重复释放同一块内存的错误。
- 深拷贝的实现:深拷贝的拷贝构造函数和赋值运算符的重载传统实现:
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;
}
这里的拷贝构造函数我们很容易理解,先开辟出和源对象一样大的内存区域,然后将需要拷贝的数据复制到目标拷贝对象 。
这种方法解决了我们的指针悬挂问题,通过不断的开空间让不同的指针指向不同的内存,以防止同一块内存被释放两次的问题。
4.5 为什么要虚析构,为什么不能虚构造
- 虚析构:将可能会被继承的父类的析构函数设置为虚函数,可以保证当我们new一个子类,然后使用基类指针指向该子类对象,释放基类指针时可以释放掉子类的空间,防止内存泄漏。如果基类的析构函数不是虚函数,在特定情况下会导致派生类无法被析构。
- 用派生类类型指针绑定派生类实例,析构的时候,不管基类析构函数是不是虚函数,都会正常析构
- 用基类类型指针绑定派生类实例,析构的时候,如果基类析构函数不是虚函数,则只会析构基类,不会析构派生类对象,从而造成内存泄漏。为什么会出现这种现象呢,个人认为析构的时候如果没有虚函数的动态绑定功能,就只根据指针的类型来进行的,而不是根据指针绑定的对象来进行,所以只是调用了基类的析构函数;如果基类的析构函数是虚函数,则析构的时候就要根据指针绑定的对象来调用对应的析构函数了。
C默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针,占用额外的内存。而对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。因此C默认的析构函数不是虚函数,而是只有当需要当作父类时,设置为虚函数。
虚表在构造函数之前写入。
- 不能虚构造:
- 从存储空间角度:虚函数对应一个vtable,这个表的地址是存储在对象的内存空间的。如果将构造函数设置为虚函数,就需要到vtable 中调用,可是对象还没有实例化,没有内存空间分配,如何调用。(悖论)
- 从使用角度:虚函数主要用于在信息不全的情况下,能使重载的函数得到对应的调用。构造函数本身就是要初始化实例,那使用虚函数也没有实际意义呀。所以构造函数没有必要是虚函数。虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
- 从实现上看,vtabtl 在构造函数调用后才建立,因而构造函数不可能成为虚函数。从实际含义上看,在调用构造函数时还不能确定对象的真实类型(因为子类会调父类的构造函数);而且构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有太大的必要成为虚函数。
4.6 模板类是在什么时候实现的
- 模板实例化:模板的实例化分为显示实例化和隐式实例化,前者是研发人员明确的告诉模板应该使用什么样的类型去生成具体的类或函数,后者是在编译的过程中由编译器来决定使用什么类型来实例化一个模板不管是显示实例化或隐式实例化,最终生成的类或函数完全是按照模板的定义来实现的
- 模板具体化:当模板使用某种类型类型实例化后生成的类或函数不能满足需要时,可以考虑对模板进行具体化。具体化时可以修改原模板的定义,当使用该类型时,按照具体化后的定义实现,具体化相当于对某种类型进行特殊处理。
- 代码示例:
```
include
using namespace std;
// #1 模板定义
template
// #2 模板显示实例化
template struct TemplateStruct
// #3 模板具体化
template<> struct TemplateStruct
int main()
{
TemplateStruct
// #4 模板隐式实例化
TemplateStruct<char> llStruct;
}
运行结果 4 —8— 1
<a name="baa79c83"></a>
### 4.7 什么是虚继承,解决什么问题,如何实现?
**虚继承是解决C++多重继承问题的一种手段**,从不同途径继承来的同一基类,会在子类中存在多份拷贝。这将存在两个问题:其一,浪费存储空间;第二,存在二义性问题,通常可以将派生类对象的地址赋值给基类对象,实现的具体方式是,将基类指针指向继承类(继承类有基类的拷贝)中的基类对象的地址,但是多重继承可能存在一个基类的多份拷贝,这就出现了二义性。
虚继承可以解决多种继承前面提到的两个问题
[菱形继承问题](https://zhuanlan.zhihu.com/p/35158136)
![](https://pic1.zhimg.com/80/v2-11644e4b1417b90c2c869cf4deda4b8c_1440w.jpg#crop=0&crop=0&crop=1&crop=1&id=NHHTK&originHeight=555&originWidth=860&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=)
**基础示例 1**
多重继承可能会造成菱形继承的问题:
```cpp
#include <iostream> // std::cout std::endl
class base
{
public:
void show(void) const noexcept;
};
class derived1 : public base
{
};
class derived2 : public base
{
};
class final_derived : public derived1, public derived2
{
};
int main(void)
{
final_derived object;
//object.show(); // 去掉开头注释编译将报错
return 0;
}
void base::show(void) const noexcept
{
std::cout << "显示" << std::endl;
}
基础讲解 1
当去掉上面代码的注释后,编译将会报错。我们知道derived1继承了base的show()函数作为自己的show()函数,而derived2也是一样。然后final_derived继承了derived1的show()函数,同时也继承了derived2的show()函数,当object调用show()函数时,
由于编译器不知道你想调用那个show()函数,所以就报错了。
基础示例 2
可以使用以下方法解决:
#include <iostream> // std::cout std::endl
class base
{
public:
void show(void) const noexcept;
};
class derived1 : public base
{
};
class derived2 : public base
{
};
class final_derived : public derived1, public derived2
{
};
int main(void)
{
final_derived object;
object.derived1::show();
object.derived2::show();
return 0;
}
void base::show(void) const noexcept
{
std::cout << "显示" << std::endl;
}
基础讲解 2
在函数前面加上基类以说明调用的是哪一个函数。虽然这个方法可以解决上面的错误,但是这样写并不优雅,更主要的是,假设base有4个long long成员变量,也就是占用32字节,那么final_derived对象就占用了两倍,也就是64字节。
虚继承
虚继承可以解决上面的部分问题,但不能解决全部问题,所以使用多重继承时需要特别小心。
基础示例
#include <iostream> // std::cout std::endl
class base
{
public:
void show(void) const noexcept;
};
class derived1 : virtual public base
{
};
class derived2 : virtual public base
{
};
class final_derived : public derived1, public derived2
{
};
int main(void)
{
final_derived object;
object.show();
return 0;
}
void base::show(void) const noexcept
{
std::cout << "显示" << std::endl;
}
基础讲解
上面代码可以正常运行并输出正确结果。
上面代码中,derived1继承base使用虚继承,derived2继承base使用虚继承,final_derived则不需要。
虚继承使派生类除了继承基类成员作为自己的成员之外,内部还会有一份内存来保存哪些是基类的成员。当final_derived继承derived1和derived2之后,编译器根据虚继承多出来的内存,查到derived1和derived2拥有共同的基类的成员,就不会从derived1和derived2中继承这些,而是直接从共同的基类中继承成员,也就是说,final_derived直接继承base的成员,然后再继承derived1和derived2各自新增的成员。
这样,final_derived就不会继承两份内存。
基础拓展
注意:如果base的成员变量都是private,那么不会有什么奇怪的问题。但是如果base有protected成员变量供派生类使用的话,就需要注意了。如果derived和derived2都操作了这个保护成员变量,这样就可能导致从derived1和derived2继承下来的操作混乱。
#include <iostream> // std::cout std::endl
#include <string> // std::u32string
class base
{
public:
// 获取字符数
std::size_t size(void) const noexcept;
protected:
std::size_t m_count;
};
class derived1 : virtual public base
{
public:
derived1(const std::u32string &text);
};
class derived2 : virtual public base
{
public:
derived2(const std::u32string &text);
};
class final_derived : public derived1, public derived2
{
public:
final_derived(void);
};
int main(void)
{
final_derived object;
std::cout << "字符数是" << object.size() << std::endl;
return 0;
}
derived1::derived1(const std::u32string &text)
{
m_count = text.size(); // 保存字符串字符数
}
derived2::derived2(const std::u32string &text)
{
m_count = text.size(); // 保存字符串字符数
}
final_derived::final_derived(void)
: derived1(U"口也*啦") // 4个字符
, derived2(U"梁非凡") // 3个字符
{
}
std::size_t base::size(void) const noexcept
{
return m_count;
}
输出结果:
字符数是3
这种情况,derived1和derived2就应该各自保存一份m_count,也就是说不应该使用虚继承;但是如果不使用虚继承,那么就会出现开头的问题。
所以说虚继承不能解决菱形继承的问题。这也是其他编程语言都不支持多重继承的主要原因。
4.8 C++ 中哪些函数不能被声明为虚函数?
参考回答
常见的不不能声明为虚函数的有:普通函数(非成员函数),静态成员函数,内联成员函数,构造函数,友元函数。
- 为什么C++不支持普通函数为虚函数?
普通函数(非成员函数)只能被overload,不能被override,声明为虚函数也没有什么意思,因此编译器会在编译时绑定函数。 - 为什么C++不支持构造函数为虚函数?
这个原因很简单,主要是从语义上考虑,所以不支持。因为构造函数本来就是为了明确初始化对象成员才产生的,然而virtual function主要是为了再不完全了解细节的情况下也能正确处理对象。另外,virtual函数是在不同类型的对象产生不同的动作,现在对象还没有产生,如何使用virtual函数来完成你想完成的动作。(这不就是典型的悖论)
构造函数用来创建一个新的对象,而虚函数的运行是建立在对象的基础上,在构造函数执行时,对象尚未形成,所以不能将构造函数定义为虚函数 - 为什么C++不支持内联成员函数为虚函数?
其实很简单,那内联函数就是为了在代码中直接展开,减少函数调用花费的代价,虚函数是为了在继承后对象能够准确的执行自己的动作,这是不可能统一的。(再说了,inline函数在编译时被展开,虚函数在运行时才能动态的绑定函数)
内联函数是在编译时期展开,而虚函数的特性是运行时才动态联编,所以两者矛盾,不能定义内联函数为虚函数 - 为什么C++不支持静态成员函数为虚函数?
这也很简单,静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,他也没有要动态绑定的必要性。
静态成员函数属于一个类而非某一对象,没有this指针,它无法进行对象的判别 - 为什么C++不支持友元函数为虚函数?
因为C++不支持友元函数的继承,对于没有继承特性的函数没有虚函数的说法。