系列文章目录
- 第一章 C/C++语言篇
- 第二章 计算机网络篇
- 第三章 操作系统篇
- 第四章 数据库MySQL篇
- 第五章 数据库Redis篇
- 第六章 场景题/算法题
- 第七篇 常见HR问题篇
秋招阶段,面过很多大中小厂,积攒了很多面经,都是高频问题!!!
前言:本文初衷是为了整理出最全面最详细的面经,非常适用于想走后端/软件开发
的同学!近些年,越来越多的人投入互联网的浪潮,由于岗位hc有限,企业筛选门槛也随之提高。以往的简单八股问答也在不断升级,面试官开始更喜欢问为什么,会围绕八股的某一点不断深问。所以本文的面经不仅仅是简单问答,而是帮你深入理解和掌握知识点,其中一些晦涩难懂的知识点,全都用案例和代码帮你彻底掌握,切记一定要理解原理,拒绝死记硬背!!!
文章目录
- 系列文章目录
- C/C++编程语言
- 1. 计算类的大小sizeof,总结所有情况
- 2. 未初始化数组的默认值问题
- 3. 容器迭代器失效情况
- 4. C++的stl容器分类?
- 5. 指针和引用的区别?
- 6. 介绍多态以及实现原理
- 7. 为什么要把析构函数定义成虚函数?
- 8. 多态的分类
- 9. 虚函数指针和虚函数表是什么时候生成的?
- 10. C++的野指针怎么造成的?怎么避免?
- 11. C++的编译和链接是什么?
- 11. static详细解释,在C和C++中有什么区别?
- 12. 构造函数里面能不能调用虚函数?调用的话会发生什么?为什么?
- 13. 构造函数为什么不能定义为虚函数?
- 14. 为什么需要纯虚函数?
- 15. 什么情况下子类必须要显示调用基类的构造函数?
- 16. 虚继承是什么?有什么用?
- 17. 为什么用初始化成员列表?和普通构造函数内赋值有什么区别?
- 18. 什么是 Lambda 表达式?
- 19. lamda表达式,[]里面的=和&有什么区别?
- 20. vector数组,emplace_back和push_back有什么区别?
- 21. vector数组,insert 和 emplace有什么区别?
- 22. 如果定义一个vector容器,大数量的push_back有什么影响?有什么改进办法?
- 23. vector中的clear()是清理内存还是元素?
- 24. 如何清理vector内存?
- 25. new和malloc有什么区别?
- 26. 链表和数组有什么区别?分析时间复杂度度?
- 27. 是否了解boost库?提供了哪些功能,简单说明
- 28. 在C++程序中,内存放在哪些存储空间?
- 29. stl容器中哪些数据类型用到了完全二叉树、平衡二叉树?
- 30. 平时C++开发用到了哪些提升性能的语法?
- 31. 自动类型推导auto可以推导出是左值还是右值吗?
- 32. C和C++编程有什么区别?
- 33. 面向对象的三大特性?
- 34. 解释面向对象的编程思想?
- 35. extern 关键字作用
- 36. 类中static变量和函数是什么时候初始化的?
- 37. volatile 关键字的作用
- 38. C++容器的迭代器有哪些类型?
- 39. 类怎么防止外部进行拷贝?
- 40. 宏的优点和缺点?
- 41. C++继承,子类继承父类的protected成员,子类可以访问吗?子类对象呢?
- 42. 对象的虚函数表指针的内存布局?和普通成员变量有什么不一样?
- 43. union 怎么区分大小端存储
- 44. delete this在类成员函数中会有问题吗?在析构函数中呢?
- 45. 编译错误、运行错误、链接错误
- 46. C++中的拷贝构造函数在哪些情况下会被调用:
- 47. 内存泄漏是什么?怎么看?怎么解决?
- 48. 解释一下浅拷贝、深拷贝
- 49. 基类、子类、子类成员变量的构造和析构函数的调用顺序
- 50. 指针常量和常量指针
- 51. 函数重载的条件
- 52. 哪些运算符不能被重载
- 53. C++ 的四种强制转换
- 54. 指针和引用的区别
- 55. 野指针与悬空指针有什么区别?
- 56. 为什么拷贝构造函数必须是引用传递,不能是值传递?
- 57. override、final 标识符的作用是什么?
- 58. 内存对齐的作用是什么呢?
- 59. 动态链接和静态链接区别
- 60. 类如何实现只能静态分配和只能动态分配
- 61. 哪些函数不能是虚函数?
- 62. 迭代器和指针的区别
- 63. STL 里 resize 和 reserve 的区别
- 64. explicit 有什么作用?
- 65. mutable 关键字作用
- 66. 内联函数的优缺点
- 67. auto是怎么实现自动识别类型的?模板是怎样实现转化成不同类型的?
- 68. C++是如何实现函数重载的?
- 69. 什么是尾递归?
- 70. 栈溢出的情况以及解决方法
- 71. 函数调用进行的操作
- 72. 什么是this指针,为什么存在this指针?
- 73. 为什么引入空指针 nullptr ?
- 74. map、set、multimap、multiset 容器的内部原理
- 75. 红黑树与普通二叉搜索平衡树(AVL树)的比较
- 76. unordered_map、unordered_set、unordered_multimap、unordered_multiset 容器的内部原理
- 77. 哈希冲突有哪些解决方法?
- 78. map容器取值的 find,[],at方法的区别
- 79. STL算法的分类
- 80. 介绍C++的智能指针
- 81. shared_ptr 线程安全吗?
- 82. 使用 make_shared 的优点,缺点
- 83. 左值右值是什么?右值可以取地址?move有什么用?
- 84. push_back 左值和右值的区别是什么?
- 85. 右值引用和万能引用
- 86. 移动语义是什么?为什么C++11要引入?
- 87. 解释什么是完美转发?
- 88. default 和 delete 关键字
- 88. C++常用设计模式
- 89. C++细节题目
C/C++编程语言
1. 计算类的大小sizeof,总结所有情况
面试原题,在32位机器上sizeof(B)为____,在64位机器上sizeof(B)为____
class A {
public:
A() = default;
virtual ~A() = default;
private:
uint32_t a1;
char a2;
};
class B : public A {
public:
B() = default;
virtual ~B() = default;
private:
void* b1;
uint32_t b2;
};
答案为20, 32, 下面有该例子的详解,算错的话快回顾一下下面所有情况的总结
这种题目要考虑字节对齐、虚函数、继承、静态成员/函数:
-
32位机器采用4字节的对齐规则,64位机器采用8字节的对齐规则,且既有字符型又有整型时才要考虑字节对齐;
首先要确定有效对齐值,即类的自身对齐值和操作系统的对齐值中取小的那个,其中类的自身对齐值就是其成员类型中字节最大的那个值。还可以通过预编译命令
#pragma pack(value)
指定对齐值value,这样的话就要选择自身对齐值和指定对齐值中较小的那个值。// 既有字符型又有整型时才要考虑字节对齐,只有字符串不考虑字节对齐 // 32:sizeof(A) == 5 64:sizeof(A) == 5 class A { char a,b,c,d,e; }; // 32:sizeof(A) == 8 64:sizeof(A) == 8 class A { char c,d; int a; // 类的自身对齐值为4 }; // 32:sizeof(A) == 12 64:sizeof(A) == 12 // 按顺序存储,不能把c d e对齐为4 class A { char c; int a; // 类的自身对齐值为4 char d,e; }; // 32:sizeof(A) == 16 64:sizeof(A) == 24 // 类的自身对齐值为8,32位操作系统对齐值为4,取最小则为4 // 类的自身对齐值为8,64位操作系统对齐值为8,取最小则为8 class A { char c; double a; char d,e; }; // 32:sizeof(A) == 2 64:sizeof(A) == 2 class A { short a; // 自身对齐值为2,选为最小 }; // 32:sizeof(A) == 6 64:sizeof(A) == 6 class A { char c,d,e; short a; // 自身对齐值为2 };
-
使用伪指令
#pragma pack(n)
改变字节对齐值,要选择类自身对齐值和指定对齐值n中较小的那个值。// 32:sizeof(A) == 12 64:sizeof(A) == 12 class A { char c; int a; // 自身对齐值为4 char d,e; }; // 32:sizeof(A) == 12 64:sizeof(A) == 12 // 指定对齐值为8,自身对齐值为4,取最小,有效对齐值为4 #pragma pack(8) class A { char c; int a; char d,e; }; // 32:sizeof(A) == 8 64:sizeof(A) == 8 // 指定对齐值为2,自身对齐值为4,取最小,有效对齐值为2 #pragma pack(2) class A { char c; int a; char d,e; };
-
如类中存在虚函数,则会有指向虚函数表的指针,存在多个虚函数等同于1个虚函数。
// 32:sizeof(A) == 4 64:sizeof(A) == 8 // 32位中指针占4个字节,64位中指针占8个字节 class A { virtual void C() {} }; // 32:sizeof(A) == 4 64:sizeof(A) == 8 class A { virtual void C() {} virtual void D() {} };
-
存在继承关系,分为多继承和单继承,继承基类中的成员变量和虚函数表,就算
private
也会继承下来,只是不能访问。// 32:sizeof(CC) == 8(1+4对齐) 64:sizeof(CC) == 16(1+8对齐) class AA {}; class BB : public AA { virtual func() {}; }; class CC : public AA, public BB {}; // 32:sizeof(B) == 20(4+1+4+4+4对齐4) 继承A的变量4+1对齐为8,B自身变量4+4,B虚表指针4 // 64:sizeof(B) == 32(4+1+8+4+8对齐8) 继承A的变量4+1对齐为8,B自身变量8+4对齐16,B虚表指针8 class A { public: virtual ~A() {}; private: uint32_t a1; char a2; }; class B : public A { public: virtual ~B() {}; private: void* b1; uint32_t b2; }; // 32:sizeof(B) == 20(4+1+4+4+4对齐4) 继承A的变量4+1对齐为8,B自身变量4+4,B继承的虚函数表,存在表指针4 // 64:sizeof(B) == 32(4+1+8+4+8对齐8) 继承A的变量4+1对齐为8,B自身变量8+4对齐16,B继承的虚函数表,存在表指针8 class A { private: virtual func() {} private: uint32_t a1; char a2; }; class B : public A { private: void* b1; uint32_t b2; };
-
空类:C++编译器强制给空类插入一个缺省成员,长度为1;如果有自定义的变量,变量将取代这个缺省成员。
// 32:sizeof(A) == 1 64:sizeof(A) == 1 class A {};
-
类普通成员函数不占空间。
// 32:sizeof(A) == 1 64:sizeof(A) == 1 等同于空类 class A { void B() { int d; } int C() {} };
-
静态成员变量和成员函数不占空间。
// 32:sizeof(A) == 1 64:sizeof(A) == 1 class A { static int a; static int b() {}; };
-
const成员变量占空间,const成员函数不占空间。
// 32:sizeof(A) == 12(4+1+4对齐) 64:sizeof(A) == 16(4+1+8) class A { public: virtual void f() {} int d() const {} int e() const {} virtual void g() {} private: const int b; const char c; };
-
成员中有位域占用时,注意:
// 32:sizeof(flags) == 8 (共38bit,int为4字节/32bit,对齐后为8字节) // 64:sizeof(flags) == 8 (共38bit,int为4字节/32bit,对齐后为8字节) struct flags { unsigned int a:1; unsigned int b:1; unsigned int c:4; unsigned int d:4; unsigned int e:4; unsigned int f:4; unsigned int g:4; unsigned int h:8; unsigned int i:8; }; // 32:sizeof(data) == 8 (4 + 4) // 64:sizeof(data) == 8 (4 + 4) struct data { int type; struct { unsigned int a:1; unsigned int b:1; } flagsEx; };
以下是32位和64位操作系统的各数据类型大小对比:(以字节为单位)
数据类型 | 32位 | 64位 |
---|---|---|
bool | 1 | 1 |
char | 1 | 1 |
unsigned char | 1 | 1 |
short int | 2 | 2 |
int | 4 | 4 |
unsigned int | 4 | 4 |
long | 4 | 8 |
unsigned long | 4 | 8 |
long long | 8 | 8 |
float | 4 | 4 |
double | 8 | 8 |
指针 | 4 | 8 |
virtual虚函数 | 4 | 8 |
字节对齐 | 4 | 8 |
细节注意:
-
结构体和类一样,都需要考虑字节对齐;
-
Linux下long是8字节,Windows下是4字节;
-
32位和64位下string差4byte,其实是一个指针的差别。string内部不保存字符串本身,而是保存了一个指向字符串开头的指针;
-
空类占1个字节,强制给空类插入一个缺省成员;
-
虚函数无论多少个,只相当于一个所占的空间,因为有虚函数表就有虚函数指针,相当于是指针大小;
-
继承:会继承基类的成员变量、虚函数表(所以会有虚表指针),多继承时若继承了空类,也要+1对齐;
-
既有字符型又有整型,要考虑字节对齐;
-
不占空间:普通成员函数、const成员函数、静态成员变量、静态成员函数;
-
占空间:普通成员变量、const成员变量、虚函数;
-
使用伪指令
#pragma pack(n)
,可指定对齐值,要选择类自身对齐值和指定对齐值n中较小的那个值。
2. 未初始化数组的默认值问题
面试原题如下,下面代码会不会报错,运行结果如何
int main(int argc, char const *argv[]) {
int a[10000000];
a[0] = 1;
a[1] = 2;
cout<<a[0]<<", "<<a[1]<<", "<<a[2]<<endl;
return 0;
}
不会报错,打印结果为:1, 2, 随机值
因为局部数组,未初始化时,默认值为随机的不确定的值;
下面为所有情况总结:
- 全局数组,未初始化时,默认值都是 0;
- 局部数组,未初始化时,默认值为随机的不确定的值;
- 全局/局部数组,初始化一部分时,未初始化的部分默认值为 0;
// 打印结果0 0 0 0 0 0 0 0 0 0
int a[10];
int main() {
for(int i = 0; i < 10; i++)
cout<< a[i] << " ";
}
// 打印结果1 2 0 0 0 0 0 0 0 0
int a[10] = {1,2};
int main() {
for(int i = 0; i < 10; i++)
cout<< a[i] << " ";
}
// 打印结果8 0 4199705 0 8 0 34 0 15155040 0
int main() {
int a[10];
for(int i = 0; i < 10; i++)
cout<< a[i] << " ";
}
// 打印结果1 2 0 0 0 0 0 0 0 0
int main() {
int a[10] = {1,2};
for(int i = 0; i < 10; i++)
cout<< a[i] << " ";
}
// 打印结果1 2 4199705 0 8 0 34 0 14106464 0
int main() {
int a[10]; // 并未初始化
a[0] = 1;
a[1] = 2;
for(int i = 0; i < 10; i++) {
cout<< a[i] << " ";
}
}
- 全局变量或者静态变量,未初始化的话就是默认值0;
- 局部变量,未初始化的话初始值随机,是以前残留在栈区里的随机值。
3. 容器迭代器失效情况
面试原题如下,下面打印结果是什么?
int main(int argc, char const *argv[]) {
vector<int> v = {1,2,3,4};
iterator it = v.end();
v.push_back(5);
cout<<*(it - 1)<<endl;
return 0;
}
答案是迭代器失效,会打印随机数字
下面是所有容器迭代器失效的情况总结:
容器底层数据结构 | 包含的具体容器 | 内存分配特点 | insert操作后迭代器失效情况 | erase操作后迭代器失效情况 |
---|---|---|---|---|
数组型数据结构 | vector, string, deque, array | 元素分配在连续的内存中 | 如果插入后重新分配空间,则所有迭代器都会失效(这种失效是彻底失效);如果插入后未重新分配空间,会使得插入点之后的元素向后移动,故插入点之后的迭代器全部失效(这种失效是指向内容改变,与原来不对应) | 会使得删除点之后的元素向前移动,故删除点及其之后的迭代器全部失效 |
链表型数据结构 | list, forward_list | 元素分配在不连续的内存中 | 插入不会使得任何迭代器失效 | 指向删除点位置的迭代器失效,但其他迭代器不会失效 |
树形数据结构 | map, set, multimap, multiset | 使用红黑树来存储数据 | 插入不会使得任何迭代器失效 | 指向删除点位置的迭代器失效,但其他迭代器不会失效 |
哈希数据结构 | unordered_map, unordered_multimap, unordered_multiset, unordered_set | 使用哈希表来存储数据 | 插入不会使得任何迭代器失效 | 指向删除点位置的迭代器失效,但其他迭代器不会失效 |
堆栈型数据结构 | stack, queue, priority_queue | stack和queue底层一般用list或deque实现,priority_queue的底层数据结构一般为vector | 不支持迭代器 | 不支持迭代器 |
看完上述解释,要是还没太看懂,来举个例子,再看几道题来感受一下吧
// 预留空间不足,彻底失效的情况
int main(int argc, char const *argv[]) {
vector<int> v = {1,2,3,4};
auto it = v.end();
v.push_back(5);
cout << *it << endl;
cout << *(it-1) << endl;
return 0;
}
两行打印都会出现随机数字
解释:这样初始化vector容器,不会预留空间,则size和capacity都为4,插入数据后要重新分配空间,则所有迭代器失效
// 预留空间足,不是彻底失效的情况
int main(int argc, char const *argv[]) {
vector<int> v = {1,2,3,4};
v.reserve(5);
auto it = v.end();
v.push_back(5);
cout << *it << endl; // 打印5
cout << *(it-1) << endl; // 打印4
}
int main(int argc, char const *argv[]) {
vector<int> v = {1,2,3,4};
v.reserve(5);
auto it = v.begin();
cout << *(it) << endl; // 打印1
v.insert(v.begin(), 0);
cout << *(it) << endl; // 打印0
}
解释:因为预留了内存,不会重新分配空间,则迭代器还会指向原来的位置,但是在插入点/删除点之后的数据会移动,所以迭代器指向的内容与插入/删除前不一样,失去意义,所以说包括插入/删除点之后的迭代器全部失效,这种失效不是彻底失效,即算出偏差后还可以使用。
// erase删除迭代器
int main(int argc, char const *argv[]) {
vector<int> v = {1,2,3,4};
auto it = v.begin();
v.erase(it); // 返回下一个有效迭代器
while(it != v.end()) {
cout << *it << endl;
it++;
}
}
打印 2 3 4
4. C++的stl容器分类?
容器分为序列式容器和关联式容器:
- 序列容器是按照元素在容器中的顺序来存储的,元素的位置由插入的顺序决定,每个元素都有自己的位置索引,比如vector,deque,list。
- 关联容器是按照键值对的方式来存储的,每个元素由一个key和一个value组成,它们根据键来快速访问和检索元素,比如map,set。
关于STL各种容器的详解,可以看我的另一篇博客 STL容器详解
5. 指针和引用的区别?
- 性质:指针是一个独立的对象(变量),而引用是已存在对象的别名。
- 使用方式:指针用于间接访问和操作对象,而引用则直接绑定到对象上。
- 内存管理:指针需要手动管理内存,而引用不需要。
- 空值处理:指针可以为空,引用不可以。
6. 介绍多态以及实现原理
关于多态,简而言之就是用父类的指针或引用指向其子类的实例对象,然后通过父类的指针调用实际子类的成员函数来实现动态绑定。这种技术可以让父类的指针有“多种形态”,是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法。
实现原理:虚函数表 + 虚表指针
若类中存在虚函数virtual
,则会为该类生成一个虚函数表,由于保存虚函数的地址。编译器处理虚函数的方法是:为每个类对象添加一个隐藏成员,隐藏成员中保存了一个指向函数地址数组的指针,称为虚表指针(vptr),这个函数地址数组成为虚函数表(vtbl),即每个类使用一个虚函数表,每个类对象用一个虚表指针。
举例:基类对象包含一个虚表指针,指向基类中所有虚函数的地址表。派生类对象也将包含一个虚表指针,指向派生类虚函数表。看下面几种情况:
-
如果派生类重写了基类的虚方法,该派生类虚函数表将保存重写后虚函数的地址,而不是基类的虚函数地址;
-
如果基类中的虚方法没有在派生类中重写,那么派生类将继承基类中的虚方法,而且派生类中虚函数表将保存基类中未被重写的虚函数的地址;
-
如果派生类中定义了新的虚方法,则该虚函数的地址也将被添加到派生类虚函数表中。
以下是虚函数的底层原理示例图:
从下图中可以看出,基类和派生类的虚函数表不是同一张表,那么虚指针也不一样。
代码解释:
class Base {
public:
void baseMethod() {
std::cout << "Base method called." << std::endl;
}
virtual void virtualMethod() {
std::cout << "Base virtual method called." << std::endl;
}
};
class Derived : public Base {
public:
void derivedMethod() {
std::cout << "Derived method called." << std::endl;
}
void virtualMethod() override {
std::cout << "Derived virtual method called." << std::endl;
}
};
int main() {
Derived derivedObj;
Base *p = &derivedObj; // 基类指针指向派生类对象
p->baseMethod(); // 可以调用基类的方法
p->virtualMethod(); // 调用派生类的覆盖方法(如果基类方法是虚函数)
// p->derivedMethod(); // 错误,不能通过基类指针调用派生类特有的方法
return 0;
}
调用基类方法:
p->baseMethod();
调用Base
类的方法,这是允许的,因为p
是Base*
类型的指针。
调用虚函数:
-
p->virtualMethod();
调用的是Derived
类中的覆盖方法。这是因为在基类中被声明为虚函数,因此调用时会发生多态。- 在
Base
类中,虚函数表包含指向Base::virtualMethod
的指针。 - 在
Derived
类中,虚函数表包含指向Derived::virtualMethod
的指针(因为Derived
覆盖了Base
的虚函数)。
运行时:
derivedObj
的 vptr 指向Derived
类的虚函数表。- 通过
p->virtualMethod()
调用虚函数时,实际是通过derivedObj
的 vptr 访问虚函数表,然后调用Derived::virtualMethod
。
- 在
调用派生类特有的方法:
p->derivedMethod();
会导致编译错误,因为p
是Base*
类型的指针,不能访问Derived
类特有的方法。
7. 为什么要把析构函数定义成虚函数?
在多态使用中,如果子类中有属性开辟到堆区中,需要在该子类的析构中delete堆区内存,就必须在父类中使用虚析构或纯虚析构,这样才能使得在父类指针析构时,调用子类中的析构函数,从而释放堆区内存。
8. 多态的分类
- 编译时多态(静态多态):
- 通过函数重载和运算符重载实现。
- 在编译时决定函数的调用方式,调用哪一个具体的函数版本。
- 运行时多态(动态多态):
- 通过虚函数和继承实现。
- 在运行时决定调用哪一个具体的函数版本,这种多态性通过虚函数表(vtable)和虚函数指针(vptr)实现。
9. 虚函数指针和虚函数表是什么时候生成的?
由上述说明,我们知道虚函数表对应类, 虚函数指针对应类对象。
生成时机
- 虚函数表的生成:
- 编译时: 虚函数表是在编译时由编译器生成的。在编译阶段,编译器为每个包含虚函数的类创建一个虚函数表,并将类的虚函数的地址填入表中。
- 编译器为类生成虚函数表时会确保派生类的虚函数表包含正确的函数地址。如果派生类覆盖了基类的虚函数,派生类的虚函数表中的相应条目将指向派生类的虚函数实现。
- 虚函数指针的设置:
- 运行时: 虚函数指针是在运行时,当对象被创建时,即初始化是在构造函数中完成的。对象的构造函数会将虚函数指针(
vptr
)指向正确的虚函数表。 - 对象的虚函数指针指向其所属类的虚函数表。因此,如果对象是基类类型的对象,其虚函数指针会指向基类的虚函数表;如果对象是派生类类型的对象,其虚函数指针会指向派生类的虚函数表。
- 运行时: 虚函数指针是在运行时,当对象被创建时,即初始化是在构造函数中完成的。对象的构造函数会将虚函数指针(
当派生类继承了两个或多个含有虚函数的基类时,派生类会有多张虚函数表,每张表对应一个基类。
10. C++的野指针怎么造成的?怎么避免?
C++野指针是指向已经被释放或未分配内存的指针。
(1) 如果指针在声明时未初始化,它将包含一个不确定的地址,使用这种指针会导致不可预知的行为。
int* ptr; // 未初始化的指针
*ptr = 42; // 使用未初始化的指针,可能导致崩溃
(2) 当指针指向的内存被释放后,该指针就成为了野指针。如果继续使用这个指针,可能会访问到无效的内存。
int* ptr = new int(42);
delete ptr; // 释放内存
*ptr = 42; // 继续使用已释放的内存,导致野指针
(3) 访问数组时,如果超出数组的边界,指针将指向未定义的内存区域,可能导致野指针问题。
int arr[5] = {1, 2, 3, 4, 5};
int* ptr = arr;
ptr += 10; // 越过数组边界
*ptr = 42; // 访问无效内存
(4) 如果一个类包含指针成员,且在拷贝构造时没有进行深拷贝,那么多个对象将共享同一块内存。在其中一个对象释放内存后,其他对象的指针将成为野指针。
解决方法
- 初始化指针:始终将指针初始化为
nullptr
或有效地址。 - 释放内存后置空:释放内存后,将指针置为
nullptr
。 - 使用智能指针:C++11引入的智能指针(如
std::unique_ptr
和std::shared_ptr
)可以自动管理内存,防止野指针问题。
11. C++的编译和链接是什么?
编译(Compilation)
编译是将源代码文件(通常是 .cpp
或 .c
文件)转换为目标文件(.o
或 .obj
文件)的过程。这个过程通常分为几个步骤:
- 预处理(Preprocessing):
- 编译器首先处理预处理指令,如
#include
、#define
等。它会将所有包含的头文件插入到源文件中,并处理宏定义。 - 生成的结果是一个“纯净”的 C++ 源代码文件。
- 编译器首先处理预处理指令,如
- 编译(Compilation):
- 编译器将预处理后的 C++ 源代码转换为汇编代码。这个过程包括语法分析、语义分析、优化等步骤。
- 如果源代码有语法错误或类型错误,编译器会在这一阶段报错。
- 汇编(Assembly):
- 汇编器将编译生成的汇编代码转换为机器代码,即目标文件(
.o
或.obj
文件)。 - 目标文件包含了机器可以理解的二进制指令,但这些指令还没有连接成一个完整的可执行程序。
- 汇编器将编译生成的汇编代码转换为机器代码,即目标文件(
链接(Linking)
链接是将多个目标文件和库文件组合成一个可执行文件的过程。链接过程可以分为以下几个步骤:
- 符号解析(Symbol Resolution):
- 链接器会解析所有目标文件中的符号,例如函数名、变量名等。
- 链接器会尝试找到每个符号的定义,并将其与符号的使用进行匹配。如果某个符号在所有目标文件和库文件中都找不到定义,就会产生链接错误(如“未定义的引用”)。
- 重定位(Relocation):
- 链接器将所有目标文件中的代码和数据组合在一起,并为每个符号分配最终的内存地址。
- 链接器会修改代码中的地址引用,使它们指向正确的内存位置。
- 生成可执行文件(Executable Generation):
- 链接器将所有经过重定位的代码和数据组合起来,生成一个完整的可执行文件。
- 这个可执行文件可以直接在操作系统上运行。
编译和链接的关系
- 编译 是针对每个源文件单独进行的,每个源文件会生成一个独立的目标文件。
- 链接 是将所有目标文件(以及可能的库文件)组合起来,生成最终的可执行文件。
11. static详细解释,在C和C++中有什么区别?
(1)普通静态变量
C 和 C++ 是一样的
-
局部静态变量:在函数内部定义时,
static
关键字使得变量在函数调用之间保持其值。变量只初始化一次,并且其生命周期贯穿整个程序执行过程,但仅在函数内部可见。void func() { static int count = 0; // 只初始化一次 count++; printf("%d\n", count); }
-
全局静态变量:在文件范围内定义时,
static
关键字使得变量的作用域限制在当前源文件内。其他源文件无法访问这个变量,防止命名冲突。static int globalVar = 0; // 只能在当前文件中使用
(2)普通静态函数
C 和 C++ 是一样的
-
文件范围内的静态函数:函数定义时使用
static
关键字,使得函数的作用域限制在当前源文件内,防止其他文件中的代码调用该函数。static void helperFunction() { // 只能在当前文件中调用 }
(3)静态类成员
C 中
- C 不支持类,因此不涉及静态类成员的概念。结构体本身不能直接包含
static
变量。
C++ 中
-
静态类成员/函数:C++ 允许在类中定义静态成员,静态成员变量和静态成员函数属于类而不是类的对象。所有对象共享静态成员变量和函数。静态成员函数可以访问静态成员变量,但不能访问类的非静态成员,因为没有
this
指针。class MyClass { public: static int staticVar; // 静态成员变量 static void staticFunc(); // 静态成员函数 }; int MyClass::staticVar = 10; // 定义静态成员变量 void MyClass::staticFunc() { /* Implementation */ } // 定义静态成员函数
static
变量的作用:
- 作用域限制:
static
变量的作用域被限制在定义它的文件内,其他文件无法访问。可以有效避免不同文件中的变量名冲突。 - 生命周期:
static
全局变量在程序启动时初始化,并在整个程序运行期间保持存在,直到程序结束时被销毁。
12. 构造函数里面能不能调用虚函数?调用的话会发生什么?为什么?
构造函数内部可以调用虚函数,实际上会调用当前类的虚函数实现,而不是派生类的实现,不会表现出多态行为。
原因是对象在构造过程中,派生类的部分还未完全构造,虚函数表指针(vptr)尚未指向派生类的虚函数表。
13. 构造函数为什么不能定义为虚函数?
虚函数的调⽤是通过实例化之后对象的虚函数表指针来找到虚函数的地址进⾏调⽤的,并且虚表指针是在构造函数中初始化的,如果说构造函数是虚的,那么虚函数表指针则是不存在的,⽆法找到对应的虚函数表来调⽤虚函数,那么这个调⽤实际上也是违反了先实例化后调用的准则。
14. 为什么需要纯虚函数?
想让派生类继承基类后,必须重写某个函数,不能出现写缺省的情况,否则无法实例化去使用。
15. 什么情况下子类必须要显示调用基类的构造函数?
- 基类没有默认构造函数。
- 需要初始化基类的成员变量。
- 使用虚继承时需要显式调用虚基类构造函数。
16. 虚继承是什么?有什么用?
虚继承是C++中一种解决多重继承中菱形继承问题的方法。它通过在继承时声明基类为虚基类,确保无论从哪个路径继承,最终派生类只会拥有一个共享的基类子对象。
菱形继承问题
菱形继承(或称钻石继承)是指当一个类派生自两个基类,而这两个基类又是从同一个基类派生时,继承关系形成一个菱形结构。
例如,考虑以下类继承结构:
class A {
public:
int data;
};
class B : public A { };
class C : public A { };
class D : public B, public C { };
在这种情况下,类 D
继承了 B
和 C
,而 B
和 C
又分别继承自 A
。因此,D
类会拥有两个 A
类的副本(一个来自 B
,一个来自 C
)。这种情况下,如果我们在 D
类中访问 A
类的成员 data
,编译器会不知该访问哪个 A
类的副本,导致二义性问题。
虚继承的解决方案
虚继承通过在继承时声明基类为虚基类,确保派生类中只有一个基类的实例。使用虚继承可以解决菱形继承问题。
class A {
public:
int data;
};
class B : public virtual A { };
class C : public virtual A { };
class D : public B, public C { };
17. 为什么用初始化成员列表?和普通构造函数内赋值有什么区别?
关键区别
-
初始化 vs 赋值:
- 成员初始化列表直接初始化成员变量。这意味着成员变量在对象创建时就被赋予了初始值。
- 普通初始化先调用成员变量的默认构造函数创建成员对象,然后再在构造函数体内进行赋值操作。这样就导致了成员变量的值被初始化两次(先默认构造,再赋值)。
-
常量成员和引用类型:
- 如果类包含
const
成员或引用类型
成员,必须使用成员初始化列表进行初始化。这是因为 const 成员和引用类型成员在对象创建后不能被重新赋值。
class MyClass { public: const int x; int& y; MyClass(int a, int& b) : x(a), y(b) { } // 必须使用成员初始化列表 };
- 如果类包含
-
子类调用基类构造函数
因为在派生类的构造函数体开始执行之前,基类部分已经需要被正确初始化,这个初始化必须在成员初始化列表中完成。
-
成员变量依赖顺序:
- 成员初始化列表的顺序与成员变量在类中声明的顺序有关。即使在列表中调换顺序,初始化仍按声明顺序进行。普通初始化则在构造函数体内按顺序执行赋值。
18. 什么是 Lambda 表达式?
Lambda表达式是一种在被调用的位置或作为参数传递给函数的位置定义匿名函数对象(闭包)的简便方法。Lambda表达式的基本语法如下:
[capture list] (parameter list) -> return type { function body }
其中:
- capture list 是捕获列表,用于指定 Lambda表达式可以访问的外部变量,以及是按值还是按引用的方式访问。捕获列表可以为空,表示不访问任何外部变量,也可以使用默认捕获模式 & 或 = 来表示按引用或按值捕获所有外部变量,还可以混合使用具体的变量名和默认捕获模式来指定不同的捕获方式。
- parameter list 是参数列表,用于表示 Lambda表达式的参数,可以为空,表示没有参数,也可以和普通函数一样指定参数的类型和名称,还可以在 c++14 中使用 auto 关键字来实现泛型参数。
- return type 是返回值类型,用于指定 Lambda表达式的返回值类型,可以省略,表示由编译器根据函数体推导,也可以使用 -> 符号显式指定,还可以在 c++14 中使用 auto 关键字来实现泛型返回值。
- function body 是函数体,用于表示 Lambda表达式的具体逻辑,可以是一条语句,也可以是多条语句,还可以在 c++14 中使用 constexpr 来实现编译期计算。
19. lamda表达式,[]里面的=和&有什么区别?
[=]
:默认按值捕获所有外部变量。
[&]
:默认按引用捕获所有外部变量。
默认的引用捕获可能会导致悬挂引用,可能会导致闭包包含一个局部变量的引用或者形参的引用,如果一个由lambda创建的闭包的生命周期超过了局部变量或者形参的生命期,那么闭包的引用将会空悬。解决方法是对个别参数使用值捕获。
[] 内所有类型总结:
20. vector数组,emplace_back和push_back有什么区别?
底层实现机制:
push_back
:首先创建一个元素,然后将其拷贝或移动到容器的尾部。这意味着push_back
可能会涉及到元素的拷贝或移动操作emplace_back
:直接在容器尾部创建元素,省去了拷贝或移动的过程。它通过直接在容器内部构造元素来避免额外的拷贝或移动操作
性能:
emplace_back
通常比push_back
更快,因为它避免了不必要的拷贝或移动操作,特别是在处理大型对象或复杂数据结构时,性能差异更明显。
隐式转换:
push_back
支持隐式转换,因为它可以接受任何可以通过隐式转换到容器元素类型的对象。emplace_back
本质上是模板,不支持隐式转换,因为它直接在容器内部构造元素,这要求传入参数的构造函数与容器元素的类型完全匹配。
21. vector数组,insert 和 emplace有什么区别?
insert
:需要一个已经构造好的对象,然后将该对象拷贝或移动到容器中。
emplace
:直接在容器中构造对象,避免了额外的拷贝或移动。
22. 如果定义一个vector容器,大数量的push_back有什么影响?有什么改进办法?
影响:
- 频繁重新分配:std::vector 在内存中以连续的方式存储元素。当新元素通过 push_back 添加到 vector 中,并且当前已分配的内存空间不足以容纳新元素时,vector 需要执行一次内存重新分配。重新分配通常涉及申请一个更大的内存块、复制现有元素到新块、释放旧内存块。这个过程是计算和时间成本较高的,尤其是当 vector 容量很大时。
- 复制开销:在每次重新分配时,所有现有的元素都必须复制到新的内存位置。这一步骤可能涉及大量的复制操作,特别是对于大型对象或复杂对象时,这一开销尤为明显。
改进办法:
- 预先分配内存:使用 std::vector::reserve() 方法预先分配足够的内存。如果你知道将要存储的元素数量,或者有一个合理的估计,预先分配内存可以防止在 push_back 过程中发生重新分配。
- 使用 emplace_back:当元素类型较为复杂时(如包含多个数据成员或动态分配的内存),使用 emplace_back 替代 push_back 可以减少一次对象的复制或移动。emplace_back 直接在容器内存位置构造元素,减少了构造和复制的开销。
23. vector中的clear()是清理内存还是元素?
清理所有元素,使得容器 size 为 0,但是内存不会被清理,保留 capacity。
24. 如何清理vector内存?
在堆上,new的话用delete清除。
在栈上的话:
- 使用
vec.swap(std::vector())
,使用swap函数可以将vector与一个空的vector进行交换,从而释放vector所占用的内存空间。 - 使用
vec = std::vector()
将一个空的vector赋值给已有的vector,从而实现清空vector的效果。 - C++11新特性,在
clear()
后在调用shrink_to_fit()
25. new和malloc有什么区别?
new
和 malloc
是 C++ 中用于动态内存分配的两种不同机制
内存分配和初始化:
new
:分配内存并调用构造函数初始化对象。MyClass* obj = new MyClass(1, 2); // 分配一个 MyClass 对象并调用构造函数
malloc
:仅分配内存,不调用构造函数,不进行初始化。
内存释放和析构:
-
delete
:调用析构函数并释放内存。delete obj; // 调用 MyClass 的析构函数并释放内存
-
free
:仅释放内存,不调用析构函数。
类型安全:
new
:不需要显式的类型转换,返回特定类型的指针。malloc
:返回void*
,需要显式的类型转换。
错误处理:
new
:分配失败时抛出异常std::bad_alloc
。malloc
:分配失败时返回NULL
。
26. 链表和数组有什么区别?分析时间复杂度度?
存储方式
- 数组是连续内存中的一组元素。所有元素在内存中是相邻存储的。因此可以通过索引直接访问任意元素,访问时间复杂度为
O(1)
。 - 链表由一系列不连续内存中的节点组成,每个节点包含数据部分和指向下一个节点的指针。无法通过索引直接访问,需要从头节点开始逐一遍历,访问时间复杂度为
O(n)
。
插入和删除操作
- 在数组中插入或删除元素可能涉及到大量元素的移动,时间复杂度为
O(n)
。如在数组的开头插入一个元素,需要将所有现有元素向后移动。 - 链表中插入或删除元素的操作更为高效。只需要调整相应节点的指针,而不需要移动其它元素,时间复杂度为
O(1)
。
访问效率
- 数组支持随机访问,可以通过下标直接访问任意元素,访问速度非常快,时间复杂度为
O(1)
。 - 链表不支持随机访问,只能通过指针从头到尾依次访问,访问某个特定位置的元素时间复杂度为
O(n)
。
内存利用率
- 数组:
- 数组要求一块连续的内存空间,这在大数据量的情况下可能会导致内存分配失败(尤其是在碎片化严重的内存中)。
- 但是数组没有额外的内存开销,所有的空间都用于存储数据。
- 链表:
- 由于链表的每个节点都包含一个额外的指针(或多个指针,视链表类型而定),因此链表在内存中有一定的额外开销。
- 链表不需要连续的内存空间,可以更好地利用内存。
27. 是否了解boost库?提供了哪些功能,简单说明
Boost 是一个广泛使用的 C++ 库集合,旨在提供高质量、可复用的 C++ 组件和工具。它补充了 C++ 标准库的功能,并且许多 Boost 组件后来成为了 C++ 标准的一部分。Boost 库被设计为高效、健壮、可移植,且具有良好的文档和测试覆盖。
Boost 的主要库和功能:
- Boost.SmartPtr: 提供智能指针,如
boost::shared_ptr
、boost::unique_ptr
和boost::weak_ptr
,用于自动管理内存和避免内存泄漏。 - Boost.Thread: 提供跨平台的线程和同步原语,如线程、互斥锁、条件变量等。
- Boost.Asio: 实现了异步 I/O 操作,适用于网络编程和其他异步操作。
- Boost.Filesystem: 提供文件系统操作的功能,如遍历目录、文件路径操作等。
- Boost.Regex: 提供正则表达式的支持,用于模式匹配和文本处理。
- Boost.LexicalCast: 提供了类型安全的字符串和基本数据类型之间的转换。
- Boost.Serialization: 用于序列化和反序列化 C++ 对象,以便存储到文件或在网络上传输。
- Boost.Graph: 提供了图论相关的数据结构和算法,如最短路径、最小生成树等。
- Boost.Spirit: 提供了一个强大的解析器框架,用于编写解析器和编译器。
28. 在C++程序中,内存放在哪些存储空间?
代码区:存储程序的机器指令和代码。代码段是只读的,防止程序意外修改它。该区域内存中的内容在程序运行时通常不会改变,编译时确定。
数据区:存储全局变量和静态变量,进一步分为两个部分:
- 已初始化数据段
- 用途:存储已初始化的全局变量和静态变量。
- 特点:该区域的内存在程序启动时分配,在程序结束时释放,变量的初始值在编译时已经确定。
int globalVar = 10; // 存储在已初始化数据段
static int staticVar = 20; // 存储在已初始化数据段
- 未初始化数据段
- 用途:存储未初始化的全局变量和静态变量(以及显式初始化为0的变量)。
- 特点:该区域的内存也在程序启动时分配,在程序结束时释放,所有变量初始化为0。
int globalUninitVar; // 存储在BSS段
static int staticUninitVar; // 存储在BSS段
栈区
- 用途:用于存储函数的局部变量、函数参数、返回地址,以及函数调用时创建的其他必要信息。
- 特点:栈内存是由编译器自动管理的。栈中的数据在函数调用时分配,在函数返回时释放。栈内存的大小有限,分配速度快。
void function() {
int localVar = 30; // 存储在栈中
}
堆区
- 用途:用于动态分配的内存,通常由程序员通过
new
和delete
操作符手动管理。 - 特点:堆内存的大小只受限于操作系统提供的可用内存。内存的分配和释放速度比栈慢,且必须显式地释放,否则可能会造成内存泄漏。
int* ptr = new int; // 动态分配的内存存储在堆中
delete ptr; // 释放堆内存
29. stl容器中哪些数据类型用到了完全二叉树、平衡二叉树?
完全二叉树
std::priority_queue
(优先队列)
- 使用的树结构:完全二叉树,通常以二叉堆(Binary Heap)的形式实现。
- 特点:二叉堆是一种完全二叉树,通常使用数组表示,能保证父节点的值大于或等于子节点(最大堆),或小于或等于子节点(最小堆)。
平衡二叉树
std::set
、std::multiset
、std::map
、std::multimap
30. 平时C++开发用到了哪些提升性能的语法?
内联函数(inline)
-
定义:内联函数是一种在编译时进行展开的函数,通过使用
inline
关键字来提示编译器将函数调用展开为内联代码。与普通函数不同,内联函数在调用时不会发生函数调用的开销(如压栈、跳转、返回等),而是将函数体直接替换到调用点,从而提高运行时性能。 -
用法:使用
inline
关键字,可以将函数的调用展开为内联代码,减少函数调用的开销。 -
注意:适用于频繁调用且代码量小的函数。
inline int add(int a, int b) {
return a + b;
}
class MyClass {
private:
int value;
public:
inline int getValue() const { return value; }
inline void setValue(int val) { value = val; }
};
常量表达式(constexpr)
- 用法:使用
constexpr
关键字,可以在编译时计算常量表达式,减少运行时的开销。 - 注意:适用于可以在编译时确定值的表达式和函数。
constexpr int square(int x) {
return x * x;
}
int arr[square(10)]; // 编译时计算出数组大小
移动语义和右值引用
- 用法:使用右值引用和
std::move
,可以避免不必要的深拷贝,提升对象的转移性能。 - 注意:特别适用于需要频繁移动大对象的场景。
class MyClass {
public:
MyClass(MyClass&& other) noexcept { /* move constructor */ }
MyClass& operator=(MyClass&& other) noexcept { /* move assignment operator */ }
};
MyClass a = std::move(b);
智能指针
- 用法:使用
std::unique_ptr
和std::shared_ptr
,可以自动管理动态内存,减少内存泄漏和指针错误。 - 注意:选择合适的智能指针类型,根据所有权和生命周期管理需求。
避免不必要的拷贝
- 用法:使用引用传递参数,返回对象时使用移动语义,尽量避免传值操作。
- 注意:特别是在处理大对象时,引用传递可以显著提升性能。
void process(const MyClass& obj); // 使用引用传递避免拷贝
模板编程
- 用法:使用模板进行泛型编程,提高代码复用性和编译时优化。
- 注意:模板的使用应平衡灵活性和复杂性,避免过度模板化导致编译时间过长。
template<typename T>
T add(T a, T b) {
return a + b;
}
避免虚函数的开销
- 用法:在性能关键的代码中,避免使用虚函数,或者使用final关键字告知编译器不再继承。
- 注意:虚函数的动态绑定会有一定的性能开销。
class Base {
virtual void func() final;
};
使用STL算法和数据结构
- 用法:充分利用STL提供的高效算法和数据结构,如
std::sort
、std::vector
等。 - 注意:STL算法和数据结构经过优化,通常比手写代码更高效。
std::vector<int> vec = {4, 2, 3, 1};
std::sort(vec.begin(), vec.end());
配置优化编译器选项
- 用法:在编译时使用优化选项,如
-O2
、-O3
(GCC/Clang)或/O2
(MSVC)。 - 注意:不同优化级别可能会影响代码的调试和行为,选择合适的优化级别。
g++ -O2 main.cpp -o main
31. 自动类型推导auto可以推导出是左值还是右值吗?
auto
关键字在 C++ 中用于自动推导变量的类型。当使用 auto
声明变量时,编译器会根据变量初始化时的表达式类型来推导出具体的类型。
auto
本身不会推导出变量是左值还是右值,它推导的是类型。如果想要推导出引用类型或右值引用类型,需要使用 auto&
或 auto&&
。
普通情况:如果表达式是一个非引用的左值,那么 auto
会推导出与表达式相同的值类型,而不是引用类型。
int x = 10;
auto y = x; // y 的类型是 int(不是 int&)
推导出引用类型:如果表达式是一个引用,且 auto
被用于引用类型的变量(如 auto&
),那么 auto
会推导出引用类型。
int x = 10;
auto& y = x; // y 的类型是 int&(左值引用)
右值引用:如果表达式是右值,并且 auto
被用于右值引用类型的变量(如 auto&&
),那么 auto
会推导出右值引用类型。
auto&& z = 10; // z 的类型是 int&&(右值引用)
32. C和C++编程有什么区别?
面向对象编程:
- C 是一种面向过程的语言,主要强调函数和过程,而不是数据。程序通过函数调用一步步地执行。
- C++ 是 C 的扩展,它引入了面向对象编程的概念,如类、对象、封装、继承、多态等。C++ 强调对象和数据的抽象化,使代码更具模块化和可重用性。
模板与泛型编程:
- C 没有模板或泛型编程的概念。
- C++ 提供了模板功能,使得代码可以在编译时生成特定类型的函数或类,支持泛型编程。
标准库:
- C 提供了标准库(如
stdio.h
,stdlib.h
等),主要用于基本的输入输出和内存管理。 - C++ 拥有一个更为丰富的标准库,包括面向对象的容器类、算法、输入输出流等,还有C++11新特性。
33. 面向对象的三大特性?
封装、继承、多态
34. 解释面向对象的编程思想?
面向对象编程是一种程序设计思想,旨在通过对象和类的概念来组织程序结构。它强调将数据和操作数据的函数封装在一起,使代码更具模块化、可重用性和可维护性。
封装(Encapsulation)
- 封装是指将对象的属性和方法封装在一个类中,并对外部隐藏对象的内部实现细节。外部只能通过对象的公共接口(公共方法)与其进行交互,而不能直接访问或修改对象的内部数据。
- 通过封装,可以保护对象的状态,使得对象内部的变化不会影响外部的代码。
继承(Inheritance)
- 继承是指一个类可以继承另一个类的属性和方法,从而重用已有的代码。被继承的类称为父类(或基类),继承它的类称为子类(或派生类)。子类可以扩展或修改父类的行为。
- 通过继承,可以实现代码的复用和层次化结构。
多态(Polymorphism)
- 多态是指同一方法在不同对象上可以有不同的行为。多态允许对象在运行时确定其行为,使得代码更具灵活性和可扩展性。
- 多态通常通过方法重载(overloading)和方法重写(overriding)来实现。
35. extern 关键字作用
(1)常规全局变量:没有 static
修饰符的全局变量默认具有外部链接,可以被其他文件通过 extern
关键字访问。
(2)static
全局变量:具有内部链接,只能在定义它的文件内访问,其他文件无法通过 extern
访问它。
(3)C++中,可以用 extern "C"
来告诉C++编译器按照C语言的规则对所包围的代码进行编译。一般要想在C++中调用C函数,我们需要告诉C++编译器不要对这些特定的函数名进行修饰,以确保能够正确链接C语言编写的代码。这是通过在C++代码中使用extern "C"来实现的:
extern "C" {
#include "c_header.h"
void function1(int);
void function2(double);
}
这是因为C++编译器的名字修饰,因为C++支持函数重载,即可以有多个同名函数,只要它们的参数类型、个数或顺序不同。为了区分这些同名函数,C++编译器会对函数名进行名字修饰,将函数参数的类型信息加入到函数名中。而C语言不支持函数重载,因此不进行名字修饰。
如果你的C++代码需要调用由C语言编写的库函数(例如,操作系统API或者旧的C库),那么你需要确保C++编译器不对这些函数名进行修饰,以保证链接时能够正确找到这些函数。
36. 类中static变量和函数是什么时候初始化的?
static 成员变量的初始化
- 声明与定义:
static
成员变量在类中声明时属于类,而不是属于类的任何对象实例,因此必须在类外部进行定义和初始化。 - 初始化时机:
static
成员变量的初始化在程序启动时(即main()
函数执行之前)进行。具体来说,它们在全局初始化阶段被初始化,因此在任何对象创建之前就已经存在。
class MyClass {
public:
static int staticVar; // 在类中声明 static 变量
};
// 在类的实现文件中定义并初始化 static 变量
int MyClass::staticVar = 0;
int main() {
MyClass::staticVar = 10; // 可以直接通过类名访问
return 0;
}
static 成员函数的初始化
- 声明与定义:
static
成员函数在类中声明时与static
成员变量类似,它们属于类本身而不是某个对象实例。static
成员函数可以直接在类内部定义,也可以在类外部定义。 - 初始化时机:
static
成员函数与普通成员函数一样,不需要显式初始化。它们在程序加载时就可用,并且可以直接通过类名调用。由于static
成员函数不依赖于对象实例,因此不涉及特殊的初始化过程。没有this指针,不能调用普通成员变量。
class MyClass {
public:
static void staticFunc() {
// static 函数体
}
};
int main() {
MyClass::staticFunc(); // 可以直接通过类名调用 static 成员函数
return 0;
}
37. volatile 关键字的作用
防止编译器优化
-
作用:编译器在优化代码时,可能会对变量的值进行缓存,以减少不必要的内存访问,从而提高性能。然而,对于使用
volatile
修饰的变量,编译器必须保证每次访问该变量时都从内存中读取其最新的值,而不能依赖缓存的值。 -
示例:
volatile int flag = 0; while (flag == 0) { // 循环等待 flag 变化 }
在这个例子中,由于
flag
变量被标记为volatile
,编译器不会将其值缓存到寄存器中,而是每次都从内存中读取。这在多线程环境或硬件编程中尤为重要。
38. C++容器的迭代器有哪些类型?
前向迭代器(Forward Iterator)
- 支持单向遍历,可以读写元素,仅支持
iterator++ / ++iterator
; - 例如:
std::forward_list
的迭代器。
双向迭代器(Bidirectional Iterator)
- 支持双向遍历,可以读写元素,支持
iterator++ / iterator--
; - 例如:
std::list
、std::set
、std::map
的迭代器。
随机访问迭代器(Random Access Iterator)
- 支持双向遍历,能够直接跳转到任意位置(随机访问),支持
iterator += n
; - 例如:
std::vector
、std::deque
、std::array
的迭代器。
39. 类怎么防止外部进行拷贝?
可以将其拷贝构造函数和拷贝赋值运算符设置为删除,或者设置为私有private。
class NonCopyable {
public:
// 默认构造函数
NonCopyable() {}
// 删除拷贝构造函数、拷贝赋值运算符
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
// 删除移动构造函数、移动赋值运算符
NonCopyable(NonCopyable&&) noexcept = default;
NonCopyable& operator=(NonCopyable&&) noexcept = default;
};
40. 宏的优点和缺点?
宏的优点
- 性能优化:宏可以在编译时展开,这意味着可以避免函数调用的开销,尤其是对于小型函数。
- 条件编译:宏广泛用于条件编译,根据不同的编译条件(如不同操作系统、不同编译器)包含或排除代码块。
- 代码复用:通过宏定义,可以在多个地方重复使用相同的代码块而不需要复制粘贴,有助于减少编码工作量和维护重复代码。
宏的缺点
- 类型不安全:宏不进行类型检查,容易引发类型相关的错误。由于宏只是简单的文本替换,它们可以用在任何表达式中,这可能导致意外的行为。
- 命名冲突:宏可能导致命名冲突,因为它们在全局命名空间中操作。如果不小心,可能会无意中覆盖某些已有的名称。
- 调试困难:宏在编译前就已展开,这使得调试过程复杂化,因为错误信息可能指向宏展开后的代码,而不是宏定义本身。
41. C++继承,子类继承父类的protected成员,子类可以访问吗?子类对象呢?
protected
成员:可以在基类内部和派生类内部访问,但不能在基类外部直接访问。
private
成员:只能在基类内部访问,不能在派生类或基类外部访问。
public
成员:可以在基类内部、派生类内部以及基类外部访问。
因此,子类可以访问父类的protected成员,但是子类对象不能访问。
42. 对象的虚函数表指针的内存布局?和普通成员变量有什么不一样?
当一个类声明了虚函数或者继承自一个包含虚函数的类时,每个对象实例都会包含一个指向虚函数表的指针(通常称为vptr),该指针也是对象的属性,占对象的一部分内存。
普通成员变量:按照它们在类定义中的声明顺序,依次在内存中进行布局。
虚函数表指针:通常在对象内存布局中的最前面,而普通成员变量则根据它们在类定义中的声明顺序排列在vptr之后。这意味着普通成员变量的地址通常比vptr的地址要高(在内存地址的后面)。
原因:
- 快速访问虚函数:当对象需要调用虚函数时,通过将虚函数表指针放在对象内存布局的最前面,编译器可以快速地定位到虚函数表,并进一步快速找到并调用对应的虚函数。
- 内存布局的一致性:无论对象属于基类还是派生类,或者是多层继承关系中的任何一个类,其对象的虚函数表指针的位置都是相同的。这样的一致性简化了对象内存管理和虚函数的调用机制,有助于提高程序的可维护性和性能。
43. union 怎么区分大小端存储
在C++中,union
是一种特殊的数据类型,允许在相同的内存位置存储不同的数据类型,但是一次只能使用其中的一个成员。所有成员共享同一块内存,因此它们的值不能同时存在,而是只能同时存储一个成员的值。
⼤端模式:是指数据的⾼字节保存在内存的低地址中,⽽数据的低字节保存在内存的⾼地址端。
⼩端模式:是指数据的⾼字节保存在内存的⾼地址中,低位字节保存在在内存的低地址端。
下面通过例子来说明如何通过 union 怎么区分大小端存储:
#include <stdio.h>
int main() {
union {
int a; // 4 字节
char b; // 1 字节
} data;
data.a = 1; // 占 4 字节,⼗六进制可表示为 0x 00 00 00 01
// b因为是char型只占 1 字节,a因为是int型占 4 字节
// 所以,在联合体data所占内存中,b所占内存是a所占内存的低地址部分
if(1 == data.b) {
// ⾛到这⾥意味着说明a的低字节,被取给到了b
// 即a的低字节存在了联合体所占内存的低地址,符合⼩端模式特征
printf("Little_Endian\n");
} else {
printf("Big_Endian\n");
}
return 0;
}
44. delete this在类成员函数中会有问题吗?在析构函数中呢?
delete this
可以在类成员函数中使用,但是要注意:
delete this
只能用于通过new
动态分配的对象;- 在调用
delete this
之后,确保当前对象不再被任何代码使用。
delete this
不能在析构函数中使用:
- 递归析构:delete会先调用析构函数再释放内存,
delete this
试图再次删除已经在被销毁的对象,导致析构函数被递归调用,最终导致栈溢出或其他严重问题。
45. 编译错误、运行错误、链接错误
- 运行错误是指代码逻辑无错,由于编译器无法发现运行时错误,这些错误往往是在程序运行时以五花八门的形式表现出来:
- WindowsXP错误报告;
- 内存不能为Read/Written;
- 非法操作:数组越界访问、除数为零、堆栈溢出、无限开辟内存耗尽、使用失效的迭代器;
- Debug错误。
- 编译错误是指写代码不规范,语法错误,主要有两种情况:
- 书写错误;
- 用法错误。
- 链接错误
- 项目工程与第三方库版本不一致,比如工程师64位的,而库是32位的;
- 找不到相应的库文件。
46. C++中的拷贝构造函数在哪些情况下会被调用:
-
使用一个类的对象去初始化该类的一个新对象;
// 会调用类A的拷贝构造函数 A a; A aa = a; // 会调用类A的拷贝构造函数,调用赋值函数 A a, aa; aa = a;
-
被调用函数的形参是类的对象,而非指针或引用;
// 会调用类A的拷贝构造函数 void func1(A a) { cout << "func" << endl; } func1(a); // 不会调用类A的拷贝构造函数 void func1(A& a) { cout << "func" << endl; } func1(a); // 不会调用类A的拷贝构造函数 void func1(A* a) { cout << "func" << endl; } func1(&a);
-
当函数的返回值是类的对象,而非指针或引用时,函数执行完成返回调用者;
// 会调用类A的拷贝构造函数 A a; A func2() { return a; } func2(); // 不会调用类A的拷贝构造函数 A func2() { A a; return a; } func2(); // 当 func2 被调用时,按照常规逻辑,应该发生以下步骤: // · 局部对象 a 在 func2 内部被创建; // · 当函数返回时,a 的副本应该被拷贝到调用点; // · 局部对象 a 被销毁。 // 由于返回值优化,编译器可能会直接在调用点构造 a,而不是在函数内部构造然后拷贝
47. 内存泄漏是什么?怎么看?怎么解决?
内存泄漏是指程序分配了内存但未能释放,这些内存在程序运行期间一直被占用,无法被操作系统或其他程序使用。new
的内存没有被释放,会造成内存泄漏,在编译/运行都不会报错。
怎么查看:
- 直接后台看内存:最简单直观的方法就是运行程序一段时间,后台看内存是否一直增加。
- 日志打印:在关键的内存分配和释放位置插入调试输出,记录分配和释放的地址,帮助定位泄漏。
- 使用工具:
Valgrind
用于Linux程序的内存调试和代码剖析。你可以在它的环境中运行你的程序来监视内存的使用情况,比如C 语言中的malloc和free或者 C++中的new和 delete。使用Valgrind的工具包,你可以自动的检测许多内存管理和线程的bug。
-tool=<name> 最常用的选项。运行 valgrind中名为toolname的工具。默认memcheck。
memcheck ------> 内存检查器,能够发现开发中绝大多数内存错误使用情况,如使用未初始化的内存,使用已经释放了的内存,内存访问越界等。
callgrind ------> 它主要用来检查程序中函数调用过程中出现的问题。
cachegrind ------> 它主要用来检查程序中缓存使用出现的问题。
helgrind ------> 它主要用来检查多线程程序中出现的竞争问题。
massif ------> 它主要用来检查程序中堆栈使用中出现的问题。
extension ------> 可以利用core提供的功能,自己编写特定的内存调试工具
-h –help 显示帮助信息。
-version 显示valgrind内核的版本,每个工具都有各自的版本。
-q –quiet 安静地运行,只打印错误信息。
-v –verbose 更详细的信息, 增加错误数统计。
-trace-children=no|yes 跟踪子线程? [default: no]
-track-fds=no|yes 跟踪打开的文件描述?[default: no]
-time-stamp=no|yes 增加时间戳到LOG信息? [default: no]
-log-fd=<number> 输出LOG到描述符文件 [2=stderr]
-log-file=<file> 将输出的信息写入到filename.PID的文件里,PID是运行程序的进行ID
-log-file-exactly=<file> 输出LOG信息到 file
-log-file-qualifier=<VAR> 取得环境变量的值来做为输出信息的文件名。 [none]
-log-socket=ipaddr:port 输出LOG到socket ,ipaddr:port
编译:g++ -g -o test test.cpp
使用:valgrind --tool=memcheck ./test
内存泄漏的原因在于没有成对地使用malloc/free和new/delete,比如下面的例子:
#include <stdio.h>
#include <stdlib.h>
int* create()
{
return (int*)malloc(sizeof(int));
}
int main() {
int *p = create();
*p= 100;
return 0;
}
Valgrind会给出程序中malloc和free的出现次数以判断是否发生内存泄漏,比如对上面的程序运行memcheck,Valgrind的记录显示上面的程序用了1次malloc,却调用了0次free,明显发生了内存泄漏。
上面提示了我们可以使用–leak-check=full进一步获取内存泄漏的信息,比如malloc和free的具体行号。
解决方法:
- 遵循约定:对于类中分配的内存,确保每个new都有对应的delete,每个new[]都有对应的delete[]。
- 使用容器:尽量使用STL容器(如std::vector、std::list等),它们会自动管理内存。
- 智能指针:使用
std::unique_ptr
和std::shared_ptr
来管理动态分配的内存。智能指针会自动释放内存,减少手动管理的复杂性。
48. 解释一下浅拷贝、深拷贝
浅拷贝:创建一个新对象,该对象的属性值与原对象相同,但对于指针类型的成员,浅拷贝仅复制指针的地址,而不是复制指针所指向的实际数据。这意味着原对象和新对象会共享同一块内存,若其中一个对象修改了数据,另一个对象也会受到影响。这种方式通常不安全,容易导致内存泄漏或悬挂指针。
深拷贝:创建一个新对象,并递归复制原对象的所有数据,包括指针所指向的实际内存。这样,原对象和新对象之间的关系是完全独立的,修改一个对象不会影响另一个对象。深拷贝通常需要自定义复制构造函数和赋值操作符,以确保所有动态分配的内存都被正确复制。
注意:
-
如果用户定义有参构造函数,C++不再提供默认无参构造,但会提供默认拷贝构造函数,该默认拷贝构造函数为
浅拷贝
; -
如果用户定义拷贝构造函数,C++不会再提供其他构造函数。用户自己定义的拷贝构造函数可以定义为
深拷贝
; -
总结:如果属性在堆区开辟,一定要自己提供拷贝构造函数,防止浅拷贝带来的问题。
49. 基类、子类、子类成员变量的构造和析构函数的调用顺序
-
对于构造函数:基类构造函数 > 子类成员变量构造函数 > 子类构造函数
-
对于析构函数:子类析构函数 > 子类成员变量析构函数 > 基类析构函数
50. 指针常量和常量指针
指针常量:指向的地址不能被修改,但可以通过指针修改该地址存储的数据。声明方式为int *const ptr
。
常量指针:指针可以指向不同的地址,但指向的地址中的数据不能被修改。声明方式为const int *ptr
。
方便记忆的方法:看const
的位置,在int前面说明是修饰int,则不能修改值;在ptr前面说明是修饰指针,则不能修改指针指向。
51. 函数重载的条件
条件:
-
同一个作用域;
-
函数名称相同;
-
函数参数
类型不同
或者个数不同
或者顺序不同
。
注意:
-
返回类型不能作为函数重载的条件;
-
数据类型没有与之相配的时候,自动找接近的类型。该情况只有一个函数时才可行,若出现多个函数重载但没有该类型时,会报错。
// 可行 void func(char); int main() { func(2.1); } // 报错 void func(char); void func(int); int main() { func(2.1); }
-
引用可以作为重载的条件,如:
void fun(int &a); void fun(const int &a); int main() { int a = 10; fun(a); // 调用第一个函数 fun(10); // 调用第二个函数 }
-
函数重载遇到默认参数时,要注意:
void fun(int a); void fun(int a, int b = 10); int main() { fun(10); // 报错,产生二义性,编译器无法判断调用哪个函数 }
-
const
在重载中的作用:// 可以重载 void A::f(); void A::f() const; // 不可以重载,int和const int都是int类型,区别是后者不能对形参进行修改而已,编译会报错 void A::f(int); void A::f(const int); // 可以重载,二者参数不同:一个是“指向字符的指针”,另一个是“指向const char的指针” void fun(const char *a); void fun(char *a); // 不可重载,与上面的“int”和“const int”类似 void fun(char * const a); void fun(char * a);
52. 哪些运算符不能被重载
.
::
?:
.*
sizeof
typeid
, 这几个运算符不能被重载。
53. C++ 的四种强制转换
static_cast
:明确指出类型转换,⼀般建议将隐式转换都替换成显示转换,因为没有动态类型检查,上⾏转换(派⽣类->基类)安全,下⾏转换(基类->派⽣类) 不安全,所以主要执行非多态的转换操作,如基本类型之间的转换,把int转换成char;dynamic_cast
:运行时处理,要进行类型检查,比static_cast更安全。专⻔⽤于派⽣类之间的转换,type-id 必须是类指针、类引⽤或 void*,对于下⾏转换是安全的,当类型不⼀致时,转换过来的是空指针,⽽static_cast,当类型不⼀致时,转换过来的事错误意义的指针,可能造成⾮法访问等问题。const_cast
:专⻔⽤于 const 属性的转换,去除 const 性质,或增加 const 性质, 是四个转换符中唯⼀⼀个可以操作常量的转换符。reinterpret_cast
:不到万不得已,不要使⽤这个转换符,⾼危操作。使⽤特点: 从底层对数据进⾏重新解释,依赖具体的平台,可移植性差; 可以将整形转换为指针,也可以把指针转换为数组;可以在指针和引⽤之间进⾏肆⽆忌惮的转换。
54. 指针和引用的区别
指针是一个变量,存储另一个变量的内存地址,可以进行算术运算和重新赋值;而引用是一个别名,必须在定义时初始化且不能改变,提供了更安全的语法。引用不能为nullptr,而指针可以。作为参数时也不同,传指针的实质是传值,传递的值是指针的地址;传引⽤的实质是传地址,传递的是变量的地址。
55. 野指针与悬空指针有什么区别?
野指针:就是没有被初始化过的指针。
悬空指针:是指针指向的内存已经被释放了的⼀种指针。
⽆论是野指针还是悬空指针,都是指向⽆效内存区域(这⾥的⽆效指的是"不安全不可控")的指针。 访问"不安全可控"(invalid)的内存区域将导致"Undefined Behavior"。
56. 为什么拷贝构造函数必须是引用传递,不能是值传递?
为了防止递归调⽤。当⼀个对象需要以值方式进⾏传递时,编译器会⽣成代码调⽤它的拷贝构造函数⽣成⼀个副本。如果类 A 的拷贝构造函数的参数不是引用传递,⽽是采用值传递,那么就⼜需要为了创建传递给拷贝构造函数的参数的临时对象,⽽⼜⼀次调⽤类 A 的拷贝构造函数,这就是⼀个⽆限递归。
57. override、final 标识符的作用是什么?
override
:用于表示派生类中的方法是一个重写的虚函数。
final
:用于防止类被继承或方法重写。
- 防止类被继承:
class Animal final {
};
// 报错
class Dog : public Animal {
};
- 防止方法被重写
class Animal {
public:
virtual void makeSound() final {
std::cout << "Animal makes a sound" << std::endl;
}
};
class Dog : public Animal {
public:
// 报错
void makeSound() override {
std::cout << "Dog barks" << std::endl;
}
};
58. 内存对齐的作用是什么呢?
经过内存对齐之后,CPU 的内存访问速度大大提升。因为 CPU把内存当成是一块一块的,块的大小可以是 2/4/8/16 个字节,因此 CPU 在读取内存的时候是一块一块进行读取的,块的大小称为内存读取粒度。比如说 CPU 要读取一个4 个字节的数据到寄存器中(假设内存读取粒度是 4),如果数据是从 0字节开始的,那么直接将 0-3 四个字节完全读取到寄存器中进行处理即可。
如果数据是从 1 字节开始的,就首先要将前 4个字节读取到寄存器,并再次读取 4-7 个字节数据进入寄存器,接着把 0 字节,5,6,7 字节的数据剔除,最后合并 1,2,3,4字节的数据进入寄存器,所以说,当内存没有对齐时,寄存器进行了很多额外的操作,大大降低了 CPU 的性能。
59. 动态链接和静态链接区别
静态链接:
- 在编译阶段,静态链接库(.a)的代码会被直接编译并打包到可执行文件中。每个使用静态库的程序都会包含一份该库的代码。
- 这种方式在编译时就将所有依赖的代码整合进可执行文件中,编译完成后不再依赖外部的库文件。
- 适用于不频繁更新的场景,且需要确保独立可执行性时使用,但会增加可执行文件的大小。
动态链接:
- 在编译阶段,动态链接库(.so)的代码不会直接打包到可执行文件中,而是在程序运行时动态加载。可执行文件中只包含对动态链接库的引用,实际的库文件在程序运行时被加载到内存中。
- 多个程序可以共享同一份动态链接库,从而减少内存占用和硬盘存储。
- 适用于需要频繁更新、节省内存和磁盘空间的场景,并且在多个程序共享同一库的情况下非常有用,但需要处理好运行时的库依赖性。
60. 类如何实现只能静态分配和只能动态分配
实现只能静态分配:把 new、delete 运算符重载为 private 属性。
实现只能动态分配:把构造、析构函数设为 protected 属性,再⽤⼦类来提供接口动态创建。
61. 哪些函数不能是虚函数?
- 构造函数:上述解释过,如果构造函数为虚函数,那么调用构造函数需要虚函数表指针,但是虚表指针还未构造,违反了先构造后实例化的原则;
- 内联函数:内联函数表示在编译阶段进⾏函数体的替换操作,⽽虚函数意味着在运⾏期间进⾏类型确定,所以内联函数不能是虚函数;
- 静态函数:静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。
- 友元函数:友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
- 普通函数:普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数。
62. 迭代器和指针的区别
迭代器⽤于提供⼀种方法顺序访问⼀个聚合对象中各个元素,⽽⼜不需暴露该对象的内部表示。
迭代器不是指针,是类模板,表现的像指针。他只是模拟了指针的一些功能,通过重载了指针的一些操作符,->
、*
、++
、--
等。迭代器封装了指针,是一个“可遍历STL容器内元素”的对象, 本质是封装了原生指针,是指针概念的一种提升,提供了比指针更高级的行为,相当于一种智能指针,他可以根据不同类型的数据结构来实现不同的++,–等操作。
迭代器产生原因:
- iterator类的访问方式就是把不同集合类的访问逻辑抽象出来,使得不用暴露集合内部的结构而达到循环遍历集合的效果。
- 迭代器提供了一个统一的接口,允许算法与数据结构分离,使得算法能够对不同容器进行统一的操作,便于代码的重用和维护。
63. STL 里 resize 和 reserve 的区别
resize
- 作用:改变容器的大小size。
- 使用:当你调用resize(n)时,容器的大小被调整为n。如果新size大于当前size,容器会增加新的元素,并使用默认构造函数初始化这些元素;如果新size小于当前size,容器会删除多余的元素。
std::vector<int> vec = {1, 2, 3};
vec.resize(5); // 变为 {1, 2, 3, 0, 0}(新增默认元素)
vec.resize(2); // 变为 {1, 2}(删除多余元素)
reserve
- 作用:预留内存空间。
- 使用:当你调用reserve(n)时,它会确保容器至少有n个元素的空间,但并不会改变容器的大小。容器的实际大小仍然是之前的大小,且使用reserve不会初始化新的元素。
std::vector<int> vec;
vec.reserve(5); // 预留空间以容纳至少5个元素
// 此时 vec.size() == 0,vec.capacity() >= 5
64. explicit 有什么作用?
作用:防止类构造函数的隐式自动转换。
只能用于修饰只有一个参数的类构造函数(也可以是,当除了第一个参数以外的其他参数都有默认值的时候此关键字依然有效),它的作用是表明该构造函数是显示的,而非隐式的,跟它对应的相反关键字是implicit,意思是隐藏的,类构造函数默认情况下声明为implicit。
class MyClass {
public:
explicit MyClass(int x) {
// 构造函数
}
};
int main() {
MyClass obj(10); // 显式调用,可以正常编译
MyClass obj = 10; // 编译错误,不能隐式转换
return 0;
}
65. mutable 关键字作用
mutable关键字用于修饰类的成员变量,允许这些变量在常量成员函数中被修改。
#include <iostream>
class MyClass {
public:
mutable int count; // 可变成员变量
MyClass() : count(0) {}
// 常量成员函数
void increment() const {
count++; // 可以修改 mutable 成员
}
};
66. 内联函数的优缺点
内联函数是在编译期将函数体内嵌到程序之中,将函数直接展开,以此来节省函数调用的开销。
优点:是节省了函数调用的开销,让程序运行更加快速。
缺点:
- 如果函数体过长,频繁使用内联函数会导致代码编译膨胀问题。
- 不能递归执行。递归函数在逻辑上是自我调用的,因此它无法在所有调用点完全展开。编译器无法确定递归的深度(即自我调用的次数),所以即使递归函数被声明为inline,也很难将递归过程展开成内联代码。
67. auto是怎么实现自动识别类型的?模板是怎样实现转化成不同类型的?
auto
只是一个占位符,在编译期间它会被真正的类型替代,或者说C++中变量必须要有明确类型的,只是这个类型是由编译器自己推导出来的。
函数模板是一个蓝图,它本身并不是函数,模板的核心机制是编译时的类型参数化,即在编译阶段,编译器会根据你提供的具体类型参数,将模板实例化为不同的具体类型的代码。
68. C++是如何实现函数重载的?
在前面,我们提到过,C++会存在名字修饰,所以可以支持重载。如下详细解释:
在编译时,编译器如果遇到了函数,就会在符号表里面命名一个符号来存放函数的地址。如果函数的定义在使用之前编译,则可以直接在符号表里找到对应函数地址直接使用。如果函数的使用在定义之前编译,无法在符号表中找到对应函数地址,则先标记为"?“(暂时未知),在全部编译结束后的链接过程将”?"在符号表里找到并替代为相应的函数地址。
在C语言中的符号表是以函数名为符号来存储函数地址,如果有两个函数名相同的函数,在符号表中会存在两个同符号的函数地址,在查找使用时会存在歧义和冲突,所以C不支持函数重载。而C++符号表中的符号不是以函数名命名的,还与参数的类型、个数、顺序有关,虽然函数名相同,但是函数参数等其他属性不同,取的符号也不同,所以不会产生查询歧义的问题,使得函数可以重载。如下举个例子说明:
// C 代码
// 两个 add 函数不能共存,因为编译器在编译时为这两个函数生成相同的符号add,没有额外信息来区分它们
int add(int a, int b) {
return a + b;
}
double add(double a, double b) {
return a + b;
}
// C++代码
// 可以重载,第一个add函数的函数符号为_add_int_int,第二个add函数的函数符号为_add_double_double
int add(int a, int b) {
return a + b;
}
double add(double a, double b) {
return a + b;
}
69. 什么是尾递归?
尾递归时递归的一种特殊情形,尾递归时一种特殊的尾调用,即在尾部直接调用自身的递归函数。核心思想是边调用便产生结果。如下说明:
普通递归版本:
int factorial(int n) {
if (n <= 1)
return 1;
return n * factorial(n - 1); // 递归调用后还有乘法运算,不是尾递归
}
尾递归版本:
int factorial_tail(int n, int result = 1) {
if (n <= 1)
return result;
return factorial_tail(n - 1, n * result); // 尾递归,递归调用是最后一步
}
原理:当编译器检测到一个函数调用是尾递归的时候,它会覆盖当前的活动记录而不是在栈中创建一个新的。编译器可以做到这一点,因为递归调用是当前活跃期内最后一条待执行的语句,于是当这个调用返回时栈帧中并没有其他事情可以做,因此也就没有保存栈帧的必要了,通过覆盖当前的栈帧而不是在其之上重新添加一个,这样所使用的栈空间就大大缩减了,这使得实际的运行效率会变得更高。
特点:在尾部调用的是函数自身,可通过优化使得计算仅占用常量栈空间
70. 栈溢出的情况以及解决方法
栈溢出主要发生在递归调用过深、局部变量过大、函数调用嵌套过深的场景。要避免栈溢出,可以采用迭代替代递归、使用尾递归优化、合理分配局部变量等手段,同时还可以通过增加栈大小来缓解问题。
71. 函数调用进行的操作
当程序调用一个函数时,系统会执行一系列操作来管理函数的调用和返回,如下:
- 传递参数:参数入栈,按照参数顺序的逆序进行,如果参数中有对象则先进行拷贝构造。
- 保存调用者上下文:调用函数之前,当前上下文中的一些寄存器值(如通用寄存器和程序计数器)需要保存到栈中,以便函数返回后恢复。
- 创建新栈帧:为被调用函数创建一个新的栈帧。栈帧是函数执行时分配的内存区域,通常包含:函数的局部变量、调用者的返回地址、寄存器保存的上下文等。
- 函数执行:函数体中的指令逐行执行,操作局部变量和参数。
- 返回值处理:函数执行完成后,会根据调用约定,将返回值存储在特定的位置。
- 恢复栈帧:销毁局部变量、恢复栈指针、恢复返回地址。
- 恢复调用者上下文:从栈中恢复调用者的寄存器状态,以确保函数返回后,调用者的状态与函数调用前一致。跳转回到调用函数的位置,继续执行调用者的代码。
72. 什么是this指针,为什么存在this指针?
this 指针是 C++ 中一个隐式存在的指针,指向调用成员函数的对象本身。在每个非静态成员函数内部,this 是一个指针,类型为 ClassName*,指向调用该函数的对象。this 指针只能用于非静态成员函数,因为静态成员函数与具体对象无关,它们不能访问对象实例,所以没有 this 指针。
this 指针的作用:
- 指向当前对象:this 指针指向当前对象,允许在成员函数中访问当前对象的成员变量和其他成员函数。
- 解决名称冲突:在成员函数中,如果局部变量或函数参数与类的成员变量同名,可以通过 this 指针来访问对象的成员,避免名称冲突。
- 返回当前对象的引用:this 指针可以用于在成员函数中返回当前对象的引用,方便链式调用。
例如:
class MyClass {
public:
int data;
MyClass& setData(int data) {
this->data = data; // 使用 this-> 来区分成员变量和函数参数
return *this; // 返回当前对象的引用
}
};
73. 为什么引入空指针 nullptr ?
C++11 引入了 nullptr
,作为一种用于表示空指针的关键字,替代传统的 NULL
。nullptr 解决了一些在 C 和 C++ 中使用 NULL 时存在的潜在问题,特别是在类型安全和代码可读性方面。原因如下:
-
消除 NULL 的歧义
在 C++ 中,NULL 通常被定义为 0。这会导致一些歧义问题,特别是在重载函数时,如下:void func(int); // 接收整数 void func(char*); // 接收指针
当我们调用 func(NULL) 时,由于 NULL 被定义为 0,编译器无法确定我们是想调用 func(int) 还是 func(char*),从而产生歧义。
引入 nullptr 解决了这个问题。nullptr 是一种专门的类型 std::nullptr_t,仅用于指针类型,避免了与整数类型的混淆:
func(nullptr); // 编译器能够确定调用的是 `func(char*)`
-
类型安全
nullptr 是一个类型安全的空指针。它的类型是 std::nullptr_t,而不是 int 或 void*,因此不会意外地将空指针与整数混淆。具体来说,nullptr 只能被隐式转换为指针类型或 bool 类型。不能将 nullptr 赋值给非指针类型(如整数)。例如:int* p = nullptr; // 正确,nullptr 是类型安全的指针 int i = nullptr; // 错误,nullptr 不能隐式转换为整数
相比之下,NULL 是一个整数常量,可能会导致意外的行为,比如将 NULL 分配给非指针类型或误用。
74. map、set、multimap、multiset 容器的内部原理
这四种容器属于关联式容器,底层结构是用红黑树实现。存入的元素具有自动排序的特性,排序的依据是 key 。而 set/miltiset 元素的 key 与 value是合二为一的,其value 就是 key。
红黑树是一种自平衡二叉搜索树,它具有以下特点:
-
节点是红色或黑色。
-
根节点是黑色。
-
所有叶子节点(NIL)是黑色。
-
红色节点的两个子节点都是黑色(即不存在两个连续的红色节点)。
-
从任一节点到其每个叶子的所有路径都包含相同数量的黑色节点。
例如下图:
不能通过迭代器改变容器元素的 key 值,因为key值关系到元素的排序规则。如果任意改变key值,会严重破坏树型组织结构。对于map容器,可以修改 key 对应的 value 值。
75. 红黑树与普通二叉搜索平衡树(AVL树)的比较
AVL树 | 红黑树 | |
---|---|---|
平衡性 | 高度平衡,严格保证每个节点的左右子树高度差不超过1 | 相对AVL树平衡性稍差,但仍然保证树的高度为 O(log n) |
查找 | 树高通常比红黑树略低,在查找操作上会稍快 | 允许更高的树高,查找操作的性能略逊于 AVL 树 |
插入和删除 | 需要多次旋转来保持平衡,导致较高的旋转成本 | 最多需要进行两次旋转,旋转次数更少,通常更高效 |
实现和维护 | 插入和删除的实现相对复杂,维护成本较高 | 插入和删除的实现和维护相对简单,尤其在删除操作中优势更明显 |
综上,所以查找方法都是一样的,只是树的结构实现不同,导致插入/删除方法不同。
实际应用中,对于查找性能特别敏感的应用,AVL 树可能是更好的选择,而对于需要频繁插入和删除操作的应用,红黑树则更具优势。
76. unordered_map、unordered_set、unordered_multimap、unordered_multiset 容器的内部原理
这四种容器是基于哈希表实现的无序关联容器,不同操作的时间复杂度为:
-
插入: O(1),最坏情况O(N)
-
查看: O(1),最坏情况O(N)
-
删除: O(1),最坏情况O(N)
HashMap是数组加链表组成的复合结构,HashMap的主干是数组,其中数组被分为一个个桶,每个桶存储有一个或多个键值对,每个键值对也称为 Entry ,通过哈希值决定了Entry对象在这个数组的下标;哈希值相同的Entry对象(键值对),则以链表形式存储。如下举例:
77. 哈希冲突有哪些解决方法?
链地址法:将所有哈希地址相同的记录都链接在同一链表中。
开放定址法:我们在遇到哈希冲突时,去寻找一个新的空闲的哈希地址。
-
线性探测法:当我们的所需要存放值的位置被占了,我们就往后面一直加1并对m取模直到存在一个空余的地址供我们存放值,取模是为了保证找到的位置在0~m-1的有效空间之中。但这样容易出现“聚集”现象,即连续的空桶变得稀少,导致性能下降。
-
平方探测法: 当我们的所需要存放值的位置被占了,会前后寻找而不是单独方向的寻找,并且每次跳跃 i^2。仍可能出现二次聚集。
查找时,需要根据哈希函数计算键的初始位置,并根据探测策略逐步检查后续位置,直到找到目标元素或者遇到空位置。这里遇到过一个关于开发地址法的面试深问:
假如有个 mod 5 的哈希表,现有数据1、6,存入后数据放在哪?如果删掉1,怎么找6?分析:
(1)1 % 5 = 1,所以1被存储在索引1。
(2)6 % 5 = 1,由于索引1被占用(哈希冲突),所以6会被存储在下一个可用位置,假设它通过线性探测存储在索引2。
(3)现在删除元素1。如果直接将索引1标记为空,那么后续查找6时,由于哈希值 6 % 5 = 1,查找会从索引1开始,一看到是空的就会认为该位置没有元素,导致查找失败。
解决方法:
为了避免这种情况,通常的做法是懒惰删除,即用一个特殊标记(如“删除标记”)代替被删除的元素,而不是简单地将位置标记为空。当删除1时,不是直接将索引1置空,而是将其标记为“删除”。在查找6时,即使碰到“删除标记”,仍然继续往后探测,直到找到目标元素或者确定表中不存在该元素。
再哈希法:同时构造多个不同的哈希函数,等发生哈希冲突时就使用第二个、第三个……等其他的哈希函数计算地址,直到不发生冲突为止。虽然不易发生聚集,但是增加了计算时间。
78. map容器取值的 find,[],at方法的区别
find
查找需要判断返回的结果才知道元素是否存在,其返回一个迭代器。[]
根据指定键取出对应的值,能直接访问和修改值。如果键不存在,会返回一个默认值。at
方法则会进行越界检查,如果键存在,返回对应的值;如果键不存在,则抛出 std::out_of_range 异常。
79. STL算法的分类
算法分为质变算法和非质变算法:
-
质变算法是指运算过程中会更改区间中的元素的内容,如拷贝、替换、删除等。
-
非质变算法是指运算过程中不会更改区间中的元素的内容,如查找、计数、遍历、寻找极值等。
80. 介绍C++的智能指针
C++ 使用内存的时候很容易出现野指针、悬空指针、内存泄露、重复释放的问题。所以C++11引入了智能指针来管理内存。智能指针是模板类,把普通指针交给智能指针对象,智能指针过期时,调用析构函数释放普通指针的内存。
C++主要有三种智能指针:
-
std::shared_ptr
std::shared_ptr<T>
是一个类模板,它能记录有多少个对象共享它管理的内存对象。多个std::shared_ptr<T>
可以共享同一个对象。当最后一个std::shared_ptr<T>
对象被销毁时,它会自动释放它所指向的内存。
如图所示,sp1和sp2指向同一个对象,内存对象的引用计数为2。当sp1被销毁时,引用计数减为1,sp2仍然指向该对象。当sp2被销毁时,引用计数减为0,内存对象被销毁。
std::shared_ptr
在内部只有两个指针成员,一个指针是所管理的数据地址;另一个指针是控制块地址,包括引用计数、weak_ptr计数、删除器等。因为不同shared_ptr指针需要共享相同的内存对象,因此引用计数的存储是在堆上的。所以一个shared_ptr对象的大小是raw_pointer(裸指针)大小的两倍。 -
std::unique_ptr
智能指针unique_ptr
,它拥有对象的独有权,只能指向一个对象,即两个 unique_ptr 不能指向一个对象,不能进行复制操作只能进行移动操作。当它指向其他对象时,之前所指向的对象会被摧毁。其次,当unique_ptr
超出作用域时,指向的对象也会被自动摧毁,帮助程序员实现了自动释放的功能。
unique_ptr
禁用拷贝构造函数和赋值函数。unique_ptr设计的目标就是独享,如果允许unique_ptr对象进行赋值,会出现多个unique_ptr指向同一块内存的情况,当其中一个unique_ptr对象过期的时候,释放内存,造成一块内存释放多次,造成操作野指针。但是它提供了一个移动构造函数,所以可以通过std::move将指针指向的对象交给另一个unique_ptr,转交之后自己就失去了这个指针对象的所有权。 -
std::weak_ptr
weak_ptr
是一种弱引用,指向shared_ptr所管理的对象,而不影响所指对象的生命周期,也就是将一个weak_ptr
绑定到一个shared_ptr
不会改变shared_ptr
的引用计数。不论是否有weak_ptr
指向,一旦最后一个指向对象的shared_ptr
被销毁,对象就会被释放。
weak_ptr
对它所指向的shared_ptr
所管理的对象没有所有权,不能对它解引用,因此若要读取引用对象,必须要转换成shared_ptr
。 C++中提供了lock函数来实现该功能。如果对象存在,lock()
函数返回一个指向共享对象的shared_ptr
,否则返回一个空shared_ptr
。std::shared_ptr<int> sp1(new int(22)); std::shared_ptr<int> sp2 = sp1; std::weak_ptr<int> wp = sp1; // point to sp1 std::cout<<wp.use_count()<<std::endl; // 2 // 判断所指对象是否已经被释放 if(!wp.expired()){ std::shared_ptr<int> sp3 = wp.lock(); std::cout<<*sp3<<std::endl; // 22 }
weak_ptr 应用场景:
(1)避免循环引用:循环引用是指两个或多个对象之间通过shared_ptr
相互引用,形成了一个环,导致它们的引用计数都不为0,从而导致内存泄漏。如下:
(2)用于实现缓存:weak_ptr可以用来缓存对象,当对象被销毁时,weak_ptr也会自动失效,不会造成野指针。
关于智能指针的详解,可以看我的另一篇博客 C++智能指针详解
81. shared_ptr 线程安全吗?
智能指针中的引用计数是线程安全的,但是智能指针所指向的对象的线程安全问题,智能指针没有做任何保障,是线程不安全的。也就是说它所管理的资源可以线程安全的释放,只保证线程安全的管理资源的生命期,不保证其资源可以线程安全地被访问。
如果多个线程同时拷贝同一个 shared_ptr 对象,不会有问题,因为 shared_ptr 的引用计数是线程安全的。但是如果多个线程同时修改同一个 shared_ptr 对象,不是线程安全的。因此,如果多个线程同时访问同一个 shared_ptr 对象,并且有写操作,需要使用互斥量来保护。
82. 使用 make_shared 的优点,缺点
优点:
- 性能:减少了内存分配的次数,提高了效率。make_shared 通常比单独调用 new 和 shared_ptr 的构造函数更高效,因为它只分配一次内存,存储控制块和对象在同一块内存中。而使用new构造的话至少会进行两次内存分配(一次为对象本身,一次为共享指针的控制块)。
- 安全性:使用 make_shared 可以避免内存泄漏的风险,特别是在异常发生时,因为它确保了共享指针的正确构造和销毁。
当new int(42)抛出异常时,sp将不会被创建,从而对应new分配的内存也不会释放,从而导致内存泄漏。std::shared_ptr<int> sp(new int(42)); // exception unsafe
缺点:
- 灵活性限制:不能直接控制对象的创建过程,无法使用自定义分配器,需要将所有参数传递给 make_shared。例如,当构造函数是保护或者私有的时候无法使用make_shared函数。
- 控制块共享:使用 make_shared 时,创建的 shared_ptr 和相应的 weak_ptr 共享同一个控制块。会导致 weak_ptr 保持控制块的生命周期,当所有的 shared_ptr 被销毁后,控制块会保持活跃,只有当最后一个 weak_ptr 离开作用域时,内存才会被释放。
83. 左值右值是什么?右值可以取地址?move有什么用?
左值:左值就是那些可以出现在赋值符号左边的东西,它标识了一个可以存储结果值的地点。例如:变量名或指针。左值是可以取地址的,一般可以对它赋值,一般可以修改(除const 修饰)。
右值:右值就是那些可以出现在赋值符号右边的东西,它必须具有一个特定的值。例如:字面常量、表达式返回值,函数返回值等等。右值不能取地址。如下右值举例:
10; // 字面常量
x + y; // 表达式
func(); // 函数返回值
小细节注意:++i是左值,i++是右值。因为++i返回 i 本身,而i++返回的是 i 的值。
++i = 10; // 正常运行,i为10
i++ = 10; // 报错
move
的功能是将一个左值强制转化为右值,继而可以通过右值引用使用该值,以用于移动语义,从实现原理上讲基本等同一个强制类型转换。
优点:可以将左值变成右值而避免拷贝构造,将对象的状态所有权从一个对象转移到另一个对象,只是转移,没有内存搬迁或者内存拷贝。
84. push_back 左值和右值的区别是什么?
如果push_back()的参数是左值,则是将元素拷贝到尾部。如果是右值,则是直接将元素移动到尾部。
左值:
std::vector<std::string> vec;
std::string str = "Hello";
vec.push_back(str); // 拷贝构造
右值:
std::vector<std::string> vec;
vec.push_back("World"); // 移动构造
85. 右值引用和万能引用
左值引用就是对左值的引用,给左值取别名,用 &
来表示。如下:
int main() {
int a = 10;
int* p = &a;
// 左值引用, a和p都是左值
int& pa = a;
int*& pp = p;
}
左值引用总结:
-
左值引用一般只能引用左值,不能引用右值。
-
但是const左值引用既可引用左值,也可引用右值。
int main() { // 左值引用只能引用左值,不能引用右值。 int a = 10; int& ra1 = a; //int& ra2 = 10; // 编译失败,因为10是右值 // const左值引用既可引用左值,也可引用右值。 const int& ra3 = 10; const int& ra4 = a; }
右值引用就是对右值的引用,给右值取别名,用 &&
来表示。如下:
int main() {
int x = 10;
int y = 10;
//右值引用
int&& _a = 10;
int&& _x = func();
int&& add = x + y;
}
右值引用总结:
-
右值引用一般只能引用右值,不能引用左值。
-
但是右值引用可以move以后的左值。
move可以将左值转为右值,但是不能将右值转为左值。move(x)不是将x变为右值,是move()后的返回值是右值,x本身属性没有改变。
int main() { // 右值引用只能右值,不能引用左值。 int&& r1 = 10; // error C2440: “初始化”: 无法从“int”转换为“int &&” // message : 无法将左值绑定到右值引用 int a = 10; int&& r2 = a; // 右值引用可以用move,move以后右值可以变为左值 int&& r3 = std::move(a); }
86. 移动语义是什么?为什么C++11要引入?
在传统的 C++ 中,当对象被拷贝时,通常会发生深拷贝,即创建一个完全独立的副本。这对于一些轻量对象是可行的,但对于包含大量数据的对象来说,深拷贝会带来性能开销。为了解决这个问题,C++11 引入了移动语义,通过资源的转移来避免不必要的拷贝。
移动语义的核心概念:
-
右值引用:表示一个临时对象,可以“移动”而不是复制。右值引用使用 && 来表示,允许修改和转移临时对象的资源。示例:
int x = 10; int &&rval = std::move(x); // rval 是右值引用,x 资源可以被转移
-
移动构造函数:通过转移一个对象的资源来构造一个新对象,而不是复制这些资源。移动构造函数接收一个右值引用作为参数。示例:
class MyClass { int* data; public: MyClass(MyClass&& other) : data(other.data) { other.data = nullptr; // 将 other 的资源移动到当前对象 } };
-
移动赋值运算符:类似于移动构造函数,移动赋值运算符用于将一个对象的资源转移到已经存在的对象中。它也接收一个右值引用作为参数。示例:
MyClass& operator=(MyClass&& other) { if (this != &other) { delete[] data; // 释放当前对象的资源 data = other.data; // 移动资源 other.data = nullptr; // 防止原对象释放资源 } return *this; }
87. 解释什么是完美转发?
完美转发是指函数模板可以将自己的参数完美的转发给内部调用的其他函数,完美是指不仅能够准确的转发参数的值,还能保证被转发参数的左、右值属性不变,即将传递进来的左值以左值传递出来,将传递进来的右值以右值的方式传出。
实现完美转发的关键:
-
万能引用:一种右值引用,它可以绑定到左值和右值。万能引用通常出现在模板函数的参数列表中,如
T&&
。 -
std::forward
:标准库中的一个函数模板,用于在参数传递过程中保持参数的左值或右值特性。
接下来,通过例子说明,看完之后再看上述文字,立马醍醐灌顶。
如下对比是否为完美转发的区别:
-
不是完美转发,会改变属性:
void process(int& x) { std::cout << "左值引用" << std::endl; } void process(int&& x) { std::cout << "右值引用" << std::endl; } // 普通模板函数 template <typename T> void forwarder(T&& arg) { process(arg); } int main() { int a = 5; // 传递左值 forwarder(a); // 绑定到左值引用的process // 传递右值 forwarder(10); // 因为不是完美转发,会改变参数属性,还是绑定到左值引用的process // a为左值,move(a)返回右值 forwarder(std::move(a)); // 结果还是绑定到左值引用的process return 0; }
上述结果说明了,forwarder 函数传递的参数不管是左值还是右值,函数中执行的都是
process(int& x)
。但我们希望的效果是传递给 forwarder 的参数 arg 是左值就调用process(int& x)
,如是右值,就调用process(int&& x)
。说明上述例子在发给内部调用的其他函数时,参数的左、右值属性发生了改变,因此不是完美转发。 -
完美转发,不改变属性:
void process(int& x) { std::cout << "左值引用" << std::endl; } void process(int&& x) { std::cout << "右值引用" << std::endl; } // 完美转发的模板函数 template <typename T> void forwarder(T&& arg) { process(std::forward<T>(arg)); } int main() { int a = 5; // 传递左值 forwarder(a); // 绑定到左值引用的process // 传递右值 forwarder(10); // 绑定到右值引用的process // a为左值,move(a)返回右值 forwarder(std::move(a)); // 绑定到右值引用的process return 0; }
加入
std::forward
函数后,结果就是我们想要的完美转发效果了。
这里再多解释一下,为什么不是完美转发,会改变属性?
因为右值被右值引用过后,该引用就变成了左值属性。如下举例:
int main() {
int&& x = 10; // 右值被右值引用过后,该引用x就变成了左值属性
x++; //可以修改
cout << &x << endl; //可以取地址
// 右值引用的底层是一个指针,实际上是将10拷贝到一个区域(相当于在栈上开了一个临时空间把10存起来)
// 然后右值引用就是指向了这块空间
}
所以函数参数传入右值时,在把该引用作为参数调用函数内的其他函数,会将属性从右值变为左值。
88. default 和 delete 关键字
编译器会为类自动⽣成⼀些⽅法,⽐如构造函数、析构函数等。现在我们可以显式地指定和禁⽌这些⾃动⾏为了。如下:
class classA {
classA() = default; // 声明⼀个⾃动⽣成的函数
classA(T value);
void *operator new(size_t) = delete; // 禁⽌⽣成new运算符
};
在上述 classA 中定义了 classA(T value) 构造函数,因此编译器不会默认⽣成⼀个⽆参数的构造函数了, 如果我们需要可以⼿动声明,或者直接 = default 。
88. C++常用设计模式
(1)单例模式
单例模式确保一个类只有一个实例,并提供一个全局访问点来访问该实例。主要用于控制全局资源,如日志管理器、线程池、配置管理器等。
实现方式:
- 构造函数为私有,防止外部创建对象。
- 提供一个静态方法,负责创建或获取该类的唯一实例。
- 确保线程安全性。
class Singleton {
private:
Singleton() {}
public:
static Singleton& getInstance() {
static Singleton instance;
return instance;
}
// 禁止拷贝和赋值操作
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
C++11规定了局部静态变量在多线程条件下的初始化行为,要求编译器保证了局部静态变量的线程安全性。这样,只有当第一次访问getInstance()方法时才创建实例。C++11之后该实现是线程安全的,C++11之前仍需加锁。
(2)工厂模式
简单工厂模式
建⽴⼀个⼯⼚类,对实现了同⼀接⼝的⼀些类进⾏实例的创建。简单⼯⼚模式的实质是由⼀个⼯⼚类根据传⼊的参数,动态决定应该创建哪⼀个产品类(这些产品类继承⾃⼀个⽗类或接⼝)的实例。
// 产品类(抽象类,不能实例化)
class Product {
public:
virtual void show() = 0;
};
class ConcreteProductA : public Product {
public:
void show() override { std::cout << "Product A\n"; }
};
class ConcreteProductB : public Product {
public:
void show() override { std::cout << "Product B\n"; }
};
// ⼯⼚类
class Factory {
public:
Product* product(const string str){
if (str == "productA")
return new ConcreteProductA();
if (str == "productB")
return new ConcreteProductB();
return nullptr;
};
};
抽象工厂模式
为了进⼀步解耦,在简单⼯⼚的基础上发展出了抽象⼯⼚模式,即连⼯⼚都抽象出来,实现了进⼀步代码解耦。通过定义一个用于创建对象的接口,让工厂子类决定实例化哪个类。工厂模式的核心思想是将对象的创建和使用分离,以提高灵活性和扩展性。
实现方式:
- 定义一个工厂基类,声明一个创建产品的接口。
- 工厂子类实现这个接口,负责具体产品的创建。
class Product {
public:
virtual void show() = 0;
};
class ConcreteProductA : public Product {
public:
void show() override { std::cout << "Product A\n"; }
};
class ConcreteProductB : public Product {
public:
void show() override { std::cout << "Product B\n"; }
};
class Factory {
public:
virtual Product* createProduct() = 0;
};
class ConcreteFactoryA : public Factory {
public:
Product* createProduct() override {
return new ConcreteProductA();
}
};
class ConcreteFactoryB : public Factory {
public:
Product* createProduct() override {
return new ConcreteProductB();
}
};
(3)观察者模式
定义了对象之间一对多的依赖关系,让多个观察对象同时监听⼀个被观察对象,被观察对象状态发⽣变化时,会通知所有的观察对象,使他们能够更新⾃⼰的状态。该模式常用于事件驱动系统、消息发布-订阅系统。
观察者模式中存在两种⻆⾊:
- 观察者:内部包含被观察者对象,当被观察者对象的状态发⽣变化时,更新自己的状态。(接收通知更新状态)
- 被观察者:内部包含了所有观察者对象,当状态发⽣变化时通知所有的观察者更新自己的状态。(发送通知)
实现方式:
- 定义一个被观察者(Subject)接口,允许添加、删除观察者。
- 定义观察者接口,声明更新方法。
- 被观察者在状态发生变化时,通知所有观察者调用更新方法。
// 观察者
class Observer {
public:
virtual void update(int value) = 0;
};
class ConcreteObserver : public Observer {
private:
int observerValue;
public:
// 被观察者对象的状态发⽣变化时,更新自己的状态
void update(int value) override {
observerValue = value;
std::cout << "Observer value updated to " << observerValue << "\n";
}
};
// 被观察者
class Subject {
private:
std::vector<Observer*> observers; // 存放所有观察者
int state;
public:
void attach(Observer* observer) {
observers.push_back(observer);
}
void setState(int value) {
state = value;
notify();
}
// 通知所有的观察者更新自己的状态
void notify() {
for (Observer* observer : observers) {
observer->update(state);
}
}
};
(4)装饰器模式
装饰器模式允许向现有对象动态地添加行为,而不影响其他同类对象。该模式通常用于给对象增加功能,同时保持对象的结构灵活可扩展。
实现方式:
- 定义一个组件接口,声明可以被装饰的操作。
- 具体组件类实现接口,执行基本操作。
- 装饰器类持有组件的引用,并在执行基本操作时添加新的功能。
// 组件接口
class Component {
public:
virtual void operation() = 0;
};
// 具体组件
class ConcreteComponent : public Component {
public:
void operation() override {
std::cout << "ConcreteComponent operation\n";
}
};
// 抽象装饰类
class Decorator : public Component {
protected:
Component* component;
public:
Decorator(Component* comp) : component(comp) {}
void operation() override {
component->operation();
}
};
// 具体装饰类
class ConcreteDecorator : public Decorator {
public:
ConcreteDecorator(Component* comp) : Decorator(comp) {}
void operation() override {
Decorator::operation();
std::cout << "ConcreteDecorator added operation\n";
}
};
// 创建一个具体组件
Component* component = new ConcreteComponent();
// 装饰组件
Component* decorator = new ConcreteDecorator(component);
(5)策略模式
策略模式定义了一系列算法,将每个算法封装到一个独立的类中,使得它们可以相互替换。此模式允许算法的变化独立于使用它的客户端。
实现方式:
- 定义一个抽象策略接口,声明策略的操作。
- 策略实现类负责具体的算法。
- 上下文类负责持有策略并执行操作。
// 抽象策略接口
class Strategy {
public:
virtual void execute() = 0;
};
// 策略实现类A
class ConcreteStrategyA : public Strategy {
public:
void execute() override {
std::cout << "Executing strategy A\n";
}
};
// 策略实现类B
class ConcreteStrategyB : public Strategy {
public:
void execute() override {
std::cout << "Executing strategy B\n";
}
};
// 上下文类
class Context {
private:
Strategy* strategy;
public:
Context(Strategy* strategy) : strategy(strategy) {}
// 选取某种策略
void setStrategy(Strategy* newStrategy) {
strategy = newStrategy;
}
// 执行选取策略的操作
void executeStrategy() {
strategy->execute();
}
};
(6)代理模式
代理模式为其他对象提供一个代理,以控制对这个对象的访问。常见的应用场景包括虚拟代理(延迟加载)、远程代理(为远程对象提供本地代理),以及保护代理(控制对象的访问权限)。
实现方式:
- 定义一个接口,声明需要被代理的操作。
- 代理类实现接口,负责控制实际对象的访问。
- 实际对象类实现接口,执行具体操作。
class Subject {
public:
virtual void request() = 0;
};
// 实际对象类
class RealSubject : public Subject {
public:
void request() override {
std::cout << "RealSubject handling request\n";
}
};
// 代理类
class Proxy : public Subject {
private:
RealSubject* realSubject;
public:
Proxy() : realSubject(nullptr) {}
~Proxy() {
delete realSubject;
}
void request() override {
if (!realSubject) {
realSubject = new RealSubject();
}
std::cout << "Proxy handling request, forwarding to RealSubject...\n";
realSubject->request();
}
};
89. C++细节题目
- 构造函数不能为const函数,构造函数不能为虚函数;
- 无参构造函数不是类必须要有的,用户自定义了有参构造函数,编译器不会合成无参构造函数。
- 一个类可以有多个拷贝构造函数,拷贝构造函数的参数可以是一个或多个,但左起第一个必须是自身类型的引用对象;
- 一个类只能有一个析构函数。
- 重载前缀++:
operator++();
表达式++a
;重载后缀++:operator++(int);
表达式a++
; - 匿名函数本质上是一个对象,在其定义的过程中会创建出一个栈对象,内部通过重载()符号实现函数调用的外表。
- C++的异常处理机制通过3个保留字throw、try和catch实现。
如有错误请大家留言指正,本文会持续更新中~