C++ 八股文(一)
C++ 八股文(一)
多态
什么是多态,有什么用
C++ 多态有两种:静态多态(早绑定)、动态多态(晚绑定)。静态多态是通过函数重载实现的;动态多态是通过虚函数实现的。
- 定义:“一个接口,多种方法”,程序在运行时才决定要调用的函数。
- 实现:C++ 多态性主要是通过虚函数实现的,虚函数允许子类重写 override(注意和 overload 的区别,overload 是重载,是允许同名函数的表现,这些函数参数列表/类型不同)。
注:多态与非多态的实质区别就是函数地址是静态绑定还是动态绑定。如果函数的调用在编译器编译期间就可以确定函数的调用地址,并产生代码,说明地址是静态绑定的;如果函数调用的地址是需要在运行期间才确定,属于动态绑定。
- 目的:接口重用。封装可以使得代码模块化,继承可以扩展已存在的代码,他们的目的都是为了代码重用。而多态的目的则是为了接口重用。
- 用法:声明基类的指针,利用该指针指向任意一个子类对象,调用相应的虚函数,可以根据指向的子类的不同而实现不同的方法。
用一句话概括:在基类的函数前加上 virtual 关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。
重写、重载与隐藏的区别
Overload 重载
在 C++ 程序中,可以将语义、功能相似的几个函数用同一个名字表示,但参数或返回值不同(包括类型、顺序不同),即函数重载。
- 相同的范围(在同一个类中);
- 函数名字相同;
- 参数不同;
- virtual 关键字可有可无;
Override(覆盖或重写)
是指派生类函数覆盖基类函数,特征是:
- 不同的范围(分别位于派生类与基类);
- 函数名字相同;参数相同;
- 基类函数必须有 virtual 关键字。
注:重写基类虚函数的时候,会自动转换这个函数为 virtual 函数,不管有没有加 virtual,因此重写的时候不加 virtual 也是可以的,不过为了易读性,还是加上比较好。
Overwrite(重写)隐藏,
是指派生类的函数屏蔽了与其同名的基类函数,规则如下:
- 如果派生类的函数与基类的函数同名,但是参数不同。此时,不论有无 virtual 关键字,基类的函数将被隐藏(注意别与重载混淆)。
- 如果派生类的函数与基类的函数同名,并且参数也相同,但是基类函数没有 virtual 关键字。此时,基类的函数被隐藏(注意别与覆盖混淆)。
虚函数和纯虚函数
- 虚函数:为了实现动态绑定。使用基类的引用或指针调用虚函数的时候会发生动态绑定。
- 纯虚函数:抽象类
- 构造函数可以重载,但不能是虚函数,析构函数可以是虚函数。
基类为什么需要虚析构函数?
防止内存泄漏。想去借助父类指针去销毁子类对象的时候,不能去销毁子类对象。假如没有虚析构函数,释放一个由基类指针指向的派生类对象时,不会触发动态绑定,则只会调用基类的析构函数,不会调用派生类的。派生类中申请的空间则得不到释放导致内存泄漏。
构造/析构函数调用虚函数
派生类对象构造期间进入基类的构造函数时,对象类型变成了基类类型,而不是派生类类型。
同样,进入基类析构函数时,对象也是基类类型。
所以,虚函数始终仅仅调用基类的虚函数(如果是基类调用虚函数),不能达到多态的效果。
虚函数表
- 产生时间:编译期
- 存储位置:只读数据段 .rodata
- 虚指针:类的每一个对象都包含一个虚指针(指向虚表),存在对象实例的最前面四个字节
- 虚指针创建时间:构造函数
注:虚表中的指针会指向其继承的最近的一个类的虚函数
const 相关
如何初始化 const 和 static 数据成员?
通常在类外申明 static 成员,但是 static const 的整型( bool,char,int,long )可以在类中声明且初始化,static const 的其他类型必须在类外初始化(包括整型数组)。
static 和 const 分别怎么用,类里面 static 和 const 可以同时修饰成员函数吗?
static 的作用:对 static 的三条作用做一句话总结。首先 static 的最主要功能是隐藏,其次因为 static 变量存放在静态存储区,所以它具备持久性和默认值 0。
对变量
局部变量
在局部变量之前加上关键字 static,局部变量就被定义成为一个局部静态变量。
- 内存中的位置:静态存储区
- 初始化:未经初始化的全局静态变量会被程序自动初始化为0(自动对象的值是任意的,除非他被显示初始化)
- 作用域:作用域仍为局部作用域,当定义它的函数或者语句块结束的时候,作用域随之结束。
注:当 static 用来修饰局部变量的时候,它就改变了局部变量的存储位置(从原来的栈中存放改为静态存储区)及其生命周期(局部静态变量在离开作用域之后,并没有被销毁,而是仍然驻留在内存当中,直到程序结束,只不过我们不能再对他进行访问),但未改变其作用域。
全局变量
在全局变量之前加上关键字 static,全局变量就被定义成为一个全局静态变量。
- 内存中的位置:静态存储区(静态存储区在整个程序运行期间都存在)
- 初始化:未经初始化的全局静态变量会被程序自动初始化为 0(自动对象的值是任意的,除非他被显示初始化)
- 作用域:全局静态变量在声明他的文件之外是不可见的。准确地讲从定义之处开始到文件结尾。
注:static 修饰全局变量,并未改变其存储位置及生命周期,而是改变了其作用域,使当前文件外的源文件无法访问该变量,好处如下:
- 不会被其他文件所访问,修改
- 其他文件中可以使用相同名字的变量,不会发生冲突。对全局函数也是有隐藏作用。而普通全局变量只要定义了,任何地方都能使用,使用前需要声明所有的 .c 文件,只能定义一次普通全局变量,但是可以声明多次(外部链接)。
注意:全局变量的作用域是全局范围,但是在某个文件中使用时,必须先声明。
对类
成员变量
用 static 修饰类的数据成员实际使其成为类的全局变量,会被类的所有对象共享,包括派生类的对象。因此,static 成员必须在类外进行初始化(初始化格式:int base::var=10;),而不能在构造函数内进行初始化,不过也可以用 const 修饰 static 数据成员在类内初始化 。因为静态成员属于整个类,而不属于某个对象,如果在类内初始化,会导致每个对象都包含该静态成员,这是矛盾的。
特点:
- 不要试图在头文件中定义(初始化)静态数据成员。在大多数的情况下,这样做会引起重复定义这样的错误。即使加上
#ifndef
#define
#endif
或者#pragma once
也不行。 - 静态数据成员可以成为成员函数的可选参数,而普通数据成员则不可以。
- 静态数据成员的类型可以是所属类的类型,而普通数据成员则不可以。普通数据成员的只能声明为 所属类类型的指针或引用。
成员函数
- 用 static 修饰成员函数,使这个类只存在这一份函数,所有对象共享该函数,不含 this 指针。
- 静态成员是可以独立访问的,也就是说,无须创建任何对象实例就可以访问。
base::func(5,3)
;当 static 成员函数在类外定义时不需要加 static 修饰符。 - 在静态成员函数的实现中不能直接引用类中说明的非静态成员,可以引用类中说明的静态成员。因为静态成员函数不含this指针。
不可以同时用 const 和 static 修饰成员函数。
C++ 编译器在实现 const 的成员函数的时候为了确保该函数不能修改类的实例的状态,会在函数中添加一个隐式的参数 const this*。但当一个成员为 static 的时候,该函数是没有 this 指针的。也就是说此时 const 的用法和 static 是冲突的。
我们也可以这样理解:两者的语意是矛盾的。static 的作用是表示该函数只作用在类型的静态变量上,与类的实例没有关系;而 const 的作用是确保函数不能修改类的实例的状态,与类型的静态变量没有关系。因此不能同时用它们。
const的作用:
- 限定变量为不可修改。
- 限定成员函数不可以修改任何数据成员。
- const 与指针:
const char *p
常量指针,可以换方向,不可以改内容
char * const p
,指针常量,不可以换方向,可以改内容
构造函数
构造函数调用顺序
- 虚基类构造函数(被继承的顺序)
- 非虚基类构造函数(被继承的顺序)
- 成员对象构造函数(声明顺序)
- 自己的构造函数
自身构造函数顺序
- 虚表指针(防止初始化列表里面调用虚函数,否则调用的是父类的虚函数)
- 初始化列表(const、引用、没有定义默认构造函数的类型)
- 花括号里的 (初始化列表直接初始化,这个先初始化后赋值)
this 指针
创建时间:成员函数调用前生成,调用后清除
如何传递给成员函数:通过函数参数的首参数来传递
extern 关键字
- 置于变量或者函数前,以标示变量或者函数的定义在别的文件中,提示编译器遇到此变量和函数时在其他模块中寻找其定义
- extern “C” void fun(); 告诉编译器按C的规则去翻译
以下关键字的作用?使用场景?
- inline:在 c/c++ 中,为了解决一些频繁调用的小函数大量消耗栈空间(栈内存)的问题,特别的引入了 inline 修饰符,表示为内联函数。
- decltype:从表达式中推断出要定义变量的类型,但却不想用表达式的值去初始化变量。还有可能是函数的返回类型为某表达式的的值类型。
- volatile:volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。
浅拷贝与深拷贝
什么时候用到拷贝函数?
- 一个对象以值传递的方式传入函数体(参数);
- 一个对象以值传递的方式从函数返回(返回值);
- 一个对象需要通过另外一个对象进行初始化(初始化)。
如果在类中没有显式地声明一个拷贝构造函数,那么,编译器将会自动生成一个默认的拷贝构造函数,该构造函数完成对象之间的位拷贝。位拷贝又称浅拷贝
默认拷贝构造函数是浅拷贝。如果一个类拥有资源,当这个类的对象发生复制过程的时候,资源重新分配,这个过程就是深拷贝,反之,没有重新分配资源,就是浅拷贝。
如果实行位拷贝,也就是把对象里的值完全复制给另一个对象,如 A=B。这时,如果 B 中有一个成员变量指针已经申请了内存,那 A 中的那个成员变量也指向同一块内存。这就出现了问题:当 B 把内存释放了(如:析构),这时A内的指针就是野指针了,出现运行错误。
C++类中成员初始化顺序
成员变量在使用初始化列表初始化时,与构造函数中初始化成员列表的顺序无关,只与定义成员变量的顺序有关。
类中 const 成员常量必须在构造函数初始化列表中初始化。类中 static 成员变量,只能在类外初始化(同一类的所有实例共享静态成员变量)。
构造过程
- 分配内存
- 进行父类的构造,按照父类的声明顺序(递归过程)
- 构造虚表指针,对虚表指针赋值
- 根据初始化列表中的值初始化变量
- 执行构造函数{}内的
构造函数初始化列表
const 或引用类型的成员。因为 const 对象或引用类型只能初始化,不能对他们赋值。
与对数据成员赋值的区别:
- 内置数据类型,复合类型(指针,引用):结果和性能上相同。
- 用户定义类型(类类型):结果上相同,但是性能上存在很大的差别。
vector 中 size() 和 capacity() 的区别
size() 指容器当前拥有的元素个数(对应的resize(size_type)
会在容器尾添加或删除一些元素,来调整容器中实际的内容,使容器达到指定的大小。);capacity()
指容器在必须分配存储空间之前可以存储的元素总数。
size 表示的这个 vector 里容纳了多少个元素,capacity 表示 vector 能够容纳多少元素,它们的不同是在于 vector 的 size 是 2 倍增长的。如果 vector 的大小不够了,比如现在的 capacity 是 4,插入到第五个元素的时候,发现不够了,此时会给他重新分配 8 个空间,把原来的数据及新的数据复制到这个新分配的空间里。(会有迭代器失效的问题)
定义一个空类编译器做了哪些操作
如果你只是声明一个空类,不做任何事情的话,编译器会自动为你生成一个默认构造函数、一个拷贝默认构造函数、一个默认拷贝赋值操作符和一个默认析构函数。这些函数只有在第一次被调用时,才会被编译器创建。所有这些函数都是 inline 和 public 的。
强制类型转换
static_cast
用法:static_cast < type-id > ( expression )
q1. 为什么需要 static_cast 强制转换?
- void指针->其他类型指针 (不安全)
- 改变通常的标准转换
- 用于类层次结构中基类和子类之间指针或引用的转换。进行上行转换(把子类的指针或引用转换成基类表示)是安全的;进行下行转换(把基类指针或引用转换成子类指针或引用)时,由于没有动态类型检查,所以是不安全的。
dynamic_cast
用法:dynamic_cast < type-id > ( expression )
dynamic_cast 主要用于类层次间的上行转换和下行转换,还可以用于类之间的交叉转换(同一基类的两个同级派生类)。
在类层次间进行上行转换时,dynamic_cast
和static_cast
的效果是一样的;在进行下行转换时,dynamic_cast
具有类型检查的功能,比static_cast
更安全。
reinpreter_cast
它可以把一个指针转换成一个整数,也可以把一个整数转换成一个指针(先把一个指针转换成一个整数,在把该整数转换成原类型的指针,还可以得到原先的指针值)。
const_cast该运算符用来修改类型的 const 或 volatile 属性。除了 const 或 volatile 修饰之外, type_id 和 expression 的类型是一样的。
常量指针被转化成非常量指针,并且仍然指向原来的对象;常量引用被转换成非常量引用,并且仍然指向原来的对象;常量对象被转换成非常量对象。
volatile 关键字
- 使用方法:
int volatile x
; - 作用:编译器不再优化。让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值 。
内存管理
C 内存分配
- malloc:在内存的动态分配区域中分配一个长度为 size 的连续空间,如果分配成功,则返回所分配内存空间的首地址,否则返回 NULL,申请的内存不会初始化。
- calloc:分配一个 num * size 连续的空间,会自动初始化为0。
- realloc:动态分配一个长度为 size 的内存空间,并把内存空间的首地址赋值给 ptr,把 ptr 内存空间调整为 size。
C++ 内存分配:
-栈区(stack):主要存放函数参数以及局部变量,由系统自动分配释放。
- 堆区(heap):由用户通过 malloc/new 手动申请,手动释放。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。
- 全局/静态区:存放全局变量、静态变量;程序结束后由系统释放。- - 字符串常量区:字符串常量就放在这里,程序结束后由系统释放。
- 代码区:存放程序的二进制代码。
结构体字节对齐问题?结构体/类大小的计算?
默认字节对齐
各成员变量存放的起始地址相对于结构的起始地址的偏移量必须是该变量的类型所占用的字节数的倍数,结构的大小为结构的字节边界数(即该结构中占用最大空间的类型所占用的字节数)的倍数 n 字节对齐。
pragma pack(n)
- 如果 n 大于等于该变量所占用的字节数,那么偏移量必须满足默认的对齐方式;
- 如果 n 小于该变量的类型所占用的字节数,那么偏移量为 n 的倍数,不用满足默认的对齐方式;
- 如果 n 大于所有成员变量类型所占用的字节数,那么结构体的总大小必须为占用空间最大的变量占用的空间数的倍数;否则必须为n的倍数(两者相比,取小);
虚函数的大小计算
假设经过成员对齐后的类的大小为 size 个字节。那么类的 sizeof 大小可以这么计算:size + 4*(虚函数指针的个数 n)。
联合体的大小计算
联合体所占的空间不仅取决于最宽成员,还跟所有成员有关系,即其大小必须满足两个条件:
- 大小足够容纳最宽的成员;
- 大小能被其包含的所有基本数据类型的大小所整除。
常见例子:
1 | class A {};: sizeof(A) = 1; |
指针和引用
区别
- 定义:指针是一个对象,引用本身不是对象,只是另一个对象的别名;
- 指针是“指向”另外一种类型的复合类型;
- 引用本身不是一个对象,所以不能定义引用的引用;
- 引用只能绑定到对象上,它只是一个对象的别名,因此引用必须初始化,且不能更换引用对象。
指针
可以有 const 指针,但是没有 const 引用(const 引用可读不可改,与绑定对象是否为 const 无关)
注:引用可以指向常量,也可以指向变量。例如int &a=b
,使引用 a 指向变量 b。而为了让引用指向常量,必须使用常量引用,如const int &a=1
; 它代表的是引用 a 指向一个const int
型,这个 int 型的值不能被改变,而不是引用 a 的指向不能被改变,因为引用的指向本来就是不可变的,无需加 const 声明。即指针存在常量指针int const *p
和指针常量int *const p
,而引用只存在常量引用int const &a
,不存在引用常量int& const a
。
- 指针可以有多级,但是引用只能是一级(int **p;合法 而 int &&a 是不合法的)
- 指针的值可以为空,但是引用的值不能为 NULL,并且引用在定义的时候必须初始化;
- 指针的值在初始化后可以改变,即指向其它的存储单元,而引用在进行初始化后就不会再改变了。
- “sizeof 引用”得到的是所指向的变量(对象)的大小,而” sizeof 指针”得到的是指针本身的大小;
- 指针和引用的自增(++)运算意义不一样;
- 指针使用时需要解引用(*),引用则不需要;
指针的注意点
1、指针指向常量存储区对象
char *p="abc"
;此时 p 指向的是一个字符串常量,不能对 *p 的内容进行写操作,如 srtcpy(p,s) 是错误的,因为 p 的内容为 “abc” 字符串常量,该数据存储在常量存储区,但可以对指针 p 进行操作,让其指向其他的内存空间。
2、资源泄漏
1 | char *p=new char[3]; //分配三个字符空间,p指向该内存空间 |
3、内存越界
1 | char *p=new char[3]; //分配三个字符空间,p指向该内存空间 |
new和malloc的区别
- new 是运算符,malloc() 是一个库函数;
- new 会调用构造函数,malloc 不会;
- new 返回指定类型指针,malloc 返回 void* 指针,需要强制类型转换;
- new 会自动计算需分配的空间,malloc 不行;
- new 可以被重载,malloc 不能。
悬空指针与野指针
- 悬空指针:当所指向的对象被释放或者收回,但是没有让指针指向NULL;
- 野指针:那些未初始化的指针;
空指针能调用类成员函数吗
可以调用成员函数。当调用p->func1()
; 这句话时,其实就是调用 A::func1(this)
,而成员函数的地址在编译时就已经确定, 所以空指针也是可以调用普通成员函数,只不过此时的 this 指针指向空而已,但函数 fun1 函数体内并没有用到 this 指针,所以不会出现问题。
不可以调用虚函数。如果一个类中包含虚函数,那么它所实例化处的对象的前四个字节是一个虚表指针,这个虚表指针指向的是虚函数表。当然,虚函数的地址也是在编译时就已经确定了,这些虚函数地址存放在虚函数表里面,而虚函数表就在程序地址空间的数据段(静态区),也就是说虚表的建立是在编译阶段就完成的;当调用构造函数的时候才会初始化虚函数表指针,即把虚表指针存放在对象前四个字节(32 位下)。试想一下,假如用空指针调用虚函数,这个指针根本就找不到对应的对象的地址,因此他也不知道虚表的地址,没有虚表的地址,怎么能调用虚函数呢
智能指针
unique_ptr
摒弃 auto_ptr 的原因:避免潜在的内存崩溃问题。如下代码用 auto_ptr 的话不会出现问题,但 p3 是无法访问的。
1 | unique_ptr<string> p3 (new string ("auto"); |
只允许基础指针的一个所有者。unique_ptr小巧高效;大小等同于一个指针且支持右值引用,从而可实现快速插入和对STL集合的检索。
注意:当程序试图将一个 unique_ptr 赋值给另一个时,如果源 unique_ptr 是个临时右值,编译器允许这么做;如果源 unique_ptr 将存在一段时间,编译器将禁止这么做。
shared_ptr
采用引用计数的智能指针,主要用于要将一个原始指针分配给多个所有者(例如,从容器返回了指针副本又想保留原始指针时)的情况。当所有的 shared_ptr 所有者超出了范围或放弃所有权,才会删除原始指针。大小为两个指针;一个用于对象,另一个用于包含引用计数的共享控制块。
最安全的分配和使用动态内存的方法是调用 make_shared 标准库函数,此函数在动态分配内存中分配一个对象并初始化它,返回对象的 shared_ptr。
堆和栈
- 栈 :只要栈的剩余空间大于所申请的空间,系统将为程序提供内存,否则将报异常提示栈溢出。
- 堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统受到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆。
编译与优化
静态链接与动态链接
静态链接:
- 定义:在生成可执行文件的时候(链接阶段),把所有需要的函数的二进制代码都包含到可执行文件中去。
- 特点:链接器需要知道参与链接的目标文件需要哪些函数,同时也要知道每个目标文件都能提供什么函数,这样链接器才能知道是不是每个目标文件所需要的函数都能正确地链接。如果某个目标文件需要的函数在参与链接的目标文件中找不到的话,链接器就报错了。目标文件中有两个重要的接口来提供这些信息:一个是符号表,另外一个是重定位表。
- 缺点:1. 程序体积会变大;2. 静态库有更新的话,所有可执行文件都需要重新链接
动态链接:
- 定义:在编译的时候不直接拷贝可执行代码,而是通过记录一系列符号和参数,在程序运行或加载时将这些信息传递给操作系统,操作系统负责将需要的动态库加载到内存中,然后程序在运行到指定的代码时,去共享执行内存中已经加载的动态库可执行代码,最终达到运行时连接的目的。
缺点:1. 运行时加载,影响性能
静态链接过程
- 操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的
“Program Header”
中读取每个“Segment”
的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置; - 操作系统就会把控制权交给可执行文件的入口地址,然后程序开始执行。
动态链接过程
- 操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的
“Program Header”
中读取每个“Segment”
的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置; - 操作系统启动一个动态链接器——ld.so,它其实是个共享对象,操作系统同样通过映射的方式将它加在到进程的地址空间中,加载完动态链接器之后,将控制权交给动态链接器的入口地址;
- 动态链接器开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作;
- 所有动态链接工作完成后,动态链接器就会将控制权交给可执行文件的入口地址,程序开始正式执行。
程序加载的内存分布
在多任务操作系统中,每个进程都运行在一个属于自己的虚拟内存中,而虚拟内存被分为许多页,并映射到物理内存中,被加载到物理内存中的文件才能够被执行。
- 代码段(.text):用来存放可执行文件的机器指令。存放在只读区域,以防止被修改。
- 只读数据段(.rodata):用来存放常量存放在只读区域,如字符串常量、全局const变量等。
- 可读写数据段(.data):用来存放可执行文件中已初始化的全局变量和局部静态变量。
- BSS 段(.bss):未初始化的全局变量和局部静态变量以及初始化为 0 的全局变量一般放在 .bss 的段里,以节省内存空间。
static int a=0
;(初始化为 0 的全局变量(静态变量)放在 .bss)。 - 堆:用来容纳应用程序动态分配的内存区域。当程序使用 malloc 或 new 分配内存时,得到的内存来自堆。堆通常位于栈的下方。向上生长
- 栈:用于维护函数调用的上下文。栈通常分配在用户空间的最高地址处分配。向下生长
- 动态链接库映射区:如果程序调用了动态链接库,则会有这一部分。该区域是用于映射装载的动态链接库。
- 保留区:内存中受到保护而禁止访问的内存区域。
溢出,越界,泄漏
溢出
1、栈溢出:栈的大小通常是 1M-2M,所以栈溢出包含两种情况,一是分配的的大小超过栈的最大值,二是分配的大小没有超过最大值,但是接收的 buff 比新 buff 小 ,具体情况如下。
1 | char a[10] = {0}; |
注意:调试时栈溢出的异常要在函数调用结束后才会检测到,因为栈是在函数结束时才会开始进行出栈操作。
2、内存溢出:使用 malloc 和 new 分配的内存,在拷贝时接收 buff 小于新 buff 时造成的现象。
越界
通常指数组越界
泄露
指堆内存泄漏,是指使用 malloc 和 new 分配的内存没有释放造成的