编译内存相关
C++ 程序编译过程★★★★☆
编译过程分为四个过程:编译(编译预处理、编译、优化),汇编,链接。
编译预处理:处理以 # 开头的指令;
编译、优化:将源码 .cpp 文件翻译成 .s 汇编代码;
汇编:将汇编代码 .s 翻译成机器指令 .o 文件;
链接:汇编程序生成的目标文件,即 .o 文件,并不会立即执行,因为可能会出现:.cpp 文件中的函数引用了另一个 .cpp 文件中定义的符号或者调用了某个库文件中的函数。那链接的目的就是将这些文件对应的目标文件连接成一个整体,从而生成可执行的程序 .exe 文件。
链接分为两种:
静态链接:代码从其所在的静态链接库中拷贝到最终的可执行程序中,在该程序被执行时,这些代码会被装入到该进程的虚拟地址空间中。
动态链接:代码被放到动态链接库或共享对象的某个目标文件中,链接程序只是在最终的可执行程序中记录了共享对象的名字等一些信息。在程序执行时,动态链接库的全部内容会被映射到运行时相应进行的虚拟地址的空间。
二者的优缺点:
静态链接:浪费空间,每个可执行程序都会有目标文件的一个副本,这样如果目标文件进行了更新操作,就需要重新进行编译链接生成可执行程序(更新困难);优点就是执行的时候运行速度快,因为可执行程序具备了程序运行的所有内容。
动态链接:节省内存、更新方便,但是动态链接是在程序运行时,每次执行都需要链接,相比静态链接会有一定的性能损失。
C++ 内存管理 ★★★★★
C++ 内存分区:栈、堆、全局/静态存储区、常量存储区、代码区。
栈:存放函数的局部变量、函数参数、返回地址等,由编译器自动分配和释放。
堆:动态申请的内存空间,就是由 malloc 分配的内存块,由程序员控制它的分配和释放,如果程序执行结束还没有释放,操作系统会自动回收。
全局区/静态存储区(.bss 段和 .data 段):存放全局变量和静态变量,程序运行结束操作系统自动释放,在 C 语言中,未初始化的放在 .bss 段中,初始化的放在 .data 段中,C++ 中不再区分了。
常量存储区(.rodata 段):存放的是常量,不允许修改,程序运行结束自动释放。
代码区(.text 段):存放代码,不允许修改,但可以执行。编译后的二进制文件存放在这里。
说明:
从操作系统的本身来讲,以上存储区在内存中的分布是如下形式(从低地址到高地址):.text 段 –> .data 段 –> .bss 段 –> 堆 –> unused –> 栈 –> env
栈和堆的区别 ★★★★★
申请方式:栈是系统自动分配,堆是程序员主动申请。
申请后系统响应:分配栈空间,如果剩余空间大于申请空间则分配成功,否则分配失败栈溢出;申请堆空间,堆在内存中呈现的方式类似于链表(记录空闲地址空间的链表),在链表上寻找第一个大于申请空间的节点分配给程序,将该节点从链表中删除,大多数系统中该块空间的首地址存放的是本次分配空间的大小,便于释放,将该块空间上的剩余空间再次连接在空闲链表上。
栈在内存中是连续的一块空间(向低地址扩展)最大容量是系统预定好的,堆在内存中的空间(向高地址扩展)是不连续的。
申请效率:栈是有系统自动分配,申请效率高,但程序员无法控制;堆是由程序员主动申请,效率低,使用起来方便但是容易产生碎片。
存放的内容:栈中存放的是局部变量,函数的参数;堆中存放的内容由程序员控制。
内存对齐 ★★☆☆☆
什么是内存对齐?内存对齐的原则?为什么要进行内存对齐,有什么优点?
内存对齐:编译器将程序中的每个“数据单元”安排在字的整数倍的地址指向的内存之中
内存对齐的原则:
- 1、对于结构的各个成员,第一个成员位于偏移为0的位置,以后每个数据成员的偏移量必须是min(#pragma pack()指定的数,这个数据成员的自身长度) 的倍数。
- 2、在数据成员完成各自对齐之后,结构(或联合)本身也要进行对齐,对齐将按照#pragma pack指定的数值和结构(或联合)最大数据成员长度中,比较小的那个进行。
进行内存对齐的原因:
(1) 性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。(访问两次内存,一次获取 “前一部分” 的值,一次获取 “后一部分” 的值.)
(2) 平台原因:不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
(3) 空间原因:没有进行内存对齐的结构体或类会浪费一定的空间,当创建对象越多时,消耗的空间越多。
内存对齐的优点:
便于在不同的平台之间进行移植,因为有些硬件平台不能够支持任意地址的数据访问,只能在某些地址处取某些特定的数据,否则会抛出异常;
提高内存的访问效率,因为 CPU 在读取内存时,是一块一块的读取。
类的大小★★☆☆☆
类大小的计算
说明:类的大小是指类的实例化对象的大小,用 sizeof 对类型名操作时,结果是该类型的对象的大小。
计算原则:
遵循结构体的对齐原则。
与普通成员变量有关,与成员函数和静态成员无关。即普通成员函数,静态成员函数,静态数据成员,静态常量数据成员均对类的大小无影响。因为静态数据成员被类的对象共享,并不属于哪个具体的对象。
虚函数对类的大小有影响,是因为虚函数表指针的影响。
虚继承对类的大小有影响,是因为虚基表指针带来的影响。
空类的大小是一个特殊情况,空类的大小为 1,当用 new 来创建一个空类的对象时,为了保证不同对象的地址不同,空类也占用存储空间。
语言对比
C++新特性 ★★★★☆
说明:C++11 的新特性有很多,从面试的角度来讲,如果面试官问到该问题,常以该问题作为引子,对面试者提到的知识点进行深入展开提问。
下面对常用的做一下总结:
1、auto 类型推导
auto 关键字:自动类型推导,编译器会在 编译期间 通过初始值推导出变量的类型,通过 auto 定义的变量必须有初始值。
2、decltype 类型推导
decltype 关键字:decltype 是“declare type”的缩写,译为“声明类型”。和 auto 的功能一样,都用来在编译时期进行自动类型推导。如果希望从表达式中推断出要定义的变量的类型,但是不想用该表达式的值初始化变量,这时就不能再用 auto。decltype 作用是选择并返回操作数的数据类型。
区别:
auto var = val1 + val2;
decltype(val1 + val2) var1 = 0;
auto 根据 = 右边的初始值 val1 + val2 推导出变量的类型,并将该初始值赋值给变量 var;decltype 根据 val1 + val2 表达式推导出变量的类型,变量的初始值和与表达式的值无关。
auto 要求变量必须初始化,因为它是根据初始化的值推导出变量的类型,而 decltype 不要求,定义变量的时候可初始化也可以不初始化。
3、lambda 表达式
lambda 表达式,又被称为 lambda 函数或者 lambda 匿名函数。
lambda匿名函数的定义:
[capture list] (parameter list) -> return type
{
function body;
};
其中:
capture list:捕获列表,指 lambda 所在函数中定义的局部变量的列表,通常为空。
return type、parameter list、function body:分别表示返回值类型、参数列表、函数体,和普通函数一样。
举例:
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
int arr[4] = {4, 2, 3, 1};
//对 a 数组中的元素进行升序排序
sort(arr, arr+4, [=](int x, int y) -> bool{ return x < y; } );
for(int n : arr){
cout << n << " ";
}
return 0;
}
4、范围 for 语句
//语法格式:
for (declaration : expression){
statement
}
参数的含义:
expression:必须是一个序列,例如用花括号括起来的初始值列表、数组、vector ,string 等,这些类型的共同特点是拥有能返回迭代器的 beign、end 成员。
declaration:此处定义一个变量,序列中的每一个元素都能转化成该变量的类型,常用 auto 类型说明符。
实例:
#include <iostream>
#include <vector>
using namespace std;
int main() {
char arr[] = "hello world!";
for (char c : arr) {
cout << c;
}
return 0;
}
/*
程序执行结果为:
hello world!
*/
5、右值引用
右值引用:绑定到右值的引用,用 && 来获得右值引用,右值引用只能绑定到要销毁的对象。为了和右值引用区分开,常规的引用称为左值引用。
举例:
#include <iostream>
#include <vector>
using namespace std;
int main()
{
int var = 42;
int &l_var = var;
int &&r_var = var; // error: cannot bind rvalue reference of type 'int&&' to lvalue of type 'int' 错误:不能将右值引用绑定到左值上
int &&r_var2 = var + 40; // 正确:将 r_var2 绑定到求和结果上
return 0;
}
6、标准库 move() 函数
move() 函数:通过该函数可获得绑定到左值上的右值引用,该函数包括在 utility 头文件中。该知识点会在后续的章节中做详细的说明。
7、智能指针
相关知识已在第一章中进行了详细的说明,这里不再重复。
8、delete 函数和 default 函数
delete 函数:= delete 表示该函数不能被调用。
default 函数:= default 表示编译器生成默认的函数,例如:生成默认的构造函数。
#include <iostream>
using namespace std;
class A
{
public:
A() = default; // 表示使用默认的构造函数
~A() = default; // 表示使用默认的析构函数
A(const A &) = delete; // 表示类的对象禁止拷贝构造
A &operator=(const A &) = delete; // 表示类的对象禁止拷贝赋值
};
int main()
{
A ex1;
A ex2 = ex1; // error: use of deleted function 'A::A(const A&)'
A ex3;
ex3 = ex1; // error: use of deleted function 'A& A::operator=(const A&)'
return 0;
}
9、consterxpr关键字
1.constexpr 关键字的功能是使指定的常量表达式获得在程序编译阶段计算出结果的能力,而不必等到程序运行阶段。
2.constexpr修饰函数
函数需要满足的条件:
整个函数的函数体中,除了可以包含 using 指令、typedef 语句以及 static_assert 断言外,只能包含一条 return 返回语句。
函数有返回值
使用之前,必须有对应的定义语句
return返回的表达式必须是常量表达式
面向对象
什么是面向对象?面向对象的三大特性 ★★★★★
重载、重写、隐藏的区别 ★★★★★
什么是多态?多态如何实现?★★★★★
关键字及库函数
变量的区别★★★☆☆
变量的四大类型:全局变量,静态全局变量,局部变量,静态局部变量。
全局变量:全局作用域:一个文件中定义,同属于一个程序的其他文件之中使用extern声明后也可使用。
静态全局变量:文件作用域,不同文件之间不共享。
局部变量:局部作用域,函数体执行完被销毁,存放在栈区。
静态局部变量:局部作用域,程序执行结束后被销毁。
static 在类中使用的注意事项(定义、初始化和使用)★★★★★
static 静态成员变量:
1.静态成员变量是在类内进行声明,在类外进行定义和初始化,在类外进行定义和初始化的时候不要出现 static 关键字和private、public、protected 访问规则。
2.静态成员变量相当于类域中的全局变量,被类的所有对象所共享,包括派生类的对象。
3.静态成员变量可以作为成员函数的参数,而普通成员变量不可以。
4.静态数据成员的类型可以是所属类的类型,而普通数据成员的类型只能是该类类型的指针或引用。出处。
static 静态成员函数:
1.静态成员函数不能调用非静态成员变量或者非静态成员函数,因为静态成员函数没有 this 指针。静态成员函数做为类作用域的全局函数。
2.静态成员函数不能声明成虚函数(virtual)、const 函数和 volatile 函数。
static 的作用★★★★☆
static修饰的常规变量,从生命周期和作用域来分析:
生命周期: 从定义该变量该开始,直到程序结束时
作用域:
若是局部变量,则作用域就是定义该变量的函数块范围
若是全局变量,则是定义该变量的文件范围内部,也即是 static 修饰的变量具有文件作用域
static 定义变量的位置在静态变量区,超过其作用域该变量并不被释放,而是在函数结束时释放
static 修饰的变量只会被初始化一次
static 修饰类:
static 修饰的成员变量要在类外初始化,属于类,为所有类对象共享,static 修饰的变量不占类的空间
static 修饰的函数,静态成员函数, 属于类,为类的所有对象共享, 不能访问类的非静态成员,和外部函数, 没有this指针,因此只能访问静态成员(静态成员变量和静态函数)
static 全局变量和普通全局变量的异同 ★★★☆☆
相同点:
存储方式:普通全局变量和 static 全局变量都是静态存储方式。
不同点:
作用域:普通全局变量的作用域是整个源程序,当一个源程序由多个源文件组成时,普通全局变量在各个源文件中都是有效的;静态全局变量则限制了其作用域,即只在定义该变量的源文件内有效,在同一源程序的其它源文件中不能使用它。由于静态全局变量的作用域限于一个源文件内,只能为该源文件内的函数公用,因此可以避免在其他源文件中引起错误。
初始化:静态全局变量只初始化一次,防止在其他文件中使用。
const 作用及用法★★★☆☆
作用:
const 修饰成员变量,定义成 const 常量,相较于宏常量,可进行类型检查,节省内存空间,提高了效率。
const 修饰函数参数,使得传递过来的函数参数的值不能改变。
const 修饰成员函数,使得成员函数不能修改任何类型的成员变量(mutable 修饰的变量除外),也不能调用非 const 成员函数,因为非 const 成员函数可能会修改成员变量。
在类中的用法:
const 成员变量只能在类内声明、定义,在构造函数初始化列表中初始化。
const 成员变量只在某个对象的生存周期内是常量,对于整个类而言却是可变的,因为类可以创建多个对象,不同类的 const 成员变量的值是不同的。因此不能在类的声明中初始化 const 成员变量,类的对象还没有创建,编译器不知道他的值。
const 成员函数:
不能修改成员变量的值,除非有 mutable 修饰;只能访问成员变量。
不能调用非常量成员函数,以防修改成员变量的值。
const int* C::Func(const int* const p) const {};
//第一个const:函数返回值是一个指向常量对象的指针
//第二个const:参数p指向的对象是一个常量对象
//第三个const:参数p是一个常量指针
//第四个const:Func函数是const类型的,函数里面不能对成员变量进行修改,也不能调用非const的成员函数
define 和 const 的区别★★★☆☆
区别:
编译阶段:define 是在编译预处理阶段进行替换,const 是在编译阶段确定其值。
安全性:define 定义的宏常量没有数据类型,只是进行简单的替换,不会进行类型安全的检查;const 定义的常量是有类型的,是要进行判断的,可以避免一些低级的错误。
内存占用:define 定义的宏常量,在程序中使用多少次就会进行多少次替换,内存中有多个备份,占用的是代码段的空间;const 定义的常量占用静态存储区的空间,程序运行过程中只有一份。
调试:define 定义的宏常量不能调试,因为在预编译阶段就已经进行替换了;const 定义的常量可以进行调试。
const 的优点:
有数据类型,在定义式可进行安全性检查。
可调式。
占用较少的空间。
new 和 malloc 如何判断是否申请到内存? ★★☆☆☆
malloc :成功申请到内存,返回指向该内存的指针;分配失败,返回 NULL 指针。
new :内存分配成功,返回该对象类型的指针;分配失败,抛出 bad_alloc 异常。
delete 实现原理?delete 和 delete[] 的区别?★★★☆☆
delete 的实现原理:
首先执行该对象所属类的析构函数;
进而通过调用 operator delete 的标准库函数来释放所占的内存空间。
delete 和 delete [] 的区别:
delete 用来释放单个对象所占的空间,只会调用一次析构函数;
delete [] 用来释放数组空间,会对数组中的每个成员都调用一次析构函数。
new 和 malloc 的区别,delete 和 free 的区别 ★★★★☆
在使用的时候 new、delete 搭配使用,malloc、free 搭配使用。
malloc、free 是库函数,而new、delete 是关键字。
new 申请空间时,无需指定分配空间的大小,编译器会根据类型自行计算;malloc 在申请空间时,需要确定所申请空间的大小。
new 申请空间时,返回的类型是对象的指针类型,无需强制类型转换,是类型安全的操作符;malloc 申请空间时,返回的是 void* 类型,需要进行强制类型的转换,转换为对象类型的指针。
new 分配失败时,会抛出 bad_alloc 异常,malloc 分配失败时返回空指针。
对于自定义的类型,new 首先调用 operator new() 函数申请空间(底层通过 malloc 实现),然后调用构造函数进行初始化,最后返回自定义类型的指针;delete 首先调用析构函数,然后调用 operator delete() 释放空间(底层通过 free 实现)。malloc、free 无法进行自定义类型的对象的构造和析构。
什么是野指针和悬空指针?★★★☆☆
悬空指针:
若指针指向一块内存空间,当这块内存空间被释放后,该指针依然指向这块内存空间,此时,称该指针为“悬空指针”。
举例:
void *p = malloc(size);
free(p);
// 此时,p 指向的内存空间已释放, p 就是悬空指针。
野指针:
“野指针”是指不确定其指向的指针,未初始化的指针为“野指针”。
指针和引用的区别?★★★★☆
指针所指向的内存空间在程序运行过程中可以改变,而引用所绑定的对象一旦绑定就不能改变。(是否可变)
指针本身在内存中占有内存空间,引用相当于变量的别名,在内存中不占内存空间。(是否占内存)
指针可以为空,但是引用必须绑定对象。(是否可为空)
指针可以有多级,但是引用只能一级。(是否能为多级)
什么是内存泄露★★★★☆
内存泄漏:由于疏忽或错误导致的程序未能释放已经不再使用的内存。
进一步解释:
并非指内存从物理上消失,而是指程序在运行过程中,由于疏忽或错误而失去了对该内存的控制,从而造成了内存的浪费。
常指堆内存泄漏,因为堆是动态分配的,而且是用户来控制的,如果使用不当,会产生内存泄漏。
使用 malloc、calloc、realloc、new 等分配内存时,使用完后要调用相应的 free 或 delete 释放内存,否则这块内存就会造成内存泄漏。
//指针重新赋值
char *p = (char *)malloc(10);
char *p1 = (char *)malloc(10);
p = np;
开始时,指针 p 和 p1 分别指向一块内存空间,但指针 p 被重新赋值,导致 p 初始时指向的那块内存空间无法找到,从而发生了内存泄漏。
strlen 和 sizeof 区别★★★☆☆
strlen是函数 sizeof是运算符。
作用于字符数组时strlen得到字符串长度,sizeof得到数组长度。
数组作为函数参数时,两者分别作用于此数组strlen得到字符串长度,sizeof得到字符指针长度。
strlen在运行时计算字符串长度,sizeof在编译时计算字符数组长度。
strlen作用于字符串,sizeof既可以用于变量又可以用于类型。
lambda 表达式(匿名函数)的具体应用和使用场景★★★☆☆
lambda 表达式,又被称为 lambda 函数或者 lambda 匿名函数。
lambda匿名函数的定义:
[capture list] (parameter list) -> return type
{
function body;
};
其中:
capture list:捕获列表,指 lambda 所在函数中定义的局部变量的列表,引用捕获方式 [&]、值捕获方式 [=]。
return type、parameter list、function body:分别表示返回值类型、参数列表、函数体,和普通函数一样。
举例:
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
int arr[4] = {4, 2, 3, 1};
//对 a 数组中的元素进行升序排序
sort(arr, arr+4, [=](int x, int y) -> bool{ return x < y; } );
for(int n : arr){
cout << n << " ";
}
return 0;
}
lambda 表达式常搭配排序算法使用。
explicit 的作用(如何避免编译器进行隐式类型转换)★★★☆☆
作用:用来声明类构造函数是显示调用的,而非隐式调用,可以阻止调用构造函数时进行隐式转换。只可用于修饰单参构造函数,因为无参构造函数和多参构造函数本身就是显示调用的,再加上 explicit 关键字也没有什么意义。
inline 作用及使用方法★★★☆☆
作用:
inline 是一个关键字,可以用于定义内联函数。内联函数,像普通函数一样被调用,但是在调用时并不通过函数调用的机制而是直接在调用点处展开,这样可以大大减少由函数调用带来的开销,从而提高程序的运行效率。
使用方法:
类内定义成员函数默认是内联函数
在类内定义成员函数,可以不用在函数头部加 inline 关键字,因为编译器会自动将类内定义的函数(构造函数、析构函数、普通成员函数等)声明为内联函数
类外定义成员函数,若想定义为内联函数,需用关键字声明
当在类内声明函数,在类外定义函数时,如果想将该函数定义为内联函数,则可以在类内声明时不加 inline 关键字,而在类外定义函数时加上 inline 关键字。
inline 函数工作原理 ★★☆☆☆
内联函数不是在调用时发生控制转移关系,而是在编译阶段将函数体嵌入到每一个调用该函数的语句块中,编译器会将程序中出现内联函数的调用表达式用内联函数的函数体来替换。
普通函数是将程序执行转移到被调用函数所存放的内存地址,当函数执行完后,返回到执行此函数前的地方。转移操作需要保护现场,被调函数执行完后,再恢复现场,该过程需要较大的资源开销。
Extern C的作用★★☆☆☆
extern “C” 的作用是让 C++ 编译器将 extern “C” 声明的代码当作 C 语言代码处理,可以避免 C++ 因符号修饰导致代码不能和C语言库中的符号进行链接的问题。
memmove 和 memcpy的区别★★☆☆☆
他们的作用是一样的,唯一的区别是,当内存发生局部重叠的时候,memmove保证拷贝的结果是正确的,memcpy不保证拷贝的结果的正确。
类相关
什么是虚函数?什么是纯虚函数?★★★★★
虚函数:被 virtual
关键字修饰的成员函数,就是虚函数。
纯虚函数:纯虚函数在类中声明时,加上 =0;
含有纯虚函数的类称为抽象类(只要含有纯虚函数这个类就是抽象类),类中只有接口,没有具体的实现方法;
继承纯虚函数的派生类,如果没有完全实现基类纯虚函数,依然是抽象类,不能实例化对象。
说明:
抽象类对象不能作为函数的参数,不能创建对象,不能作为函数返回类型;
可以声明抽象类指针,可以声明抽象类的引用;
子类必须继承父类的纯虚函数,并全部实现后,才能创建子类的对象。
虚函数的实现机制 ★★★★★
实现机制:虚函数通过虚函数表来实现。虚函数的地址保存在虚函数表中,在类的对象所在的内存空间中,保存了指向虚函数表的指针(称为“虚表指针”),通过虚表指针可以找到类对应的虚函数表。虚函数表解决了基类和派生类的继承问题和类中成员函数的覆盖问题,当用基类的指针来操作一个派生类的时候,这张虚函数表就指明了实际应该调用的函数。
虚函数表相关知识点:
虚函数表存放的内容:类的虚函数的地址。
虚函数表建立的时间:编译阶段,即程序的编译过程中会将虚函数的地址放在虚函数表中。
虚表指针保存的位置:虚表指针存放在对象的内存空间中最前面的位置,这是为了保证正确取到虚函数的偏移量。
注:虚函数表和类绑定,虚表指针和对象绑定。即类的不同的对象的虚函数表是一样的,但是每个对象都有自己的虚表指针,来指向类的虚函数表。
单继承和多继承的虚函数表结构 ★★★★☆
编译器处理虚函数表:
编译器将虚函数表的指针放在类的实例对象的内存空间中,该对象调用该类的虚函数时,通过指针找到虚函数表,根据虚函数表中存放的虚函数的地址找到对应的虚函数。
如果派生类没有重新定义基类的虚函数 A,则派生类的虚函数表中保存的是基类的虚函数 A 的地址,也就是说基类和派生类的虚函数 A 的地址是一样的。
如果派生类重写了基类的某个虚函数 B,则派生的虚函数表中保存的是重写后的虚函数 B 的地址,也就是说虚函数 B 有两个版本,分别存放在基类和派生类的虚函数表中。
如果派生类重新定义了新的虚函数 C,派生类的虚函数表保存新的虚函数 C 的地址。
虚函数和纯虚函数的区别? ★★★☆☆
虚函数和纯虚函数可以出现在同一个类中,该类称为抽象基类。(含有纯虚函数的类称为抽象基类)
使用方式不同:虚函数可以直接使用,纯虚函数必须在派生类中实现后才能使用;
定义形式不同:虚函数在定义时在普通函数的基础上加上 virtual 关键字,纯虚函数定义时除了加上virtual 关键字还需要加上 =0;
虚函数必须实现,否则编译器会报错;
对于实现纯虚函数的派生类,该纯虚函数在派生类中被称为虚函数,虚函数和纯虚函数都可以在派生类中重写;
析构函数最好定义为虚函数,特别是对于含有继承关系的类;析构函数可以定义为纯虚函数,此时,其所在的类为抽象基类,不能创建实例化对象
构造函数、析构函数是否需要定义成虚函数?为什么?★★★★☆
构造函数不定义为虚函数,原因:
从存储空间的角度考虑:构造函数是在实例化对象的时候进行调用,如果此时将构造函数定义成虚函数,需要通过访问该对象所在的内存空间才能进行虚函数的调用(因为需要通过指向虚函数表的指针调用虚函数表,虽然虚函数表在编译时就有了,但是没有虚函数的指针,虚函数的指针只有在创建了对象才有),但是此时该对象还未创建,便无法进行虚函数的调用。所以构造函数不能定义成虚函数。
析构函数一般定义成虚函数,原因:
将父类的析构函数定义为虚函数。原理是:析构函数定义为虚函数时:基类指针可以指向派生类的对象(多态性),如果删除该指针(delete p),就会调用该指针指向的派生类析构函数,而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象完全被释放。
什么是类的默认构造函数?★★★☆☆
默认构造函数:未提供任何实参,来控制默认初始化过程的构造函数称为默认构造函数。
如何减少构造函数开销?★★☆☆☆
在构造函数中使用类初始化列表,会减少调用默认的构造函数产生的开销
为什么用成员初始化列表会快一些?★★★☆☆
说明:数据类型可分为内置类型和用户自定义类型(类类型),对于用户自定义类型,利用成员初始化列表效率高。
原因:用户自定义类型如果使用类初始化列表,直接调用该成员变量对应的构造函数即完成初始化;如果在构造函数中初始化,因为 C++ 规定,对象的成员变量的初始化动作发生在进入构造函数本体之前,那么在执行构造函数的函数体之前首先调用默认的构造函数为成员变量设初值,在进入函数体之后,调用该成员变量对应的构造函数。因此,使用列表初始化会减少调用默认的构造函数的过程,效率高。
那么如果我写了一个有参构造函数隐藏了默认构造函数,又在另一个类中声明一个该类成员,应该就无法在构函内部初始化该成员了(只能使用成员初始化列表),因为第一次的默认构造无法通过(vscode报错:类不存在默认构造函数))
多重继承时会出现什么状况?如何解决?★★★★☆
多重继承(多继承):是指从多个直接基类中产生派生类。
多重继承容易出现的问题:命名冲突和数据冗余问题。
解决方法 1: 声明出现冲突的成员变量来源于哪个类
解决方法 2: 虚继承
使用虚继承的目的:保证存在命名冲突的成员变量/成员函数在派生类中只保留一份
为什么拷贝构造函数必须为引用?★★☆☆☆
原因:避免拷贝构造函数无限制的递归,最终导致栈溢出。
实例化一个对象需要哪几个阶段★★★☆☆
1.分配空间
创建类对象首先要为该对象分配内存空间。不同的对象,为其分配空间的时机未必相同。全局对象、静态对象、分配在栈区域内的对象,在编译阶段进行内存分配;存储在堆空间的对象,是在运行阶段进行内存分配。
2.初始化
首先明确一点:初始化不同于赋值。初始化发生在赋值之前,初始化随对象的创建而进行,而赋值是在对象创建好后,为其赋上相应的值。这一点可以联想下上一个问题中提到:初始化列表先于构造函数体内的代码执行,初始化列表执行的是数据成员的初始化过程,这个可以从成员对象的构造函数被调用看的出来。
3.赋值
对象初始化完成后,可以对其进行赋值。对于一个类的对象,其成员变量的赋值过程发生在类的构造函数的函数体中。当执行完该函数体,也就意味着类对象的实例化过程完成了。(总结:构造函数实现了对象的初始化和赋值两个过程,对象的初始化是通过初始化列表来完成,而对象的赋值则才是通过构造函数的函数体来实现。)
注意:
对于拥有虚函数的类的对象,还需要给虚表指针赋值。
没有继承关系的类,分配完内存后,首先给虚表指针赋值,然后再列表初始化以及执行构造函数的函数体,即上述中的初始化和赋值操作。
有继承关系的类,分配内存之后,首先进行基类的构造过程,然后给该派生类的虚表指针赋值,最后再列表初始化以及执行构造函数的函数体,即上述中的初始化和赋值操作
C++ 类对象的初始化顺序★★★☆☆
构造函数调用顺序:
按照派生类继承基类的顺序,即派生列表中声明的顺序,依次调用基类的构造函数;
按照派生类中成员变量的声明顺序,依次调用派生类中成员变量所属类的构造函数;
执行派生类自身的构造函数。
综上可以得出,类对象的初始化顺序:基类构造函数–>派生类成员变量的构造函数–>自身构造函数
注:
基类构造函数的调用顺序与派生类的派生列表中的顺序有关;
成员变量的初始化顺序与声明顺序有关;
析构顺序和构造顺序相反。
对于一个类X进行初始化:
- 按照继承声明顺序,初始化基类对象
- 初始化X的成员对象
- 调用X构造函数
对象创建限制在堆或栈 ★★★☆☆
说明:C++ 中的类的对象的建立分为两种:静态建立、动态建立。
静态建立:由编译器为对象在栈空间上分配内存,直接调用类的构造函数创建对象。例如:A a;
动态建立:使用 new 关键字在堆空间上创建对象,底层首先调用 operator new() 函数,在堆空间上寻找合适的内存并分配;然后,调用类的构造函数创建对象。例如:A *p = new A();
限制对象只能建立在堆上:
最直观的思想:避免直接调用类的构造函数,因为对象静态建立时,会调用类的构造函数创建对象。但是直接将类的构造函数设为私有并不可行,因为当构造函数设置为私有后,不能在类的外部调用构造函数来构造对象,只能用 new 来建立对象。但是由于 new 创建对象时,底层也会调用类的构造函数,将构造函数设置为私有后,那就无法在类的外部使用 new 创建对象了。因此,这种方法不可行。
解决方法 1:
将析构函数设置为私有。
原因:静态对象建立在栈上,是由编译器分配和释放内存空间,编译器为对象分配内存空间时,会对类的非静态函数进行检查,即编译器会检查析构函数的访问性。当析构函数设为私有时,编译器创建的对象就无法通过访问析构函数来释放对象的内存空间,因此,编译器不会在栈上为对象分配内存。
该方法存在的问题:
用 new 创建的对象,通常会使用 delete 释放该对象的内存空间,但此时类的外部无法调用析构函数,因此类内必须定义一个 destory() 函数,用来释放 new 创建的对象。
无法解决继承问题,因为如果这个类作为基类,析构函数要设置成 virtual,然后在派生类中重写该函数,来实现多态。但此时,析构函数是私有的,派生类中无法访问。
解决方法 2:
构造函数设置为 protected,并提供一个 public 的静态函数来完成构造,而不是在类的外部使用 new 构造;将析构函数设置为 protected。原因:类似于单例模式,也保证了在派生类中能够访问析构函数。通过调用 create() 函数在堆上创建对象。
限制对象只能建立在栈上:
解决方法:将 operator new() 设置为私有。原因:当对象建立在堆上时,是采用 new 的方式进行建立,其底层会调用 operator new() 函数,因此只要对该函数加以限制,就能够防止对象建立在堆上。
static A *create()为什么必须是静态的?
static函数可以通过类名调用;而非静态函数只能通过对象来调用。
创建一个对象的时候,该对象并不存在,所以无法通过对象来调用一个非静态函数。
如何禁止一个类被实例化?★★☆☆☆
方法一:
- 在类中定义一个纯虚函数,使该类成为抽象基类,因为不能创建抽象基类的实例化对象;
方法二:
- 将类的构造函数声明为私有
private
方法三:
使用delete 关键字
如何让类不能被继承?
借助 final
关键字,用该关键字修饰的类不能被继承
深拷贝和浅拷贝的区别 ★★★★★
如果一个类拥有资源,该类的对象进行复制时,如果资源重新分配,就是深拷贝,否则就是浅拷贝。
深拷贝:该对象和原对象占用不同的内存空间,既拷贝存储在栈空间中的内容,又拷贝存储在堆空间中的内容。
浅拷贝:该对象和原对象占用同一块内存空间,仅拷贝类中位于栈空间中的内容。
当类的成员变量中有指针变量时,最好使用深拷贝。因为当两个对象指向同一块内存空间,如果使用浅拷贝,当其中一个对象的删除后,该块内存空间就会被释放,另外一个对象指向的就是垃圾内存。
如何避免拷贝? ★★☆☆☆
noncopyable(const noncopyable&) = delete; // 拷贝构造函数
noncopyable& operator=(const noncopyable&) = delete; // 赋值构造函数
友元函数的作用及使用场景★★☆☆☆
作用:友元提供了不同类的成员函数之间、类的成员函数与一般函数之间进行数据共享的机制。通过友元,一个不同函数或另一个类中的成员函数可以访问类中的私有成员和保护成员。
使用场景:
普通函数定义为友元函数,使普通函数能够访问类的私有成员。
友元类:类之间共享数据。
静态绑定和动态绑定的区别?★★★☆☆
静态绑定和动态绑定:
静态绑定是指程序在 编译阶段 确定对象的类型(静态类型)。
动态绑定是指程序在 运行阶段 确定对象的类型(动态类型)。
静态类型和动态类型:
静态类型:变量在声明时的类型,是在编译阶段确定的。静态类型不能更改。
动态类型:目前所指对象的类型,是在运行阶段确定的。动态类型可以更改。
编译时多态和运行时多态的区别 ★★★☆☆
编译时多态:在程序编译过程中出现,发生在模板和函数重载中(泛型编程)。
运行时多态:在程序运行过程中出现,发生在继承体系中,是指通过基类的指针或引用访问派生类中的虚函数。
编译时多态和运行时多态的区别:
时期不同:编译时多态发生在程序编译过程中,运行时多态发生在程序的运行过程中;
实现方式不同:编译时多态运用泛型编程来实现,运行时多态借助虚函数来实现。
新特性
智能指针有哪几种?智能指针的实现原理?★★★★★
智能指针是为了解决动态内存分配时带来的内存泄漏以及多次释放同一块内存空间而提出的。C++11 中封装在了
C++11 中智能指针包括以下三种:
共享指针(shared_ptr):资源可以被多个指针共享,使用计数机制表明资源被几个指针共享。通过 use_count() 查看资源的所有者的个数,可以通过 unique_ptr、weak_ptr 来构造,调用 release() 释放资源的所有权,计数减一,当计数减为 0 时,会自动释放内存空间,从而避免了内存泄漏。
独占指针(unique_ptr):独享所有权的智能指针,资源只能被一个指针占有,该指针不能拷贝构造和赋值。但可以进行移动构造和移动赋值构造(调用 move() 函数),即一个 unique_ptr 对象赋值给另一个 unique_ptr 对象,可以通过该方法进行赋值。
弱指针(weak_ptr):指向 share_ptr 指向的对象,能够解决由shared_ptr带来的循环引用问题。
智能指针的实现原理: 计数原理。
使用智能指针会出现什么问题?怎么解决?★★★★★
智能指针可能出现的问题:循环引用
在如下例子中定义了两个类 Parent、Child,在两个类中分别定义另一个类的对象的共享指针,由于在程序结束后,两个指针相互指向对方的内存空间,导致内存无法释放。
循环引用的解决方法: weak_ptr
循环引用:该被调用的析构函数没有被调用,从而出现了内存泄漏。
weak_ptr 对被 shared_ptr 管理的对象存在 非拥有性(弱)引用,在访问所引用的对象前必须先转化为 shared_ptr;
weak_ptr 用来打断 shared_ptr 所管理对象的循环引用问题,若这种环被孤立(没有指向环中的外部共享指针),shared_ptr 引用计数无法抵达 0,内存被泄露;令环中的指针之一为弱指针可以避免该情况;
weak_ptr 用来表达临时所有权的概念,当某个对象只有存在时才需要被访问,而且随时可能被他人删除,可以用 weak_ptr 跟踪该对象;需要获得所有权时将其转化为 shared_ptr,此时如果原来的 shared_ptr 被销毁,则该对象的生命期被延长至这个临时的 shared_ptr 同样被销毁。
左值和右值的区别?左值引用和右值引用的区别,如何将左值转换成右值?★★★★☆
左值:指表达式结束后依然存在的持久对象。
右值:表达式结束就不再存在的临时对象。
左值和右值的区别:左值持久,右值短暂
右值引用和左值引用的区别:
- 左值引用不能绑定到要转换的表达式、字面常量或返回右值的表达式。右值引用恰好相反,可以绑定到这类表达式,但不能绑定到一个左值上。
- 右值引用必须绑定到右值的引用,通过
&&
获得。右值引用只能绑定到一个将要销毁的对象上,因此可以自由地移动其资源。
std::move
可以将一个左值强制转化为右值,继而可以通过右值引用使用该值,以用于移动语义。
std::move() 函数的实现原理 ★★★☆☆
std::move() 函数原型:
template <typename T>
typename remove_reference<T>::type&& move(T&& t)
{
return static_cast<typename remove_reference<T>::type &&>(t);
}
说明:引用折叠原理
右值传递给上述函数的形参 T&& 依然是右值,即 T&& && 相当于 T&&。
左值传递给上述函数的形参 T&& 依然是左值,即 T&& & 相当于 T&。
小结:通过引用折叠原理可以知道,move() 函数的形参既可以是左值也可以是右值。
remove_reference 具体实现:
//原始的,最通用的版本
template
typedef T type; //定义 T 的类型别名为 type
};
//部分版本特例化,将用于左值引用和右值引用
template
{ typedef T type; }
template
{ typedef T type; }
//举例如下,下列定义的a、b、c三个变量都是int类型
int i;
remove_refrence<decltype(42)>::type a; //使用原版本,
remove_refrence<decltype(i)>::type b; //左值引用特例版本
remove_refrence<decltype(std::move(i))>::type b; //右值引用特例版本
举例:
C++
int var = 10;
转化过程:
std::move(var) => std::move(int&& &) => 折叠后 std::move(int&)
此时:T 的类型为 int&,typename remove_reference
::type 为 int,这里使用 remove_reference 的左值引用的特例化版本 通过 static_cast 将 int& 强制转换为 int&&
整个std::move被实例化如下
string&& move(int& t)
{
return static_cast<int&&>(t);
}
总结:
std::move() 实现原理:
利用引用折叠原理将右值经过 T&& 传递类型保持不变还是右值,而左值经过 T&& 变为普通的左值引用,以保证模板可以传递任意实参,且保持类型不变;
然后通过 remove_refrence 移除引用,得到具体的类型 T;
最后通过 static_cast<> 进行强制类型转换,返回 T&& 右值引用。
强制类型转换有哪几种?★★★★☆
static_cast:用于数据的强制类型转换,强制将一种数据类型转换为另一种数据类型。
用于基本数据类型的转换。
用于类层次之间的基类和派生类之间 指针或者引用 的转换(不要求必须包含虚函数,但必须是有相互联系的类),进行上行转换(派生类的指针或引用转换成基类表示)是安全的;进行下行转换(基类的指针或引用转换成派生类表示)由于没有动态类型检查,所以是不安全的,最好用 dynamic_cast 进行下行转换。
可以将空指针转化成目标类型的空指针。
可以将任何类型的表达式转化成 void 类型。
const_cast:
强制去掉常量属性,不能用于去掉变量的常量性,只能用于去除指针或引用的常量性,将常量指针转化为非常量指针或者将常量引用转化为非常量引用(注意:表达式的类型和要转化的类型是相同的)。
reinterpret_cast:
改变指针或引用的类型、将指针或引用转换为一个足够长度的整型、将整型转化为指针或引用类型。
dynamic_cast:
其他三种都是编译时完成的,动态类型转换是在程序运行时处理的,运行时会进行类型检查。
只能用于带有虚函数的基类或派生类的指针或者引用对象的转换,转换成功返回指向类型的指针或引用,转换失败返回 NULL;不能用于基本数据类型的转换。
在向上进行转换时,即派生类类的指针转换成基类类的指针和 static_cast 效果是一样的,(注意:这里只是改变了指针的类型,指针指向的对象的类型并未发生改变)。
在下行转换时,基类的指针类型转化为派生类类的指针类型,只有当要转换的指针指向的对象类型和转化以后的对象类型相同时,才会转化成功。
什么是单例模式?如何实现?应用场景?★★★★★
单例模式:保证类的实例化对象仅有一个,并且提供一个访问他的全局访问点。
应用场景:
表示文件系统的类,一个操作系统一定是只有一个文件系统,因此文件系统的类的实例有且仅有一个。
打印机打印程序的实例,一台计算机可以连接好几台打印机,但是计算机上的打印程序只有一个,就可以通过单例模式来避免两个打印作业同时输出到打印机。
实现方式:
单例模式可以通过全局或者静态变量的形式实现,这样比较简单,但是这样会影响封装性,难以保证别的代码不会对全局变量造成影响。
默认的构造函数、拷贝构造函数、赋值构造函数声明为私有的,这样禁止在类的外部创建该对象;
全局访问点也要定义成 静态类型的成员函数,没有参数,返回该类的指针类型。因为使用实例化对象的时候是通过类直接调用该函数,并不是先创建一个该类的对象,通过对象调用。
不安全的实现方式:
原因:考虑当两个线程同时调用 getInstance 方法,并且同时检测到 instance 是 NULL,两个线程会同时实例化对象,不符合单例模式的要求。
分类:
懒汉模式:直到第一次用到类的实例时才去实例化,上面是懒汉实现。
饿汉模式:类定义的时候就实例化。
线程安全的懒汉模式实现:
方法:加锁
存在的问题:每次判断实例对象是否为空,都要被锁定,如果是多线程的话,就会造成大量线程阻塞。
方法:内部静态变量,在全局访问点 getInstance 中定义静态实例。
饿汉模式的实现:
饿汉模式本身就是线程安全的不用加锁。
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 351134995@qq.com