C++——基础
文章目录
- 一、C++中值传递和引用传递的区别?
- 1.1 回答重点
- 1.2 示例
- 1.3 深入理解
- 1.3.1什么场景下使用引用传递?
- 1.3.2 什么场景下使用值传递?
- 二、C和C++的区别?
- 2.1 回答重点
- 2.2 知识拓展
- 三、什么是C++的左值和右值?有什么区别?
- 3.1 回答重点
- 3.2 知识扩展
- 3.2.1 左值引用
- 3.2.2 常引用 (`const int &d = 10;`):
- 3.2.3 右值引用
- 3.2.4 纯右值
- 3.2.5 左值、右值引用的使用场景
- 3.2.5.1 移动构造和移动赋值
- 3.2.5.2 返回值优化
- 3.2.5.3 完美转发
- 四、什么是C++的移动语义和完美转发?
- 4.1 回答重点
- 4.2 移动语义
- 4.3 完美转发
- 4.4 知识扩展
- 五、什么是C++的列表初始化?
- 5.1 回答重点
- 5.2 知识扩展
- 5.2.1 什么是类型窄化?
- 5.2.2 std::initializer_list:
- 六、C++中move有什么作用?它的原理是什么?
- 6.1 回答重点
- 6.2 知识扩展
- 七、C++11中有哪些常用的新特性?
- 7.1 回答重点
- 7.2 知识扩展
- 7.2.1 auto
- 7.2.2 智能指针
- 7.2.3 RAIl lock
- 7.2.4 std:thread
- 7.2.5 std:function 和 lambda 表达式
- 7.2.6 std:chrono
- 7.2.7 条件变量
- 八、C++中static的作用?什么场景下用到static?
- 8.1 回答重点
- 8.2 扩展知识
- 九、C++中const的作用?谈谈你对const的理解?
- 9.1 回答重点
- 9.2 扩展知识
- 9.2.1 常量指针(整形指针、浮点型指针)
- 9.2.2 指针常量
- 9.2.3 常量指针常量
- 9.2.4 如何区分常量和只读变量
- 9.2.5 关键字:contexpr
- 十、C++ 中 define 和const 的区别?
- 10.1 回答重点
- 10.2 知识扩展
- 十一、C++ 中 char*、const char*、char* const、const char* const的区别?
- 11.1 回答重点
- 十二、C++中inline的作用?它有什么优缺点?
- 12.1 回答重点
- 12.2 知识扩展
- 十二、数组和指针的区别
- 12.1 回答重点
- 12.2 扩展知识
- 十三、C++ 中 sizeof 和 strlen的区别?
- 13.1 回答重点
- 13.2 知识扩展
- 十四、C++中extern有什么作用?extern"C"有什么作用?
- 14.1 回答重点
- 14.2 扩展知识
- 十五、C++中explicit的作用?
- 15.1 回答重点
- 15.2 扩展知识
- 十六、 C++中final关键字的作用?
- 16.1 回答重点
- 16.2 扩展知识
- 十七、C++中野指针和悬挂指针的区别?
- 17.1回答重点
- 17.2 扩展知识:
- 十八、什么是内存对齐?为啥要内存对齐?
- 18.1 回答重点
- 十九、C++中四种类型转换的使用场景?
- 19.1 回答重点
- 19.2 扩展知识
- 二十、C++中volatile关键字的作用?
- 20.1 **回答重点**
- 20.2 扩张知识
- 二十一、什么是多态?简单介绍下C++的多态?
- 21.1 扩展知识
- 二十二、C++中虚函数的原理?
- 22.1 回答重点
- 22.2 扩展知识
- 二十三、C++中构造函数可以是虚函数吗?
- 23.1 回答重点
- 23.2 扩展知识
- 23.3 问题:基类的析构函数必须要写成虚函数吗?
- 23.4 问题: 基于上面问题,我把所有的基类析构函数都写成虚函数,可行?
- 23.5 问题:友元函数的作用?
- 使用示例
- 二十四、C++中析构函数一定要是虚函数吗?
- 24.1回答重点
- 24.2 扩展知识
- 二十五、什么场景用到移动构造函数和移动赋值运算符?
- 25.1 回答重点
- 25.2 扩展知识
- 二十六、什么是C++中的虚继承?
- 26.1 回答重点:
- 26.2 扩展知识
- 二十七、什么是C++的函数重载?它的优点是什么?和重写有什么区别?
- 27.1 回答重点
- 27.2 扩展知识
- 二十八、什么是C++的运算符重载?
- 28.1 回答重点
- 28.2 扩展知识
- 二十九、struct 和 class的区别?
- 29.1 回答重点
- 29.2 扩展知识
- 三十、C++ 中 struct和union的区别?如何使用union做优化?
- 30.1 回答重点
- 30.2 扩展知识
- 三十一、C++ 中 using 和 typedef 的区别?
- 31.1 回答重点
- 31.2 扩展知识
- 三十二、 enum 和 enum class的区别?
- 32.1 回答重点
- 32.2 扩展知识
- 三十三、C++ 中 new 和 malloc 的区别? delete 和 free 的区别?
- 33.1 回答重点
- 33.2 扩展知识
- 三十四、C++中类定义中delete关键字和default关键字的作用?
- 34.1 回答重点:
- 34.2 扩展知识
- 三十五、C++中this指针的作用?
- 35.1 回答重点
- 35.2 扩展知识
- 三十六、C++ 中可以使用delete this吗?
- 36.1 回答重点
- 36.2 扩展知识
- 三十七、C++ 中 vector的原理? resize 和 reserve 的区别是什么?size 和 capacity的区别?
- 37.1 回答重点
- 37.2 扩展知识
- 三十八、deque的原理?它内部是如何实现的?
- 38.1 回答重点
- 38.2 扩展知识
- 三十九、C++ 中 map和 unordered_map 的区别?分别在什么场景下使用?
- 39.1 回答重点
- 39.2 扩展知识
- 四十、C++中list的使用场景?
- 40.1 回答重点
- 40.2 扩展知识
- 四十一、什么是C++中的RAIl?它的使用场景?
- 41.1 回答重点
- 41.2 扩展知识
- 四十二、 lock_guard 和 unique_lock 的区别?
- 40.1 回答重点
- 40.2 扩展知识
- 四十三、thread 的 join 和 detach 的区别?
- 43.1回答重点
- 43.2 扩展知识
- 四十四、中 jthread 和 thread 的区别?
- 44.1回答重点
- 44.2 扩展知识
- 四十五、C++ 中 memcpy和 memmove 有什么区别?
- 45.1 回答重点
- 45.2 扩展知识
- 四十六、C++的 function、bind、lambda都在什么场景下会用到?
- 46.1 回答重点
- 46.2 扩展知识
- 46.2.1 std:function
- 46.2.2 **std:bind**
- 46.2.3 Lambda表达式
- 四十七、请介绍C++中使用模板的优缺点?
- 47.1 回答重点
- 47.2 扩展知识
- 四十八、C++中函数模板和类模板有什么区别?
- 48.1 回答重点
- 48.2 扩展知识
- 四十九、请介绍下C++模板中的SFINAE?它的原则是什么?
- 49.1 回答重点
- 49.2 扩展知识
- 五十、C++的 strcpy和 memcpy有什么区别?
- 50.1 回答重点
- 50.2 扩展知识
- 五十一、C++中为什么要使用std:array?它有什么优点?
- 51.1 回答重点
- 51.2 扩展知识
- 五十二、C++中堆内存和栈内存的区别?
- 52.1 回答重点
- 52.2扩展知识
- 五十三、C++的栈溢出是什么?
- 53.1 回答重点
- 52.2 扩展知识
- 五十四、什么是C++的回调函数?为什么需要回调函数?
- 54.1 回答重点
- 54.2 扩展知识
- 五十五、C++中为什么要使用nullptr而不是NULL?
- 55.1 回答重点
- 55.2 扩展知识
- 五十六、什么是大端序?什么是小端序?
- 56.1 回答重点
- 56.2 扩展知识
- 五十七、C++ 中 include<a.h>和 include"a.h"有什么区别?
- 57.1 回答重点
- 57.2 扩展知识
- 五十八、C++是否可以 include源文件?
- 58.1 回答重点
- 58.2 扩展知识
- 五十九、C++中什么是深拷贝?什么是浅拷贝?写一个标准的拷贝构造函数?
- 59.1 回答重点
- 59.2 扩展知识
- 六十、C++中命名空间有什么作用?如何使用?
- 60.1 回答重点
- 60.2 扩展知识
- 六十一、C++中友元类和友元函数有什么作用?
- 61.1 回答重点
- 61.2 扩展知识
- 六十二、C++中如何设计一个线程安全的类?
- 62.1 回答重点
- 62.2 扩展知识
- 六十三、C++如何调用C语言的库?
- 63.1 回答重点
- 63.2 扩展知识
- 六十四、指针和引用的区别是什么?
- 64.1 回答重点
- 64.2扩展知识
- 六十五、介绍C++中三种智能指针的使用场景?
- 65.1 回答重点
- 65.2 扩展知识
- 六十六、**头文件中的** **ifndef/define/endif** 的作用,及和 **program once** 的区别
- 66.1 回答重点
- 66.2 扩展知识
- 六十七、C++中有哪些类型的全局变量?
- 64.1 回答重点
- 67.2 扩展知识
- 六十八、sizeof相关
- 68.1 回答重点
- 六十九、模板类型推导
- 69.1 回答重点
- 69.1.1 **初级模板推导:**
- 69.1.2 进阶类型推导
- 69.2 扩展知识
- 七十、auto 类型推导
- 70.1 回答重点
- 70.2 扩展知识
- 七十一、decltype
- 71.1 回答重点
- 71.1.1 三个场景
- 71.2 扩展知识
- 七十二、类的六种特殊的成员函数
- 72.1 回答重点
- 72.2 扩展知识
- 七十三、类的特点
- 73.1 回答重点
- 七十四、构造顺序和析构顺序
- 74.1 回答重点
- 74.2 扩展知识
- 七十五、匿名函数 lambda
- 75.1 回答重点
- 七十六、std::function、std::bind 用法
- 76.1 回答重点
- 76.1.1 `std::function`:通用多态函数包装器
- 76.1.2 `std::bind`:函数参数绑定与适配器
- 七十七、异常处理及其捕获方法(try/catch、异常安全)
- 77.1 回答重点
- 77.2 扩展知识
- 七十八、函数可变参数的处理(std::variadic 模板、va_list 兼容)
- 78.1 回答重点
- 78.2 扩展知识
- 七十九、C++面向对象的三大特征
- 79.1 回答重点
- 八十、类A中调用类B,使用类B对象和类B指针的区别?编译阶段需要引用类B的头文件吗?
- 80.1 类 B 对象与类 B 指针的区别:
- 80.2 编译阶段对类 B 头文件的依赖:
- 80.3 类 B 的定义(`B.h`)
- 80.4 类 A 使用类 B 对象的情况
- 80.5 类 A 使用类 B 指针的情况
- 80.6 主函数测试(`main.cpp`)
- 80.7 总结
- 八十一、Pimpl惯用法
- 81.1 Pimpl 惯用法的核心思想
- 81.2 Pimpl 的代码示例
- 81.3 Pimpl 的优势
- 81.4 与单纯使用指针的区别
- 81.5 总结
- 八十二、内存分布图
- 82.1 分布图
- 八十三、纯右值、将亡值
- 83.1 先理清 C++ 值类别的整体框架
- 83.2 纯右值(prvalue):无身份的 “临时数据”
- 1. 纯右值的典型场景
- 2. 纯右值的核心特性
- 83.3 将亡值(xvalue):有身份的 “即将废弃对象”
- 1. 将亡值的典型场景
- 2. 将亡值的核心特性
- 83.4 纯右值与将亡值的核心区别
- 83.5 关键辨析:易混淆的场景
- 83.6 总结:一句话区分
- 八十四、如果要你设计智能指针你要考虑什么?
- 84.1 设计共享指针?
- 84.2 设计独占指针?
- 八十五、什么时候用智能指针?
- 85.1 核心原则:替代 “裸指针(Raw Pointer)” 管理动态内存
- 85.2 具体使用场景分类
- 85.3 绝对不适合使用智能指针的场景
- 85.4 总结:智能指针的使用决策流程
- 八十六、智能指针如何计数那些情况会加1?如何清零?
- 八十七、如何判断基类的派生类中?是那个类型?(考察强制转化)
- 87.1 dynamic_cast 类型转换(推荐)
- 87.2 typeid 运算符(获取类型信息)
- 八十七、Vector可以直接用std::move去拷贝大资源吗?
- 八十八、什么时候可以用std::move呢?
- 88.1 **转移容器或大型对象的所有权**
- 88.2 **函数参数传递:避免不必要的拷贝**
- **88.3** **函数返回值:优化返回大型对象**
- 88.4 **在容器中插入临时对象或即将废弃的对象**
- 88.5 **实现移动构造函数和移动赋值运算符**
- 88.6 **交换两个对象(实现高效 swap)**
- 88.7 不适合使用 `std::move` 的场景
- 88. 8核心原则
- 八十九、还有啥方法可以实现大规模拷贝吗?
- 89.1 **返回值优化(RVO/NRVO,编译器自动优化)**
- 89.2 **`std::swap` 交换资源**
- 89.3 **emplace 系列函数(直接在容器中构造对象)**
- 89.4 **右值引用参数(函数接口设计)**
- 89.5 **自定义资源管理(指针 / 智能指针)**
- 89.6 **`std::forward`(完美转发,保留值类别)**
- 89.7 总结
- 九十、`std::emplace_back` 和 `std::emplace`?
- 90.1 `emplace_back`:仅在容器末尾构造元素
- 90.2 `emplace`:在指定位置构造元素
- 90.3 关键总结
- 九十一、对迭代器的理解?
- 91.1 迭代器的核心作用
- 91.2 迭代器的基本操作
- 91.3 迭代器的类型(按功能划分)
- 91.4 容器与迭代器的对应关系
- 91.5 迭代器的使用示例
- 91.6 注意事项
- 91.7 总结
- 九十二、单例中用static 和call_once有啥区别?
- 92.1 基础实现对比
- 92.2 核心区别分析
- 92.3 关键细节补充
- 92.4 如何选择?
一、C++中值传递和引用传递的区别?
1.1 回答重点
值传递:在函数调用时,会触发一次参数的拷贝动作,所以对参数的修改不会影响原始的值。如果是较大的对象,复制整个对象,效率较低。
引用传递:函数调用时,函数接收的就是参数的引用,不会触发参数的拷贝动作,效率较高,但对参数的修改会直接作用于原始的值。
1.2 示例
看两种传递方式的示例代码:
值传递
void modify_value(int value) {value = 100; // 只会修改函数内部的副本,不会影响原始变量
}int main() {int a = 20;modify_value(a);std::cout << a; // 20,没变return 0;
}
引用传递
void modify_value(int& value) {value = 100; // 修改引用指向的原始变量
}int main() {int a = 20;modify_value(a);std::cout << a; // 100,因为是引用传递,所以这里已经改为了100return 0;
}
1.3 深入理解
1.3.1什么场景下使用引用传递?
- 避免不必要的数据拷贝:对于比较大的对象参数(比如std:vector、std:string、std:list),因为拷贝会导致大量的内存和时间开销。而引用传递可以避免这些开销。
- 允许函数修改实参原始值:有时候,我们就是希望函数能够直接修改传入的变量值,这时使用引用传递很合理。
1.3.2 什么场景下使用值传递?
- 小型数据结构:对于int、char、double、float这种基础数据类型,可以直接简单的使用值传递。
- 不希望函数修改实参:有时候,我们需要修改变量数据,但是又不希望修改原始值,可以考虑使用值传递。
二、C和C++的区别?
2.1 回答重点
可以考虑从以下几个方面回答:
1)面向对象 还是 面向过程:
- C语言是一门面向过程的语言,侧重于通过过程(函数)来解决问题。
- C++是一门多范式语言,主要支持面向对象,侧重于使用类和对象来组织代码。
2)继承:
- C++支持继承,允许一个子类继承一个或多个父类,达到代码复用的目的。
- C语言中没有继承的概念。
3)函数重载:
- C++支持函数通过参数类型和参数个数的重载。
- C语言不支持重载,函数名必须唯一才行。
4)模板:
- C++支持模板,支持静态和动态形式的多态。
- C语言对此都不支持。
5)内存管理:
- C++使用new和delete操作符来管理内存,也支持使用智能指针来动态管理内存。
- C语言需要使用malloc和free来申请和释放内存。
2.2 知识拓展
1)其实可以理解为C++是C语言的超集,绝大多数的C代码可以直接在C++中使用(Win平台可能兼容性稍微差一点)。
2)性能层面,C语言相对于C++来说,更简洁,更接近底层,性能应该会更好一些,但因为轮子太少,虽然入门相对容易,但实现复杂模块更麻烦。
三、什么是C++的左值和右值?有什么区别?
3.1 回答重点
什么是左值?什么是右值?
- 左值:可以出现在赋值运算符的左边,并且可以被取地址,通常是有名字的变量
- 右值:不能出现在赋值运算符的左边,不可以被取地址,表示一个具体的数据值,通常是常量、临时变量
一般可以从两个方向区分左值和右值。
方向1:
- 左值:可以放到等号左边的东西叫左值。
- 右值:不可以放到等号左边的东西就叫右值。
方向2:
- 左值:可以取地址并且有名字的东西就是左值。
- 右值:不能取地址的没有名字的东西就是右值。
示例:
int a = b + c;
a是左值,有变量名,可以取地址,也可以放到等号左边,表达式b+c的返回值是右值,没有名字且不能取地址,&(b+c)不能通过编译,而且也不能放到等号左边。
int a = 4; // a是左值,4作为普通字面量是右值
3.2 知识扩展
3.2.1 左值引用
可以理解为是对左值的引用。对于左值引用,等号右边的值必须可以取地址,如果不能取地址,则会编译失败,或者可以使用const引用形式,但这样就只能通过引用来读取输出,不能修改数组,因为是常量引用。
示例代码:
int a = 5;
int &b = a; // b是左值引用
b = 4;
int &c = 10; // error,10无法取地址,无法进行引用
const int &d = 10; // ok,因为是常引用,引用常量数字,这个常量数字会存储在内存中,可以取地址
3.2.2 常引用 (const int &d = 10;
):
- 常引用允许绑定到临时对象(右值)。在此情况下,字面量
10
会被视为一个临时对象,尽管它是一个右值,但 C++ 允许const
引用绑定到右值。 - 当你写
const int &d = 10;
时,编译器会创建一个临时对象来存储值10
。这个临时对象存在于该引用的生命周期内,因此能够安全地引用。 - 临时对象会在其绑定的常引用的生命周期结束后立即被销毁。
3.2.3 右值引用
①为什么引入右值引用呢?
- 在C++中在进行对象赋值操作的时候,很多情况下会发生对象之间的深拷贝,如果堆内存很大,这个拷贝的代价也就非常大,在某些情况下,如果想要避免对象的深拷贝,就可以使用右值引用进行性能的优化。
②什么是右值引用?
- 可以理解为是对右值的引用。即对一个临时对象或者即将销毁的对象的引用,开发者可以利用这些临时对象,却不需要复制它们。
如果使用右值引用,那表达式等号右边的值需要是右值,可以使用std:move
函数强制把左值转换为右值。
int a = 4;
int &&b = a; // error, a是左值
int &&c = std::move(a); // ok
3.2.4 纯右值
与之对于的是将亡值:与右值引用相关的表达式,比如,T&&类型函数的返回值、 std::move 的返回值等。
纯右值属于右值。运算表达式产生的临时变量、不和对象关联的原始字面量、非引用返回的临时变量、lambda表达式等都是纯右值。
举例:
- 除字符串字面值外的字面值
- 返回非引用类型的函数调用
- 后置自增自减表达式i++、i–
- 算术表达式(a+b, a*b, a&&b,a==b等)
- 取地址表达式等(&a)
3.2.5 左值、右值引用的使用场景
- 左值引用:当你需要修改对象的值,或者需要引用一个持久对象时使用。
- 右值引用:当你需要处理一个临时对象,并且想要避免复制,或者实现移动语义时使用。
右值引用是C++11引入的一项重要特性,主要用于实现移动语义和完美转发,以优化资源管理。
以下是一些常见的右值引用使用场景:
3.2.5.1 移动构造和移动赋值
右值引用允许你高效地转移资源而不是拷贝。这在处理大对象时非常有用,例如,当你有一个大的数据结构需要在两个对象之间传递时,通过移动语义可以避免昂贵的深拷贝。
示例:
#include <iostream>
#include <chrono>
#include <utility>// 自定义类
class MyClass {
private:int* data;size_t size;
public:// 构造函数MyClass(size_t s) : size(s) {data = new int[size];for (size_t i = 0; i < size; ++i) {data[i] = i;}}// 拷贝构造函数MyClass(const MyClass& other) : size(other.size) {std::cout << "拷贝构造函数" << std::endl;data = new int[size];for (size_t i = 0; i < size; ++i) {data[i] = other.data[i];}}// 移动构造函数MyClass(MyClass&& other) noexcept : data(other.data), size(other.size) {std::cout << "移动构造函数" << std::endl;other.data = nullptr;other.size = 0;}// 普通赋值运算符MyClass& operator=(const MyClass& other) {if (this != &other) {std::cout << "普通赋值运算符" << std::endl;delete[] data;size = other.size;data = new int[size];for (size_t i = 0; i < size; ++i) {data[i] = other.data[i];}}return *this;}// 移动赋值运算符MyClass& operator=(MyClass&& other) noexcept {if (this != &other) {std::cout << "移动赋值运算符" << std::endl;delete[] data;data = other.data;size = other.size;other.data = nullptr;other.size = 0;}return *this;}// 析构函数~MyClass() {delete[] data;}
};// 计时器类
class Timer {
public:Timer() : start_time(std::chrono::high_resolution_clock::now()) {}~Timer() {auto end_time = std::chrono::high_resolution_clock::now();auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end_time - start_time).count();std::cout << "耗时: " << duration << " 微秒" << std::endl;}private:std::chrono::time_point<std::chrono::high_resolution_clock> start_time;
};#define MAX_SIZE 1000000int main() {MyClass obj1(MAX_SIZE);{std::cout << "=======测试拷贝构造效率:======= " << std::endl;Timer timer;MyClass obj2(obj1);}{std::cout << "=======测试移动构造效率:======= " << std::endl;Timer timer;MyClass obj3(std::move(obj1));}MyClass obj4(MAX_SIZE);MyClass obj5(MAX_SIZE);{std::cout << "=======测试普通赋值运算符效率:=======" << std::endl;Timer timer;obj4 = obj5;}{std::cout << "=======测试移动赋值运算符效率:======= " << std::endl;Timer timer;obj4 = std::move(obj5);}return 0;
}
结果:
3.2.5.2 返回值优化
当函数返回一个大的对象时,使用右值引用可以避免不必要的拷贝,从而提高性能。
示例:
MyVector createVector() {return MyVector(std::vector<int>{6, 7, 8, 9, 10}); // 返回右值
}int main() {MyVector v = createVector(); // 移动构造return 0;
}
3.2.5.3 完美转发
右值引用可以与模板结合使用,能够将参数的值类别(左值或右值)保持不变,支持完美转发和更加灵活的函数参数处理。
示例:
#include <utility>
#include <iostream>template<typename T>
void process(T&& t) {// 完美转发// 如果t是左值,std::forward<T>(t)将其转发为左值// 如果t是右值,std::forward<T>(t)将其转发为右值std::cout << "Processing..." << std::endl;
}int main() {int x = 5;process(x); // 传递左值process(10); // 传递右值return 0;
}
四、什么是C++的移动语义和完美转发?
4.1 回答重点
移动语义和完美转发都是C++11引入的新特性。
4.2 移动语义
一种优化资源管理的机制。常规的资源管理是拷贝别人的资源。而移动语义是转移所有权,转移了资源而不是拷贝资源,性能会更好。
移动语义通常用于那些比较大的对象,搭配移动构造函数或移动赋值运算符来使用。
示例代码:
#include <iostream>class A {
public:A(int size) : size_(size) {data_ = new int[size];for (int i = 0; i < size; ++i) {data_[i] = i;}}A() {}A(const A& a) {size_ = a.size_;data_ = new int[size_];for (int i = 0; i < size_; ++i) {data_[i] = a.data_[i];}std::cout << "copy " << std::endl;}A(A&& a) {this->data_ = a.data_;this->size_ = a.size_;a.data_ = nullptr;std::cout << "move " << std::endl;}~A() {if (data_ != nullptr) {delete[] data_;}}// 新增一个打印数组元素的方法void printData() const {if (data_ != nullptr) {for (int i = 0; i < size_; ++i) {std::cout << data_[i] << " ";}std::cout << std::endl;}else {std::cout << "data_ is nullptr, cannot print." << std::endl;}}int* data_;int size_;
};int main() {A a(10);std::cout << "Before move, a's data: ";a.printData();A b = a;std::cout << "After copy, b's data: ";b.printData();A c = std::move(a);std::cout << "After move, a's data: ";a.printData();std::cout << "After move, c's data: ";c.printData();return 0;
}
结果:
如果不使用std:move,会有很大的拷贝代价,使用移动语义可以避免很多无用的拷贝,提供程序性能,**C++所有的STL都实现了移动语义,方便我们使用 **。例如:
std::vector<string> vecs;
...
std::vector<string> vecm = std::move(vecs); // 免去很多拷贝
4.3 完美转发
效果:
完美转发指可以写一个接受任意实参的函数模板,并转发到其它函数,目标函数会收到与转发函数完全相同的实参,转发函数实参是左值那目标函数实参也是左值,转发函数实参是右值那目标函数实参也是右值。
作用:
在函数调用链中,有时候需要将一个函数接收到的参数原封不动地传递给另一个函数,此时就需要确保参数的左值或右值属性不被改变。如果没有完美转发,在传递参数时可能会触发额外的拷贝或移动操作,而完美转发可以避免这种情况。
那如何实现完美转发呢,答案是使用std:forward,可参考以下代码:
#include <iostream>
#include <utility>void PrintV(int& t) {//std::cout << t << std::endl;std::cout << "lvalue" << std::endl;
}void PrintV(int&& t) {//std::cout << t << std::endl;std::cout << "rvalue" << std::endl;
}template<typename T>
void Test(T&& t) { //疑问点:这里是一个右值引用,但是为啥可以传递一个左值呢? //回答:T&& 是通用引用,它能依据传递的实参类型灵活推导为左值引用或者右值引用,所以既可以传递左值,也可以传递右值。PrintV(t);PrintV(std::forward<T>(t));PrintV(std::move(t));
}int main() {Test(1); // lvalue rvalue rvalueint a = 1;Test(a); // lvalue lvalue rvalueTest(std::forward<int>(a)); // lvalue rvalue rvalueTest(std::forward<int&>(a)); // lvalue lvalue rvalueTest(std::forward<int&&>(a)); // lvalue rvalue rvaluereturn 0;
}
分析:
- Test(1):1是右值,模板中T&&t这种为万能引用,右值1传到Test函数中变成了右值引用,但是调用PrintV()时候,t变成了左值,因为它变成了一个拥有名字的变量,所以打印Ivalue,而PrintV(std:forward(t)时候,会进行完美转发,按照原来的类型转发,所以打印rvalue,PrintV(std:move(t))毫无疑问会打印 rvalue。
- Test(a):a是左值,模板中T&&这种为万能引用,左值a传到Test函数中变成了左值引用,所以有代码中打印。
- Test(std:forward(a)):转发为左值还是右值,依赖于T,T是左值那就转发为左值,T是右值那就转发为右值。
4.4 知识扩展
深拷贝与浅拷贝
实例代码:
class A {
public:A(int size) : size_(size) {data_ = new int[size];}A(){}A(const A& a) {size_ = a.size_;data_ = a.data_;cout << "copy " << endl;}~A() {delete[] data_;}int *data_;int size_;
};
int main() {A a(10);A b = a;cout << "b " << b.data_ << endl;cout << "a " << a.data_ << endl;return 0;
}
上面代码中,两个输出的是相同的地址,a和b的data_指针指向了同一块内存,这就是浅拷贝,只是数据的简单赋值,那在析构时data_内存会被释放两次,如何消除这种隐患呢,可以使用如下深拷贝:
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() {delete[] data_;}int *data_;int size_;
};
int main() {A a(10);A b = a;cout << "b " << b.data_ << endl;cout << "a " << a.data_ << endl;return 0;
}
深拷贝就是在拷贝对象时,如果被拷贝对象内部还有指针引用指向其它资源,自己需要重新开辟一块新内存存储资源,而不是简单的赋值。
五、什么是C++的列表初始化?
5.1 回答重点
C++11中引入了列表初始化,它的语法比较简单,就是可以使用花括号{}来初始化变量或对象。
列表初始化可以应用于内置类型、用户自定义类型(类、结构体等)以及其它容器等。
它有以下几点好处:
- 方便,基本上可以替代普通括号初始化
- 可以使用初始化列表接受任意长度
- 可以防止类型窄化,避免精度丢失的隐式类型转换
下面深入看下列表初始化的几个用法:
1)基础数据类型
int a{10}; // 列表初始化
int a = {19}; // 列表初始化(也可以不使用等号)
2)初始化数组
int arr[3] = {1, 2, 3}; // 使用花括号初始化数组
3)类对象初始化,构造函数需要支持列表初始化
class Point {
public:int x, y;Point(int a, int b) : x{a}, y{b} {}
};Point p{1, 2}; // 使用花括号初始化对象
4)容器初始化
std::vector<int> vec = {1, 2, 3, 4};
5)防止类型窄化
int x{3.14}; // error,float转int会触发类型窄化
示例:
#include <iostream>
#include <utility>int main() {int a = 31.4;std::cout << a << std::endl; //不会报错int b = { 31.4 }; //报错 需要收缩转化std::cout << b << std::endl;return 0;
}
6)聚合类型的列表初始化
聚合类型是指没有用户定义的构造函数、没有私有或受保护的非静态数据成员、没有基类以及没有虚函数的类、结构体或联合体。对于聚合类型,列表初始化会直接按顺序初始化其成员。
struct Aggregate { int a; double b;
}; Aggregate agg{1, 2.3}; // 初始化a为1,b为2.3
5.2 知识扩展
5.2.1 什么是类型窄化?
- 从浮点类型到整数类型的转换
- 从long double到double或 float 的转换,以及从double到 float 的转换,除非源是常量表达式且不发生溢出
- 从整数或无作用域枚举类型到不能表示原类型所有值的整数类型的转换,除非源是其值能完全存储于目标类型的常量表达式
示例:
1)从整数类型到不能表示原类型所有值的整数类型:
#include <iostream>int main() {// 示例 1: 从 int 到 char 的转换,int 可能无法完全用 char 表示int largeInt = 300; // 类型窄化,char 通常只能表示 -128 到 127 或者 0 到 255char smallChar = largeInt; std::cout << "示例 1: " << static_cast<int>(smallChar) << std::endl;// 示例 2: 从 int 到 unsigned char 的转换,int 可能无法完全用 unsigned char 表示int anotherLargeInt = -10; // 类型窄化,unsigned char 只能表示 0 到 255unsigned char smallUnsignedChar = anotherLargeInt; std::cout << "示例 2: " << static_cast<int>(smallUnsignedChar) << std::endl;// 示例 3: 常量表达式的转换,源值能完全存储于目标类型const int validInt = 100; // 不会发生类型窄化,因为 100 可以用 char 表示char validChar = validInt; std::cout << "示例 3: " << static_cast<int>(validChar) << std::endl;return 0;
}
2)从无作用域枚举类型到不能表示原类型所有值的整数类型:
#include <iostream>// 无作用域枚举类型
enum Color {RED = 1000,GREEN = 2000,BLUE = 3000
};int main() {// 示例 4: 从无作用域枚举类型到 char 的转换Color myColor = RED;// 类型窄化,char 无法表示枚举的值char colorChar = myColor; std::cout << "示例 4: " << static_cast<int>(colorChar) << std::endl;return 0;
}
5.2.2 std::initializer_list:
我们平时开发使用STL过程中可能发现它的初始化列表可以是任意长度,有没有想过它是怎么实现的呢?
答案是std:initializer_list,看这段代码:
struct CustomVec {std::vector<int> data;CustomVec(std::initializer_list<int> list) {for (auto iter = list.begin(); iter != list.end(); ++iter) {data.push_back(*iter);}}
};
std:initializer_list,它可以接收任意长度的初始化列表,但是里面必须是相同类型T,或者都可以转换为T。
六、C++中move有什么作用?它的原理是什么?
6.1 回答重点
move是C++11引入的一个新特性,用来实现移动语义。它的主要作用是将对象的资源从一个对象转移到另一个对象,而无需进行深拷贝,减少了资源内存的分配,可提高性能。
它的原理很简单,我们直接看它的源码实现:
// move
template <class T>
LIBC_INLINE constexpr cpp::remove_reference_t<T> &&move(T &&t) {return static_cast<typename cpp::remove_reference_t<T> &&>(t);
}
源码来源👇
从源码中你可以看到,std:move的作用只有一个,无论输入参数是左值还是右值,都强制转成右值。
源码阅读:
这段代码实现了 C++ 标准库中的 std::move 函数,它的主要作用是将一个左值强制转换为右值引用,从而可以触发移动语义,避免不必要的拷贝操作,提高程序的性能。下面我们逐行来解读这段代码:
代码整体结构
// move
template <class T>
LIBC_INLINE constexpr cpp::remove_reference_t<T> &&move(T &&t) {return static_cast<typename cpp::remove_reference_t<T> &&>(t);
}
详细解读
- 模板声明
template <class T>
这行代码声明了一个模板函数,T 是模板参数。模板函数允许你编写通用的代码,它可以处理不同类型的数据。在调用 move 函数时,编译器会根据传入的实参自动推导 T 的类型。
- 函数修饰符
LIBC_INLINE constexpr
-
LIBC_INLINE:这通常是一个自定义的宏,用于指示编译器将该函数作为内联函数处理。内联函数的作用是在调用该函数的地方直接展开函数体,避免函数调用的开销,提高程序的执行效率。
-
constexpr:表示该函数是一个常量表达式函数,它可以在编译时求值。这意味着在编译阶段,编译器就可以计算出函数的返回值,从而进行一些优化。
- 返回类型
cpp::remove_reference_t<T> &&
-
cpp::remove_reference_t:这是一个类型特征,它的作用是移除 T 的引用类型。例如,如果 T 是 int& 或者 int&&
cpp::remove_reference_t 会得到 int。
-
&&:表示右值引用。所以整个返回类型的意思是返回一个移除引用后的 T 类型的右值引用。
- 函数参数
T &&t
这是一个通用引用(也称为转发引用)。通用引用可以绑定到左值或右值。当传入的实参是左值时,T 会被推导为左值引用类型;当传入的实参是右值时,T 会被推导为非引用类型。
- 函数体
return static_cast<typename cpp::remove_reference_t<T> &&>(t);
-
static_cast:这是 C++ 中的强制类型转换运算符,用于将一个表达式转换为指定的类型。
-
typename cpp::remove_reference_t &&:将 t 强制转换为移除引用后的 T 类型的右值引用。
-
通过这种强制类型转换,无论传入的 t 是左值还是右值,最终都会被转换为右值引用返回,从而触发移动语义。
6.2 知识扩展
1)move 转成右值有什么好处?
这就涉及到移动语义的概念,右值可以触发移动语义,那什么是移动语义?我们可以理解为在对象转换的时候,通过右值可以触发到类的移动构造函数或者移动赋值函数。
因为触发了移动构造函数或者移动赋值函数,我们就默认,原对象后面已经不会再使用了(包括内部的某些内存),这样我们就可以在新对象中直接使用原对象的那部分内存,减少了数据的拷贝操作,昂贵的拷贝转为了廉价的移动,提升了程序的性能。
2)是不是std:move后的对象就没法使用了?
其实不是,还是取决于搭配的移动构造函数和移动赋值函数是如何实现的。如果在移动构造函数+移动赋值函数中,还是使用了拷贝动作,那原对象还是可以使用的,见下面示例。
#include <chrono>
#include <functional>
#include <future>
#include <iostream>
#include <string>class A {
public:A() {std::cout << "A() \n";}~A() {std::cout << "~A() \n";}A(const A& a) {count_ = a.count_;std::cout << "A copy \n";}A& operator=(const A& a) {count_ = a.count_;std::cout << "A = \n";return *this;}A(A&& a) {count_ = a.count_;std::cout << "A move \n";}A& operator=(A&& a) {count_ = a.count_;std::cout << "A move = \n";return *this;}std::string count_;
};int main() {A a;a.count_ = "12345";A b = std::move(a);std::cout << a.count_ << std::endl;std::cout << b.count_ << std::endl;return 0;
}
如果我们在移动构造函数+移动赋值函数中,将原对象内部内存废弃掉,新对象使用原对象内存,那原对象的内存就不可以用了,示例代码如下:
#include <chrono>
#include <functional>
#include <future>
#include <iostream>
#include <string>class A {
public:A() {std::cout << "A() \n";}~A() {std::cout << "~A() \n";}A(const A& a) {count_ = a.count_;std::cout << "A copy \n";}A& operator=(const A& a) {count_ = a.count_;std::cout << "A = \n";return *this;}A(A&& a) {count_ = std::move(a.count_);std::cout << "A move \n";}A& operator=(A&& a) {count_ = std::move(a.count_);std::cout << "A move = \n";return *this;}std::string count_;
};int main() {A a;a.count_ = "12345";A b = std::move(a);std::cout << a.count_ << std::endl;std::cout << b.count_ << std::endl;return 0;
}
总结:
- std:move函数的作用是将参数强制转换为右值。而且,只是转换为右值,并不会对对象进行任何操作。
- 转换为右值可以触发移动语义,减少数据的拷贝操作,提升程序的性能。
- 在使用std:move函数后,原对象是否可以继续使用取决于移动构造函数和移动赋值函数的实现。
七、C++11中有哪些常用的新特性?
7.1 回答重点
C++11新特性几乎是面试必问的一个话题,可以主要回答以下几个特性:
- auto类型推导
- 智能指针
- RAll lock
- std::thread
- 左值右值
- std:function和lambda表达式
7.2 知识扩展
7.2.1 auto
auto可以让编译器在编译时就推导出变量的类型,看代码:
auto a = 10; // 10是int型,可以自动推导出a是intint i = 10;
auto b = i; // b是int型auto d = 2.0; // d是double型
auto f = []() { // f是啥类型?直接用auto就行return std::string("d");
}
利用auto可以通过=右边的类型推导出变量的类型
什么时候使用auto呢?简单类型其实没必要使用auto,某些复杂类型就有必要使用auto,比如lambda表
达式的类型,async函数的类型等,例如:
auto func = [&] { cout << "xxx";
}; // 对于func你难道不使用auto吗,反正我是不关心lambda表达式究竟是什么类型。
auto asyncfunc = std::async(std::launch::async, func);
7.2.2 智能指针
C++11新特性中主要有两种智能指针std::shared_ptr和std::unique_ptr。
那什么时候使用std::shared_ptr,什么时候使用std::unique_ptr呢?
- 当所有权不明晰的情况,有可能多个对象共同管理同一块内存时,要使用std::shared_ptr;
- 而std::unique_ptr强调的是独占,同一时刻只能有一个对象占用这块内存,不支持多个对象共同管
理同一块内存。
两类智能指针使用方式类似,拿std:unique_ptr举例:
using namespace std;struct A {~A() {cout << "A delete" << endl;}void Print() {cout << "A" << endl;}
};int main() {auto ptr = std::unique_ptr<A>(new A);auto tptr = std::make_unique<A>(); // error, c++11还不行,需要c++14std::unique_ptr<A> tem = ptr; // error, unique_ptr不允许移动,编译失败ptr->Print();return 0;
}
7.2.3 RAIl lock
C++11提供了两种锁封装,通过RAII方式可动态的释放锁资源,防止编码失误导致始终持有锁。
这两种封装是std::1ock_guard和std::unique_lock,使用方式类似,看下面的代码:
#include <iostream>
#include <mutex>
#include <thread>
#include <chrono>using namespace std;
std::mutex mutex_;int main() {auto func1 = [](int k) {// std::lock_guard<std::mutex> lock(mutex_);std::unique_lock<std::mutex> lock(mutex_);for (int i = 0; i < k; ++i) {cout << i << " ";}cout << endl;};std::thread threads[5];for (int i = 0; i < 5; ++i) {threads[i] = std::thread(func1, 200);}for (auto& th : threads) {th.join();}return 0;
}
普通情况下建议使用std:lock_guard,因为std:lock_guard更加轻量级,但如果用在条件变量的wait中环境中,必须使用std:unique_lock。
7.2.4 std:thread
什么是多线程这里就不过多介绍,新特性关于多线程最主要的就是std:thread的使用,它的使用也很简单,看代码:
#include <iostream>
#include <thread>using namespace std;int main() {auto func = []() {for (int i = 0; i < 10; ++i) {cout << i << " ";}cout << endl;};std::thread t(func);if (t.joinable()) {t.detach();}auto func1 = [](int k) {for (int i = 0; i < k; ++i) {cout << i << " ";}cout << endl;};std::thread tt(func1, 20);if (tt.joinable()) { // 检查线程可否被jointt.join();}return 0;
}
这里记住,std::thread在其对象生命周期结束时必须要调用join())或者detach(),否则程序会
terminate),这个问题在C++20中的std::jthread得到解决,但是C++20现在多数编译器还没有完全
支持所有特性,先暂时了解下即可,项目中没必要着急使用。
7.2.5 std:function 和 lambda 表达式
这两个可以说是很常用的特性,使用它们会让函数的调用相当方便。使用std::function可以完全替代以
前那种繁琐的函数指针形式。
还可以结合std::bind一起使用,直接看一段示例代码:
std::function<void(int)> f; // 这里表示function的对象f的参数是int,返回值是void
#include <functional>
#include <iostream>struct Foo {Foo(int num) : num_(num) {}void print_add(int i) const { std::cout << num_ + i << '\n'; }int num_;
};void print_num(int i) { std::cout << i << '\n'; }struct PrintNum {void operator()(int i) const { std::cout << i << '\n'; }
};int main() {// 存储自由函数std::function<void(int)> f_display = print_num;f_display(-9);// 存储 lambdastd::function<void()> f_display_42 = []() { print_num(42); };f_display_42();// 存储到 std::bind 调用的结果std::function<void()> f_display_31337 = std::bind(print_num, 31337);f_display_31337();// 存储到成员函数的调用std::function<void(const Foo&, int)> f_add_display = &Foo::print_add;const Foo foo(314159);f_add_display(foo, 1);f_add_display(314159, 1);// 存储到数据成员访问器的调用std::function<int(Foo const&)> f_num = &Foo::num_;std::cout << "num_: " << f_num(foo) << '\n';// 存储到成员函数及对象的调用using std::placeholders::_1;std::function<void(int)> f_add_display2 = std::bind(&Foo::print_add, foo, _1);f_add_display2(2);// 存储到成员函数和对象指针的调用std::function<void(int)> f_add_display3 = std::bind(&Foo::print_add, &foo, _1);f_add_display3(3);// 存储到函数对象的调用std::function<void(int)> f_display_obj = PrintNum();f_display_obj(18);
}
从上面可以看到std::function的使用方法,当给std::function填入合适的参数表和返回值后,它就变成
了可以容纳所有这一类调用方式的函数封装器。std::function还可以用作回调函数,或者在C++里如果
需要使用回调那就一定要使用std::function,特别方便。
lambda表达式可以说是C++11引入的最重要的特性之一,它定义了一个匿名函数,可以捕获一定范围的变
量在函数内部使用,一般有如下语法形式:
auto func = [capture] (params) opt -> ret { func_body; };
其中func是可以当作lambda表达式的名字,作为一个函数使用,capture是捕获列表,params是参数
表,opt是函数选项(mutable之类),ret是返回值类型,func_body是函数体。
看下面这段使用lambda表达式的示例:
auto func1 = [](int a) -> int { return a + 1; };
auto func2 = [](int a) { return a + 2; };
cout << func1(1) << " " << func2(2) << endl;
std::function和std::bind使得我们平时编程过程中封装函数更加的方便,而lambda表达式将这种方便
发挥到了极致,可以在需要的时间就地定义匿名函数,不再需要定义类或者函数等,在自定义STL规则时
候也非常方便,让代码更简洁,更灵活,提高开发效率。
7.2.6 std:chrono
chrono很强大,平时的打印函数耗时,休眠某段时间等,都可使用chrono。
在C++11中引l入了duration、time_point和clocks,在C++20中还进一步支持了日期和时区。这里简要
介绍下C++11中的这几个新特性。
duration
std::chrono::duration表示一段时间,常见的单位有s、ms等,示例代码:
// 拿休眠一段时间举例,这里表示休眠100ms
std::this_thread::sleep_for(std::chrono::milliseconds(100));
sleep_for里面其实就是std::chrono::duration,表示一段时间,实际是这样:
typedef duration<int64_t, milli> milliseconds;
typedef duration<int64_t> seconds;
duration具体模板如下:
template <class Rep, class Period = ratio<1> > class duration;
Rep表示一种数值类型,用来表示Period的数量,比如int、float、double,Period是ratio类型,用来表
示【用秒表示的时间单位】比如second,常用的duration已经定义好了,在std::chrono::duration下:
- ratio<3600,1>:hours
- ratio<60, 1>:minutes
- ratio<1,1>: seconds
- ratio<1, 1000>:microseconds
- ratio<1, 1000000>:microseconds
- ratio<1, 1000000000>:nanosecons
ratio的具体模板如下:
template <intmax_t N, intmax_t D = 1> class ratio;
N代表分子,D代表分母,所以ratio表示一个分数,我们可以自定义Period,比如ratio<2,1>表示单位时
间是2秒。
time_point
表示一个具体时间点,如2020年5月10日10点10分10秒,拿获取当前时间举例:
std::chrono::time_point<std::chrono::high_resolution_clock> Now() {return std::chrono::high_resolution_clock::now();
}
// std::chrono::high_resolution_clock为高精度时钟,下面会提到
clocks
时钟,chrono里面提供了三种时钟:
- steady_clock
稳定的时间间隔,表示相对时间,相对于系统开机启动的时间,无论系统时间如何被更改,后一次调用
now(肯定比前一次调用now(的数值大,可用于计时。 - system_clock
表示当前的系统时钟,可以用于获取当前时间:
int main() {using std::chrono::system_clock;system_clock::time_point today = system_clock::now();std::time_t tt = system_clock::to_time_t(today);std::cout << "today is: " << ctime(&tt);return 0;
}
// today is: Sun May 10 09:48:36 2020
- high_resolution_clock
high_resolution_clock表示系统可用的最高精度的时钟,实际上就是system_clock或者steady_clock其
中一种的定义,官方没有说明具体是哪个,不同系统可能不一样,之前看gccchrono源码中
high_resolution_clock是steady_clock的typedef。
7.2.7 条件变量
条件变量是C++11引入的一种同步机制,它可以阻塞一个线程或多个线程,直到有线程通知或者超时才会
唤醒正在阻塞的线程,条件变量需要和锁配合使用,这里的锁就是上面介绍的std::unique_lock。
这里使用条件变量实现一个CountDownLatch:
class CountDownLatch {public:explicit CountDownLatch(uint32_t count) : count_(count);void CountDown() {std::unique_lock<std::mutex> lock(mutex_);--count_;if (count_ == 0) {cv_.notify_all();}}void Await(uint32_t time_ms = 0) {std::unique_lock<std::mutex> lock(mutex_);while (count_ > 0) {if (time_ms > 0) {cv_.wait_for(lock, std::chrono::milliseconds(time_ms));} else {cv_.wait(lock);}}}uint32_t GetCount() const {std::unique_lock<std::mutex> lock(mutex_);return count_;}private:std::condition_variable cv_;mutable std::mutex mutex_;uint32_t count_ = 0;
};
八、C++中static的作用?什么场景下用到static?
8.1 回答重点
谈到C++的static,可以重点回答以下几个方面:
1)修饰局部变量:当static用于修饰局部变量时,这个变量的存储位置会在程序执行期间保持不变,且只在程序执行到该变量的声明处时初始化一次。即使函数被多次调用,static局部变量也只在第一次调用时初始化,之后的调用将不会重新初始化它。
2)修饰全局变量或函数:当static用于修饰全局变量或函数时,限制了这些变量或函数的作用域,它们只能在定义它们的文件内部访问。有助于避免在不同文件之间的命名冲突。
3)**修饰类的成员变量或函数:**在类内部,static成员变量或函数属于类本身,而不是类的任何特定对象。这意味着所有对象共享同一个static成员变量,无需每个对象都存储一份拷贝。static成员函数可以在没有类实例的情况下调用。
8.2 扩展知识
所有用static
修饰的变量,其生命周期都与程序(进程)一致:
- 程序启动时完成初始化(早于普通局部变量)
- 程序运行期间一直存在,不会被销毁
- 程序终止时才会被释放
这一点与变量的作用域无关:
- 静态全局变量:作用域限于当前文件,但生命周期随程序
- 静态局部变量:作用域限于函数内部,但生命周期随程序
本质上,static
的核心特性之一就是将变量存储在静态区,从而拥有与程序相同的生命周期。
static的用法
1.static局部变量
#include <iostream>
using namespace std;void func() {static int count = 0; // 只在第一次调用func时初始化cout << "Count is: " << count << endl;count++;
}int main() {for(int i = 0; i < 5; i++) {func(); // 每次调用都会显示增加的count值}return 0;
}
2.static全局变量或函数
// file1.cpp
static int count = 10; // count变量只能在file1.cpp中访问static void func() { // func函数只能在file1.cpp中访问cout << "Function in file1" << endl;
}// file2.cpp
extern int count; // 这里会导致编译错误,因为count是static的,不能在file2.cpp中访问void anotherFunc() {func(); // 这里也会导致编译错误,因为func是static的,不能在file2.cpp中访问
}
3.static类的成员变量或函数
#include <iostream>
using namespace std;class MyClass {
public:static int staticValue; // 静态成员变量static void staticFunction() { // 静态成员函数cout << "Static function called" << endl;}
};int MyClass::staticValue = 10; // 静态成员变量的初始化int main() {MyClass::staticFunction(); // 调用静态成员函数cout << MyClass::staticValue << endl; // 访问静态成员变量return 0;
}
使用场景:
static
局部变量:当你需要在函数的多次调用之间保持某个变量的值时。static
全局变量或函数:当你想要限制变量或函数的作用域,防止它们在其他文件中被访问时。static
类的成员变量或函数:当你想要类的所有对象共享某个变量或函数时,或者当你想要在没有类
实例的情况下访问某个函数时。
九、C++中const的作用?谈谈你对const的理解?
9.1 回答重点
const最主要的作用就是声明一个变量为常量,即这个变量的值在初始化之后就不能被修改。
但const不仅可以用作普通常量,还可以用于指针、引用、成员函数、成员变量等。
具体作用如下:
1、定义普通常量:当修饰基本数据类型的变量时,表示常量含义,对应的值不能被修改。
const int MAX_SIZE = 100; // MAX_SIZE是一个常量,其值不能被修改
2)修饰指针:这里分多种情况,比如指针本身是常量,指针指向的数据是常量,或者指针本身和其指向的数据都是常量。
3)修饰引用:const修饰引用时,一般用作函数参数,表示函数不会修改传递的参数值。
void func(const int& a) { // a是一个对常量的引用,不能通过a修改其值 // ...
}
4)修饰类成员函数:const修饰成员函数,表示函数不会修改类的任何成员变量,除非这些成员变量被声明为mutable。
class MyClass {
public: void myFunc() const { // myFunc是一个const成员函数,它不会修改类的任何成员变量 // ... }
};
5)修饰类成员变量:const修饰成员变量,表示生命期内不可改动此值。
class MyClass {
public: const int a = 5;
};
9.2 扩展知识
9.2.1 常量指针(整形指针、浮点型指针)
指向常量的指针:指针指向的内容是常量,不能通过该指针修改其所指向的值。
constint*ptr=&x;//ptr是一个指向常量的指针,不能通过ptr修改x的值
9.2.2 指针常量
指针常量:指针本身是常量,指针的值(即指向的地址)不能被修改,但可以通过该指针修改其所指向
的值(如果所指向的不是常量)。
int*constptr=&x;//ptr是一个常量,ptr的值(地址)不能被修改,但x的值可以被修改
9.2.3 常量指针常量
指向常量的常量指针:指针本身是常量,且指针指向的内容也是常量。
const int*constptr =&x; // ptr的值和ptr指向的值都不能被修改v
9.2.4 如何区分常量和只读变量
都是用const 修饰的变量,谁比谁高贵啊?凭啥const int count是常量,const in num是只读变量?
void func(const int num)
{const int count = 24;int array[num]; // error,num是一个只读变量,不是常量int array1[count]; // ok,count是一个常量int a1 = 520;int a2 = 250;const int& b = a1;b = a2; // errora1 = 1314;cout << "b: " << b << endl; // 输出结果为1314
}
在 C++ 中,num
不是常量(Constant),而是 只读变量(Read-Only Variable),二者的核心区别在于 是否在编译期确定值。
常量的关键特性:值在编译时确定,编译器可将其直接嵌入代码中。相对于的 只读变量,是运行期(函数调用时传入)确定的!
9.2.5 关键字:contexpr
参考爱编程的大丙
承接上文,那么有没有一种办法,可以让我们用一个变量表示动态数组呢?C++17引入了一种新特性:contexpr
void func(constexpr int num) { // C++17支持constexpr参数int array[num]; // 合法,num是编译期常量
}
func(10); // 合法,10是编译期常量
constexpr
参数的核心作用是强制实参必须是编译期常量,从而确保函数内部可以使用该参数进行编译期计算(如数组大小、模板参数等)。它与普通 const
参数的区别如下:
参数类型 | 实参要求 | 能否用于数组大小 |
---|---|---|
const int num | 实参可以是变量(只读) | 不能(非编译期常量) |
constexpr int num | 实参必须是编译期常量 | 能(编译期确定大小) |
详细解释:
用来修饰常量表达式,所谓常量表达式,指的就是由多个(≥1)常量(值不会改变)组成并且在编译过程中就得到计算结果的表达式。
常量表达式和非常量表达式的计算时机不同,非常量表达式只能在程序运行阶段计算出结果,但是常量表达式的计算往往发生在程序的编译阶段,这可以极大提高程序的执行效率,因为表达式只需要在编译阶段计算一次,节省了每次程序运行时都需要计算一次的时间。
八股文:
constexpr用来表示在编译时可以确定其值的常量,要求它修饰的变量或函数在编译时就能求值,从而在编译期间进行优化,减少运行时的开销。它可以作用于变量和函数,使其分别成为常量和常量函数。
作用于变量:这个变量将变成常量,会在编译时计算。
作用于函数:如果修饰的是函数表达式的话,有点类似于inline函数,不能出现太复杂的程序(比如for之类的),最好只有constexpr修饰的东西!其函数中不可以有全局变量、静态变量及动态变量,不可以调用非常量函数。如果其参数是常量,则可以在编译时求值;如果参数是变量,则和普通函数一样,运行时计算。当它被作用于变量时,其变量会被存储在只读存储段。
作用于模板函数:不多概述!点击参考链接了解!
十、C++ 中 define 和const 的区别?
10.1 回答重点
如果单纯从定义常量的角度,优先使用const:
1)#define是一个预处理指令,用于定义宏,但它只是在预处理阶段进行文本替换,并不进行类型检
查,且出现问题后调试起来困难。
2)const是一个关键字,用于定义常量,它在编译时确定类型和值,并且具有类型安全的特点。
10.2 知识扩展
我们可以再从以下几个方面来深入回答:
1)作用机制:
#define x 10只是在预处理阶段,将所有出现的x替换为10,这种替换在编译之前(预处理阶段)完成,不进行任何类型检查。因此,#define它既可以用于定义常量,也可以用于定义宏函数。
const int x=10则是在编译阶段真真实实的定义了一个类型为int的常量x,并赋值为10。这种处理不仅能保留类型信息,还能进行常规的语法和语义检查。
2)类型安全:
#define没有类型,一切都是文本替换,所以在操作上更容易出现错误。比如#define PI 3.14,当用在一个整数运算中时可能不会得到预期的结果。
const有完整的类型信息,编译器可以对其进行类型检查和类型转换。
3)作用域:
#define定义的宏在整个源文件中保持有效,直到被#undef(取消宏定义)。PS:如果一个.cpp文件定义某宏,宁外一个.cpp文件不会生效的,除非用了某
const则遵循C++的作用域规则,仅在定义的作用域内有效。
4)调试和编译输出:
#define的调试过程中,宏的值无法直接在调试器中看到,因为它只是一个文本替换。而且,如果宏定义中存在错误,比较难排查到具体错误。
const可以被调试器识别和追踪,调试时方便查看变量值和类型。
示例代码:
// using #define
#define PI 3.14159int main() {double area = PI * 10 * 10; // 没有类型检查,只是文本替换
}// using const
const double pi = 3.14159;int main() {double area = pi * 10 * 10; // 类型安全,编译器知道pi是个double类型
}
总结,在定义常量方面,const更具优势,特别是在类型安全和可调试性方面,因此在大部分情况下,我
会推荐使用const而不是#define。
另外,现代C++更倾向于使用constexpr关键字来定义编译时常量,它比const更有优势,因为它保
证了表达式尽量在编译期计算,代码性能更优。
十一、C++ 中 char*、const char*、char* const、const char* const的区别?
11.1 回答重点
一个小技巧,从后往前读:
1)char*:
这是一个指向char类型数据的指针,指针以及它指向的数据都是可变的。可以改变指针的
指向和指向的数据。
char data[] = "Hello";
char* p = data;
p[0] = 'h'; // 修改数据内容,是允许的
p = nullptr; // 改变指针指向,是允许的
2)const char*:
指向const char,这是一个指向const char类型数据的指针。指针本身是可变的,
但指针指向的数据是不可变的。简单来说,可以改变指针的指向,但不能改变它指向的数据内容。
const char* p = "Hello";
p[0] = 'h'; // 错误:不能修改数据内容
p = "World"; // 允许:改变指针指向
3)char** const:
const修饰char*,这是一个指向char类型数据的常量指针。指向的数据是可变
的,但指针本身是不可变的。也就是说,不能改变指针的指向,但能修改指向的数据。
char data[] = "Hello";
char* const p = data;
p[0] = 'h'; // 允许:修改数据内容
p = nullptr; // 错误:不能改变指针指向
4)const char* const:
这是一个指向const char类型数据的常量指针。指针和指向的数据都是不可变
的,也就是既不能改变指针的指向,也不能修改指针指向的数据。
const char* const p = "Hello";
p[0] = 'h'; // 错误:不能修改数据内容
p = "World"; // 错误:不能改变指针指向
十二、C++中inline的作用?它有什么优缺点?
12.1 回答重点
inline的作用是建议编译器将函数调用替换为函数体,以减少函数调用的开销,和宏比较类似。
使用inline函数的目的一定是希望可以提高程序的运行效率,特别是那些频繁调用的小函数。
优点:
1)降低函数调用的开销,原理就是因为省去了调用和返回的指令开销。
2)如果函数体较小,可以提高代码执行的效率。
缺点:
1)容易导致代码膨胀,整个可执行程序体积变大,特别是当inline函数体较大且被多次调用时。
2)内联是一种建议,编译器可以选择忽略inline关键字。
12.2 知识扩展
下面深入了解下inline函数。
1)内联函数的典型应用场景:
内联函数不仅适用于短小的函数,例如简单的getter和setter,它还适合一些占用时间较短的算法
很多算法都会在语言层面考虑内联来提升性能。
需要频繁调用而且内联能显著提高性能的地方。
2)内联函数与宏的区别:
宏是在预处理阶段进行文本替换,而内联函数是在编译阶段展开。内联函数有类型安全和作用域控制,宏
没有这一特性。内联函数可以更好地报告调试信息,相对来说调试比较方便。
3)在优化级别较高时,即使未加inline关键字,编译器也可能自动将频繁调用的小函数设为内联。
4)inline的作用不仅仅是优先内联,它逐渐演变成了允许多重定义的含义。
十二、数组和指针的区别
12.1 回答重点
主要的区别可以总结为以下几点:
1)内存分配:
—数组:编译器会在栈上为数组的所有元素分配连续的内存空间。
—指针:指针本身只占用一个内存单元(通常是4或8字节),它存储的是一个地址。初始化指针之后,
可以通过动态内存分配(例如使用new或malloc)来分配内存。
2)固定与动态大小:
—数组:数组的大小在声明时就确定了,数组的大小需要是常量,无法在运行时改变。
—指针:指针比较灵活,它指向的内存如果是堆内存,可以在运行时动态分配和释放内存,灵活性更
好。
3)类型安全性:
—数组:数组在声明时绑定了具体的类型,编译器在访问数组时可以进行类型检查。
—指针:指针声明时也有类型,但指针所指向的内存地址可以重新赋值,容易引起类型不匹配的问题,
可能导致运行时错误,特别是指针类型经常转换的场景,比如int转void等等。
4)运算操作:
—数组:数组名可以看作是数组首元素的常量指针,但不能直接进行算术运算(如++或–)。
—指针:指针变量可以直接进行算术运算,比如递增、递减操作,从而访问不同的位置。
12.2 扩展知识
我们可以从几个方面进一步探讨:
1)数组和指针的转换:
在表达式中,数组名会被自动转换为指向数组首元素的地址。例如,假设int arr[5],则arr会被转
换为&arr[e]。
2)动态数组:
动态数组的实现需要使用指针。例如,int* arr=new int[5];这种方式在运行时分配的内存可以随
意调整大小。
3)内存管理:
编程时需要注意内存泄漏问题。使用指针分配的内存(例如使用new)需要显式地释放(例如使用
delete或delete[]),否则会导致内存泄漏。
4)多维数组与指针:
多维数组在内存中是按行优先顺序存储的,理解这一点有助于使用指针遍历多维数组。
5)高效代码:
在写高性能代码时,指针有时可以比数组更高效,因为指针的算术运算更加灵活。
十三、C++ 中 sizeof 和 strlen的区别?
13.1 回答重点
两者的功能其实有很大区别:
-
sizeof是一个编译时运算符,用于获取一个类型或者对象的大小(以字节为单位)。sizeof在编译时
计算结果,不涉及实际内容。 -
strlen是一个库函数,用于计算C风格字符串的长度(不包括终止字符’O’)。strlen是在运行时计
算结果的,因为它需要遍历字符串内容。
13.2 知识扩展
1) sizeof的应用:
·用于计算基础类型的大小,比如sizeof(int)。
·用来计算结构体或类的内存占用,比如sizeof(MyClass)。
·对于静态数组,可以获取整个数组的内存大小,比如sizeof(arr),其中arr是一个静态数组。
注意,对于指针,sizeof返回的是指针本身的大小,而不是指针所指向的内存区域的大小。例如:
int *p = new int[10];
std::cout << sizeof(p); // 这会返回指针的大小,通常是4或8个字节,具体取决于系统架构。
2) strlen 的应用:
·常用于计算C风格字符串的长度。注意,strlen是不包括字符串末尾的终止符”\0’的。
·对于一些特定字符数组,strlen非常有用,比如char arr[]=“Hello”;,strlen(arr)返回的是5。
注意,strlen不能用于未以\e结尾的字符数组,否则会导致未定义行为。例如:
char arr[5] = {'H', 'e', 'l', 'l', 'o'};
std::cout << strlen(arr); // 这会导致未定义行为,因为没有终止符'\0'。
3)获取动态分配内存的大小:
对于使用new动态分配的内存,sizeof不能直接获取分配的内存大小。在这种情况下,需要在分配内存时
显式记录内存大小。
int main() {int n = 10;int* arr = new int[n];// std::cout << "Size of dynamic array: "<< sizeof(arr) << " bytes"<< std::endl; // 错误:这将输出指针的大小,而不是数组的大小std::cout << "Size of dynamic array: " << n * sizeof(int) << " bytes"<< std::endl; // 正确:手动计算数组大小delete[] arr;return 0;
}
4)空类的大小:
即使是一个空类(不包含任何数据成员和成员函数的类),sizeof返回的值也至少为一个字节。这是为了
确保在不同的编译器和平台上,空类的对象具有唯一标识。
class EmptyClass {};
int main() {std::cout << "Size of EmptyClass: "<< sizeof(EmptyClass) << " bytes"<< std::endl;return 0;
}
总结,sizeof和strlen有其各自的用途和使用场景,一个用于计算类型大小,一个用于计算字符串长度。
十四、C++中extern有什么作用?extern"C"有什么作用?
14.1 回答重点
在C++中,"extern”关键字有两个主要作用:
1)声明变量的外部链接:
当一个变量在一个文件中声明,但在另一个文件中定义时,我们可以使用"extern”关键字来告知编译器该变量在其他地方定义,避免编译器的编译错误。
2)extern"C":
在导出C++函数符号时,通过extern"C",可以保证导出的符号为C符号,而不是C++的符号(namemangling),这样可以更好的做兼容。比如Ilvm编译器导出的库,通过C符号可以做到MSVC编译器也可以正常链接使用。
14.2 扩展知识
变量的外部链接:
假设我们有两个文件main.cpp和helper.cpp,并且我们想在main.cpp中使用在helper.cpp中定义的变量。
main.cpp
#include <iostream>
extern int count; // 告诉编译器,count变量在其他地方定义了
int main() {std::cout << "Count: " << count << std::endl;return 0;
}
helper.cpp
int count = 10;
当我们编译main.cpp时,编译器会知道count变量在别的地方定义了。
extern “C”:
通过extern"C",可以将C++函数的符合导出为C符号:
#ifdef __cplusplus
extern "C" {
#endif__declspec(dllexport) void F() {}#ifdef __cplusplus
}
#endif
比如再插件开发的过程中,我们的插件程序向主程序提供接口的时候,就需要用extern “C”:来防止C++的名称修饰,否则的话,主程序找不到这个接口!
十五、C++中explicit的作用?
15.1 回答重点
关键字explicit的主要作用是防止构造函数或转换函数在不合适的情况下被隐式调用。
例如,如果有一个只有一个参数的构造函数,加上explicit关键字后,编译器就不会自动用该构
造函数进行隐式转换。这可以避免由于意外的隐式转换导致的难以调试的行为。
class Foo {
public:explicit Foo(int x) : value(x) {}
private:int value;
};void func(Foo f) {// ...
}int main() {Foo foo = 10; // 错误,必须使用 Foo foo(10) 或 Foo foo = Foo(10)func(10); // 错误,必须使用 func(Foo(10))
}
如果没有explicit关键字,Foo foo=10;以及func(10);这样的代码是可以通过编译的,这会
导致一些意想不到的隐式转换。
15.2 扩展知识
1)历史背景
explicit关键字在C++98标准中引l入,用来增强类型安全,防止不经意的隐式转换。从C++11
开始,explicit可以用于 conversion operator。
2)使用场景
- 防止单参数构造函数隐式转换
如果一个类的构造函数接受一个参数,而你并不希望通过隐式转换来创建这个类的实例,就应该在构造函数前加explicit。这也是它最主要的作用。
class Bar {
public:explicit Bar(int x) : value(x) {}
private:int value;
};Bar bar = 10; // 错误,无法隐式转换
- 防止conversion operator隐式转换
类中有时会定义一些转换操作符,但有些转换是需要显式调用的,这时也可以使用explicit。
class Double {
public:explicit operator int() const {return static_cast<int>(value);}
private:double value;
};Double d;
int i = d; // 错误,无法隐式转换
int j = static_cast<int>(d); // 正确,显式转换
-3)复杂构造函数
对于那些带有默认参数的复杂构造函数,explicit尤其重要,它们可能会被意外地调用。
class Widget {
public:explicit Widget(int x = 0, bool flag = true) : value(x), flag(flag) {}
private:int value;bool flag;
};
这种情况下,如果不加explicit,没有任何参数传递给构造函数也可能会进行隐式转换,引发难以察觉的错误。
十六、 C++中final关键字的作用?
16.1 回答重点
final关键字在C++11中引|入,它主要用于防止类被继承或防止虚函数被覆盖。
**1)防止类被继承:**当一个类被声明为final,这个类不能被进一步继承。
class Base final {// 类的实现
};// 下面的代码会导致编译错误
class Derived : public Base {// 类的实现
};
**2))防止虚函数被覆盖:**当一个虚函数被声明为final,这个虚函数在派生类中不能被重新定义。
class Base {
public:virtual void doSomething() final {// 函数实现}
};class Derived : public Base {
public:// 下面的代码会导致编译错误virtual void doSomething() override {// 函数实现}
};
16.2 扩展知识
通过final关键字,代码在设计初期就可明确意图,避免了不必要的继承操作和函数重写。
1)设计意图清晰:
使用final明确表明某些部分不应该被更改,有助于其他开发者理解类的设计意图,减少误用。
2)性能优化:
在某些情况下,编译器可以利用final的信息进行优化,例如在调用final函数时可以直接展开,减少虚函数调用的开销。
3)与其它C++11特性的结合使用:
override关键字:final通常和override关键字一起使用,可以显式指出该函数是覆盖基类中的某个虚函数,并且不允许再被派生类覆盖。
class Base {
public:virtual void displayMessage() const {std::cout << "Base class message" << std::endl;}
};class Derived : public Base {
public:void displayMessage() const override final {std::cout << "Derived class message" << std::endl;}
};// 下面的代码会导致编译错误
class MoreDerived : public Derived {
public:void displayMessage() const override {std::cout << "MoreDerived class message" << std::endl;}
};
十七、C++中野指针和悬挂指针的区别?
17.1回答重点
两者都可能导致程序产生不可预测的行为。但它们有明显的区别:
**1)野指针:**一种未被初始化的指针,通常会指向一个随机的内存地址。这个地址不可控,使用它可能会导致程序崩溃或数据损坏。
int *p;
std::cout<< *p << std::endl;
**2)悬挂指针:**一个原本合法的指针,但指向的内存已被释放或重新分配。当访问此指针指向的内存时,会导致未定义行为,因为那块内存数据可能已经不是期望的数据了。
int main(void) {int * p = nullptr;int* p2 = new int;p = p2;delete p2;
}
17.2 扩展知识:
展开说一说,弄清楚这两个概念不难,但如何避免和处理它们才是关键,这直接关系到写出更健壮的代码。
1)如何避免野指针:
**初始化指针:**在声明一个指针时,立即赋予它一个明确的数值,可以是一个有效的地址,也可以是 nullptr。
int *ptr = nullptr; // 初始化
**使用智能指针:**C++中的智能指针(如std::unique_ptr和std::shared_ptr)可以帮助自动管理指针的生命周期,减少手动管理的错误。
std::unique_ptr<int> ptr(new int(10));
2)如何避免悬挂指针
在删除对象后,将指针设置为nullptr,确保指针不再指向已经释放的内存。
delete ptr;
ptr = nullptr;
尽量使用智能指针,它们会自动处理指针的生命周期,减少悬挂指针的产生。
std::shared_ptr<int> ptr1 = std::make_shared<int>(10);
{std::shared_ptr<int> ptr2 = ptr1;// 当 ptr2 离开作用域后,资源仍然被 ptr1 管理
}
// 仍然可以使用 ptr1
3)检测工具
●静态分析工具(如Clang-Tidy、cppcheck)和动态分析工具(Valgrind、AddressSanitizer)可以帮助检测这些错误,确保代码质量。
十八、什么是内存对齐?为啥要内存对齐?
18.1 回答重点
内存对齐是指计算机在访问内存时,会根据一些规则来为数据指定一个合适的起始地址。
计算机的内存是以字节为基本单位进行编址,但是不同类型的数据所占据的内存空间大小是不一样的,在C++语言中可以用sizeof(来获得对应数据类型的字节数,一些计算机硬件平台要求存储在内存中的变量按照自然边界对齐,也就是说必须使数据存储的起始地址可以整除数据实际占据内存的字节数,这叫做内存对齐。
通常,这些地址是固定数字的整数倍。这样做,可以提高CPU的访问效率,尤其是在读取和写入数据时。
为什么要内存对齐?主要有以下几个原因:
**1)性能提升:**对齐的数据操作可以让CPU在一次内存周期内更高效地读取和写入,减少内存访问次数。
**2)硬件限制:**某些架构要求数据必须对齐,否则可能会引发硬件异常或需要额外的处理时间。
**3)可移植性:**代码在不同架构上运行时,遵从内存对齐规则可以减少潜在的问题。
**4)与缓存一致性相关:**内存对齐有时候还与缓存一致性联系在一起。CPU有自己的缓存系统,合
理的内存对齐往往能使缓存更高效地工作,减少cachemiss的概率。
5)结构体内存对齐
例子1:
假设32位系统,long占4个字节。
struct AlignA
{char a;long b;int c;
}
该结构体,占用内存为4+4+4=12个字节,这就是内存对齐的原因。
例子2:
struct AlignB
{int b;char c[10];double a;
};
6)成员变量布局调整
通过上面了解了结构体的内存对齐,是不是就可以想到,如果修改了结构体内变量的位置,就可以减少结构体的大小了。
十九、C++中四种类型转换的使用场景?
19.1 回答重点
在C++中,有四种常用的类型转换:
1) static_cast:
用于在有明确定义的类型之间进行转换,如基本数据类型之间的转换以及指针或引用的上行转换(从派生类到基类)。
2) dynamic_cast:
主要用于多态类型的指针或引l用转换,特别适用于需要安全地执行下行转换
(从基类到派生类)。
3) const_cast:
用于移除对象的const或添加const属性,这在需要更改常量数据时非常有用。
4) reinterpret_cast:
提供了一种最底层的转换方式,类似于C语言中的强转,通常用于指针类型
之间的转换。
19.2 扩展知识
我们详细地讨论下每种类型转换的更多细节和注意事项。
- static_cast:
**用法:**主要用于已知类型之间的转换,比如int转换为f1oat或者从派生类指针转换为基类指针。
示例:
int a = 10;
float b = static_cast<float>(a); // 将 int 转换为 float
2.dynamic_cast:
**用法:**主要用于多态基类,能够基于运行时类型信息将基类指针或引用转换为派生类指针或引用。
示例:
Base* basePtr = new Derived();
Derived* derivedPtr = dynamic_cast<Derived*>(basePtr); // 运行时检查并转换
**注意事项:**只有在类包含虚函数时才能使用dynamic_cast,而如果转换失败,指针会返回nullptr,引l用则会抛出std::bad_cast异常。
-
const_cast:
**用法:**移除或者添加常量修饰,通常在需要修改被标记为const的数据时使用。示例:
const int a = 10;
int* b = const_cast<int*>(&a); // 移除 const 属性
*b = 20; // 修改原本为 const 的值
**注意事项:**const_cast仅影响底层const属性,而并不影响顶层const。同样,修改原本为const的变量可能会引发未定义行为,应该谨慎使用。
4.reinterpret_cast:
**用法:**主要用于在几乎无关的类型之间进行转换,比如将指针类型转换为整型,或相反。这通常用于底层操作,比如硬件编程,或某些预期内的操作。
示例:
long p = 0x12345678;
int* i = reinterpret_cast<int*>(p); // 将 long 转换为 int 指针
**注意事项:**这种转换不会进行类型安全检查,可能会改变位模式,应尽量避免使用,除非确实需要进行底层操作。
二十、C++中volatile关键字的作用?
20.1 回答重点
在C++中,volatile关键字的主要作用是告知编译器某个变量可能会在程序的其他地方被改变,
防止编译器对这样的变量进行优化。保证对该变量进行的读写操作不会被编译器优化掉,每一次访
问都是实际发生的。
具体来说,volatile关键字会影响以下几个方面:
1)防止编译器对代码进行优化,确保每次读写都是直接从内存中操作,而不是使用寄存器中的值。
2)通常用于多线程编程中,表示变量可以被其他线程修改。
3)硬件编程中,如处理器寄存器,内存映射I/O等场景,常与硬件交互的变量声明为volatile。
20.2 扩张知识
下面来具体看看它在实际应用中的几个常见场景以及注意事项。
1) 硬件编程
在嵌入式系统开发中,volatile关键字比较常见。因为某些变量对应的是硬件寄存器或内存映射I/O,这些变量的值可能会随时发生变化。例如:
volatile int *uartStatusReg = (int *)0x4000; // 假设这是一个 UART 状态寄存器的内存地址while (!(uartStatusReg & 0x01)) {// 等待 UART 准备好
}
在这种情况下,不使用volatile关键字可能会导致编译器优化掉循环中的变量读取,导致我们的程序不能正确地工作。
2)注意事项
尽管volatile能确保每次访问时的数据一致性,但它并不能解决所有的问题。例如,volatile并不能保证线程间的同步问题。如果你需要更复杂的线程同步机制,应该考虑使用互斥锁(mutex)、条件变量等工具。另外,过度使用volatile也可能导致性能问题,因为每次都要直接访问内存。
二十一、什么是多态?简单介绍下C++的多态?
回答重点:
多态作为面向对象三大特征之一,指的是一个接口可以有多个不同的实现。
简单来说,就是同一个函数或方法调用,可以根据上下文的不同执行不同的功能。在C++中,多态主要通过基类的指针或引用,来调用子类的重写函数实现。
C++中的多态主要是通过虚函数来实现,以下为示例代码:
#include <iostream>
using namespace std;class Base {
public:virtual void show() {cout << "Base class show function" << endl;}
};class Derived : public Base {
public:void show() override {cout << "Derived class show function" << endl;}
};int main() {Base* basePtr;Derived derivedObj;basePtr = &derivedObj;basePtr->show(); // 输出:Derived class show functionreturn 0;
}
在这个例子中,通过基类指针basePtr调用了派生类Derived的show方法,这就是多态。
21.1 扩展知识
1)静态多态与动态多态
在C++中,多态分为静态多态和动态多态。静态多态通过函数重载和模板实现,而动态多态则是通过虚函数实现的。
**静态多态:**函数在编译时确定调用哪个函数,这种方式提高了效率,但是灵活性较差。
void print(int i) {cout << "Integer: " << i << endl;
}void print(double f) {cout << "Float: " << f << endl;
}template <typename T>
void print(T t) {cout << "Template: " << t << endl;
}
**动态多态:**函数在运行时确定调用哪个函数,这种方式更灵活但是效率较低。
2)虚函数表
为了实现动态多态,C++编译器会为每个包含虚函数的类生成一个虚函数表。虚函数表中存储了指向该类虚函数的指针。每个对象实例包含一个指向虚函数表的指针,通过这个指针来调用实际的函数实现。
3)纯虚函数和抽象类
如果一个类中有一个或者多个纯虚函数(即函数声明后面有=),这个类就称为抽象类,不能直接实例化。纯虚函数和抽象类的出现,是为了让基类只提供接口,而不提供具体实现。
class Shape {
public:virtual void draw() = 0; // 纯虚函数
};class Circle : public Shape {
public:void draw() override {cout << "Drawing a circle" << endl;}
};
4) 接口的分离与设计模式
在实际开发中,多态的思想常常用于设计灵活的系统结构。像策略模式、工厂模式等设计模式,都是基于多态思想来解决复杂的系统设计问题的。
二十二、C++中虚函数的原理?
22.1 回答重点
虚函数是C++中实现多态的一个关键机制。简单来说,虚函数允许你在基类里通过virtua1声明一个函数,然后在派生类里对其进行重新定义。
通过使用虚函数,C++可以根据对象的实际类型(而不是引用或指针的静态类型)调用派生类的函
数实现。实现虚函数的关键在于虚函数表(vtable)和虚函数表指针(vptr)。
每个含有虚函数的类都有一张虚函数表,表中存有该类的虚函数的地址。每个对象都有一个虚函数表指针,指向这个类的虚函数表。当调用虚函数时,程序会通过对象的虚函数表指针找到相应的虚函数地址,然后进行函数调用。
22.2 扩展知识
1)虚函数的实现原理:
**虚函数表(vtable):**是一个存储虚函数地址的数组。每个包含虚函数的类会有一个虚函数表。表里存有该类或者基类中重写虚函数的实际地址。
**虚函数表指针(vptr):**每个对象在内存布局中会有一个指向虚函数表的指针。编译器会自动管理这个指针的初始化和赋值。
2)多态的实现:
虚函数是实现多态的一种手段,允许程序在运行时决定调用哪个类的函数,实现动态绑定。当基类指针或引用指向派生类对象时,调用虚函数会根据实际对象类型选择合适的函数实现,下面是示例
代码:
class Base {
public:virtual void show() {cout << "Base class show" << endl;}
};class Derived : public Base {
public:void show() override {cout << "Derived class show" << endl;}
};// 用例
Base *b;
Derived d;
b = &d;
b->show(); // 将会调用 Derived 的 show 方法
3)注意点:
虚函数的调用比普通函数多了一个vtable查找过程,运行时略有开销。
析构函数如果需要在派生类中被正确的调用,应该声明为虚函数。
4)虚函数和纯虚函数:
如果类中包含纯虚函数,那么这个类就是一个抽象类,不能实例化,只能被继承。
class Abstract {
public:virtual void pure_virtual_func() = 0; // 纯虚函数
};
5)常见误区:
静态绑定的成员函数(static关键字)不能是虚函数。
二十三、C++中构造函数可以是虚函数吗?
23.1 回答重点
构造函数不能是虚函数。
虚函数的机制依赖于虚函数表,而虚表对象的建立需要在调用构造函数之后才能完成。因为构造函
数是用来初始化对象的,而在对象的初始化阶段虚表对象还没有被建立,如果构造函数是虚函数,
就会导致对象初始化和多态机制的矛盾,因此,构造函数不能是虚函数。
23.2 扩展知识
1)析构函数可以是虚函数
虽然构造函数不能是虚函数,但是析构函数应当是虚函数,特别是在基类中。这样做的目的是为了确保在删除一个指向派生类对象的基类指针时,能正确调用派生类对象的析构函数,从而避免资源泄露。
2)其他特殊成员函数也不是虚函数
除了构造函数外,静态成员函数和友元函数也不能是虚函数。静态成员函数与类而不是与某个对象相关联,而友元函数则不属于类的成员函数,它们不具备多态性所需的对象上下文。
3)解决方案
如果需要在对象创建时实现多态性,可以考虑工厂模式等设计模式来间接实现多态性。这些设计模式可以通过一些间接的手段,在对象创建过程中提供多态行为。
23.3 问题:基类的析构函数必须要写成虚函数吗?
不需要将基类析构函数设为虚函数的情况
当你不打算通过基类指针或引用去删除派生类对象时,基类的析构函数无需是虚函数。例如下面的代码:
#include <iostream>class Base {
public:~Base() {std::cout << "Base destructor" << std::endl;}
};class Derived : public Base {
public:~Derived() {std::cout << "Derived destructor" << std::endl;}
};int main() {Derived d;return 0;
}
在这个例子中,Derived
对象 d
是直接被创建和销毁的,并非通过基类指针。所以在对象销毁时,会先调用 Derived
的析构函数,再调用 Base
的析构函数,不会出现资源泄漏问题。
需要将基类析构函数设为虚函数的情况
当你使用基类指针或引用指向派生类对象,并且通过该基类指针或引用删除对象时,就需要把基类的析构函数定义为虚函数。不然,只会调用基类的析构函数,派生类的析构函数不会被调用,进而造成资源泄漏。以下是示例代码:
#include <iostream>class Base {
public:virtual ~Base() {std::cout << "Base destructor" << std::endl;}
};class Derived : public Base {
public:~Derived() {std::cout << "Derived destructor" << std::endl;}
};int main() {Base* ptr = new Derived();delete ptr;return 0;
}
在这个例子里,Base
的析构函数被声明为虚函数。当通过基类指针 ptr
删除 Derived
对象时,会先调用 Derived
的析构函数,再调用 Base
的析构函数,从而确保所有资源都能被正确释放。
综上所述,若要通过基类指针或引用删除派生类对象,基类的析构函数应设为虚函数;反之,则无需设置。
23.4 问题: 基于上面问题,我把所有的基类析构函数都写成虚函数,可行?
在大多数情况下,把基类的析构函数都写成虚函数是可行的,但这也并非毫无弊端,下面为你详细分析其优缺点。
优点
- 避免资源泄漏:当通过基类指针或引用删除派生类对象时,若基类析构函数为虚函数,那么在删除对象时会先调用派生类的析构函数,再调用基类的析构函数,能确保派生类中动态分配的资源(如堆内存、文件句柄等)被正确释放,避免资源泄漏。
- 代码的一致性与可维护性:无论后续是否会通过基类指针删除派生类对象,统一将基类析构函数设为虚函数可以让代码更加一致,也减少了后续维护时因忘记设置虚析构函数而导致问题的可能性。
缺点
- 性能开销:虚函数的实现依赖于虚函数表(VTable)和虚表指针(VPTR)。每个包含虚函数的类对象都会额外包含一个虚表指针,这会增加对象的内存开销。并且,调用虚函数时需要通过虚表指针查找虚函数表,进而确定要调用的函数地址,这会带来一定的性能损耗,尽管在现代计算机中这种损耗通常较小。
- 增加代码复杂度:虚函数机制会使代码变得更加复杂,尤其是在处理多继承等复杂的继承结构时。这可能会增加理解和调试代码的难度。
总结
如果你的类是作为基类使用,且存在通过基类指针或引用删除派生类对象的可能性,或者你不确定后续是否会有这样的需求,那么将基类析构函数写成虚函数是一个不错的选择。但如果你的类不会作为基类使用,或者明确不会通过基类指针或引用删除派生类对象,那么将析构函数设为虚函数就没有必要,反而会带来不必要的开销。
23.5 问题:友元函数的作用?
在 C++ 里,友元函数是一种特殊的函数,它虽然不是类的成员函数,却能访问该类的私有和保护成员。下面为你详细介绍友元函数的定义、语法、特点和使用示例。
定义和语法
要定义一个友元函数,需要在类的定义里使用 friend
关键字对函数进行声明。声明可以置于类的公有、私有或者保护部分,这对友元函数的访问权限并无影响。
以下是友元函数的基本声明语法:
class ClassName {// 类的成员friend return_type function_name(parameters);// 类的其他成员
};
在上述代码中:
ClassName
指的是类的名称。return_type
是友元函数的返回类型。function_name
是友元函数的名称。parameters
是友元函数的参数列表。3
特点
- 访问权限:友元函数能够访问类的私有和保护成员,这打破了类的封装性原则。不过,合理运用友元函数可以在某些情况下提升代码的灵活性与效率。
- 非成员函数:友元函数并非类的成员函数,所以它没有
this
指针。调用友元函数时,不能通过对象来调用,而是要像调用普通函数那样进行调用。 - 声明位置:友元函数的声明可以放在类的任何访问区域(公有、私有或保护),其访问权限不会受到声明位置的影响。
使用示例
下面通过一个示例来展示友元函数的使用:
#include <iostream>class Rectangle {
private:double length;double width;public:Rectangle(double l, double w) : length(l), width(w) {}// 声明友元函数friend double calculateArea(const Rectangle& rect);
};// 定义友元函数
double calculateArea(const Rectangle& rect) {return rect.length * rect.width;
}int main() {Rectangle rect(5.0, 3.0);double area = calculateArea(rect);std::cout << "矩形的面积是: " << area << std::endl;return 0;
}
在这个例子中:
Rectangle
类包含私有成员length
和width
。- 在
Rectangle
类中使用friend
关键字声明了calculateArea
函数为友元函数。 calculateArea
函数定义在类外部,它可以访问Rectangle
类的私有成员length
和width
,用于计算矩形的面积。- 在
main
函数中,创建了一个Rectangle
对象rect
,并调用calculateArea
函数计算其面积。
需要注意的是,友元函数虽然提供了访问类私有成员的便利,但也会破坏类的封装性。因此,在使用友元函数时,要谨慎考虑,仅在必要的情况下使用。
二十四、C++中析构函数一定要是虚函数吗?
24.1回答重点
C++中析构函数并不一定要是虚函数,但在多态条件下,我是建议一定将其声明为虚函数。
如果一个类可能会被继承,并且你需要在删除指向派生类对象的基类指针时确保正确调用派生类的析构函数,那么基类的析构函数必须是虚函数。如果没有这样做,可能会导致资源泄漏或者未能正确释放派生类对象的资源。
24.2 扩展知识
1)虚函数的机制及其应用场景
当一个类的成员函数被声明为虚函数时,C++会为该类生成一个虚函数表,这个表存储指向虚函数的指针。在运行时,基于当前对象的实际类型,虚函数表指针用于动态绑定,调用正确的函数版本。
场景:假设有一个基类Base和一个派生类Derived:
class Base {
public:virtual ~Base() { std::cout << "Base destructor" << std::endl; }
};class Derived : public Base {
public:~Derived() { std::cout << "Derived destructor" << std::endl; }
};
如果基类的析构函数不是虚函数,那么以下代码可能会产生问题:
Base *obj = new Derived();
delete obj; // 只调用了 Base 的析构函数,可能导致内存泄漏或未释放资源
2)默认情况下析构函数的行为
如果基类的析构函数不是虚函数,当你通过基类指针删除派生类对象时,只会调用基类的析构函数,而不会调用派生类的析构函数。这种情况下,派生类中分配的资源可能不会及时释放,导致资源泄漏。
3)什么时候不需要虚析构函数
如果一个类不设计为基类,或者不会通过基类指针删除派生类对象,那么就不需要将析构函数声明为虚函数。
二十五、什么场景用到移动构造函数和移动赋值运算符?
25.1 回答重点
移动构造函数和移动赋值运算符主要用于搭配移动语义来提高性能,特别是在需要转移大对象资源
的时候。具体场景包括:
1)当函数返回一个对象时,用移动构造函数可以避免返回值拷贝。
2)当函数传递参数时,使用右值+移动构造函数可以避免参数拷贝。
3)当需要将一个大对象从一个容器(如vector)移动到另一个容器时,用移动赋值运算符可避免
重复的资源分配和释放。
25.2 扩展知识
理解右值引用和std::move有助于我们更好地掌握移动构造函数和移动赋值运算符的用法。
1)右值引用:
右值引用是用两个连续的&&符号表示的。在函数声明中,如果参数是右值引用,那么它表示这个参数可以接受右值而不是左值(即只能绑定到临时对象或是要被销毁的对象)。
例如:
void foo(std::string&& str);
这个函数foo只能接受右值std::string。
2)标准库中的std:: move:
std::move函数可以将一个左值显式地转换成右值引用,这样就可以调用移动构造函数或者移动赋值运算符。例如:
std::string s1 = "Hello";
std::string s2 = std::move(s1); // 调用移动构造函数
在这段代码中,通过std::move,s1被转换成右值引l用,s2调用了std::string的移动构造函
数,将资源从s1转移到s2,而原的s1将只剩下无效的状态。
3)实现移动构造函数和赋值运算符:
一般来说,移动构造函数和移动赋值运算符的实现需要遵循两个步骤:
1.通过std::move或右值引l用转移资源所有权。
2.将源对象置于有效但未指定的状态,确保原对象的析构函数能够安全地调用。
class MyClass {
public:MyClass(MyClass&& other) noexcept {// 转移资源this->ptr = other.ptr;other.ptr = nullptr; // 确保 other 处于有效状态,但未指定}MyClass& operator=(MyClass&& other) noexcept {if (this != &other) {delete this->ptr; // 释放当前对象资源this->ptr = other.ptr;other.ptr = nullptr; // 确保 other 处于有效状态}return *this;}
private:int* ptr;
};
通过这段代码,我们可以看到如何使用移动构造函数和移动赋值运算符来转移资源,而不是执行深拷贝,可以明显提高了代码的效率。
二十六、什么是C++中的虚继承?
26.1 回答重点:
虚继承主要用于解决“菱形继承”问题,避免多个派生类继承自同一个基类时带来的重复和冲突。
具体来讲:当一个类通过多个派生类多继承同一个基类时,虚继承确保基类的成员只有一个实例,而不是生成多个冗余实例。
26.2 扩展知识
1)菱形继承问题:
这是虚继承主要用来解决的问题。假设有四个类A、B、C和D,其中B和C都继承自A,而D又同时继承自B和C。这样就会形成一个“菱形”结构。若不使用虚继承,D类中会包含两个独立的A类子对象,导致冗余。
2)虚继承的语法:
在派生类声明时,使用virtual关键字表示虚继承。
class A { /* 基类 */ };
class B : virtual public A { /* 派生自A */ };
class C : virtual public A { /* 派生自A */ };
class D : public B, public C { /* 派生自B和C */ };
这样,类D中就只有一个A类子对象,避免数据冗余。
3)继承结构的内存布局:
虚继承会影响类的内存布局。为了管理菱形结构中的单一基类实例,编译器会增加额外的指针控制块,使类的内存结构变得复杂。因此,使用虚继承一般会有一些额外的空间开销。
4)解决问题的核心:
虚继承的核心在于确保派生类中,基类仅有一个实例。因此,当涉及到访问基类成员时,不会产生歧义。
5)实践应用及注意事项:
在实际编程中,如果需要使用虚继承,一般建议明确地设计类的结构,避免不必要的复杂继承关系,尽量避免虚继承。
二十七、什么是C++的函数重载?它的优点是什么?和重写有什么区别?
27.1 回答重点
在C++中,函数重载是指在同一个作用域内允许存在多个同名函数,它们的参数个数不同或者参数类型不同,注意函数的返回类型不同不能算作重载。
函数重载的优点是:
1.增强了代码的可读性。使用同名的函数,而不用为不同的功能选择完全不同的函数名,程序员可以更直观地理解代码。
2.改善了程序的可维护性。函数重载让我们可以定义一个通用接口,让同名函数实现不同的功能,减轻了函数命名的负担。
函数重载和函数重写(覆盖)的区别:
1.函数重载可以发生在同一个类中,而函数重写发生在继承关系的子类中。
2.函数重载要求参数列表必须不同,而函数重写要求方法签名(包括参数列表和返回类型)必须与父类方法一致。
3.函数重载在编译时决定调用哪个函数(静态绑定),而函数重写在运行时决定调用哪个函数(动态绑定)。
27.2 扩展知识
- **overload(重载):**将语义相近的几个函数用同一个名字表示,但是参数列表(参数的类型,个数,顺序不
同)不同,这就是函数重载,返回值类型可以不同 ; 特征:相同范围(同一个类中)、函数名字
相同、参数不同、virtual关键字可有可无
下面是函数重载的实例代码:
#include <iostream>
#include <string> // 重载print函数以打印整数
void print(int i) { std::cout << "Printing int: " << i << std::endl;
} // 重载print函数以打印浮点数
void print(double f) { std::cout << "Printing float: " << f << std::endl;
} // 重载print函数以打印字符串
void print(const std::string& s) { std::cout << "Printing string: " << s << std::endl;
} // 重载print函数以打印字符
void print(char c) { std::cout << "Printing char: " << c << std::endl;
} int main() { print(5); // 调用打印整数的print print(500.263); // 调用打印浮点数的print print("Hello"); // 调用打印字符串的print print('A'); // 调用打印字符的print return 0;
}
- **override(重写):**派生类覆盖基类的虚函数,实现接口的重用,返回值类型必须相同; 特征:不同范围
(基类和派生类)、函数名字相同、参数相同、基类中必须有virtual关键字(必须是虚函数)
- **overwrite(重定义):**派生类屏蔽了其同名的基类函数,返回值类型可以不同; 特征:不同范围(基类和派
生类)、函数名字相同、参数不同或者参数相同且无virtual关键字
#include <iostream>class Base {
public:void func() { std::cout << "Base::func()" << std::endl; }void func(int x) { std::cout << "Base::func(int): " << x << std::endl; }
};class Derived : public Base {
public:using Base::func; // 引入基类的所有func()重载// 隐藏基类的func(int),但返回类型不同(这里假设你笔误,实际参数应匹配)void func(double x) { std::cout << "Derived::func(int): " << x << std::endl; }
};int main() {Derived d;d.func(10); // 调用Derived::func(int)d.Base::func(10); // 显式调用Base::func(int)// d.func(); // 错误:基类的func()被隐藏return 0;
}
函数重载在实际应用中非常广泛,比如标准库中的std::cout就是很多重载的运算符<实现的,使我们可以打印不同类型的变量。而且,C++STL(标准模板库)中的许多算法和容器类方法如:std::sort和std::vector也采用了重载函数,以此来处理不同类型的输入。
除了函数重载和重写外,C++还支持运算符重载,允许对用户自定义的类型重载内置运算符,从而使自定义类型的使用和内置类型一样直观便捷。这个特性在实现复杂数据结构(如矩阵、复数等)时特别有用,使代码更易读、更自然。
二十八、什么是C++的运算符重载?
28.1 回答重点
通过运算符重载,我们可以定义如何使用运算符(如+,-,*,/,==等)来操作你的自定义类型。
例如,假设有一个表示复数的类Complex,我们可以重载+运算符,使其能够方便地对复数进行相加:
class Complex {
public:Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) {}// 重载加号运算符Complex operator+ (const Complex& other) const {return Complex(real + other.real, imag + other.imag);}private:double real;double imag;
};
这样,下面的代码就可以正常工作:
Complex a(1.0, 2.0);
Complex b(3.0, 4.0);
Complex c = a + b; // 使用重载的+运算符
28.2 扩展知识
运算符重载要遵循一些基本规则和注意事项:
**1)只能重载已有的运算符,不能创建新的运算符。**就像上面的例子中,+运算符可以重载,但不能创建一个新的比如**运算符。
**2)不能改变运算符的优先级、结合性和内在语义。**重载只是改变运算符的操作对象和行为,但它们在表达式中的优先级和结合性是固定的。此外,重载的运算符不能改变它们在基本类型上的预期行为。例如,你不能让+运算符变成减法。
**3)有些运算符不能被重载。**比如(成员访问运算符),?:(三元条件运算符)等都不能被重载。但是大多数运算符合适当的规定都可以重载。
**4)运算符重载可以作为成员函数或者全局函数。**作为成员函数时,第一个参数隐含为this指针。如果你需要对两个不同类型的对象进行操作,通常会使用全局函数形式进行重载。同时,全局函数形式需要友元声明来访问类的私有成员。
class Complex {
public:Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) {}// 声明为友元函数,使其访问私有成员friend Complex operator+ (const Complex& lhs, const Complex& rhs);private:double real;double imag;
};// 作为全局函数重载 + 运算符
Complex operator+ (const Complex& lhs, const Complex& rhs) {return Complex(lhs.real + rhs.real, lhs.imag + rhs.imag);
}class MyClass {
private:int value;
public:MyClass(int v) : value(v) {}friend MyClass operator+(const MyClass& a, int b); // 友元声明
};MyClass operator+(const MyClass& a, int b) {return MyClass(a.value + b);
}
**5)运算符重载要确保符合逻辑和直观,**以免造成困惑。例如,== 运算符通常用于比较对象是否相等,那么它的返回值应该是一个布尔值。
二十九、struct 和 class的区别?
29.1 回答重点
在C++中,struct和class的主要区别就在于它们的默认访问级别:
1)struct的默认成员访问级别是public。
2)class的默认成员访问级别是 private。
29.2 扩展知识
虽然默认访问级别是struct和class之间的主要区别,但在实际编程中,还有一些方面也值得注意:
1)内存布局和性能:
- 在大多数情况下,struct和class的内存布局是一样的,因为它们本质上除了访问级别之外,其他都是相同的。
- 在访问级别相同的情况下,二者的性能并无区别。
2)习惯用法:
- struct一般用于表示简单的数据结构或POD(PlainO1dData)类型。POD类是所有非静态数据成员共享相同访问级别、没有虚函数、没有继承的类。
- class一般用于表示复杂的数据类型,特别是在对象需要封装和抽象,以及需要使用功能性的成员函数时。
3)继承模型:
- 类可以有继承关系,通过private、protected和 public指定继承的访问级别,默认是private继承。
- 结构体同样可以有继承关系,默认是public继承。
- 上述特指在C++中,在C语言中的struct是没有继承能力的。
4)编程风格:
- 代码风格和团队规范可能对struct和c1ass的使用有明确的指示。例如,在一个面向对象的项目中,使用class定义所有对象可能更为规范,而struct则更多地用于数据传输对象(DTO)。
5)友元函数:
- 友元函数或友元类在struct和class中都适用,用法完全一样,唯一的区别是,如果你在struct里通常不需要那么多封装,在这种情况下友元可能用的不多。
三十、C++ 中 struct和union的区别?如何使用union做优化?
30.1 回答重点
struct和union二者有以下主要区别:
1)存储方式:struct中的所有成员变量各自占据独立的内存空间,而union中的所有成员变量公用同一块内存空间,且大小是最大成员的大小。
2)访问方式:struct的所有成员变量可以同时存在并被访问,而union每次只能有一个成员变量有效,如果在一个成员值改变后访问另一个成员,结果不可预知。
**3)用途:**struct一般用于逻辑上关联的不同数据存储,而union通常用于节省内存空间,作为一个优化项使用。例如在嵌入式系统或资源有限的场景中。通过让变量共用内存,可以减少内存消耗。很多底层库为了性能极致,也会使用union,我们如果开发业务层代码,建议直接使用struct,好用且不容易出bug。
30.2 扩展知识
举个简单的例子说明struct和union的区别:
struct例子:
struct MyStruct {int intVal;float floatVal;char charVal;
};MyStruct s;
s.intVal = 10;
s.floatVal = 3.14f;
s.charVal = 'A';
在这个struct例子中,MyStruct的每个成员intVal、floatVal和charVal各自都有独立的内存空间,并且它们可以独立存储和访问。
union例子:
union MyUnion {int intVal;float floatVal;char charVal;
};MyUnion u;
u.intVal = 10;
std::cout << "intVal: " << u.intVal << std::endl;u.floatVal = 3.14f;
std::cout << "floatVal: " << u.floatVal << std::endl;u.charVal = 'A';
std::cout << "charVal: " << u.charVal << std::endl;
在这个union例子中,MyUnion的所有成员intVal、floatVal和charVal共享同一块内存空间。每次只能一个成员变有效,其他成员的值会被覆盖。
使用union优化的例子
在某些情况(比如解析网络数据包,或内存有限的嵌入式开发),我们可以利用union来节省内存。在下例中,我们假设一个数据包可以包含整数、浮点数或字符数组中的其中一种:
struct DataPacket {int type;union {int intData;float floatData;char charData[4];} data;
};DataPacket packet;// 用于指示数据类型
packet.type = 0; // 0 表示整数,1 表示浮点数,2表示字符数组
packet.data.intData = 10;
std::cout << "intData: " << packet.data.intData << std::endl;// 再更改为浮点数数据
packet.type = 1;
packet.data.floatData = 5.5;
std::cout << "floatData: " << packet.data.floatData << std::endl;
在这一例子中,通过使用一个结构体中的union来表示多种类型的数据,我们可以动态切换数据类型,同时节省内存开销。
注意事项
1)使用union时要特别小心,不要在不确定哪个成员变量有效的情况下访问那个成员。
2)在真实项目中,最好用enum来标记union当前的有效类型以避免错误。
三十一、C++ 中 using 和 typedef 的区别?
31.1 回答重点
using在C++11中引I入,using和typedef都可以用来为已有的类型定义一个新的名称。最主要的区别在于,using可以用来定义模板别名,而typedef不能。
1)typedef主要用于给类型定义别名,但是它不能用于模板别名。
typedef unsigned long ulong;
typedef int (*FuncPtr)(double);
2)using可以取代typedef的功能,语法相对简洁。
using ulong = unsigned long;
using FuncPtr = int (*)(double);
3)对于模板别名,using显得非常强大且直观。
template<typename T>
using Vec = std::vector<T>;
总之,更推荐使用using,尤其是当你处理模板时。
31.2 扩展知识
**1)模板别名(TemplateAliases):**using在处理模板时,如定义容器模板别名,非常方便。假如我们需要一个模板类std::vector的别名:
template<typename T>
using Vec = std::vector<T>;
Vec<int> vecInt; // 相当于 std::vector<int> vecInt;
2)作用范围:using还可以用于命名空间引l入,typedef没有此功能。
namespace LongNamespaceName {int value;
}using LNN = LongNamespaceName;
LNN::value = 42; // 相当于 LongNamespaceName::value
**3)可读性与调试:**using相对typedef更易读。
typedef void (*Func)(int, double);
using FuncAlias = void(*)(int, double);
在这个例子中,using显然定义和解释都更加直观。
**4)现代C++代码规范:**在C++11之后,许多代码规范建议优先使用using而不是typedef。这证明了在实际应用和代码维护中,using更具有优势。
三十二、 enum 和 enum class的区别?
32.1 回答重点
在C++中,enum和enumclass(也叫做强类型枚举)主要的区别在于作用域和类型安全。
1)作用域:
- enum:枚举成员是直接进入包含它的作用域(也就是说,在定义枚举后,你可以直接使用枚举成员,而不需要前缀)。
- enum class:枚举成员只能通过显式地指定它们的枚举类型来访问(即使用枚举名作为前缀,类似于作用域解析)。
2)类型安全:
- enum:传统枚举类型不安全,枚举成员会隐式转换为整数类型。
- enum class:强类型枚举是类型安全的,不能隐式转换为其他类型,必须显式转换。
举个例子:
// 传统枚举
enum Color {RED,GREEN,BLUE
};// 强类型枚举
enum class ColorClass {RED,GREEN,BLUE
};// 使用示例
int main() {// 对于传统枚举Color c = RED; // 直接访问,不需要前缀int value = GREEN; // 可能的隐式转换// 对于强类型枚举ColorClass cc = ColorClass::RED; // 需要前缀// int value = ColorClass::GREEN; // 错误,不能隐式转换,需要显式转换return 0;
}
32.2 扩展知识
了解了enum和enum class的基本区别,我们可以深入一点谈谈它们在实际应用中的一些注意事
项。
1)选择使用哪种枚举:
- 如果你的枚举类型是为了兼容C语言代码或需要与旧系统交互,那么使用传统的enum可能会比较方便。
- 如果你的枚举需要更好的类型安全和作用域控制,并且编译器支持C++11及以上版本,那enum class是更好的选择。
2)枚举的底层类型:
-
在传统的enum中,内置类型通常是int,但这个在不同编译器实现中可能会有所不同。
-
强类型枚举允许你明确指定底层类型,例如:
enum class ColorClass : unsigned int {RED,GREEN,BLUE };
3)继承性和单一职责:
- 强类型枚举还有一个优点是更加容易维护和扩展。如果你的枚举值大量增加或改变时,强类型枚举能更好地避免命名冲突。
三十三、C++ 中 new 和 malloc 的区别? delete 和 free 的区别?
33.1 回答重点
在C++中,new和malloc以及delete和free是内存管理的两对主要操作符和函数。它们虽
然都有分配和释放内存的功能,但在很多方面都有区别。
1)new vs malloc :
- new是C++的操作符,而malloc是C标准库的函数。
- new分配内存并调用构造函数,而malloc仅仅分配内存,不调用构造函数。
- new返回一个类型安全的指针,而malloc返回void*,需要显式类型转换。
- new在分配失败时抛出std::bad_alloc异常,而malloc返回NULL。
2)deletevs free:
- delete是C++的操作符,而free是C标准库的函数。
- delete销毁对象并调用析构函数,然后释放内存,而free仅仅释放内存,不调用析构函数。
- delete必须与 new配对使用,而 free必须与 malloc配对使用。
- delete和delete[]是不同的,前者用于单一对象,后者用于数组。free没有这种区分。
33.2 扩展知识
1)更多关于new和 malloc的不同:
-
异常处理:在new语句中,如果内存分配失败,会抛出std::bad_alloc异常,你可以使用try-catch块处理这个异常。相比之下,malloc返回NULL值,需要程序员手动检查并处理。
-
**类型兼容:**new更适合C++中的类对象,因为它自动调用构造函数进行初始化,而
malloc更适合简单的数据类型或C风格编程。
2)更多关于delete和free的不同:
- **使用安全性:**使用delete时,不会像free那样导致未定义行为,因为它会调用析构函数来清理对象。在涉及复杂对象管理时,这种自动调用析构函数的特性非常有用。
- **灵活性和匹配:**使用不同类型的delete操作符(delete和delete[])来区分释放单个对象和对象数组。free函数则没有这种灵活性。
3)开发建议:
- 建议尽量使用智能指针(如std::unique_ptr和std::shared_ptr),它们可以自动管理内存,减少内存泄漏和其他潜在的内存管理问题。
三十四、C++中类定义中delete关键字和default关键字的作用?
34.1 回答重点:
两者都用于控制类的行为。
- delete关键字用来禁用某些默认的成员函数。主要的作用就是禁用拷贝构造函数和拷贝赋值运算符,如下例:
class MyClass {
public:MyClass() = default; // 使用默认构造函数MyClass(const MyClass&) = delete; // 禁用拷贝构造函数MyClass& operator=(const MyClass&) = delete; // 禁用拷贝赋值运算符
};
- default关键字用于显式地指示编译器为某个成员函数生成默认的实现。它经常用在构造函数、析构函数,以及拷贝构造函数上。
class MyClass {
public:MyClass() = default; // 使用默认构造函数~MyClass() = default; // 使用默认析构函数MyClass(const MyClass&) = default; // 使用默认拷贝构造函数MyClass& operator=(const MyClass&) = default; // 使用默认拷贝赋值运算符
};
34.2 扩展知识
使用delete关键字的高级操作:
除了可以禁用特定的默认成员函数,delete还可以用来禁用某些传统函数的重载。
例如,你可能不希望一个整数被隐式地转换为你的类类型。
class MyClass {
public:MyClass(int value) = delete; // 禁用带一个整数参数的构造函数
};
结合 delete 和default 的构造更安全的类:
通过合理地组合delete和default,你可以更好地控制类的行为和接口,防止编写不安全的代码。
class NonCopyable {
public:NonCopyable() = default; // 默认构造函数NonCopyable(const NonCopyable&) = delete; // 禁用拷贝构造函数NonCopyable& operator=(const NonCopyable&) = delete; // 禁用拷贝赋值运算符
};
C++中的unique_ptr的原理就是使用delete禁用了拷贝构造函数和拷贝赋值运算符。
三十五、C++中this指针的作用?
35.1 回答重点
this指针是一个隐含在每一个非静态成员函数中的指针。它指向的是调用成员函数的那个对象的地
址。主要作用包括:
- 访问类的成员变量和成员函数,特别是当局部变量与成员变量同名时,用this指针可以明确的区分出来。
- 2.链式调用:可以通过返回*this来支持链式调用。
- 3.动态绑定:在基类指针或引l用调用派生类对象时,利用this指针可以直观的实现动态绑定。
35.2 扩展知识
以下为示例代码:
1.访问类成员变量和成员函数:
class Box {
private:double length;public:Box(double length) {this->length = length; // 用this指针区分成员变量和构造函数参数}void setLength(double length) {this->length = length; // 同样在成员函数中区分成员变量和参数}double getLength() {return this->length; // 使用this指针访问成员变量}
};
**2.链式调用:**通过返回*this指针,可以实现链式调用:
class Box {
private:double length;public:Box& setLength(double length) {this->length = length;return *this; // 返回对象本身的引用}void display() {std::cout << "Length: " << length << std::endl;}
};int main() {Box box;box.setLength(5.0).display(); // 链式调用return 0;
}
**3.动态绑定:**在基类函数中使用this指针调动派生类对象,示例代码如下:
class Base {
public:virtual void show() {std::cout << "Base show" << std::endl;}void display() {this->show(); // 调用的是实际对象的show()方法}
};class Derived : public Base {
public:void show() override {std::cout << "Derived show" << std::endl;}
};int main() {Derived d;Base *b = &d;b->display(); // 尽管指针是Base类的,但调用的是Derived的show()方法return 0;
}
三十六、C++ 中可以使用delete this吗?
36.1 回答重点
可以使用delete this,但是必须非常谨慎,因为滥用可能会导致未定义行为。delete this的主要作用是允许对象在其成员函数中自行销毁。但这对程序员的要求很高,你需要明确知道这会产生什么样的影响。
一般建议普通开发者不要使用delete this,因为日常业务开发中,几乎不需要这样使用。起码笔者十数年的编程生涯中,只见过标准库中这样使用,自已编写业务代码时还没有这样使用过。
36.2 扩展知识
1)使用场景:
- delete this通常用于一些特殊场景,比如对象的生命周期严格受其方法控制。
- 一些特定类型的设计模式,如引l用计数机制的智能指针管理,shared_ptr源码中有这样使用。
2)前提条件:
- 当前的对象必须是通过new分配的(不能是栈上分配或通过其他方式分配)。
- 在调用deletethis后,应该保证对象的成员函数或者成员变量不会再被访问。
3)风险和问题:
- **双重删除风险:**如果在使用deletethis后,指向这个对象的其他指针再试图进行删除操作,会引起未定义行为(比如双重删除)。
**成员函数调用:**在对象使用deletethis后,任何对该对象的成员函数调用都是未定义行为。
4)替代方案:
- 使用智能指针(如std::shared_ptr或std::unique_ptr)更安全地管理对象的生命周期,避免手动管理的潜在问题。
- 更好的设计模式或类结构来避免让对象自行删除自己的需求。
三十七、C++ 中 vector的原理? resize 和 reserve 的区别是什么?size 和 capacity的区别?
37.1 回答重点
vector是一个动态数组,它可以根据需要进行自动伸缩。这里的关键词是动态数组,动态两字很关
键。
内部实现上,vector通过一个指向连续内存的指针来管理对应元素,并根据需要动态扩容,分配内
存来满足容量需求。
1)resize 和 reserve 的区别:
- resize(n):调整vector的大小为n。如果n大于当前大小,会向vector末尾添加值初始化的新元素;如果n小于当前大小,会删除超出部分的元素。如果n大于capacity,会自动扩容,满足容量需求。
- reserve(n):预分配内存,确保vector可以存储n个元素,但不改变vector的当前大小。适用于在已知将要添加大量元素的情况下进行预分配,以避免频繁重新分配内存。
2)size和 capacity 的区别:
- size:vector中当前包含的元素数量。上面的resize(n),会改变size大小。
- capacity:vector当前分配的内存能够容纳的最大元素数量。上面的reserve(n),会改变capacity 大小。
37.2 扩展知识
1)内存管理和扩容策略:
- 当vector需要增加容量时,它通常会以一定的倍数进行扩容(例如,1.5倍或2倍,Windows和Linux扩容策略可能不相同),以减少频繁的内存分配频率,从而提高性能。
- 每次扩容时,会分配一块新的更大的内存,并将旧数据复制到新内存块,然后释放旧的内存块。
2)时间复杂度:
- 访问元素:通过下标随机访问,时间复杂度为O(1)。
- 插入和删除:在末尾插入删除元素的时间复杂度平均为O(1),在任意位置插入删除元素的时间复杂度为O(n)。
- 扩容:内存重新分配和数据复制的时间复杂度为O(n)。
3)使用场景的考虑:
- resize常用于需要明确vector大小的情况,例如初始化固定大小数组,并可能进行后续操作。
- reserve适用于大数据量的预分配场景,例如一次性插入大量数据,以减少多次扩容所导致的性能损耗。
4)容量缩减:
- 使用shrink_to_fit()函数可以将vector的容量缩减到当前大小,释放未使用的内存,但这仅是一个请求,编译器可能选择忽略这个操作。个人认为,平时编程无需使用此方法。
三十八、deque的原理?它内部是如何实现的?
38.1 回答重点
deque,double-ended queue,是个双端队列,是一个sTL容器,通过deque,我们在两端都能高效地插入和删除元素。
它内部的实现依靠一个分段连续的内存结构,而不是类似vector的单一连续块,因此在头部插入和删除操作的时间复杂度是O(1),更高效。
38.2 扩展知识
deque的内部实现原理相比vector复杂很多:
1)分段存储结构:
deque采用分段存储的方式。具体来说,它不是像vector一样直接分配一大块连续内存,而是分配若干小块的连续内存,并用一个映射表(类似指针数组)来管理这些小块内存。这种方式的好处在于,不需要频繁的内存拷贝和移动,尤其在头部插入或删除元素时。
2)中央控制块:
deque有一个中央控制块,称为“map”或“指针数组”。这个数组的每个元素是指向一块子内存的指针,这些内存块称为"缓冲区”(buffer)。deque利用这个数组来记录所有的缓冲区,从而可以灵活管理内存。
3)双端操作:
由于deque是双端队列,因此它提供了高效的头尾插入和删除操作。这些操作之所以高效,主要是中央控制块的设计:插入和删除元素时,仅需在这些小块内存上进行操作,而不需要像vector那样在整个数组上操作。
4)内存增长机制:
当deque需要更多空间时,它会分配新的缓冲区,并更新中央控制块。与vector不同的是,当deque扩展时,不需要移动现有的元素,因为每个缓冲区已经是独立分布的,小块内存的重新分配和地址调整在控制块中完成。
5)缓存局部性(Cache Locality):
deque的分段存储结构可能影响缓存局部性,因为数据并不是存储在一块连续的内存中。当频繁访问大量元素时,它在缓存命中率方面不如vector,但是在插入和删除方面更加灵活和高效。如果你既需要数组下标访问又需要在任意位置插入和删除,可以考虑使用deque。如果你只是在尾部插入删除,且大量下标访问元素,可以考虑使用vector。
三十九、C++ 中 map和 unordered_map 的区别?分别在什么场景下使用?
39.1 回答重点
两者都是常用的关联容器。但有一些区别:
1)底层实现:
- map:基于有序的红黑树(具体实现依赖于标准库)。
- unordered_map:基于哈希表。
2)时间复杂度:
- map:插入、删除、查找的时间复杂度为O(Iog n)。
- unordered_map:插入、删除、查找的时间复杂度为O(1)(摊销)。
3)元素顺序:
- map:元素按键值有序排列。
- unordered_map:元素无序排列。
4)内存使用:
- map:由于底层是红黑树,内存使用较少。
- unordered_map:需要额外的空间存储哈希表,但在处理大量数据时,可能具有更好的表现。
场景选择
1)map:当需要按键值有序访问元素时,适合使用map,例如按顺序遍历键值对。
2)unordered_map:当主要关注查找速度、不关心元素顺序时,使用unordered_map会更高效,例如需要高效的键值存储和快速查找的场景。
39.2 扩展知识
**1)迭代器稳定性:**在map中,由于基于红黑树,其迭代器在插入和删除元素时通常依然有效(除了指向被删除元素的迭代器),但unordered_map中,插入和删除操作可能会使所有迭代器失效。
**2)复杂数据类型的键:**如果键是复杂数据类型(需要自定义比较函数),可以在map中利用自定义键比较器的排序规则:
struct MyKey {int id;std::string name;bool operator<(const MyKey& other) const {return id < other.id; // 按id排序}
};
std::map<MyKey, int> m;
3)哈希函数的定制:在unordered_map中,如果键类型是用户自定义类型,需要自行提供哈希函数和比较器:
struct MyKey {int id;std::string name;
};
struct HashFunction {std::size_t operator()(const MyKey& k) const {return std::hash<int>()(k.id) ^ std::hash<std::string>()(k.name);}
};
struct KeyEqual {bool operator()(const MyKey& lhs, const MyKey& rhs) const {return lhs.id == rhs.id && lhs.name == rhs.name;}
};
std::unordered_map<MyKey, int, HashFunction, KeyEqual> um;
四十、C++中list的使用场景?
40.1 回答重点
数组和链表的区别想必大家都知道,而list就是双向链表。它适用于频繁插入和删除的场景,尤其是插入和删除操作多于遍历操作的场景,插入和删除操作的时间复杂度是O(1)。
40.2 扩展知识
1)与其他容器比较:
list与vector和deque等其它容器各有优缺点。例如:
- vector更适用于频繁访问和修改元素,但在中间插入和删除时效率较低。
- deque特点是双端快速插入和删除,同时支持随机访问。
- set和map之类的关联容器可以进行快速查找(基于平衡二叉树),但不适合频繁修改结构。
2)排序:
注意list的排序应该使用list自己的类成员sort函数,不应该使用std::sort()函数。
3)专用成员函数:
list还提供了一些独有的成员函数,比如splice、merge、reverse、sort等:
- splice:可以快速将某段元素移动到另一个list位置。
- merge:合并两个有序链表。
- reverse:反转链表元素。
- sort:对链表进行排序。
4)迭代器的使用:
由于1ist是双向链表,双向迭代器是最常用的迭代器类型,它可以向前和向后遍历容器。随机访问迭代器不能用于list。
四十一、什么是C++中的RAIl?它的使用场景?
41.1 回答重点
RAII,全称是"Resource Acquisition Is Initialization"(资源获取即初始化)。
它的核心思想是将资源的获取与对象的生命周期绑定,通过构造函数获取资源(如内存、文件句柄、网络连接等),通过析构函数释放资源。这样,即使程序在执行过程中抛出异常或多路径返回,也能确保资源最终得到正确释放,特别是可以避免内存泄漏。
41.2 扩展知识
1)使用场景:
- **内存管理:**标准库中的std::unique_ptr和std::shared_ptr是RAll 的经典实现,用于智能管理动态内存。
- **文件操作:**std::fstream类在打开文件时获取资源,在析构函数中关闭文件。
- **互斥锁:**std::1ock_guard和std::unique_lock用于在多线程编程中自动管理互斥锁的锁定和释放。
2)示例代码:
#include <iostream>
#include <fstream>class FileHandler {
public:FileHandler(const std::string& filename) : file(filename) { // 资源获取if (!file.is_open()) {throw std::runtime_error("Unable to open file");}}~FileHandler() {file.close(); // 资源释放}void write(const std::string& data) {if (file.is_open()) {file << data << std::endl;}}private:std::ofstream file;
};int main() {try {FileHandler fh("example.txt");fh.write("Hello, RAII!");} catch (const std::exception& e) {std::cerr << e.what() << std::endl;}return 0;
}
在这个示例中,FileHandler类在构造函数中打开文件,在析构函数中关闭文件。即使main函数中发生异常或提前返回,析构函数也会自动调用,确保文件被正确关闭。
3)RAIl的好处:
- 异常安全:使用RAII能够确保在异常发生时自动释放资源,避免资源泄漏。
- 简化资源管理:将资源的获取和释放逻辑封装在类内,使代码更加简洁且方便维护。
4)与智能指针的结合:
std::unique_ptr<int> ptr(new int(5));
5)扩展应用:
- 锁管理:通过std::lock_guard对锁进行管理,确保锁在作用范围内被正确释放。
std::mutex mtx;
{std::lock_guard<std::mutex> lock(mtx);// 临界区代码
} // mtx 在此处自动释放
四十二、 lock_guard 和 unique_lock 的区别?
40.1 回答重点
两者都是RAll形式的锁管理类,用于管理互斥锁(mutex)。不过它们有一些关键区别:
1)lock_guard是一个简单且轻量级的锁管理类。在构造时锁定给定的互斥体,并在销毁时自动解锁。它不可以显式解锁,也不支持锁的转移。
2)unique_lock提供了更多的灵活性。它允许显式的锁定和解锁操作,还支持锁的所有权转移。unique_1ock可以在构造时选择不锁定互斥体,并在稍后需要时手动锁定。
40.2 扩展知识
1)lock_guard
1ock_guard很简洁,它的唯一任务就是确保在作用域结束时自动释放互斥锁。因为它的简洁,所以性能上更有优势。下面是个简单的示例:
std::mutex mtx;
void example() {std::lock_guard<std::mutex> lock(mtx);// 互斥锁已经锁定,可以安全地访问共享资源
} // 作用域结束,mtx 自动解锁
2) unique_lock
unique_1ock提供了更灵活的锁管理方式,适用于需要延迟锁定、显式解锁和锁所有权转移的场景。以下是一些特性和用法:
- 延迟锁定:你可以在构造unique_lock时选择不锁定互斥锁,而在后续调用lock()方法时显式锁定。
std::mutex mtx;
void example() {std::unique_lock<std::mutex> lock(mtx, std::defer_lock);// 在需要时显式锁定lock.lock();// 互斥锁已经锁定,可以安全地访问共享资源
} // 作用域结束,mtx 自动解锁
- 显式解锁:你可以在中间需要时显式解锁互斥锁,然后再次锁定。
std::mutex mtx;
void example() {std::unique_lock<std::mutex> lock(mtx);// 访问共享资源lock.unlock();// 互斥锁已解锁// 其他不能并发访问的操作lock.lock();// 再次锁定共享资源
} // 作用域结束,mtx 自动解锁
- 锁所有权转移:unique_lock的所有权可以在不同作用域之间转移,这在一些需要精细控制锁生命周期的场景中非常有用。
std::mutex mtx;
void example() {std::unique_lock<std::mutex> lock1(mtx);// 访问共享资源std::unique_lock<std::mutex> lock2 = std::move(lock1);// lock1 不再拥有互斥锁// lock2 拥有互斥锁
} // 作用域结束,mtx 自动解锁(如果 lock2 尚未解锁)
总结:lock_guard适合简单的场合,不需要复杂的锁定/解锁逻辑,性能更好;而unique_lock提供了更多的灵活性,适合更复杂的并发编程需求,性能相对一般。
四十三、thread 的 join 和 detach 的区别?
43.1回答重点
join和detach是std::thread 的成员方法:
**1.join():**阻塞当前的调用线程,直到子线程完成。这意味着主线程将等待子线程执行完毕后再继续执行。这种方法确保了子线程的完成。
**2.detach():**将子线程从调用线程中分离开来,子线程在后台独立执行,不会阻塞调用线程。使用detach后,子线程的资源在它独立执行完成后自动释放,但主线程无法再与其通信或得到其执行结果了。
简单来说,join是一种同步机制,保证子线程完成后主线程再继续;而detach是一种让子线程独立执行的方式,主线程不再等待和管理它。
43.2 扩展知识
应用场景
- 使用join适合于那些需要确保所有子线程都完成任务,主线程才能进行后续操作的场景。举个例子,如果我们有一个多线程处理任务,每个任务互相依赖或者最终结果需要汇总,那么使用join会更妥当。
- 使用detach则适合于这些不需要等待子线程完成就可以结束主线程的情况。比如在后台执行一些简单的日志记录、定时检查等非关键性任务。我们希望子线程可以独立运行,而主线程可以尽早释放资源。
注意事项
- 使用join时,必须确保在某个适当的时间点调用join,否则可能会引起阻塞甚至是死锁问题。
- 使用detach时,要格外小心内存泄漏和资源管理的问题,因为主线程不会等待子线程,也不会自动释放资源,子线程的生命周期复杂且不可控。
示例代码:
#include <iostream>
#include <thread>
#include <chrono>void threadFunction() {std::this_thread::sleep_for(std::chrono::seconds(2));std::cout << "Thread finished executing." << std::endl;
}int main() {// Joinstd::thread t1(threadFunction);t1.join();std::cout << "Main thread waits for thread t1 to finish." << std::endl;// Detachstd::thread t2(threadFunction);t2.detach();std::cout << "Main thread proceeds without waiting for thread t2." << std::endl;std::this_thread::sleep_for(std::chrono::seconds(3)); // to allow detached thread time to completereturn 0;
}
在这个例子中,t1是通过join方法调用的,因此主线程会等待它执行完毕。而t2是用detach方法调用的,主线程不等待它,它会在后台完成执行。要注意的时,主线程休眠时间设置成了3秒,以确保t2有足够的时间完成执行。
四十四、中 jthread 和 thread 的区别?
44.1回答重点
两者都是用于创建并发线程的类,但它们有些许区别:
**1)自动资源管理:**std::jthread是C++20引l入的,它通过RAII来管理线程生命周期,当std::jthread对象被销毁时,它所管理的线程会join。而std::thread需要程序员手动调用join()或detach()方法,否则在std::thread对象销毁时如果线程仍未join,会导致程序终止。
**2)中断支持:**std::jthread设计来更优雅地支持线程中断机制,而在std::thread中没有直接的
中断支持,程序员需要自己实现中断逻辑。
44.2 扩展知识
详细差异
1)自动资源管理:
std::jthread的自动管理方式有效减少了内存泄漏的风险。例如:
void myTask() {std::this_thread::sleep_for(std::chrono::seconds(1));std::cout << "Task completed." << std::endl;
}int main() {std::jthread jt(myTask); // 自动管理线程生命周期// 不需要显式调用 join()return 0;
}
对比std::thread,我们必须显式地 join:
void myTask() {std::this_thread::sleep_for(std::chrono::seconds(1));std::cout << "Task completed." << std::endl;
}int main() {std::thread t(myTask);t.join(); // 必须显式调用 join(),否则会崩溃return 0;
}
2)中断支持:
std::jthread提供了一些改善中断的接口,但要注意标准库中没有真正的中断功能。
void myTask(std::stop_token stoken) {while (!stoken.stop_requested()) {std::this_thread::sleep_for(std::chrono::milliseconds(100));std::cout << "Running task..." << std::endl;}
}int main() {std::jthread jt(myTask);std::this_thread::sleep_for(std::chrono::seconds(1));jt.request_stop(); // 请求中断return 0;
}
上面例子展示了我们如何利用std::stop_token来请求线程中断。
3)标准库支持:std::jthread仅在C++20及以后版本提供,如果运行环境不支持C++20,那么只能使用std::thread。
四十五、C++ 中 memcpy和 memmove 有什么区别?
45.1 回答重点
两者都是用于内存拷贝的函数,但它们的主要区别在于处理内存重叠区域的能力。
1)memcpy:用于从源地址复制指定数量的字节到目标地址。如果源和目标地址重叠,行为是未定义的,因为memcpy不处理重叠。
2)memmove:也是用于从源地址复制指定数量的字节到目标地址,但与memcpy不同的是,它可以安全地处理源和目标地址的重叠情况。
memmove保证重叠情况下的数据也是被正确地复制。
45.2 扩展知识
1)使用场景:
memcpy通常用于明确知道源和目标不会重叠的情况下,比如复制一个数组的内容到另一个数组。
memmove则用于可能存在内存重叠的场景,比如在字符串操作或者缓冲区移动时,源和目标可能会部分重叠。
2)性能差异:
memcpy相对来说更快,因为它不需要处理重叠的情况,而只是简单地逐字节(或者逐块)复制。
memmove由于需要处理重叠情况,内存拷贝时可能会有一些额外开销。例如,它需要首先判断内存区域的位置关系,然后决定是从前往后复制还是从后往前复制。
3)示例代码:
以下是两个简单的示例,用memcpy和memmove进行内存拷贝。
#include <iostream>
#include <cstring>int main() {char src1[] = "Hello, world!";char dest1[20];// 使用 memcpy 进行内存拷贝memcpy(dest1, src1, sizeof(src1));std::cout << "Result of memcpy: " << dest1 << std::endl;char src2[] = "Overlap Example";// 让目标和源重叠memmove(src2 + 5, src2, 8);std::cout << "Result of memmove with overlap: " << src2 << std::endl;return 0;
}
4)最佳实践:
- 当确定不会有重叠时,优先使用memcpy以获得更好的性能。
- 如果不确定是否有重叠,或者明确会有重叠时,使用memmove以保证数据正确性。
四十六、C++的 function、bind、lambda都在什么场景下会用到?
46.1 回答重点
三者都用于处理函数和可调用对象:
**1)std::function:**用于存储和调用任意可调用对象(函数指针、Lambda、函数对象等)。常用场景包括回调函数、事件处理、作为函数参数和
返回值。
**2)std::bind:**用于绑定函数参数,生成函数对象,特别是当函数参数不完全时。常见于将已有函数适配为接口要求的回调、将成员函数与对象
绑定。
**3)Lambda表达式:**用于定义匿名函数,通常在短期和局部使用函数时比如一次性回调函数、算法库中的自定义操作等。
46.2 扩展知识
46.2.1 std:function
std:function在C++11中引I入,它是一个类模板,用于封装任何形式的可调用对象。使用std::function可以很方便地存储各种不同类型的函数,以便后面调用。
常见使用场景:
1)**回调函数:**在图形用户界面程序或网络编程中,经常需要定义回调函数。
2)**事件处理:**在观察者模式中,可以用std::function存储和调用事件处理函数。
3)**作为函数参数和返回值:**方便传递函数或存储函数以在其他地方调用。
示例:
#include <functional>
#include <iostream>
#include <vector>void exampleFunction(int num) {std::cout << "Number: " << num << std::endl;
}int main() {std::function<void(int)> func = exampleFunction;func(42);return 0;
}
46.2.2 std:bind
std:bind是一个函数模板,用于从一个可调用对象(如函数或成员函数)和其部分参数创建新的函数对象。这在处理不完全的函数参数或需要绑
定特定对象的时候特别有用。
常见使用场景:
**1)适配接口:**当接口要求的函数签名与现有函数不匹配时,可以通过std::bind进行参数适配。
**2)绑定成员函数:**通过std::bind可以绑定类的成员函数与具体的实例对象,从而创建可以调用的对象。
示例:
#include <functional>
#include <iostream>void exampleFunction(int a, int b) {std::cout << "Sum: " << a + b << std::endl;
}int main() {auto boundFunction = std::bind(exampleFunction, 10, std::placeholders::_1);boundFunction(32); // Output: Sum: 42return 0;
}
46.2.3 Lambda表达式
Lambda表达式是一种匿名函数,它可以在定义的地方直接使用,通常用于简单的计算。如果某个函数逻辑仅在某个特定范围内有用,使用Lambda表达式可以使代码更简洁。
常见使用场景:
**1)一次性回调函数:**与算法和容器一起使用,以简化代码。
**2)自定义操作:**在标准库算法(如std:for_each,std::transform等)中,使用Lambda表达式进行自定义操作。
示例代码:
#include <algorithm>
#include <iostream>
#include <vector>int main() {std::vector<int> vec = {1, 2, 3, 4, 5};std::for_each(vec.begin(), vec.end(), [](int &n) { n *= 2; });for (int n : vec) {std::cout << n << " ";}std::cout << std::endl;return 0;
}
四十七、请介绍C++中使用模板的优缺点?
47.1 回答重点
模板的优缺点主要有:
优点:
1.代码重用性:模板允许我们编写与数据类型无关的代码,减少了重复代码,提高代码可重用性,遵循Don’trepeatyourself原则。
**2.类型安全:**模板可以在编译时进行类型检查,避免了运行时错误,提高程序的安全性。
**3.效率高:**由于模板是在编译时生成具体类型的代码,避免了运行时类型检查,提升了运行效率。
**4.灵活性强:**模板可以用来实现泛型编程,可以处理各种数据类型和操作,实现更为通用的算法。
缺点:
**1.编译时间增加:**因为模板会在编译时生成具体类型的代码,可能会导致编译时间显著增加。
**2.错误信息复杂:**模板引起的错误往往信息量巨大且难以理解,新手在调试时可能会比较头疼。
**3.代码膨胀:**如果使用不当,模板可能会导致生成大量冗余代码,增加最终模块的尺寸。
**4.可读性和维护性:**由于模板代码的泛型特性,代码的可读性和维护性可能会下降,理解起来比较困难,比如标准库代码,真的比较难理解。
47.2 扩展知识
**1.模板类型参数:**C++中的模板不仅可以接受类型参数(class或者typename),还可以接受非类型参数(如整数)。
template <typename T, int size>
class Array {T arr[size];
};
**2.模板特化:**在某些情况下,我们需要对某些特定类型进行特殊处理,这就需要用到模板特化。
template <typename T>
class Example {// 默认实现
};template <>
class Example<int> {// 针对 int 类型的实现
};
**3.变参模板:**C++11引I入了变参模板,允许模板接受可变数量的模板参数。
template <typename... Args>
void func(Args... args) {// 可以处理任意数量和类型的参数
}
**4.模板的实际应用:**STL广泛使用了模板来实现泛型数据结构和算法,如std::vector,std:1ist,std::map等。
**5.概念(Concepts):**C++20引l入了concepts,进一步拓展了模板的使用范围,简化了模板的写法,让代码更加清晰易读。
四十八、C++中函数模板和类模板有什么区别?
48.1 回答重点
两者的区别主要还是在于用法和实例化方式不同:
1)定义和使用:
- **函数模板:**用于创建可以接受不同类型参数的函数。我们定义一个一次性模板,然后生成多个函数版本。
template <typename T>
T max(T a, T b) {return (a > b) ? a : b;
}
在上面的例子中,max函数可以用于任何支持">”运算符的类型。
- **类模板:**用于创建可以接受不同类型参数的类。通过创建一次模板类,我们可以生成多个不同类型的类实例。
template <typename T>
class Stack {
private:std::vector<T> elements;
public:void push(T const& elem) { elements.push_back(elem); }void pop() { elements.pop_back(); }T top() const { return elements.back(); }
};
这个例子里,Stack类可以处理不同类型的堆栈元素。
2)实例化方式:
- **函数模板:**编译器在实际调用函数时,根据传入参数的类型自动生成具体类型的函数。
int a = 10, b = 20;
std::cout << max(a, b); // 调用 max<int>(int a, int b)
- **类模板:**在使用类模板时,我们需要显式地声明模板类型。
Stack<int> intStack;
Stack<double> doubleStack;
48.2 扩展知识
我们也需要了解一些高级概念:
1)模板特化:
模板特化是指我们可以为特定的类型定义一个专门的模板版本,对某些情况提供更合适的实现。
template <>
class Stack<bool> {
// bool类型的特化实现
};
2)模板偏特化:
偏特化使我们为模板的某些参数提供特定类型,但同时保留其他参数的泛型性质。
template <typename T, typename Allocator = std::allocator<T>>
class MyContainer {
// ...
};
另外,模板编程涉及到编译期计算,即所有模板实例化在编译阶段完成,这意味着与运行时相比,模板代码在执行期没有额外的开销,性能更优。
四十九、请介绍下C++模板中的SFINAE?它的原则是什么?
49.1 回答重点
SFINAE是Substitution Failure Is Not AnError的缩写,意思是在模板定义中,类型替换失败并不是一个编译错误。
通过SFINAE,可以实现某些代码只针对特定类型有效,而对其他类型无效,增强泛型编程的灵活性。
SFINAE的原则说白了就是:当替换模板参数失败时,编译器不会把这个失败当作一个错误,而是会继续尝试其他的模板匹配。如果找不到匹配的模板,才会报错。
49.2 扩展知识
**1)应用场景:**SFINAE常用于实现条件编译、类型特征检测以及模板函数的重载。比如,你可以根据类型是否支持某些操作来选择不同的函数实现。
2)**类型特征检测:**借助std::enable_if和std:is_same,可以实现更高级的类型特征检测。假如我们有一个函数模板,需要它仅在特定条件下
针对特定类型生效,那么SFINAE就派上用场了。例如:
#include <type_traits>
#include <iostream>template<typename T>
typename std::enable_if<std::is_integral<T>::value>::type
printIfInteger(T t) {std::cout << t << " is an integer.\n";
}template<typename T>
typename std::enable_if<!std::is_integral<T>::value>::type
printIfInteger(T t) {std::cout << t << " is not an integer.\n";
}int main() {printIfInteger(42); // 输出:42 is an integer.printIfInteger(3.14); // 输出:3.14 is not an integer.
}
3)编译时条件:std:::enable_if是SFINAE的一种常见工具,通过它可以在编译时有条件地启用或禁用某些函数模板或类模板。例如:
template <typename T>
typename std::enable_if<std::is_integral<T>::value, T>::type
add(T a, T b) {return a + b;
}
**4)函数模板重载:**SFINAE还可以用于控制函数模板重载。例如,如果你有多个模板函数,需要根据模板参数的不同选择合适的实现,可以使用SFINAE来达到效果。
5)类模板特化:SFINAE也适用于类模板的特化。通过特化模板,可以在模板类中实现不同类型有不同的处理方式。例如:
#include <type_traits>
#include <iostream>template<bool B, class T = void>
struct enable_if {};template<class T>
struct enable_if<true, T> { typedef T type; };template<typename T, typename Enable = void>
class MyClass;template<typename T>
class MyClass<T, typename enable_if<std::is_integral<T>::value>::type> {
public:void display() { std::cout << "Integral type\n"; }
};template<typename T>
class MyClass<T, typename enable_if<!std::is_integral<T>::value>::type> {
public:void display() { std::cout << "Non-integral type\n"; }
};int main() {MyClass<int> intObj;MyClass<double> doubleObj;intObj.display(); // 输出:Integral typedoubleObj.display(); // 输出:Non-integral type
}
五十、C++的 strcpy和 memcpy有什么区别?
50.1 回答重点
两者都用于复制数据,但使用场景略有不同:
1)strcpy用于复制字符串(null-terminated字符数组)。它会从源字符串复制字符到目标字符数组,直到遇到终止符’\e’。
2)memcpy用于复制任意类型的数据块,不限于字符串。它会复制指定长度的内存块(以字节为单位),不会检查终止符。
50.2 扩展知识
strcpy的用法:
#include <cstring>char src[] = "Hello, World!";
char dest[20];strcpy(dest, src);
在这个例子中,strcpy函数将src中的字符串复制到dest,并且会包括结束符’\0。使用strcpy时要注意:
1)确保目标数组足够大,能容纳源字符串及其结束符,否则可能会导致缓冲区溢出。
2)strcpy只能用于字符串(以’\0’结尾的字符数组),不能用于非字符数据。
memcpy 的用法:
#include <cstring>int src[] = {1, 2, 3, 4, 5};
int dest[5];memcpy(dest, src, 5 * sizeof(int));
在这个例子中,memcpy函数将src中的数据以字节为单位复制到dest。注意事项包括:
1)memcpy可以复制任意数据类型的内存块,不限于字符或字符串。
2)参数包括源地址、目标地址和要复制的字节数,确保字节数正确。
3)memcpy不处理内存重叠的情况,如果src和dest所指的内存区域重叠,可以使用memmove。
总结
1)strcpy适合字符串复制,且必须包含终止符’\0’。
2)memcpy用于复制任意类型的数据块,以字节为单位,适用范围广,但需注意目标和源内存重叠问题。
五十一、C++中为什么要使用std:array?它有什么优点?
51.1 回答重点
std::array是C++11 标准引入的新特性,它有很多优点:
**1)固定大小:**std::array是一个固定大小的序列容器,一旦创建,大小就不能改变,它使用的是栈内存。它与std::vector不同,std::vector是动态大小的。
**2)性能优势:**std::array在性能上很接近C风格的数组,因为它使用连续的栈内存布局。
**3)类型安全:**与C风格数组相比,std::array提供了类型安全的at()接口。
**4)接口友好:**std::array提供了STL容器的标准接口,如size(),begin()end()等,使用上也非常方便。
**5)与现代C++特性结合:**作为STL的一部分,std::array可以很自然地和其他标准库功能配合使用,比如范围for循环、算法函数等。
51.2 扩展知识
**1)与C风格数组对比:**虽然C风格数组在声明时看起来更简单,但是它们不支持拷贝赋值和交换操作,容易出现越界问题,不提供大小信息。而std::array则具有这些优势。
**2)与其他STL容器对比:**std::array和std::vector都是数组类型的容器,但std:vector是动态大小的,可以在运行时调整长度,在需要动态容纳元素的场合非常有用。但如果你确定数组长度不会改变,选择std::array会更高效。
3)用法示例:
#include <iostream>
#include <array>
#include <algorithm>int main() {// 创建并初始化一个 std::arraystd::array<int, 5> arr = {1, 2, 3, 4, 5};// 使用范围 for 循环遍历for (int num : arr) {std::cout << num << ' ';}std::cout << std::endl;// 使用标准算法std::sort(arr.begin(), arr.end());// 再次遍历看看排序后的结果for (int num : arr) {std::cout << num << ' ';}std::cout << std::endl;return 0;
}
**4)其他注意点:**std::array的大小必须在编译时已知,因为它是一个模板类,大小作为模板参数传递。
五十二、C++中堆内存和栈内存的区别?
52.1 回答重点
堆内存和栈内存的区别主要体现在分配方式、管理方式、生命周期和性能等方面:
1)分配方式:
- 栈内存:由编译器在程序运行时自动分配和释放。典型的例子是局部变量的分配。
- 堆内存:需要程序员手动分配和释放,使用new和delete操作符。在C++11之后,也可以使用智能指针来管理堆内存。
2)管理方式:
- 栈内存:由编译器自动管理,程序员无需担心内存泄漏,生命周期由作用域决定。
- 堆内存:由程序员手动管理,如果没有正确释放内存,会导致内存泄漏。
3)生命周期:
- 栈内存:变量在离开作用域之后自动销毁。
- 堆内存:只要不手动释放,内存会持续存在,直到程序终止。
4)性能:
- 栈内存:内存分配和释放速度极快,性能上优于堆内存。
- 堆内存:涉及到复杂的内存管理和分配机制,性能上较慢。
52.2扩展知识
1)内存分配函数:
- 除了new和delete,堆内存还可以使用malloc和free来管理。区别在于new会调用构造函数,而malloc只是纯粹的内存分配。
2)内存溢出和内存泄漏:
- 内存溢出:栈空间是有限的,如果递归过深或者分配的局部变量太大,可能导致栈溢出。
- 内存泄漏:堆内存如果没有正确释放,会导致内存泄漏,尤其在长时间运行的程序中,会影响系统性能。
3)智能指针:
- C++11引入了智能旨针std:unique_ptr和std::shared_ptr,可以自动管理堆内存,大大降低了内存泄漏的风险。
4)虚拟内存:
- 虚拟内存机制,使物理内存和逻辑内存独立,程序可以看到的是一个巨大的连续地址空间,但实际上可能是分散的物理内存和硬盘上的交换空间。
5)栈与堆的容量:
- 栈的容量往往较小,通常为几MB,主要用于局部变量和函数调用管理。
- 堆的容量通常较大,依赖于系统可用内存,适合动态分配大量内存。
五十三、C++的栈溢出是什么?
53.1 回答重点
栈溢出(StackOverflow)是在程序执行过程中,栈空间被耗尽的一种现象。
栈空间是操作系统为每个线程分配的有限内存,用于存储函数调用、局部变量等。当递归过深(例如无限递归)或局部变量占用内存过大时,栈空间就会被用尽,导致栈溢出。
在C++中,典型的栈溢出情况包括:
1)无限递归调用:函数不断调用自身,导致栈帧无限增长。
2)大局部变量:定义了过多或过大的局部变量,超过了栈内存的限制。
52.2 扩展知识
1)堆和栈的区别:
- 栈内存是自动分配和释放的,速度快但空间有限。适用于较小的局部变量和函数调用。
- 堆内存是手动管理的,使用new和delete进行分配和释放。它的空间较大,适合动态分配的大对象或数组。
2)递归调用的优化:
- 尾递归优化:在尾递归中,递归调用是函数最后一个执行的操作。编译器可以将尾递归优化为迭代,避免栈溢出问题。但需要注意,并非所有编译器都支持尾递归优化。
3)常见的解决方法:
- 使用动态内存分配:对于需要大内存的局部变量,考虑使用堆代替栈。例如,通过new动态分配而不是直接定义在栈上。
- 控制递归深度:通过增加递归深度限制条件,避免无限递归的发生。
- 增大栈空间:在某些情况下,可以通过操作系统或编译器选项增大栈的大小。
4)错误处理:
- 检查栈溢出的线索,包括异常崩溃、SegmentationFault(段错误)等。这些错误往往是栈溢出的直接结果。
- 使用调试工具,例如Valgrind、GDB等,可以帮助追踪和定位栈溢出问题。
5)代码示例:
下面是一个简单的示例代码,演示了如何防止栈溢出:
#include <iostream>void safeRecursiveFunction(int depth) {if (depth > 1000) {std::cerr << "Maximum depth reached!" << std::endl;return;}safeRecursiveFunction(depth + 1);
}int main() {safeRecursiveFunction(1);return 0;
}
五十四、什么是C++的回调函数?为什么需要回调函数?
54.1 回答重点
回调函数是一种通过函数指针或者函数对象(例如std::function或lambda表达式)将一个函数作为参数传递给另一个函数的机制。
实际上,就是把函数的调用权从一个地方转移到另一个地方,这个调用会在未来某个时刻进行,而不是立即执行。之所以称为“回调”,可以理解为某种倒叙执行:先安排好函数的调用,不立即执行,
等到合适的时机再“回头”执行。
需要回调函数的主要原因包括:
**1)异步编程:**在异步操作中,比如网络请求、文件读取、事件处理等,可以在操作完成后调用回调函数,而主程序可以继续执行其它任务,避免等待操作完成。
**2) 解耦代码:**回调函数有助于将代码模块化和解耦,允许我们创建更灵活和可复用的代码。例如,一个通用的排序算法可以接受一个比较函数,允许用户自定义排序逻辑。
**3) 事件驱动编程:**在GUI或者其他事件驱动程序中,回调函数经常用于处理用户输入事件,如点击、鼠标移动、键盘输入等。
54.2 扩展知识
回调函数的实际应用:
1)使用函数指针作为回调函数:
在C风格接口中,最常见的回调函数形式就是使用函数指针。例如:
#include <iostream>// 定义一个函数指针类型
typedef void (*CallbackFunc)(int);void RegisterCallback(CallbackFunc cb) {// 模拟某些操作std::cout << "Registering callback...\n";cb(42); // 调用回调函数
}void MyCallback(int value) {std::cout << "Callback called with value: " << value << std::endl;
}int main() {RegisterCallback(MyCallback); // 传递回调函数return 0;
}
2)使用C++11之后的std::function和 lambda 表达式:
#include <iostream>
#include <functional>void RegisterCallback(std::function<void(int)> cb) {std::cout << "Registering callback...\n";cb(42); // 调用回调函数
}int main() {auto myCallback = [](int value) {std::cout << "Callback called with value: " << value << std::endl;};RegisterCallback(myCallback); // 传递 lambda 回调函数return 0;
}
3)GUI编程中的回调:
在图形用户界面编程中,回调函数常用于处理用户事件。例如,在一个按钮点击事件中调用用户提供的回调函数。
例如,使用一个假设的GUI库:
class Button {public:void setOnClick(std::function<void()> cb) {onClick = cb;}void simulateClick() {if (onClick) {onClick();}}private:std::function<void()> onClick;
};int main() {Button button;button.setOnClick([]() {std::cout << "Button clicked!" << std::endl;});button.simulateClick(); // 模拟一次点击事件return 0;
}
五十五、C++中为什么要使用nullptr而不是NULL?
55.1 回答重点
主要原因是nullptr有明确的类型,它是std::nullptr_t类型,可以避免代码中出现类型不一致的问题。
55.2 扩展知识
1)类型安全:NULL通常被定义为数字(在C++代码中一般是#define NULL 0),它实际上是整型值。这就可能会带来类型不一致的问题,比如传递参数时,编译器无法准确判断是整数还是空指针。而nullptr则是std::nullptr_t类型的,能够明确表示空指针,使编译器更容易理解代码。
**2)代码可读性:**使用nullptr使得代码更具有可读性和可维护性。它明确传达了变量是用作指针而非整数值,例如:
void process(int x) {std::cout << "Integer: " << x << std::endl;
}void process(void* ptr) {std::cout << "Pointer: " << ptr << std::endl;
}int main() {process(NULL); // int 还是指针?process(nullptr); // 指针return 0;
}
**3)避免潜在的错误:**在函数重载和模板中使用可能导致编译器选择错误的重载版本。另外,模模板编程中特别是涉及类型推断时,NULL会带来一些不期望的效果。
template<typename T>
void foo(T x) {std::cout << typeid(x).name() << std::endl;
}int main() {foo(0); // 0 是int型foo(NULL); // 你希望是int还是指针呢foo(nullptr); // std::nullptr_treturn 0;
}
在上面的代码中,使用nullptr可以让我们精确控制模板的类型。
五十六、什么是大端序?什么是小端序?
56.1 回答重点
通俗点讲就是数据在内存中的存放顺序。
**1)大端序:**高字节存储在内存的低地址处,低字节存储在高地址处。例如,对于16进制数0x12345678,大端序在内存中的存储方式是:12345678。
**2)小端序:**低字节存储在内存的低地址处,高字节存储在高地址处。对于同样的16进制数0x12345678,小端序在内存中的存储方式是:78563412。
56.2 扩展知识
不同类型的计算机系统可能采取不同的字节序。比如,大多数的x86架构计算机采用小端序,而一些网络协议则规定采用大端序。
**1)字节序的影响:**字节序主要影响在多字节数据的存储与传输上。如果不同字节序的系统相互通信,需要注意字节序的转换,否则可能会读到错误的数据。
**2)字节序的检测:**可以通过如下方式进行检测:
#include <iostream>bool isLittleEndian() {uint16_t num = 1;return *(reinterpret_cast<char*>(&num)) == 1;
}int main() {if(isLittleEndian()) {std::cout << "System is Little-Endian" << std::endl;} else {std::cout << "System is Big-Endian" << std::endl;}return 0;
}
**3)字节序的转换:**有时候我们需要在大端和小端之间进行转换,C++提供了一些库函数,例如htons()和hton1()用于将主机字节序转换为网络字节序(一般是大端),ntohs()和ntohl()用于将网络字节序转换为主机字节序:
#include <arpa/inet.h>
#include <cstdint>
#include <iostream>int main() {uint32_t num = 0x12345678;uint32_t n_num = htonl(num); // Host to Network Longuint32_t h_num = ntohl(n_num); // Network to Host Longstd::cout << "Original: " << std::hex << num << std::endl;std::cout << "Network Byte Order: " << std::hex << n_num << std::endl;std::cout << "Converted Back: " << std::hex << h_num << std::endl;return 0;
}
**4)浮点数:**注意,只有整数才有大端序小端序的概念,浮点数是不区分大端序和小端序的。
五十七、C++ 中 include<a.h>和 include"a.h"有什么区别?
57.1 回答重点
两者都是用来包含头文件的指令,区别在于搜索头文件的路径。
1)#include<a.h>:编译器会在预定义的系统目录中搜索头文件,这种路径搜索方式适用于标准库和第三方库的头文件。
2)#include"a.h":编译器会在当前源文件所在目录和自定义目录中搜索头文件。
总结来说,#include<a.h>查找系统目录,而#include"a.h"查找当前目录和自定义目录。
57.2 扩展知识
为了更深入了解,让我来继续扩展一下这两种方式在实际应用中的使用场景和一些最佳实践。
1)标准库和第三方库头文件:
如果你要包含的是C++标准库的头文件,通常会使用尖括号<>。例如:
#include <iostream>
#include <vector>
第三方库的头文件也推荐使用尖括号,这样利于明确这些头文件的搜寻路径。例如:
#include <boost/asio.hpp>
2)项目内头文件:
在项目内开发时,你的头文件通常会放在项目的特定目录下。如果你想引l用这些文件,使用双引号""形式的#include会更加直观。例如,你有一个名为myheader.h的头文件在项目根目录下,你应该这样引用它:
#include "myheader.h"
这样编译器首先会在当前源文件的目录中查找myheader.h。
3)路径设置与管理:
在大型项目中,通过设置编译器的包含目录选项(例如-I选项),可以灵活控制头文件的查找方式。
五十八、C++是否可以 include源文件?
58.1 回答重点
可以include源文件。不过,尽管技术上可行,但这并不是一个推荐的做法,也不符合良好的编程习惯。
58.2 扩展知识
为什么可以include源文件?
#include指令的本质是将指定文件的内容插入到当前文件中。所以,理论上你可以include任何文件——无论是头文件(.h)还是源文件(.cpp等)。
但是为什么不推荐这样做呢?
1)代码组织和维护:头文件(.h)通常用于声明,而源文件(cpp)用于实现。如果你include源文件,会导致代码的组织变得混乱,难以维护和理解。
2)模块化设计原则:头文件和源文件的分离是为了遵循模块化设计原则,有助于减少耦合。
3)潜在的链接问题:include源文件可能会导致多重定义问题,这会在链接阶段引发错误。典型的错误是“multiple definition of…”。
更好的方式是:
1)在头文件中声明函数或类的接口,在源文件中进行具体实现。
2)如果需要重复使用一些较为独立的代码片段,可以考虑将其封装在模板或者类中,用头文件来声明和定义这些模板或者小的类。
可以通过如下实例更直观地理解:
// math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
int add(int a, int b);
#endif// math_utils.cpp
#include "math_utils.h"
int add(int a, int b) {return a + b;
}// main.cpp
#include <iostream>
#include "math_utils.h"
int main() {std::cout << add(3, 5) << std::endl; // 输出 8return 0;
}
如上代码,如果将math_utils.cppinclude到main.cpp中将导致重复定义问题,从而无法编译通过。
五十九、C++中什么是深拷贝?什么是浅拷贝?写一个标准的拷贝构造函数?
59.1 回答重点
**1)浅拷贝:**浅拷贝只是简单地复制对象的值,而不复制对象所拥有的资源或内存。也就是说,两个对象共享同一个资源或内存。当一个对象修改了该资源或内存,另一个对象也会受到影响。这种情况
通常发生在默认的拷贝构造函数或赋值操作中。
**2)深拷贝:**深拷贝不仅复制对象的值,还会新分配内存并复制对象所拥有的资源。这样两个对象之间就不会共享同一个资源或内存,修改其中一个对象的资源或内存不会影响到另一个对象。
举个例子,一个标准的深拷贝构造函数可以这样写:
#include <cstring> // for std::strlen and std::strcpyclass MyClass {
private:char* data;
public:MyClass(const char* inputData) {data = new char[std::strlen(inputData) + 1];std::strcpy(data, inputData);}// 深拷贝构造函数MyClass(const MyClass& other) {data = new char[std::strlen(other.data) + 1];std::strcpy(data, other.data);}// Destructor~MyClass() {delete[] data;}// 打印数据void printData() {std::cout << data << std::endl;}
};int main() {MyClass obj1("Hello");MyClass obj2 = obj1;obj1.printData(); // Output: Helloobj2.printData(); // Output: Helloreturn 0;
}
在这个例子中,Myclass类有一个指向字符数组的指针data。拷贝构造函数对另一个对象进行深拷贝,即为data分配新的内存并复制字符串,因此两个对象各自独立地拥有自己的数据。
59.2 扩展知识
**1)赋值操作符:**除了拷贝构造函数,赋值操作符重载(operator=)也可以用来实现深拷贝。标准做法是先释放已有资源,然后再进行深拷贝。
MyClass& operator=(const MyClass& other) {if (this != &other) { // Self-assignment checkdelete[] data; // Free existing resourcedata = new char[std::strlen(other.data) + 1];std::strcpy(data, other.data);}return *this;
}
**2)移动语义:**C++11引入了移动语义,改进了资源管理和效率问题。移动构造函数和移动赋值操作符可以避免不必要的深拷贝。
**3)避免陷阱:**浅拷贝会导致悬垂指针问题和双重释放错误。因为共享的资源被一个对象释放后,其他共享该资源的对象也有可能释放这块已释放的资源,引发未定义行为。
六十、C++中命名空间有什么作用?如何使用?
60.1 回答重点
命名空间(namespace)主要用于解决名字冲突问题。当项目规模较大,包含很多函数、类、变量的时候,很容易出现名字相同的情况,这时候命名空间就显得特别重要。
命名空间的基本用法如下:
namespace MyNamespace {int myVar;void myFunc() {// do something}
}
60.2 扩展知识
1)使用using关键字:
如果你不想每次使用成员时都加上命名空间的名称,可以使用using关键字,比如常见的using namespace std:
using namespace MyNamespace;
myVar = 10;
myFunc();
但是要小心,这样做可能会引入新的名字冲突。
2)嵌套命名空间:
你可以在一个命名空间里再定义一个命名空间,形成嵌套的结构:
namespace OuterNamespace {namespace InnerNamespace {int myVar;void myFunc() {// do something}}
}
访问的时候需要逐层访问:OouterNamespace:InnerNamespace::myVar。
3)匿名命名空间:
如果你不希望某些名字在文件外部被访问,可以使用匿名命名空间:
namespace {int myVar;void myFunc() {// do something}
}
这等同于给这些名字添加static关键字,使其仅在当前文件内可见。
4)标准命名空间:
C++标准库中的所有内容都位于std命名空间中,不同于自己的命名空间,直接使用标准库中的东西时必须加上std::前缀:
std::cout << "Hello World" << std::endl;
或者使用usingnamespacestd;后可以省略前缀,不过通常不推荐这种用法,可能会引入名字冲突。
六十一、C++中友元类和友元函数有什么作用?
61.1 回答重点
两者主要用于提供访问私有成员和保护成员的权限。
友元关系是一种单向的访问权限,并不会破坏封装性,同时也不会牵涉到类之间的继承关系。友元的使用在以下情况下特别有用:
**1)友元函数:**允许一个函数访问某个类的私有成员和保护成员。
class MyClass {private:int privateMember;public:MyClass() : privateMember(0) {}//声明友元函数friend void friendFunction(MyClass &obj);
};void friendFunction(MyClass &obj) {//访问 privateMemberobj.privateMember = 10;
}
**2)友元类:**允许另一个类访问某个类的私有成员和保护成员。
class B; //前向声明class A {private:int privateMember;public:A() : privateMember(0) {}//声明B为友元类friend class B;
};class B {public:void accessA(A &obj) {//访问 A 的 privateMemberobj.privateMember = 20;}
};
61.2 扩展知识
下面进一步讨论下它们的作用场景和设计考量:
1)封装与开放:
- 封装是面向对象编程的基本原则之一,它将数据和操作数据的方法绑定到一起,防止外部代码直接访问对象的内部状态。友元的引入让类在需要的时候能够部分地开放它的内部状态,通常不会滥用。
- 友元函数和友元类提供了一种在不破坏封装性的条件下,安全访问私有成员的方式。
2)友元的替代方案:
- 如果友元机制的使用本质上意味着违反封装性或设计初衷,那么可能需要重新考量类的设计。
- 你可以选择通过公开接口提供访问权限(如getter/setter方法),或利用继承、多态等其他OOP特性来实现同样的目的。
3)访问控制复杂度:
- 使用友元可能会增加代码的复杂度,因为它打破了类的封装性,代码的维护变得相对困难。所以,在维护代码时,需要非常小心,确保友元使用的合理性和必要性。
友元是一种方便但需要慎用的工具,合理使用能够简化代码,但滥用则会破坏类的封装性,增加代码维护的难度。建议在实际编程中能够权衡利弊,合理利用这一机制。
六十二、C++中如何设计一个线程安全的类?
62.1 回答重点
如何设计一个线程安全的类?回答重点可以放在如何避免多线程环境下资源冲突的问题,可以从以下几个方面避免:
- 使用互斥锁(mutex)保护共享资源。
- 部分逻辑可以使用无锁编程,原子变量控制。
- 使用线程消息队列形式,保证此类里的所有操作任务在一个队列里,都在一个线程内调度,自然而然就解决了多线程问题。
要设计一个线程安全的类,通常情况下,我们会使用互斥锁(mutex)来保护共享资源,确保在任何时刻只有一个线程可以访问修改这些资源。
下面是一个简单示例,展示了如何使用std::mutex来实现一个线程安全的类:
#include <iostream>
#include <thread>
#include <mutex>class SafeCounter {
public:SafeCounter() : value(0) {}void increment() {std::lock_guard<std::mutex> lock(mutex_);++value;}int getValue() {std::lock_guard<std::mutex> lock(mutex_);return value;}private:int value;std::mutex mutex_;
};int main() {SafeCounter counter;auto increment_func = [&counter]() {for (int i = 0; i < 100; ++i) {counter.increment();}};std::thread t1(increment_func);std::thread t2(increment_func);t1.join();t2.join();std::cout << "Final value: " << counter.getValue() << std::endl;return 0;
}
1)std::mutex用于保护共享数据的访问。
2)std:lock_guard是一个RAIl类型的锁机制,用来确保在作用域结束时自动释放锁。
3)increment方法和getValue方法都使用锁保护共享数据。
62.2 扩展知识
再看下其他技巧:
1)读写锁
有时我们需要实现的场景是多线程可以同时读数据,但写数据时需要独占锁。这可以使用std:shared_mutex(C++17引I入)来实现。std::shared_1ock允许多个线程同时获取读锁,而std::unique_1ock则用于写锁。
2)原子操作
对于一些简单的整型操作,可以使用std::atomic来代替互斥锁。std:atomic提供了高效的原子操作,避免了锁的开销。
#include <atomic>
class AtomicCounter {
public:AtomicCounter() : value(0) {}void increment() {value.fetch_add(1, std::memory_order_relaxed);}int getValue() {return value.load(std::memory_order_relaxed);}private:std::atomic<int> value;
};
3)使用合适的同步机制
当多个线程需要协调工作时,可以使用条件变量来等待特定条件满足后再进行操作。
六十三、C++如何调用C语言的库?
63.1 回答重点
可以使用extern"c"来告诉编译器按照C语言的链接方式处理某些代码:
1)在C++代码中包含C语言头文件时,用extern"c"进行声明,比如:
extern "C" {#include "your_c_library.h"
}
2)需要在链接阶段确保C++项目和C语言库都被正确链接。可通过编写合适的CMakeLists.txt或Makefile来实现。
3)也可以不使用extern"C",源文件后缀名改为.c也行。
63.2 扩展知识
说到extern"c",得从C和C++的兼容性说起。C++是C的增强版本,但它们的编译方式还是有些差异的。C++支持函数的重载,而C语言不支持。
C++编译器会对函数进行"名字修饰”(Name Mangling)。
extern"c"的作用是让编译器按C方式编译,避免函数名被修饰,保证C语言库里的函数能被正确调用。
举个例子:
假设有一个简单的C库math_library.c:
// math_library.c
int add(int a, int b) {return a + b;
}
你先编写一个头文件math_library.h:
// math_library.h
#ifndef MATH_LIBRARY_H
#define MATH_LIBRARY_H
int add(int a, int b);
#endif
然后在你的C++项目中这么用:
// main.cpp
#include <iostream>
extern "C" {#include "math_library.h"
}int main() {int result = add(3, 4);std::cout << "Result: " << result << std::endl;return 0;
}
最后,确保编译和链接。可以使用以下命令:
g++ -o main main.cpp math_library.c
另外,有几点需要注意:
1)如果你的C库里有C++不支持的特性,比如变量长度数组(VLA),需要仔细考虑兼容性。
2)如果C库包含了结构体,尤其是那些带有复杂数据类型或指针的结构体,要确保它们在C++中能够正确处理。
3)最好是C和C++不要混用,如果要混用,建议做一个封装层,对C做一层C++的封装,然后上层的业务代码还是统一使用C++。
六十四、指针和引用的区别是什么?
64.1 回答重点
1.引用必须在声明时初始化,指针可以不需要初始化
// 引用示例
int a = 10;
int& ref_a = a; // 引用初始化
// int& ref_b; // 错误:引用必须在声明时初始化
// 指针示例
int b = 20;
int* ptr_b = &b; // 指针初始化
int* ptr_c = nullptr; // 指针可以为空
2.指针是一个变量,存储的是一个地址,引用跟原来的变量实质上是同一个东西,是原变量的别名,引用本身并不存储地址,而是在底层通过指针来实现对原变量的访问
3.引用被创建之后,就不可以进行更改,指针可以更改
#include<iostream>
int main() {int a = 10;int b = 20;// 引用int& ref_a = a; // ref_a 是 a 的引用// ref_a = b; // 错误:引用不能被重新绑定// 指针int* ptr_a = &a; // ptr_a 是一个指针,指向 aptr_a = &b; // 指针可以被重新赋值,现在指向 bstd::cout << "a: " << a << ", b: " << b << std::endl;// 如果取消注释 ref_a = b; 这行代码,将会导致编译错误// 输出 "a: 10, b: 20",因为引用不能被重新绑定*ptr_a = 30; // 通过指针修改 b 的值std::cout << "a: " << a << ", b: " << b << std::endl; // 输出 "a: 10, b: 30"return 0;
}
4.不存在指向空值的引用,必须有具体实体;但是存在指向空值的指针。尽量使用智能指针
64.2扩展知识
- sizeof指针得到的是本指针的大小,sizeof弓l用得到的是引用所指向变量的大小
#include<iostream>
int main() {int *p = new int[3]{1, 2, 3};std::cout << "Size of pointer p:<< sizeof(p) << " bytes"<< std::endl; // 输出指针 p 的大小std::cout << "Size of dynamic array:<< sizeof(*p) * 3 << " bytes"<< std::endl; // 计算动态数组的大小delete[] p;return 0;
}
六十五、介绍C++中三种智能指针的使用场景?
65.1 回答重点
C++中的智能指针主要用于管理动态分配的内存,避免内存泄漏。
C++11标准引l入了三种主要的智能指针:std::unique_ptr、std::shared_ptr和std::weak_ptr:
1)std::unique_ptr
std::unique_ptr是一种独占所有权的智能指针,意味着同一时间内只能有一个unique_ptr指向一个特定的对象。当unique_ptr被销毁时,它所指向的对象也会被销毁。
使用场景:
- 当你需要确保一个对象只被一个指针所拥有时。
- 当你需要自动管理资源,如文件句柄或互斥锁时。
示例代码:
#include <iostream>
#include <memory>class Test {
public:Test() { std::cout << "Test::Test()\n"; }~Test() { std::cout << "Test::~Test()\n"; }void test() { std::cout << "Test::test()\n"; }
};int main() {std::unique_ptr<Test> ptr(new Test());ptr->test();// 当ptr离开作用域时,它指向的对象会被自动销毁return 0;
}
2)std::shared_ptr
std::shared_ptr是一种共享所有权的智能指针,多个shared_ptr可以指向同一个对象。内部使用引l用计数来确保只有当最后一个指向对象的shared_ptr被销毁时,对象才会被销毁。
使用场景:
- 当你需要在多个所有者之间共享对象时。
- 当你需要通过复制构造函数或赋值操作符来复制智能指针时。
示例代码:
#include <iostream>
#include <memory>class Test {
public:Test() { std::cout << "Test::Test()\n"; }~Test() { std::cout << "Test::~Test()\n"; }void test() { std::cout << "Test::test()\n"; }
};int main() {std::shared_ptr<Test> ptr1(new Test());std::shared_ptr<Test> ptr2 = ptr1;ptr1->test();// 当ptr1和ptr2离开作用域时,它们指向的对象会被自动销毁return 0;
}
3)std::weak_ptr
std:weak_ptr是一种不拥有对象所有权的智能指针,它指向一个由std:shared_ptr管理的对象。weak_ptr用于解决shared_ptr之间的循环引用问题。
使用场景:
- 当你需要访问但不拥有由shared_ptr管理的对象时。
- 当你需要解决shared_ptr之间的循环引用问题时。
- 注意weak_ptr肯定要和shared_ptr搭配使用。
示例代码:
#include <iostream>
#include <memory>class B; // 前向声明class A {
public:// 使用 weak_ptr 避免循环引用std::weak_ptr<B> b_ptr;~A() {std::cout << "A 被销毁" << std::endl;}
};class B {
public:// B 持有 A 的 shared_ptrstd::shared_ptr<A> a_ptr;~B() {std::cout << "B 被销毁" << std::endl;}
};int main() {// 创建 A 和 B 对象std::shared_ptr<A> a = std::make_shared<A>();std::shared_ptr<B> b = std::make_shared<B>();// 建立双向引用a->b_ptr = b; // A 持有 B 的 weak_ptrb->a_ptr = a; // B 持有 A 的 shared_ptr// 通过 weak_ptr 访问对象if (auto temp = a->b_ptr.lock()) { // lock() 方法获取 shared_ptrstd::cout << "成功通过 weak_ptr 访问 B 对象" << std::endl;} else {std::cout << "B 对象已被销毁" << std::endl;}return 0;
}
这三种智能指针各有其用途,选择哪一种取决于你的具体需求。
65.2 扩展知识
1)智能指针方面的建议:
- 尽量使用智能指针,而非裸指针来管理内存,很多时候利用RAII机制管理内存肯定更靠谱安全的多。
- 如果没有多个所有者共享对象的需求,建议优先使用unique_ptr管理内存,它相对shared_ptr会更轻量一些。
- 在使用shared_ptr时,一定要注意是否有循环引l用的问题,因为这会导致内存泄漏。
- shared_ptr的引l用计数是安全的,但是里面的对象不是线程安全的,这点要区别开。
2)为什么std::unique_ptr可以做到不可复制,只可移动?
因为把拷贝构造函数和赋值运算符标记为了delete,见源码:
template <typename _Tp, typename _Tp_Deleter = default_delete<_Tp> >
class unique_ptr {// Disable copy from lvalue.unique_ptr(const unique_ptr&) = delete;template<typename _Up, typename _Up_Deleter> unique_ptr(const unique_ptr<_Up, _Up_Deleter>&) = delete;unique_ptr& operator=(const unique_ptr&) = delete;template<typename _Up, typename _Up_Deleter> unique_ptr& operator=(const unique_ptr<_Up, _Up_Deleter>&) = delete;
};
3) shared_ptr的原理:
每个std:shared_ptr对象包含两个成员变量:一个指向被管理对象的原始指针,一个指向引用计数块的指针(control block pointer)。
引用计数块是一个单独的内存块,引用计数块允许多个std:shared_ptr对象共享相同的引l用计数,从而实现共享所有权。
当创建一个新的std:shared_ptr时,引l用计数初始化为1,表示对象当前被一个shared_ptr管理。
1.拷贝std:shared_ptr:当用一个shared_ptr拷贝出另一个shared_ptr时,需要拷贝两个成员变量(被管理对象的原始指针和引l用计数块的指针),并同时将引用计数值加1。这样,多个shared_ptr对象可以共享相同的引用计数。
2.析构std:shared_ptr:当shared_ptr对象析构时,引用计数值减1。然后检测引l用计数是否为0。如果引用计数为0,说明没有其他shared_ptr对象指向该资源,因此需要同时删除原始对象(通过调用自定义删除器,如果有的话)。
4)智能指针的缺点
1.性能开销,需要额外的内存来存储他们的控制块,控制块包括引用计数,以及运行时的原子操作来增加或减少引用技术,这可能导致裸指针的性能下降。
2.循环引用问题,如果两个对象通过成员变量shared_ptr相互引用,并且没有其他指针指向这两个对象中的任何一个,那么这两个对象的内存将永远不会被释放,导致内存泄露。
#include<iostream>
#include<memory>
class B; // 前向声明
class A {
public:std::shared_ptr<B> b_ptr;~A() {std::cout << "A has been destroyed."<< std::endl;}
};
class B {
public:std::shared_ptr<A> a_ptr;~B() {std::cout << "B has been destroyed."<< std::endl;}
};
int main() {std::shared_ptr<A> a = std::make_shared<A>();std::shared_ptr<B> b = std::make_shared<B>();a->b_ptr = b; // A 引用 Bb->a_ptr = a; // B 引用 A// 由于存在循环引用,A 和 B 的析构函数将不会被调用,从而导致内存泄漏return 0;
}
六十六、头文件中的 ifndef/define/endif 的作用,及和 program once 的区别
66.1 回答重点
相同点:
- 它们的作用是防止头文件被重复包含。
不同点:
-
ifndef 由语言本身提供支持,但是 program once 一般由编译器提供支持,也就是说,有可能出现编译器不支持的情况(主要是比较老的编译器)。
-
通常运行速度上 ifndef 一般慢于 program once,特别是在大型项目上, 区别会比较明显,所以越来越多的编译器开始支持 program once。
-
ifndef 作用于某一段被包含(define 和 endif 之间)的代码, 而 program once 则是针对包含该语句的文件, 这也是为什么 program once 速度更快的原因。
-
如果用 ifndef 包含某一段宏定义,当这个宏名字出现“撞车”时,可能会出现这个宏在程序中提示宏未定义的情况(在编写大型程序时特别需要注意,因为有很多程序员在同时写代码)。相反由于program once 针对整个文件, 因此它不存在宏名字“撞车”的情况, 但是如果某个头文件被多次拷贝,program once 无法保证不被多次包含,因为program once 是从物理上判断是不是同一个头文件,而不是从内容上。
66.2 扩展知识
#pragma once
:现代、高效、简洁,推荐作为默认选择。#ifndef/#define/#endif
:兼容性强,适合复杂条件编译和跨平台项目
六十七、C++中有哪些类型的全局变量?
64.1 回答重点
1. 普通全局变量
普通全局变量是在函数和类的外部定义的变量,整个程序都能访问它。其作用域是从定义的位置开始,一直到文件结束。若想在其他文件中使用,需要借助extern
关键字进行声明。
int globalVar = 10; // 定义一个普通全局变量void func() {globalVar = 20; // 可以直接访问和修改全局变量
}
- 静态全局变量
静态全局变量同样是在函数和类的外部定义的,不过要使用static
关键字来修饰。它的作用域仅限于定义它的文件,其他文件无法访问。
static int staticGlobalVar = 5; // 定义一个静态全局变量void func() {staticGlobalVar++; // 只能在本文件中访问和修改
}
- 常量全局变量
常量全局变量使用const
或者constexpr
关键字来定义,一旦初始化之后就不能再修改。const
变量默认具有内部链接属性,也就是说每个文件都有自己独立的副本;而constexpr
变量默认具有外部链接属性。
const int constGlobalVar = 100; // 定义一个常量全局变量
constexpr double pi = 3.14159; // 定义一个常量表达式全局变量
- 外部全局变量
外部全局变量使用extern
关键字声明,这表明该变量在其他文件中已经定义过了。它主要用于在多个文件之间共享同一个全局变量。
// file1.cpp
int sharedVar = 20; // 定义共享的全局变量// file2.cpp
extern int sharedVar; // 声明外部全局变量void func() {sharedVar = 30; // 使用在其他文件中定义的全局变量
}
- 线程局部存储(TLS)全局变量
线程局部存储全局变量使用thread_local
关键字来定义,每个线程都会拥有这个变量的独立副本。
thread_local int threadSpecificVar = 0; // 定义线程局部存储全局变量void increment() {threadSpecificVar++; // 每个线程操作自己的副本
}
- 类静态成员变量
类静态成员变量是在类内部声明、在类外部定义的变量,它为所有类对象所共享。
class MyClass {
public:static int classStaticVar; // 声明类静态成员变量
};int MyClass::classStaticVar = 0; // 定义并初始化类静态成员变量
总结
类型 | 关键字 | 作用域 | 链接属性 | 特点 |
---|---|---|---|---|
普通全局变量 | 无 | 整个程序 | 外部 | 所有文件可访问 |
静态全局变量 | static | 当前文件 | 内部 | 仅本文件可访问 |
常量全局变量 | const/constexpr | 当前文件 / 整个程序 | 内部 / 外部 | 值不可修改 |
外部全局变量 | extern | 整个程序 | 外部 | 引用其他文件的变量 |
线程局部变量 | thread_local | 每个线程 | 内部 | 每个线程有独立副本 |
类静态成员变量 | static(类内) | 类作用域 | 外部 | 所有对象共享 |
在使用全局变量时,要谨慎考虑,因为不合理地使用全局变量可能会让代码的依赖性变得复杂,还会带来线程安全方面的问题。
67.2 扩展知识
全局变量的初始化和销毁时机(构造顺序、静态变量特性)?
六十八、sizeof相关
68.1 回答重点
-
sizeof计算的是在栈中分配的内存大小。
-
sizeof不计算static变量占得内存;
sizeof
不能直接计算类的静态成员变量的大小(因为静态成员属于类而非对象),但可以计算其类型的大小:class MyClass { public:static int staticVar; };// sizeof(MyClass::staticVar); // 错误:静态成员不在对象中 sizeof(decltype(MyClass::staticVar)); // 合法:等同于 sizeof(int)
-
32位系统的指针的大小是4个字节,64位系统的指针是8字节,而不用管指针类型;
int* ptr; char* cptr; void* vptr;sizeof(ptr); // 取决于系统架构(32位:4字节,64位:8字节) sizeof(cptr); // 同上 sizeof(vptr); // 同上
-
char型占1个字节,int占4个字节,short int占2个字节 long int占4个字节,float占4字节,double占8字节,string占4字节 一个空类占1个字节,单一继承的空类占1个字节,虚继承涉及到虚指针所以占4个字节
#include <iostream> #include <string>// 空类 class EmptyClass {};// 单一继承的空类 class DerivedEmpty : public EmptyClass {};// 虚继承的空类 class VirtualBase {}; class VirtualDerived : virtual public VirtualBase {};// 包含虚函数的类 class BaseWithVirtual {virtual void func() {} };// 包含数据成员的类 class DataClass {char c; // 1字节int i; // 4字节double d; // 8字节 };int main() {// 基本数据类型std::cout << "=== 基本数据类型 ===" << std::endl;std::cout << "char: " << sizeof(char) << " 字节" << std::endl; // 1 字节 (32/64位相同)std::cout << "short: " << sizeof(short) << " 字节" << std::endl; // 2 字节 (32/64位相同)std::cout << "int: " << sizeof(int) << " 字节" << std::endl; // 4 字节 (32/64位相同)std::cout << "long: " << sizeof(long) << " 字节" << std::endl; // 4 字节 (32位) / 8 字节 (64位)std::cout << "long long: " << sizeof(long long) << " 字节" << std::endl; // 8 字节 (32/64位相同)std::cout << "float: " << sizeof(float) << " 字节" << std::endl; // 4 字节 (32/64位相同)std::cout << "double: " << sizeof(double) << " 字节" << std::endl; // 8 字节 (32/64位相同)std::cout << "void*: " << sizeof(void*) << " 字节" << std::endl; // 4 字节 (32位) / 8 字节 (64位)// 复合类型std::cout << "\n=== 复合类型 ===" << std::endl;std::cout << "std::string: " << sizeof(std::string) << " 字节" << std::endl; // 典型值: 24 字节 (64位) / 16 字节 (32位)// 类和继承std::cout << "\n=== 类和继承 ===" << std::endl;std::cout << "空类: " << sizeof(EmptyClass) << " 字节" << std::endl; // 1 字节 (32/64位相同)std::cout << "单一继承的空类: " << sizeof(DerivedEmpty) << " 字节" << std::endl; // 1 字节 (32/64位相同)std::cout << "虚继承的空类: " << sizeof(VirtualDerived) << " 字节" << std::endl; // 8 字节 (64位) / 4 字节 (32位)std::cout << "含虚函数的类: " << sizeof(BaseWithVirtual) << " 字节" << std::endl; // 8 字节 (64位) / 4 字节 (32位)// 数据成员对齐std::cout << "\n=== 内存对齐 ===" << std::endl;std::cout << "DataClass: " << sizeof(DataClass) << " 字节" << std::endl; // 16 字节 (64位系统,按8字节对齐) / 12 字节 (32位系统,按4字节对齐)return 0; }
-
数组的长度: 若指定了数组长度,则不看元素个数,总字节数=数组长度*sizeof(元素类型) 若没有指定长度,则按实际元素个数类确定 Ps:若是字符数组,则应考虑末尾的空字符。
#include <iostream> using namespace std;int main() {// 1. 指定长度的数组(元素不足时补默认值)int arr1[5] = {1, 2}; // 指定长度为5,但只初始化2个元素cout << "arr1大小: " << sizeof(arr1) << " 字节" << endl; // 5*4=20字节cout << "arr1长度: " << sizeof(arr1)/sizeof(arr1[0]) << endl; // 5// 2. 未指定长度的数组(根据初始化元素个数确定)int arr2[] = {1, 2, 3, 4, 5}; // 未指定长度,自动推导为5cout << "arr2大小: " << sizeof(arr2) << " 字节" << endl; // 5*4=20字节cout << "arr2长度: " << sizeof(arr2)/sizeof(arr2[0]) << endl; // 5// 3. 字符数组的特殊情况(自动添加空字符'\0')char str1[] = "Hello"; // 未指定长度,自动包含末尾的'\0'cout << "str1大小: " << sizeof(str1) << " 字节" << endl; // 6字节('H','e','l','l','o','\0')cout << "str1长度: " << sizeof(str1)/sizeof(str1[0]) << endl; // 6// 4. 指定长度的字符数组(需手动处理空字符)char str2[5] = "Hi"; // 指定长度为5,初始化2个字符+'\0'cout << "str2大小: " << sizeof(str2) << " 字节" << endl; // 5字节cout << "str2长度: " << sizeof(str2)/sizeof(str2[0]) << endl; // 5(包含未初始化的元素)// 5. 手动初始化字符数组(不含空字符则不是C风格字符串)char str3[] = {'H', 'i'}; // 未指定长度,仅2个字符,不含'\0'cout << "str3大小: " << sizeof(str3) << " 字节" << endl; // 2字节cout << "str3长度: " << sizeof(str3)/sizeof(str3[0]) << endl; // 2return 0; }
-
结构体对象的长度 在默认情况下,为方便对结构体内元素的访问和管理,当结构体内元素长度小于处理器位数的时候,便以结构体内最长的数据元素的长度为对齐单位,即为其整数倍。若结构体内元素长度大于处理器位数则以处理器位数为单位对齐。
#include <iostream> using namespace std;// 示例1:按最长元素对齐(4字节) struct S1 {char a; // 1字节int b; // 4字节short c; // 2字节 }; // 布局:[a][填充3][bbbb][cc][填充2] → 总大小12字节// 示例2:不同顺序的成员(同样4字节对齐) struct S2 {int b; // 4字节short c; // 2字节char a; // 1字节 }; // 布局:[bbbb][cc][a][填充1] → 总大小8字节// 示例3:包含double(8字节对齐) struct S3 {char a; // 1字节double b; // 8字节int c; // 4字节 }; // 布局:[a][填充7][bbbbbbbb][cccc][填充4] → 总大小24字节// 示例4:紧凑排列的成员(8字节对齐) struct S4 {double b; // 8字节int c; // 4字节char a; // 1字节 }; // 布局:[bbbbbbbb][cccc][a][填充3] → 总大小16字节// 示例5:包含数组(按元素类型对齐) struct S5 {char a; // 1字节int b[2]; // 2*4=8字节short c; // 2字节 }; // 布局:[a][填充3][bbbb][bbbb][cc][填充2] → 总大小16字节// 示例6:嵌套结构体(内部结构体按其最大成员对齐) struct Inner {char x; // 1字节int y; // 4字节 }; // 内部大小8字节:[x][填充3][yyyy]struct S6 {Inner in; // 8字节double d; // 8字节 }; // 布局:[in.in][in.in][in.in][in.in][in.in][in.in][in.in][in.in][dddddddd] → 总大小16字节int main() {cout << "sizeof(S1): " << sizeof(S1) << " 字节" << endl; // 输出12cout << "sizeof(S2): " << sizeof(S2) << " 字节" << endl; // 输出8cout << "sizeof(S3): " << sizeof(S3) << " 字节" << endl; // 输出24cout << "sizeof(S4): " << sizeof(S4) << " 字节" << endl; // 输出16cout << "sizeof(S5): " << sizeof(S5) << " 字节" << endl; // 输出16cout << "sizeof(S6): " << sizeof(S6) << " 字节" << endl; // 输出16return 0; }
-
unsigned影响的只是最高位的意义,数据长度不会改变,所以sizeof(unsigned int)=4
-
自定义类型的sizeof取值等于它的类型原型取sizeof
-
对函数使用sizeof,在编译阶段会被函数的返回值的类型代替
#include <iostream> using namespace std;int func() { return 42; } // 返回 int 类型的函数int main() {// 对函数名使用 sizeof,等价于 sizeof(int)cout << "sizeof(func): " << sizeof(func) << endl; // 输出 4(int 类型大小)cout << "sizeof(int): " << sizeof(int) << endl; // 输出 4// 注意:这里不会调用函数,而是直接使用返回值类型return 0; }
-
sizeof后如果是类型名则必须加括号,如果是变量名可以不加括号,这是因为sizeof是运算符
#include <iostream> using namespace std;int main() {int x = 10;// 对变量使用sizeof,可以不加括号cout << sizeof x << endl; // 合法:输出4(变量x的大小)cout << sizeof(x) << endl; // 同样合法// 对类型名使用sizeof,必须加括号cout << sizeof(int) << endl; // 合法:输出4(int类型的大小)// cout << sizeof int << endl; // 错误:编译失败!// 复杂类型也必须加括号cout << sizeof(double*) << endl; // 合法// cout << sizeof double* << endl; // 错误// 对表达式使用sizeof,通常加括号提高可读性cout << sizeof(x + 5) << endl; // 合法:输出4(表达式结果类型为int)return 0; }
-
当使用结构类型或者变量时,sizeof返回实际的大小。当使用静态数组时返回数组的全部大小,sizeof不能返回动态数组或者外部数组的尺寸
#include <iostream> using namespace std;// 结构体(对齐后占8字节) struct S { char c; int i; };int main() {// 1. 结构体类型/变量cout << sizeof(S) << endl; // 输出8(类型大小)S obj;cout << sizeof(obj) << endl; // 输出8(变量大小)// 2. 静态数组(编译时已知大小)int staticArr[5];cout << sizeof(staticArr) << endl; // 输出20(5*4)cout << sizeof(staticArr)/sizeof(int) << endl; // 正确计算元素个数:5// 3. 动态数组(运行时分配,仅返回指针大小)int* dynamicArr = new int[10];cout << sizeof(dynamicArr) << endl; // 输出8(指针大小,非数组实际大小)delete[] dynamicArr;// 4. 字符串字面量(隐式包含'\0')char str[] = "hello";cout << sizeof(str) << endl; // 输出6(5个字符 + '\0')return 0; }
-
六十九、模板类型推导
69.1 回答重点
模板类型推导是 C++ 模板编程中的核心机制,编译器通过函数实参自动推断模板参数的具体类型。
69.1.1 初级模板推导:
#include <iostream>
using namespace std;// 模板函数:参数类型为T
template<typename T>
T add(T a, T b) {return a + b;
}int main() {// 编译器自动推导T为intcout << add(3, 5) << endl; // 输出8(T=int)// 编译器自动推导T为doublecout << add(3.14, 2.71) << endl; // 输出5.85(T=double)// 显式指定T为floatcout << add<float>(1.5, 2.5) << endl; // 输出4.0(T=float)return 0;
}
- 自动推导类型
- 调用
add(3, 5)
时,实参为int
,编译器自动推导T = int
。 - 调用
add(3.14, 2.71)
时,实参为double
,编译器自动推导T = double
。
- 调用
- 实参类型必须一致
- 若传递不同类型的实参(如
add(3, 3.14)
),会导致编译错误(除非使用重载或类型转换)。
- 若传递不同类型的实参(如
- 显式指定类型
- 通过
add<float>(1.5, 2.5)
可以强制指定模板参数类型,避免自动推导。
- 通过
69.1.2 进阶类型推导
// 模板参数为引用类型
template<typename T>
void printRef(const T& value) {cout << value << endl;
}// 模板参数为数组
template<typename T, size_t N>
size_t arraySize(T (&arr)[N]) {return N; // 返回数组的真实大小
}int main() {string hello = "Hello";printRef(hello); // T = string,参数为 const string&int arr[5];cout << arraySize(arr) << endl; // 输出5(通过引用推导数组大小)return 0;
}
核心特性总结:
场景 | 示例 | 推导结果 |
---|---|---|
基本类型 | add(1, 2) | T = int |
常量表达式 | add(3.14, 2.71) | T = double |
引用类型 | printRef(str) | T = string ,参数为 const string& |
数组类型 | arraySize(arr) | T = int ,N = 5 |
模板类型推导是泛型编程的基础,使代码能够自动适应不同的数据类型,提高复用性。
69.2 扩展知识
假设有这样的函数模板和这样的调用:
template<typename T>
void f(ParamType param);
f(expr); //使用表达式调用f
T
的类型推导不仅取决于expr
的类型,也取决于ParamType
的类型。下面份三种情况讨论这个事情。
情况 | ParamType 形式 | 推导规则 |
---|---|---|
1 | 指针 / 引用(非万能引用) | 忽略顶层const / 引用,匹配实参与形参类型 |
2 | 万能引用(T&& ) | 左值推导为左值引用,右值推导为非引用类型 |
3 | 按值传递(T ) | 忽略顶层const / 引用,实参退化为指针 / 函数指针 |
掌握这三种情况是理解 C++ 模板类型推导的关键,也是编写高效泛型代码的基础。
情况1: ParamType是一个指针或引用,但不是万能引用
template<typename T>
void f(ParamType param);
f(expr); //使用表达式调用f
在这种情况下,类型推导会这样进行:
-
如果
expr
的类型是一个引用,忽略引用部分 -
然后
expr
的类型与ParamType
进行模式匹配来决定T
有如下例子:
template<typename T>
void f(T& param); //param是一个引用
int x=27; //x是int
const int cx = x; //cx是const int
const int& rx = x; //rx是指向作为const int的x的引用
//不同的调用中,对param和T推导的类型会是这样
f(x); //T是int,param的类型是int&
f(cx); //T是const int,param的类型是const int&
f(rx); //T是const int,param的类型是const int&
在第二个和第三个调用中,注意因为cx
和rx
被指定为const
值,所以T
被推导为const int
。
因为他们传递一个const
对象给一个引用类型的形参时,他们期望对象保持不可改变性,也就是说,形参是reference-to-const
的。这也是为什么将一个const
对象传递给以T&
类型为形参的模板安全的:对象的常量性const
ness会被保留为T
的一部分。
在第三个例子中,注意即使rx
的类型是一个引用,T
也会被推导为一个非引用 ,因为rx
的引用性(reference-ness)在类型推导中会被忽略。
如果我们将f
的形参类型T&
改为const T&
,情况有所变化:
template<typename T>
void f(const T& param); //param现在是reference-to-const
int x=27; //x是int
const int cx = x; //cx是const int
const int& rx = x; //rx是指向作为const int的x的引用
f(x); //T是int,param的类型是const int&
f(cx); //T是int,param的类型是const int&
f(rx); //T是int,param的类型是const int&
cx
和rx
的const
ness依然被遵守,但是因为现在我们假设param
是reference-to-const
,const
不再被推导为T
的一部分:
如果param
是一个指针(或者指向const
的指针)而不是引用,情况本质上也一样:
template<typename T>
void f(T* param); //param现在是指针
int x = 27; //x是int
const int *px = &x; //px是指向作为const int的x的指针
f(&x); //T是int,param的类型是int*
f(px); //T是const int,param的类型是const int*
情况2:ParamType是一个通用引用
template<typename T>
void f(ParamType param);
f(expr); //使用表达式调用f
其推导规则如下:
-
如果
expr
是左值,T
和ParamType
都会被推导为左值引用。 -
如果
expr
是右值,就使用正常的(也就是情景一)推导规则
示例如下:
template<typename T>
void f(T&& param); //param现在是一个通用引用类型
int x=27; //x是int
const int cx = x; //cx是const int
const int& rx = x; //rx是指向作为const int的x的引用
f(x); //x是左值,所以T是int&,//param类型也是int&
f(cx); //cx是左值,所以T是const int&,//param类型也是const int&
f(rx); //rx是左值,所以T是const int&,//param类型也是const int&
f(27); //27是右值,所以T是int,//param类型就是int&&
情况3:ParamType既不是指针也不是引用
template<typename T>
void f(ParamType param);
f(expr); //使用表达式调用f
当ParamType
既不是指针也不是引用时,会通过传值(pass-by-value)的方式处理,这意味着无论传递什么param
都会成为它的一份拷贝——一个完整的新对象。事实上param
成为一个新对象这一行为会影响T
如何从expr
中推导出结果。
-
和之前一样,如果
expr
的类型是一个引用,忽略这个引用部分 -
如果忽略
expr
的引用性之后,expr
是一个const
,那就再忽略const
(只消除自身的常量性)。如果它是volatile
,也忽略volatile
示例如下:
template<typename T>
void f(T param); // ParamType 是 T(按值传递)int x = 10;
const int cx = x;
const int& rx = cx;
int* const ptr = &x; // 指向 int 的常量指针f(x); // T 推导为 int,param 类型为 int
f(cx); // T 推导为 int,param 类型为 int(忽略 cx 的 const)
f(rx); // T 推导为 int,param 类型为 int(忽略 rx 的引用和 const)
f(ptr); // T 推导为 int*,param 类型为 int*(忽略 ptr 的 const,但保留底层 const)
注意即使cx
和rx
表示const
值,param
也不是const
。这是有意义的。param
是一个完全独立于cx
和rx
的对象——是cx
或rx
的一个拷贝。
但是考虑这样的情况, expr
是一个const
指针,指向const
对象,expr
通过传值传递给param
:
template<typename T>
void f(T param); //仍然以传值的方式处理param
const char* const ptr = //ptr是一个常量指针,指向常量对象"Fun with pointers";
f(ptr); //传递const char *类型的实参 /推导为const char*,param类型为const char*
在这里,解引用符号 * 的右边的const
表示ptr
本身是一个const
:ptr
不能被修改为指向其它地址,也不能被设置为null(解引用符号左边的const
表示ptr
指向一个字符串,这个字符串是const
,因此字符串不能被修改)。当ptr
作为实参传给f
,组成这个指针的每一比特都被拷贝进param
。像这种情况,ptr
自身的值会被传给形参,根据类型推导的第三条规则,ptr
自身的常量性const
ness将会被省略,所以param
是const char*
,也就是一个可变指针指向const
字符串。在类型推导中,这个指针指向的数据的常量性const
ness将会被保留,但是当拷贝ptr
来创造一个新指针param
时,ptr
自身的常量性const
ness将会被忽略。
七十、auto 类型推导
[参考爱编程的大丙][https://subingwen.cn/cpp/autotype/]
70.1 回答重点
背景目的:(C++11 特性、简化语法)
使用注意点:
- 使用auto声明的变量必须要进行初始化,以让编译器推导出它的实际类型,在编译时将auto占位符替换为真正的类型。
auto x = 3.14; // x 是浮点型 double
auto y = 520; // y 是整形 int
auto z = 'a'; // z 是字符型 char
auto nb; // error,变量必须要初始化
auto double nbl; // 语法错误, 不能修改数据类型
- 当变量不是指针或者引用类型时,推导的结果中不会保留const、volatile关键字
- 当变量是指针或者引用类型时,推导的结果中会保留const、volatile关键字
四个不能:
-
①不能作为函数参数使用。因为只有在函数调用的时候才会给函数参数传递实参,auto要求必须要给修饰的变量赋值,因此二者矛盾。
int func(auto a, auto b) // error { cout << "a: " << a <<", b: " << b << endl; }
-
②不能用于类的非静态成员变量的初始化
class Test {auto v1 = 0; // errorstatic auto v2 = 0; // error,类的静态非常量成员不允许在类内部直接初始化static const auto v3 = 10; // ok }
-
③不能使用auto关键字定义数组
int func() {int array[] = {1,2,3,4,5}; // 定义数组auto t1 = array; // ok, t1被推导为 int* 类型auto t2[] = array; // error, auto无法定义数组auto t3[] = {1,2,3,4,5};; // error, auto无法定义数组 }
-
④无法使用auto推导出模板参数
template <typename T> struct Test{}int func() {Test<double> t;Test<auto> t1 = t; // error, 无法推导出模板类型return 0; }
2个应用:
-
①遍历容器
#include <map> int main() {map<int, string> person;/*C++11之前*/map<int, string>::iterator it = person.begin();for (; it != person.end(); ++it){// do something}#include <map>/*引入了auto之后*/for (auto it = person.begin(); it != person.end(); ++it){// do something}return 0; }
-
②泛型编程:在使用模板的时候,很多情况下我们不知道变量应该定义为什么类型,比如下面的代码
#include <iostream> #include <string> using namespace std;class T1 { public:static int get(){return 10;} };class T2 { public:static string get(){return "hello, world";} };template <class A> void func(void) {auto val = A::get();cout << "val: " << val << endl; }int main() {func<T1>();func<T2>();return 0; }
在这个例子中定义了泛型函数func,在函数中调用了类A的静态方法 get() ,这个函数的返回值是不能确定的,如果不使用auto,就需要再定义一个模板参数,并且在外部调用时手动指定get的返回值类型,具体代码如下:
#include <iostream> #include <string> using namespace std;class T1 { public:static int get(){return 0;} };class T2 { public:static string get(){return "hello, world";} };template <class A, typename B> // 添加了模板参数 B void func(void) {B val = A::get();cout << "val: " << val << endl; }int main() {func<T1, int>(); // 手动指定返回值类型 -> intfunc<T2, string>(); // 手动指定返回值类型 -> stringreturn 0; }
70.2 扩展知识
原理基础:auto
是建立在模板类型推导的基础上的。
auto
类型推导和模板类型推导有一个直接的映射关系,比如以下模板:
template<typename T>
void f(ParmaType param);
f(expr); //使用一些表达式调用f
当一个变量使用auto
进行声明时,auto
扮演了模板中T
的角色,变量的类型说明符扮演了ParamType
的角色。
比如以下示例:
auto x = 27; //这里的x的类型说明符是auto自己
const auto cx = x; //这里的x的类型说明符是const auto
const auto & rx=cx; //这里的x的类型说明符是const auto &
因此Item1描述的三个情景稍作修改就能适用于auto:
-
情景一:类型说明符是一个指针或引用但不是通用引用
-
情景二:类型说明符一个通用引用
-
情景三:类型说明符既不是指针也不是引用
auto
类型推导和模板类型推导几乎一样的工作,它们就像一个硬币的两面,除了一个例外。下面来说说这个例外。
auto
类型推导和模板类型推导的真正区别在于,auto
类型推导假定花括号表示std::initializer_list
而模板类型推导不会这样。
auto x1 = 27; //类型是int,值是27
auto x2(27); //同上
auto x3 = { 27 }; //类型是std::initializer_list<int>,值是{ 27 }
auto x4{ 27 }; //同上
template<typename T> //带有与x的声明等价的
void f(T param); //形参声明的模板
f({ 11, 23, 9 }); //错误!不能推导出T
然而如果在模板中指定T
是std::initializer_list
而留下未知T
,模板类型推导就能正常工作:
template<typename T>
void f(std::initializer_list<T> initList);
f({ 11, 23, 9 }); //T被推导为int,initList的类型为//std::initializer_list<int>
C++14允许auto
用于函数返回值并会被推导,也允许lambda函数在形参声明中使用auto
。但是在这些情况下auto
实际上使用模板类型推导的那一套规则在工作,而不是auto
类型推导,所以说下面这样的代码不会通过编译:
auto createInitList()
{return { 1, 2, 3 }; //错误!不能推导{ 1, 2, 3 }的类型
}
std::vector<int> v;
…
auto resetV =[&v](const auto& newValue){ v = newValue; }; //C++14
…
resetV({ 1, 2, 3 }); //错误!不能推导{ 1, 2, 3 }的类型
七十一、decltype
71.1 回答重点
decltype 是“declare type”的缩写,意思是“声明类型”。decltype的推导是在编译期完成的,它只是用于表达式类型的推导,并不会计算表达式的值。来看一组简单的例子:
int a = 10;
decltype(a) b = 99; // b -> int
decltype(a+3.14) c = 52.13; // c -> double
decltype(a+b*c) d = 520.1314; // d -> double
可以看到decltype推导的表达式可简单可复杂,在这一点上auto是做不到的,auto只能推导已初始化的变量类型。
71.1.1 三个场景
通过上面的例子我们初步感受了一下 decltype 的用法,但不要认为 decltype 就这么简单,在它简单的背后隐藏着很多的细节,下面分三个场景依次讨论一下:
①表达式为普通变量或者普通表达式或者类表达式,在这种情况下,使用decltype推导出的类型和表达式的类型是一致的
#include <iostream>
#include <string>
using namespace std;class Test
{
public:string text;static const int value = 110;
};int main()
{int x = 99;const int &y = x;decltype(x) a = x; //变量`a`被推导为 int类型decltype(y) b = x; //变量`b`被推导为 const int &类型decltype(Test::value) c = 0; //变量`c`被推导为 const int类型Test t;decltype(t.text) d = "hello, world"; //变量`d`被推导为 string类型return 0;
}
②表达式是函数调用,使用decltype推导出的类型和函数返回值一致。
class Test{...};
//函数声明
int func_int(); // 返回值为 int
int& func_int_r(); // 返回值为 int&
int&& func_int_rr(); // 返回值为 int&&const int func_cint(); // 返回值为 const int
const int& func_cint_r(); // 返回值为 const int&
const int&& func_cint_rr(); // 返回值为 const int&&const Test func_ctest(); // 返回值为 const Test//decltype类型推导
int n = 100;
decltype(func_int()) a = 0; //变量a被推导为 int类型
decltype(func_int_r()) b = n; //变量b被推导为 int&类型
decltype(func_int_rr()) c = 0; //变量c被推导为 int&&类型
decltype(func_cint()) d = 0; //变量d被推导为 int类型
decltype(func_cint_r()) e = n; //变量e被推导为 const int &类型
decltype(func_cint_rr()) f = 0; //变量f被推导为 const int &&类型
decltype(func_ctest()) g = Test(); //变量g被推导为 const Test类型
函数 func_cint() 返回的是一个纯右值(在表达式执行结束后不再存在的数据,也就是临时性的数据),对于纯右值而言,只有类类型可以携带const、volatile限定符,除此之外需要忽略掉这两个限定符,因此推导出的变量d的类型为 int 而不是 const int。
③表达式是一个左值,或者被括号( )包围,使用 decltype推导出的是表达式类型的引用(如果有const、volatile限定符不能忽略)。
#include <iostream>
#include <vector>
using namespace std;class Test
{
public:int num;
};int main() {const Test obj;//带有括号的表达式decltype(obj.num) a = 0; //obj.num 为类的成员访问表达式,符合场景1,因此 a 的类型为intdecltype((obj.num)) b = a;//obj.num 带有括号,符合场景3,因此b 的类型为 const int&。//加法表达式int n = 0, m = 0;decltype(n + m) c = 0;//n+m 得到一个右值,符合场景1,因此c的类型为 intdecltype(n = n + m) d = n;//n=n+m 得到一个左值 n,符合场景3,因此d的类型为 int&return 0;
}
71.2 扩展知识
decltype
总是不加修改的产生变量或者表达式的类型。
对于T
类型的不是单纯的变量名的左值表达式,decltype
总是产出T
的引用即T&
。
作用于右值表达式,返回一个变量类型,非引用
C++14支持decltype(auto)
,就像auto
一样,推导出类型,但是它使用decltype
的规则进行推导。
以下说明decltype的一个重要的用法:
有如下函数:
template<typename Container, typename Index> //可以工作,
auto authAndAccess(Container& c, Index i) //但是需要改良
{authenticateUser();return c[i];
}
std::deque<int> d;
…
authAndAccess(d, 5) = 10; //认证用户,返回d[5],//然后把10赋值给它//无法通过编译器!
对一个T
类型的容器使用operator[]
通常会返回一个T&
对象,比如std::deque
就是这样。(但是std::vector
有一个例外,对于std::vector<bool>
,operator[]
不会返回bool&
,它会返回一个全新的对象, 这是一个例外)。
函数返回类型中使用auto
,编译器实际上是使用的模板类型推导的那套规则。如果那样的话这里就会有一些问题。正如我们之前讨论的,operator[]
对于大多数T
类型的容器会返回一个T&
,但是在模板类型推导期间,表达式的引用性(reference-ness)会被忽略。基于这样的规则,在这里d[5]
本该返回一个int&
,但是模板类型推导会剥去引用的部分,因此产生了int
返回类型。函数返回的那个int
是一个右值,上面的代码尝试把10赋值给右值int
,C++11禁止这样做,所以代码无法编译。
要想让authAndAccess
像我们期待的那样工作,我们需要使用decltype
类型推导来推导它的返回值,C++期望在某些情况下当类型被暗示时需要使用decltype
类型推导的规则,C++14通过使用decltype(auto)
说明符使得这成为可能。
template<typename Container, typename Index> //最终的C++14版本
decltype(auto)
authAndAccess(Container&& c, Index i)
{authenticateUser();return std::forward<Container>(c)[i];
}
decltype(auto)
的使用不仅仅局限于函数返回类型,当你想对初始化表达式使用decltype
推导的规则,你也可以使用:
Widget w;
const Widget& cw = w;
auto myWidget1 = cw; //auto类型推导//myWidget1的类型为Widget
decltype(auto) myWidget2 = cw; //decltype类型推导//myWidget2的类型是const Widget&
七十二、类的六种特殊的成员函数
72.1 回答重点
类的组成部分、类有六种特殊的成员函数,分别是:
- 默认构造函数(Default Constructor)
- 功能:创建对象时自动调用,初始化对象。
- 形式:无参数或所有参数都有默认值的构造函数。
- 自动生成条件:当类没有显式定义任何构造函数时,编译器生成默认构造函数。
示例:
class MyClass {
public:// 默认构造函数(用户未定义时,编译器自动生成)MyClass() = default; // 显式请求默认实现
};
- 析构函数(Destructor)
- 功能:对象销毁时自动调用,释放资源(如动态内存、文件句柄等)。
- 形式:类名前加波浪号
~
,无参数、无返回值。 - 自动生成条件:当类没有显式定义析构函数时,编译器生成默认析构函数。
示例:
class MyClass {
public:~MyClass() {// 清理资源(如delete动态分配的内存)}
};
- 拷贝构造函数(Copy Constructor)
- 功能:用一个已存在的对象初始化新对象时调用。
- 形式:参数为
const 类名&
(常量左值引用)。 - 自动生成条件:当类没有显式定义拷贝构造函数时,编译器生成默认拷贝构造函数(逐成员复制)。
示例:
class MyClass {
public:// 拷贝构造函数MyClass(const MyClass& other) {// 复制成员变量}
};
- 拷贝赋值运算符(Copy Assignment Operator)
- 功能:将一个已存在的对象赋值给另一个已存在的对象时调用。
- 形式:返回
类名&
,参数为const 类名&
。 - 自动生成条件:当类没有显式定义拷贝赋值运算符时,编译器生成默认版本(逐成员赋值)。
示例:
class MyClass {
public:// 拷贝赋值运算符MyClass& operator=(const MyClass& other) {if (this != &other) {// 复制成员变量}return *this;}
};
- 移动构造函数(Move Constructor)
- 功能:用一个临时对象(右值)初始化新对象时调用,高效转移资源所有权。
- 形式:参数为
类名&&
(右值引用)。 - 自动生成条件:当类没有显式定义移动构造函数、拷贝构造函数、拷贝赋值运算符和析构函数时,编译器生成默认移动构造函数。
示例
class MyClass {
public:// 移动构造函数MyClass(MyClass&& other) noexcept {// 转移资源所有权(如指针)}
};
- 移动赋值运算符(Move Assignment Operator)
- 功能:将一个临时对象(右值)赋值给另一个已存在的对象时调用,高效转移资源所有权。
- 形式:返回
类名&
,参数为类名&&
。 - 自动生成条件:当类没有显式定义移动赋值运算符、拷贝构造函数、拷贝赋值运算符和析构函数时,编译器生成默认移动赋值运算符。
示例:
class MyClass {
public:// 移动赋值运算符MyClass& operator=(MyClass&& other) noexcept {if (this != &other) {// 转移资源所有权}return *this;}
};
72.2 扩展知识
关于直接初始化、拷贝初始化和拷贝赋值函数的区别:
string dots (10, '.'); //直接初始化
string s(dots); //直接初始化
string s2 = dots; //拷贝初始化
string null_book = "9-999-99999-9"; //拷贝初始化
string nines = string(lOO, '9'); //拷贝初始化
拷贝初始化,是需要使用拷贝构造函数或移动构造函数完成的初始化
拷贝构造函数使用传入对象的值生成一个新的对象的实例,而赋值运算符是将对象的值复制给一个已经存在的实例
一个完整的关于六种特殊成员函数的示例:
class A
{
public:A() = default;A(const A &a);A& operator=(const A &a);A(A &&a) noexcept;A& operator=(A &&a) noexcept;~A();
private:int a;
}
C++11对于编译器生成的特殊成员函数处理的规则如下:
-
默认构造函数:仅当类不存在用户声明的构造函数时才自动生成(还需要符合是 nontrivial 函数)。
-
析构函数:析构函数默认
noexcept
。当基类析构为虚函数时该类析构自动为虚函数。 -
拷贝构造函数和拷贝赋值运算符:逐成员拷贝non-static数据。仅当类没有用户定义的移动操作时才生成(还需要符合是 nontrivial 函数)。
-
移动构造函数和移动赋值运算符:都对非static数据执行逐成员移动。仅当类没有用户定义的移动操作才自动生成。
C++的静态成员:受访问控制符的控制,都需要在类外去定义。
编译器生成的函数都是public的
七十三、类的特点
73.1 回答重点
C++ class类中成员有三个访问级别,分别是private、protect、public
,默认是private
。
如果类中有构造函数,那么类对象被创建时首先会调用其构造函数;
如果有析构函数,那么类对象被销毁前会先调用析构函数。
类的友元函数和友元类,可以直接访问类中的任何成员,不受访问级别的控制。
类可以被继承,继承一个类,则相当于拥有了这个类中除构造和析构外的全部成员,只是对public
和protect
成员才有访问权限。
七十四、构造顺序和析构顺序
74.1 回答重点
#include <iostream>
using namespace std;// 基类A
class A {
public:A() { cout << "A::A()" << endl; }~A() { cout << "A::~A()" << endl; }
};// 基类B
class B {
public:B() { cout << "B::B()" << endl; }~B() { cout << "B::~B()" << endl; }
};// 成员类X
class X {
public:X() { cout << "X::X()" << endl; }~X() { cout << "X::~X()" << endl; }
};// 成员类Y
class Y {
public:Y() { cout << "Y::Y()" << endl; }~Y() { cout << "Y::~Y()" << endl; }
};// 派生类C,继承自A和B
class C : public A, public B {
private:X x; // 先声明Y y; // 后声明
public:C() { cout << "C::C()" << endl; }~C() { cout << "C::~C()" << endl; }
};int main() {cout << "Constructing C object..." << endl;C c;cout << "\nDestroying C object..." << endl;return 0;
}
输出:
Constructing C object...
A::A() ← 基类A构造(按继承列表顺序)
B::B() ← 基类B构造
X::X() ← 成员x构造(按声明顺序)
Y::Y() ← 成员y构造
C::C() ← 派生类构造函数体Destroying C object...
C::~C() ← 派生类析构函数体
Y::~Y() ← 成员y析构(逆序)
X::~X() ← 成员x析构
B::~B() ← 基类B析构(逆序)
A::~A() ← 基类A析构
74.2 扩展知识
阶段 | 顺序规则 | 示例(类 C 继承自 A、B,含成员 x、y) |
---|---|---|
构造 | 1. 基类(按继承列表顺序) 2. 成员(按声明顺序) 3. 派生类函数体 | A → B → x → y → C |
析构 | 1. 派生类函数体 2. 成员(逆序) 3. 基类(逆序) | C → y → x → B → A |
注意:
-
初始化列表顺序无关性
成员变量的初始化顺序由声明顺序决定,与初始化列表中的顺序无关。错误的初始化列表顺序可能导致逻辑错误:class Test {int a;int b; public:Test() : b(a), a(10) {} // 危险!b先被初始化为未定义的a值 };
-
虚析构函数
当通过基类指针删除派生类对象时,基类析构函数必须声明为virtual
,否则只会调用基类析构函数,导致派生类资源泄漏:class Base { public:virtual ~Base() {} // 声明为virtual,确保正确析构派生类对象 };
3.静态对象和全局对象
- 静态对象的构造在程序启动时执行(按编译单元顺序),析构在程序结束时执行(逆序)。
- 局部静态对象在首次调用时构造,程序结束时析构。
七十五、匿名函数 lambda
(语法、捕获列表、函数对象替代)
爱编程的大丙参考
75.1 回答重点
其中capture是捕获列表,params是参数列表,opt是函数选项,ret是返回值类型,body是函数体。
[capture](params) opt -> ret {body;};
功能:
- 声明式的编程风格:就地匿名定义目标函数或函数对象,不需要额外写一个命名函数或函数对象 ,让程序更加的简介。
- C++ Lambda 是 C++11 标准引入的一种匿名函数对象,它允许你在需要的地方内联定义轻量级的函数,无需显式定义命名函数或函数对象类。其核心作用是简化回调函数、算法参数的传递,让代码更简洁高效。
应用:
- 作为 STL 算法的回调
#include <algorithm>
#include <vector>std::vector<int> nums = {1, 2, 3, 4, 5};
// 使用Lambda实现元素平方
std::transform(nums.begin(), nums.end(), nums.begin(), [](int x) { return x * x; });
- 自定义排序规则
struct Person {std::string name;int age;
};std::vector<Person> people = {{"Alice", 25}, {"Bob", 20}};
// 按年龄升序排序
std::sort(people.begin(), people.end(), [](const Person& a, const Person& b) { return a.age < b.age; });
- 线程中的异步任务
#include <thread>int result = 0;
std::thread t([&result]() { // 引用捕获resultresult = 100;
});
t.join();
七十六、std::function、std::bind 用法
76.1 回答重点
C++语言中有五种可调用对象:函数、函数指针、lambda表达式、bind创建的对象以及重载了函数调用运算符的类。
在设计回调函数的时候,无可避免地会接触到可回调对象。在C++11中,提供了std::function和std::bind两个方法来对可回调对象进行统一和封装。
76.1.1 std::function
:通用多态函数包装器
- 作用
std::function
是一个模板类,用于存储、复制和调用任何可调用对象(函数、函数指针、成员函数指针、Lambda 表达式、std::bind
的结果等)。它提供了一种类型安全的方式来抽象函数调用,类似于函数指针的高级版本。
- 基本语法
#include <functional>// 声明一个接受两个int并返回int的函数对象
std::function<int(int, int)> addFunc;// 绑定普通函数
int add(int a, int b) { return a + b; }
addFunc = add;// 绑定Lambda表达式
addFunc = [](int a, int b) { return a + b; };// 调用
int result = addFunc(3, 4); // 结果为7
- 常见应用场景
- 回调函数管理:存储和调用不同实现的回调。
- 事件处理:在 Qt 中替代传统的信号槽(如
QAction
的triggered
信号可绑定std::function
)。 - 函数式编程:作为参数传递函数对象。
- 与 Qt 的结合
在 Qt 中,std::function
可以替代部分信号槽场景,特别是需要更灵活的回调管理时:
// 声明一个存储无参数、无返回值函数的成员变量
std::function<void()> onClickHandler;// 绑定Lambda到function
onClickHandler = [this]() {qDebug() << "Button clicked via std::function";
};// 在按钮点击时调用
connect(button, &QPushButton::clicked, [this]() {if (onClickHandler) onClickHandler();
});
76.1.2 std::bind
:函数参数绑定与适配器
- 作用
std::bind
是一个函数模板,用于创建一个新的函数对象,并将原始函数的某些参数预先绑定为特定值,或者重新排列参数顺序。它常用于调整函数参数的数量或顺序,使不匹配的函数接口能够兼容。
- 基本语法
#include <functional>// 绑定普通函数
int add(int a, int b) { return a + b; }
auto add5 = std::bind(add, 5, std::placeholders::_1); // 绑定第一个参数为5
int result = add5(3); // 等价于 add(5, 3),结果为8// 绑定成员函数
class MyClass {
public:void print(int value) { qDebug() << "Value:" << value; }
};MyClass obj;
auto boundFunc = std::bind(&MyClass::print, &obj, std::placeholders::_1);
boundFunc(42); // 调用 obj.print(42)
- 占位符(Placeholders)
std::placeholders::_1
, _2
, _3
, … 用于表示新函数对象的参数位置:
– _1
表示新函数的第一个参数,_2
表示第二个参数,依此类推。
– 例如:std::bind(func, std::placeholders::_2, std::placeholders::_1)
会交换原函数的参数顺序。
- 与 Qt 的结合
– 在 Qt 中,std::bind
可以用于调整信号与槽的参数匹配:
// 假设现有槽函数:void MyClass::onValueChanged(int value, const QString& text);// 绑定部分参数,创建一个单参数的函数对象
auto handler = std::bind(&MyClass::onValueChanged, this, std::placeholders::_1, "default");// 连接信号到绑定后的函数对象
connect(spinBox, &QSpinBox::valueChanged, handler);
七十七、异常处理及其捕获方法(try/catch、异常安全)
77.1 回答重点
什么是异常处理?
在编程中,异常处理是应对程序运行时意外情况(如除以零、内存分配失败、文件不存在等)的机制,它能让程序在出错时更优雅地处理错误,而非直接崩溃,同时分离 “正常逻辑” 和 “错误处理逻辑”,提升代码可读性和可维护性。
C++ 中的异常处理机制?
C++ 异常处理基于三个核心关键字:throw
(抛出异常)、try
(检测异常)、catch
(捕获异常)。
- 异常的抛出(
throw
)
当程序检测到异常情况(如参数非法、资源不足)时,使用 throw
关键字主动抛出异常,异常可以是基本类型(如int
、string
)或自定义类型(更推荐)。
示例:
void divide(int a, int b) {if (b == 0) {// 抛出异常(这里用字符串描述错误)throw std::string("除数不能为零"); }std::cout << "结果:" << a / b << std::endl;
}
- 异常的捕获(
try-catch
块)
使用 try
块包裹可能抛出异常的代码,随后用 catch
块捕获并处理异常。一个 try
可以对应多个 catch
,分别处理不同类型的异常。
基本语法:
try {// 可能抛出异常的代码divide(10, 0);
} catch (const std::string& e) { // 捕获特定类型的异常(字符串类型)// 处理异常:输出错误信息、日志记录、重试等std::cerr << "捕获到异常:" << e << std::endl;
} catch (...) { // 捕获所有未被前面catch处理的异常(万能捕获)std::cerr << "捕获到未知异常" << std::endl;
}
- 自定义异常类型(推荐实践)
在实际开发中,通常会定义自定义异常类(继承自标准库的std::exception
),更清晰地分类异常类型,便于精准处理。
示例:
// 自定义异常类
class DivideByZeroException : public std::exception {
public:const char* what() const noexcept override { // 重写what()方法返回错误信息return "除数不能为零";}
};// 抛出自定义异常
void divide(int a, int b) {if (b == 0) {throw DivideByZeroException(); // 抛出自定义异常对象}// ...
}// 捕获自定义异常
try {divide(10, 0);
} catch (const DivideByZeroException& e) { // 精准捕获特定异常std::cerr << "处理除数为零的异常:" << e.what() << std::endl;
} catch (const std::exception& e) { // 捕获其他继承自std::exception的异常std::cerr << "标准异常:" << e.what() << std::endl;
} catch (...) { // 兜底捕获std::cerr << "未知异常" << std::endl;
}
77.2 扩展知识
void passAndThrowWidget() {Widget localWidget;cin >> localWidget;throw localWidget;
}
被当作exception的localWidget, 不论被捕获的exception是以by value或 by reference 方式传递,都会发生 localWidget 的复制行为,交到catch字句手上的正是那个副本,因为一旦控制权离开passAndThrowWidget,localWidget便离开了作用域,于是它便被销毁了。因此catch只能使用它的副本。即使是static的localWidget,也是一样。
当对象被当作exception时,复制行为是由对象的copy constructor执行的。这个copy constructor相当于该对象的静态类型而非动态类型。任何时候,复制动作都是以对象的静态类型为本。
一个被抛出的对象(必为临时对象)可以简单地用by reference的方式捕捉,不需要以by reference-to-const的方式捕捉。但是函数调用过程中,将一个临时对象传递给一个non-const reference 参数是不允许的。
对于捕获语句,有如下三条条语句:
catch (Widget w) ...
catch (Widget& w) ...
catch (const Widget& w) ...
对于第一条语句,需要付出“被抛出物”的 “两个副本” 的构造代价。
对于第二三条语句,只需要付出 “被抛出物” 的 “一个副本” 的构造代价。
关于类型转换:
“exceptions 与 catch 子句相匹配” 的过程中,仅有两种转换可以发生。第一种是 “继承架构中的类转换”,第二种是从一个“有型指针”转为“无型指针”。
catch子句总是依出现顺序做匹配尝试,而非最佳匹配。而虚函数采用的是最佳吻合策略。因此绝不要将“针对base class而设计的catch子句” 放在 “针对derived class 而设计的 catch 子句”之前。
七十八、函数可变参数的处理(std::variadic 模板、va_list 兼容)
78.1 回答重点
这里使用模板辅助处理:
第一种方法, 递归处理:
template <typename T>
std::ostream &print(std::ostream &os, T &&t)
{return os << t << std::endl;
}
template <typename T, typename... Args>
std::ostream &print(std::ostream &os, T t, Args&&... args)
{os << t << ", ";return print(os, args...); // 包扩展
}
第二种方法,将可变参数转化为元组处理:
template <typename... Args>
void test(Args... args)
{constexpr int count = sizeof...(args);std::cout << count << std::endl;auto tup = std::make_tuple(args...);if (count >= 1){std::cout << std::get<1>(tup) << std::endl;}
}
78.2 扩展知识
#include <iostream>
#include <cstdarg> // C风格可变参数
#include <initializer_list> // C++11同类型参数包
#include <utility> // C++11参数包展开
#include <any> // C++17任意类型参数// 1. C风格可变参数(需指定参数数量)
int sum_c_style(int count, ...) {va_list args;va_start(args, count);int result = 0;for (int i = 0; i < count; ++i) {result += va_arg(args, int); // 需指定类型}va_end(args);return result;
}// 2. 参数包展开(递归终止函数)
void print_all() { std::cout << "\n"; }// 2. 参数包展开(递归展开参数包)
template<typename T, typename... Args>
void print_all(T first, Args... args) {std::cout << first;if constexpr (sizeof...(args) > 0) {std::cout << ", ";}print_all(args...); // 递归展开剩余参数
}// 3. std::initializer_list(同类型参数)
int sum_initializer_list(std::initializer_list<int> list) {int result = 0;for (int num : list) result += num;return result;
}// 4. std::any(任意类型参数)
void print_any(std::any arg) {if (arg.type() == typeid(int)) {std::cout << "int: " << std::any_cast<int>(arg);} else if (arg.type() == typeid(std::string)) {std::cout << "string: " << std::any_cast<const std::string&>(arg);}std::cout << "\n";
}int main() {// 1. C风格调用std::cout << "C风格: " << sum_c_style(3, 1, 2, 3) << "\n";// 2. 参数包展开调用std::cout << "参数包展开: ";print_all(1, "hello", 3.14);// 3. initializer_list调用std::cout << "initializer_list: " << sum_initializer_list({1, 2, 3, 4}) << "\n";// 4. std::any调用std::cout << "std::any:\n";print_any(42);print_any(std::string("world"));return 0;
}
C风格: 6
参数包展开: 1, hello, 3.14
initializer_list: 10
std::any:
int: 42
string: world
- C 风格(
va_list
)- 依赖宏
va_start
/va_arg
/va_end
- 缺点:需显式指定参数数量,类型不安全
- 依赖宏
- 参数包展开(C++11)
- 通过递归模板实现类型安全的参数展开
- C++17 增强:可用折叠表达式简化为一行代码
std::initializer_list
(C++11)- 处理同类型参数列表,使用
{}
初始化 - 限制:所有参数必须为同一类型
- 处理同类型参数列表,使用
std::any
(C++17)- 存储任意类型参数,运行时类型检查
- 代价:堆分配和动态类型转换开销
七十九、C++面向对象的三大特征
79.1 回答重点
封装(Encapsulation)
-
定义:将数据(属性)和操作数据的函数(方法)捆绑在一起,并通过访问控制(
public
/private
/protected
)隐藏内部实现细节,仅对外暴露必要的接口。 -
作用:
数据保护:防止外部直接访问和修改对象的内部状态,通过方法间接操作,确保数据合法性。
解耦实现:隐藏具体实现细节,使外部只需关注接口,降低模块间依赖。
继承(Inheritance)
-
定义:一个类(派生类)继承另一个类(基类)的属性和方法,并可以在此基础上扩展或修改,形成层次化的类结构。
-
作用:
代码复用:避免重复定义通用属性和方法,提高开发效率。
层次化设计:通过基类抽象共性,派生类专注特性,符合 “高内聚、低耦合” 原则。
多态(Polymorphism)
-
定义:通过基类的接口调用方法时,实际执行的是派生类的具体实现,实现 “同一接口,不同行为”。
-
作用:
接口统一:通过基类指针或引用操作派生类对象,降低代码耦合度。
可扩展性:新增派生类时无需修改现有代码,符合 “开闭原则”。
八十、类A中调用类B,使用类B对象和类B指针的区别?编译阶段需要引用类B的头文件吗?
在 C++ 中,类 A 调用类 B 时,使用类 B 对象和类 B 指针的核心区别体现在内存管理、访问方式、生命周期等方面,而编译阶段对类 B 头文件的依赖也因使用方式不同而有所差异。以下是详细说明:
80.1 类 B 对象与类 B 指针的区别:
假设类 A 中需要使用类 B,两种方式的核心差异如下:
维度 | 类 B 对象(B b; ) | 类 B 指针(B* b_ptr; ) |
---|---|---|
内存分配 | 对象本身存储在栈上(或类 A 的内存空间中),直接占用类 B 大小的内存。 | 指针本身是一个地址(通常 4/8 字节),存储在栈上,指向的类 B 实例需单独分配(栈或堆)。 |
生命周期 | 随类 A 的生命周期自动创建 / 销毁(调用构造 / 析构函数)。 | 指针本身随类 A 销毁,但指向的对象需手动管理(如new 分配的需delete ,否则内存泄漏)。 |
访问成员 | 使用. 运算符(如b.func() )。 | 使用-> 运算符(如b_ptr->func() )。 |
拷贝行为 | 赋值时会触发类 B 的拷贝构造函数(深拷贝 / 浅拷贝需显式控制)。 | 赋值时仅复制指针地址(浅拷贝,可能导致多个指针指向同一对象)。 |
多态支持 | 不支持多态(若 B 是基类,赋值子类对象会发生 “对象切片”)。 | 支持多态(指向子类对象时,可通过基类指针调用子类重写的虚函数)。 |
空值状态 | 不存在 “空对象”,对象始终有效(除非未初始化,行为未定义)。 | 可赋值nullptr 表示 “无指向对象”,便于判断有效性。 |
80.2 编译阶段对类 B 头文件的依赖:
编译阶段是否需要引用类 B 的头文件,取决于使用类 B 的方式:
-
使用类 B 对象(
B b;
)
必须包含类 B 的完整头文件(#include "B.h"
)。
原因:编译器需要知道类 B 的完整定义(成员变量大小、构造函数等),才能确定类 A 中对象b
的内存布局,以及正确分配内存。 -
使用类 B 指针(
B\* b_ptr;
)
仅需类 B 的前向声明(class B;
)即可,无需包含完整头文件。
原因:指针本质是一个地址,编译器不需要知道类 B 的具体成员,只需知道 “B 是一个类” 即可声明指针(指针大小固定,与指向类型无关)。注意:如果在类 A 的成员函数实现中通过指针访问类 B 的成员(如
b_ptr->func()
),则实现文件(.cpp
)中仍需包含类 B 的头文件(因为此时需要知道func()
的具体定义)。
总结:
- 对象 vs 指针:对象是 “值语义”,直接管理实体;指针是 “引用语义”,间接管理实体,更灵活(支持多态、动态内存)但需手动控制生命周期。
- 头文件依赖:
- 用对象:必须包含类 B 的头文件(需要完整定义)。
- 用指针:声明时只需前向声明(减少编译依赖),实现时若访问成员则需头文件。
合理使用指针(或引用)可减少头文件包含,降低编译耦合度,这在大型项目中尤为重要。
下面通过具体代码示例说明类 A 调用类 B 时,使用对象和指针的区别,以及对应的头文件依赖情况。
80.3 类 B 的定义(B.h
)
首先定义类 B 作为基础:
// B.h
#ifndef B_H
#define B_Hclass B {
private:int value;
public:B(int v = 0) : value(v) {} // 构造函数void print() const { // 成员函数std::cout << "B的value: " << value << std::endl;}void setValue(int v) {value = v;}
};#endif
80.4 类 A 使用类 B 对象的情况
当类 A 中直接包含类 B 的对象时,必须包含类 B 的完整头文件:
// A_with_object.h
#ifndef A_WITH_OBJECT_H
#define A_WITH_OBJECT_H#include "B.h" // 必须包含B的头文件,因为要定义B的对象
#include <iostream>class A {
private:B b_obj; // 类B的对象(不是指针)
public:// 构造函数中初始化B的对象A(int v) : b_obj(v) {}void useB() {b_obj.print(); // 使用.访问成员b_obj.setValue(100);b_obj.print();}
};#endif
说明:
- 因为
A
中包含B
的对象b_obj
,编译器需要知道B
的完整定义(如大小、构造函数等)才能分配内存,所以必须#include "B.h"
。 - 访问成员时使用
.
运算符。
80.5 类 A 使用类 B 指针的情况
当类 A 中使用类 B 的指针时,声明阶段只需前向声明,无需完整头文件:
// A_with_ptr.h
#ifndef A_WITH_PTR_H
#define A_WITH_PTR_H// 仅需前向声明,无需包含"B.h"
class B; // 告诉编译器:B是一个类,后续可以声明它的指针class A {
private:B* b_ptr; // 类B的指针
public:A(int v); // 构造函数声明(实现放在.cpp中)~A(); // 析构函数(需要释放指针)void useB(); // 成员函数声明
};#endif
对应的实现文件(.cpp
)中才需要包含B.h
(因为要访问B
的成员):
// A_with_ptr.cpp
#include "A_with_ptr.h"
#include "B.h" // 实现中需要B的完整定义,所以包含头文件
#include <iostream>// 构造函数:创建B的实例并让指针指向它
A::A(int v) {b_ptr = new B(v); // 需要知道B的构造函数,所以依赖B的完整定义
}// 析构函数:释放指针指向的B对象
A::~A() {delete b_ptr;
}// 使用B的成员函数
void A::useB() {b_ptr->print(); // 使用->访问成员b_ptr->setValue(200);b_ptr->print();
}
说明:
- 头文件
A_with_ptr.h
中仅用class B;
前向声明,避免了对B.h
的直接依赖,减少了编译耦合(如果B.h
修改,A_with_ptr.h
的使用者无需重新编译)。 - 实现文件
.cpp
中必须包含B.h
,因为需要调用B
的构造函数、成员函数等,这些都依赖B
的完整定义。 - 访问成员时使用
->
运算符。
80.6 主函数测试(main.cpp
)
#include "A_with_object.h"
#include "A_with_ptr.h"int main() {// 测试使用对象的情况std::cout << "=== 使用B对象 ===" << std::endl;A objA(10);objA.useB(); // 输出B的value: 10 → 100// 测试使用指针的情况std::cout << "\n=== 使用B指针 ===" << std::endl;A ptrA(20);ptrA.useB(); // 输出B的value: 20 → 200return 0;
}
输出结果:
=== 使用B对象 ===
B的value: 10
B的value: 100=== 使用B指针 ===
B的value: 20
B的value: 200
总结:
- 对象方式:必须包含被调用类的头文件,内存自动管理,适合简单场景。
- 指针方式:声明时只需前向声明(减少依赖),实现时才需头文件,内存需手动管理,适合需要多态、动态内存的场景。
在大型项目中,优先使用指针(或引用)+ 前向声明的方式,可以减少头文件包含,提高编译效率。
80.7 总结
在头文件中直接#include
其他类的头文件并非 “不行”,但会导致编译依赖增加,进而可能显著影响大型项目的编译效率。具体来说:
1. 直接#include
的问题:编译连锁反应
假设项目中有如下依赖关系:
A.h
包含 B.h
,B.h
包含 C.h
,而有 100 个.cpp
文件都包含了A.h
。
此时如果修改了C.h
的内容(哪怕只是加了一个空格),编译器需要:
- 重新编译
C.h
及其依赖者B.h
- 重新编译依赖
B.h
的A.h
- 重新编译所有包含
A.h
的 100 个.cpp
文件
这就是 “编译连锁反应”—— 一个微小的修改可能触发大量文件的重新编译,在大型项目中会显著拖慢编译速度。
2. 前向声明的优势:减少编译依赖
如果A.h
中仅使用B*
(指针)或B&
(引用),并通过前向声明(class B;
)替代#include "B.h"
,则:
A.h
不再直接依赖B.h
,B.h
的修改不会触发A.h
的重新编译- 只有实际使用
B
成员的.cpp
文件(如A.cpp
)才依赖B.h
,修改B.h
时,仅需重新编译这些.cpp
这种方式能切断不必要的编译依赖链,尤其在大型项目中(如包含数千个头文件),可大幅提升编译效率。
3. 什么时候必须#include
?
前向声明并非万能,以下情况必须在头文件中#include
被依赖类的头文件:
- 类 A 中包含类 B 的对象(而非指针 / 引用):编译器需要知道 B 的完整大小才能分配内存
- 类 A 继承自类 B(
class A : public B
):需要知道 B 的继承结构 - 在类 A 的头文件中直接调用类 B 的成员函数或访问成员变量
除这些情况外,应优先使用前向声明减少#include
。
总结:
- 直接
#include
是 “可行的”,但会增加编译依赖,导致修改时需要重新编译的文件更多,拖慢大型项目的编译速度。 - 前向声明 + 指针 / 引用的方式能减少依赖,是 C++ 中推荐的 “最小依赖原则” 实践,尤其适合大型项目。
简单说:能不用#include
就不用,用前向声明能解决的就优先用,这是写出高效可维护 C++ 代码的重要技巧。
八十一、Pimpl惯用法
通过指针减少头文件依赖的思路,本质上就是 C++ 中著名的Pimpl 惯用法(Pointer to Implementation,指向实现的指针)。
81.1 Pimpl 惯用法的核心思想
Pimpl 通过在类的公开头文件中只声明一个指向私有实现的指针(通常是std::unique_ptr
),而将所有成员变量和具体实现细节隐藏在.cpp
文件中,从而实现:
- 减少头文件依赖:公开头文件无需包含其他类的头文件,仅需前向声明。
- 隐藏实现细节:外部无法通过头文件得知类的内部结构。
- 接口与实现分离:修改实现时无需重新编译依赖该类的代码。
81.2 Pimpl 的代码示例
以类A
使用类B
为例,用 Pimpl 惯用法实现:
- 公开头文件(
A.h
)
// A.h
#ifndef A_H
#define A_H#include <memory> // 用于std::unique_ptr// 前向声明:无需包含"B.h"
class B;class A {
public:A(int v);~A(); // 析构函数需在.cpp中定义(因为unique_ptr需要知道B的完整定义)void useB(); // 公开接口private:// 指向私有实现的指针(Pimpl核心)class Impl; // 前向声明内部实现类std::unique_ptr<Impl> pimpl; // 智能指针管理实现
};#endif
- 实现文件(
A.cpp
)
// A.cpp
#include "A.h"
#include "B.h" // 仅在实现中包含B的头文件
#include <iostream>// 定义内部实现类(隐藏细节)
class A::Impl {
public:Impl(int v) : b_obj(v) {} // 实际使用B的对象void doSomething() {b_obj.print();b_obj.setValue(100);b_obj.print();}private:B b_obj; // 具体实现中可以直接用B的对象
};// A的构造函数:初始化pimpl
A::A(int v) : pimpl(std::make_unique<Impl>(v)) {}// A的析构函数:unique_ptr需要此处知道Impl的完整定义
A::~A() = default;// 公开接口通过pimpl调用实现
void A::useB() {pimpl->doSomething();
}
81.3 Pimpl 的优势
- 极致减少编译依赖:
A.h
中既不包含B.h
,也不暴露任何与B
相关的细节。即使B.h
大幅修改,只要A
的公开接口不变,所有依赖A.h
的代码都无需重新编译。 - 二进制兼容性:
由于类A
的内存布局(仅包含一个指针)永远不变,升级库时只需替换.cpp
编译的二进制文件,无需重新编译依赖它的程序(对库开发者尤为重要)。 - 信息隐藏:
外部无法通过A.h
得知A
使用了B
,也无法窥探A
的内部状态,符合封装原则。
81.4 与单纯使用指针的区别
Pimpl 是对 “指针减少依赖” 思想的系统化封装:
- 单纯使用指针:可能在类中直接暴露
B*
,仍需外部知道B
的存在 - Pimpl:通过内部
Impl
类进一步隐藏所有实现细节,外部完全不知道B
的存在
81.5 总结
Pimpl 惯用法是 C++ 中解决头文件依赖、实现接口与细节分离的经典方案,其核心就是通过 “指针 + 前向声明 + 内部实现类” 的组合,实现编译效率提升和封装性增强。你的理解非常准确 —— 前面讨论的 “用指针减少#include
” 正是 Pimpl 的基础思想。
八十二、内存分布图
82.1 分布图
┌──────────────────────── 程序内存 ────────────────────────┐
│ │
│ ┌─────────────── 代码段(Text Segment) ───────────────┐ │
│ │ 存储可执行指令(二进制代码) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────── 数据区域(静态区/全局区) ────────────┐ │
│ │ ┌──────────── 数据段(.data) ────────────┐ │ │
│ │ │ 已初始化全局变量 │ │ │
│ │ │ 已初始化静态变量(全局/局部) │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────── BSS段(.bss) ─────────────┐ │ │
│ │ │ 未初始化全局变量 │ │ │
│ │ │ 未初始化静态变量(全局/局部) │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────── 常量区(.rodata) ──────────┐ │ │
│ │ │ 字符串常量(如 "hello") │ │ │
│ │ │ const修饰的全局常量 │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────── 栈区(Stack) ───────────────────┐ │
│ │ 局部变量、函数参数、返回地址等(自动分配/释放) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────── 堆区(Heap) ───────────────────┐ │
│ │ 动态分配内存(如 malloc/new)(手动分配/释放) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
八十三、纯右值、将亡值
要理解纯右值(prvalue, Pure Rvalue) 和将亡值(xvalue, Expiring Value) 的区别,首先需要明确 C++11 及以后的值类别体系—— 这两类均属于 “右值(rvalue)”,但在 “对象身份”“产生场景” 和 “核心用途” 上存在本质差异。
83.1 先理清 C++ 值类别的整体框架
C++ 将表达式的 “值类别” 分为三大类、四小类,纯右值和将亡值的定位如下:
- **泛左值(glvalue, Generalized Lvalue):**有 “身份(identity)”(可被取地址、能区分是否为同一对象),包括:
- 左值(lvalue, Left Value):如变量、函数名、数组名等(可被赋值);
- 将亡值(xvalue):有身份,但 “即将废弃”(允许移动语义);
- 纯右值(prvalue):无身份,仅用于传递 “数据值”;
- 右值(rvalue):泛右值的统称,即 prvalue + xvalue。
83.2 纯右值(prvalue):无身份的 “临时数据”
纯右值的核心特征是无身份(无法取地址、不能识别为独立对象),仅用于提供 “临时数据” 或 “临时对象”,表达式结束后默认销毁。
1. 纯右值的典型场景
-
字面量(除字符串字面量):如
10
(int 字面量)、3.14
(double 字面量)、true
(bool 字面量)—— 这些是 “纯粹的数据”,没有对应的内存对象(无法取地址,&10
编译报错)。 -
算术 / 比较 / 逻辑表达式结果:如
a + b
、x > 5
、!flag
—— 结果是临时数据,无独立对象身份。 -
非引用返回的临时对象:函数返回值为 “值类型”(非T&非T&&)时,返回表达式是纯右值。例如:
struct MyClass {}; MyClass createObj() { return MyClass(); } // createObj() 是 prvalue(返回临时对象)
-
空指针常量:如
nullptr
。
2. 纯右值的核心特性
-
无身份:无法通过地址或标识区分是否为同一对象(如
10 == 10
比较的是值,而非 “对象”),&prvalue
编译报错(如&(a+b)
不合法)。 -
生命周期短:若未被绑定到const T&或T&&,纯右值对应的临时对象会在表达式结束时立即销毁。
例如:
MyClass obj = createObj(); // createObj() 是 prvalue,临时对象被绑定到obj,生命周期延长至obj的生命周期 createObj(); // 临时对象在表达式结束后立即销毁(无绑定)
-
用途:生成临时数据(如计算结果)或临时对象,仅用于 “传递值”,不支持移动语义(因为无身份,无法转移资源)。
83.3 将亡值(xvalue):有身份的 “即将废弃对象”
将亡值是 C++11 为移动语义引入的新值类别,核心特征是有身份(可取地址、能识别为独立对象),但 “即将被废弃”—— 本质是 “标记对象可被移动”,避免不必要的拷贝。
1. 将亡值的典型场景
-
右值引用返回的表达式:函数返回值为T&&(右值引用)时,返回表达式是将亡值。例如:
MyClass globalObj; MyClass&& getExpiringObj() { return std::move(globalObj); // 返回右值引用,getExpiringObj() 是 xvalue }
-
std::move()
的结果:std::move(obj)
会将左值 / 右值转换为 “右值引用”,其返回表达式是将亡值(标记obj即将被废弃)。例如:
MyClass obj; MyClass&& ref = std::move(obj); // std::move(obj) 是 xvalue
-
右值引用类型的强制转换:如
static_cast<MyClass&&>(obj)
,结果是将亡值。
2. 将亡值的核心特性
-
有身份:可被取地址(如
&std::move(obj)
编译通过,因为std::move(obj)
指向obj
这个有身份的对象),能区分是否为同一对象。 -
“即将废弃” 的语义:标记对象后续不再被使用(语法上仍可访问,但语义上视为 “资源可转移”),适合通过移动构造函数 / 移动赋值运算符转移其资源(如内存、文件句柄),避免拷贝开销。
-
**生命周期:**指向的对象可能是 “临时对象” 或 “已有对象”(如局部变量、全局变量),但无论哪种,其生命周期 “即将结束”(或被标记为可废弃)。例如:
void moveFunc(MyClass&& param) { /* 转移param的资源 */ } MyClass obj; moveFunc(std::move(obj)); // std::move(obj) 是 xvalue,标记obj即将废弃,允许moveFunc转移其资源 // 注意:obj此时仍存在,但语义上资源已被转移,后续使用obj需谨慎(取决于MyClass的移动实现)
83.4 纯右值与将亡值的核心区别
下表从 4 个关键维度对比两者:
对比维度 | 纯右值(prvalue) | 将亡值(xvalue) |
---|---|---|
对象身份 | 无身份(无法取地址,&prvalue 报错) | 有身份(可取地址,&xvalue 合法) |
产生场景 | 字面量、算术表达式、非引用返回的临时对象 | std::move() 、右值引用返回、右值引用转换 |
核心用途 | 传递临时数据 / 临时对象(仅 “传值”) | 标记对象可被移动(转移资源,避免拷贝) |
资源关联 | 无独立资源(或临时资源无转移意义) | 有可转移的资源(如堆内存、文件句柄) |
83.5 关键辨析:易混淆的场景
-
右值引用变量本身是左值,不是将亡值
右值引用变量(如T&& ref
)虽然绑定右值,但变量本身是左值(可被取地址,&ref
合法)。只有 “产生右值引用的表达式”(如std::move(obj)
、getExpiringObj()
)才是将亡值。例如:MyClass obj; MyClass&& ref = std::move(obj); // std::move(obj) 是 xvalue,ref是左值(可取地址) moveFunc(ref); // 错误!ref是左值,无法绑定到T&&参数 moveFunc(std::move(ref)); // 正确!std::move(ref) 是 xvalue
-
C++17 对纯右值的优化不影响本质区别
C++17 引入 “临时对象物化延迟”(如T{}
这样的 prvalue 不会立即创建临时对象,而是作为 “构造指令” 传播),但纯右值 “无身份” 的本质未变;将亡值仍需 “有身份” 以支持移动语义,两者界限清晰。
83.6 总结:一句话区分
- 纯右值:“没有对象身份的临时数据”,仅用于传值,无法移动;
- 将亡值:“有对象身份但即将废弃的对象”,专为移动语义设计,允许转移资源。
两者的核心矛盾是 “是否有身份”,而核心用途差异是 “传值” vs “移动”。
八十四、如果要你设计智能指针你要考虑什么?
设计智能指针需核心考虑:所有权模型(独占 / 共享)、引用计数的线程安全(共享时)、拷贝 / 移动语义的正确实现(禁止拷贝 / 引用计数增减)、自定义删除器(适配不同资源释放)、避免循环引用(弱引用机制),以及像原生指针一样的使用体验(重载 *、->)和异常安全性。
84.1 设计共享指针?
-
引用计数管理:
用一个堆上的计数器记录资源被多少指针共享,每复制一个共享指针,计数器 + 1;每销毁一个,计数器 - 1;当计数器为 0 时,释放资源。
-
拷贝与移动语义:
- 拷贝:复制指针并增加引用计数(共享资源)。
- 移动:转移资源所有权,不改变引用计数(效率更高)。
-
线程安全:
引用计数的增减需是原子操作(避免多线程竞态条件)。
-
自定义删除器:
支持用户指定资源释放方式(如数组
delete[]
、文件句柄关闭等)。 -
指针操作模拟:
重载
*
和->
运算符,提供与原生指针一致的使用体验。
#include <atomic> // 原子操作(线程安全计数)
#include <utility> // 移动语义
#include <cassert> // 断言// 前置声明
template <typename T>
class WeakPtr;// 共享指针核心类
template <typename T, typename Deleter = std::default_delete<T>>
class SharedPtr {// 允许WeakPtr访问私有成员(用于弱引用)template <typename U>friend class WeakPtr;public:// 1. 构造函数:管理原始指针,初始化引用计数为1explicit SharedPtr(T* ptr = nullptr, const Deleter& deleter = Deleter()): m_ptr(ptr), m_deleter(deleter) {if (m_ptr != nullptr) {// 计数块:包含共享计数和弱引用计数(为兼容weak_ptr)m_count = new CountBlock();m_count->shared_count = 1;} else {m_count = nullptr;}}// 2. 析构函数:减少计数,为0时释放资源和计数块~SharedPtr() {release();}// 3. 拷贝构造:共享资源,计数+1SharedPtr(const SharedPtr& other) noexcept: m_ptr(other.m_ptr), m_count(other.m_count), m_deleter(other.m_deleter) {if (m_count != nullptr) {m_count->shared_count++; // 原子操作确保线程安全}}// 4. 拷贝赋值:先释放当前资源,再共享新资源SharedPtr& operator=(const SharedPtr& other) noexcept {if (this != &other) {release(); // 释放当前资源m_ptr = other.m_ptr;m_count = other.m_count;m_deleter = other.m_deleter;if (m_count != nullptr) {m_count->shared_count++;}}return *this;}// 5. 移动构造:转移所有权,不改变计数SharedPtr(SharedPtr&& other) noexcept: m_ptr(other.m_ptr), m_count(other.m_count), m_deleter(std::move(other.m_deleter)) {other.m_ptr = nullptr;other.m_count = nullptr;}// 6. 移动赋值:转移所有权,释放当前资源SharedPtr& operator=(SharedPtr&& other) noexcept {if (this != &other) {release(); // 释放当前资源m_ptr = other.m_ptr;m_count = other.m_count;m_deleter = std::move(other.m_deleter);// 源对象置空,不再管理资源other.m_ptr = nullptr;other.m_count = nullptr;}return *this;}// 7. 指针操作:模拟原生指针T& operator*() const noexcept {assert(m_ptr != nullptr && "解引用空指针");return *m_ptr;}T* operator->() const noexcept {assert(m_ptr != nullptr && "访问空指针成员");return m_ptr;}// 8. 资源管理接口T* get() const noexcept { return m_ptr; } // 获取原始指针size_t use_count() const noexcept { // 获取当前引用计数return (m_count != nullptr) ? m_count->shared_count : 0;}bool unique() const noexcept { // 是否唯一拥有资源return use_count() == 1;}void reset(T* ptr = nullptr) { // 重置资源SharedPtr<T, Deleter>(ptr).swap(*this); // 利用临时对象释放旧资源}void swap(SharedPtr& other) noexcept { // 交换资源std::swap(m_ptr, other.m_ptr);std::swap(m_count, other.m_count);std::swap(m_deleter, other.m_deleter);}explicit operator bool() const noexcept { // 检查是否持有资源return m_ptr != nullptr;}private:// 计数块:包含共享计数和弱引用计数(支持weak_ptr)struct CountBlock {std::atomic<size_t> shared_count; // 共享引用计数(原子操作保证线程安全)std::atomic<size_t> weak_count; // 弱引用计数};// 释放资源:计数-1,为0时清理void release() {if (m_count == nullptr) return;// 共享计数减1,若为0则释放资源if (--m_count->shared_count == 0) {m_deleter(m_ptr); // 用自定义删除器释放资源m_ptr = nullptr;// 若弱引用计数也为0,释放计数块if (m_count->weak_count == 0) {delete m_count;m_count = nullptr;}}}T* m_ptr; // 管理的资源指针CountBlock* m_count; // 引用计数块(堆上分配,共享)Deleter m_deleter; // 自定义删除器
};// 辅助函数:创建SharedPtr(类似std::make_shared)
template <typename T, typename... Args>
SharedPtr<T> make_shared(Args&&... args) {return SharedPtr<T>(new T(std::forward<Args>(args)...));
}
扩展方向
- 完善
weak_ptr
支持(解决循环引用问题); - 优化内存(将计数块与资源内存合并分配,如
std::make_shared
的实现); - 支持数组类型(需特化模板处理
delete[]
)。
共享指针的核心是 “通过引用计数实现资源共享”,设计时需平衡安全性(线程安全、自动释放)和易用性(模拟原生指针)。
84.2 设计独占指针?
#include <utility> // 用于std::move和std::swaptemplate <typename T>
class UniquePtr {
public:// 构造函数:管理原始指针explicit UniquePtr(T* ptr = nullptr) : m_ptr(ptr) {}// 析构函数:释放资源(独占性核心)~UniquePtr() {delete m_ptr; // 自动释放资源,避免内存泄漏m_ptr = nullptr;}// 禁止拷贝构造和拷贝赋值(保证独占性)UniquePtr(const UniquePtr& other) = delete;UniquePtr& operator=(const UniquePtr& other) = delete;// 允许移动构造(所有权转移)UniquePtr(UniquePtr&& other) noexcept : m_ptr(other.m_ptr) {other.m_ptr = nullptr; // 源对象释放所有权}// 允许移动赋值(所有权转移)UniquePtr& operator=(UniquePtr&& other) noexcept {if (this != &other) {delete m_ptr; // 释放当前资源m_ptr = other.m_ptr; // 接管源对象资源other.m_ptr = nullptr; // 源对象释放所有权}return *this;}// 重载*和->,模拟原生指针行为T& operator*() const { return *m_ptr; }T* operator->() const { return m_ptr; }// 获取原始指针(谨慎使用)T* get() const { return m_ptr; }// 释放所有权(返回原始指针,之后智能指针不再管理)T* release() {T* temp = m_ptr;m_ptr = nullptr;return temp;}// 重置指针(管理新资源,先释放旧资源)void reset(T* ptr = nullptr) {if (m_ptr != ptr) {delete m_ptr;m_ptr = ptr;}}// 交换两个智能指针管理的资源void swap(UniquePtr& other) noexcept {std::swap(m_ptr, other.m_ptr);}// 检查是否持有资源explicit operator bool() const { return m_ptr != nullptr; }private:T* m_ptr; // 管理的原始指针
};// 辅助函数:创建UniquePtr(类似std::make_unique)
template <typename T, typename... Args>
UniquePtr<T> make_unique(Args&&... args) {return UniquePtr<T>(new T(std::forward<Args>(args)...));
}
使用示例
cpp
运行
// 使用make_unique创建智能指针
auto ptr = make_unique<int>(42);
// 访问数据
std::cout << *ptr << std::endl; // 输出42// 所有权转移(移动后原指针失效)
auto ptr2 = std::move(ptr);
if (!ptr) {std::cout << "ptr已失去所有权" << std::endl;
}// 管理自定义类型
struct MyType {int value;MyType(int v) : value(v) {}
};
auto objPtr = make_unique<MyType>(100);
std::cout << objPtr->value << std::endl; // 输出100
八十五、什么时候用智能指针?
在 C++ 编程中,智能指针(Smart Pointer) 是管理动态内存(new
分配)的核心工具,其本质是封装了原始指针的类模板,能通过RAII(资源获取即初始化)机制自动释放内存,从根本上避免内存泄漏、野指针等问题。何时使用智能指针,核心取决于 “是否需要自动管理动态内存的生命周期”,具体场景可按需求细分如下:
85.1 核心原则:替代 “裸指针(Raw Pointer)” 管理动态内存
只要你用 new
分配了内存(如 int* p = new int(10)
),就必须手动用 delete
释放,一旦遗漏(如函数提前返回、异常抛出)就会导致内存泄漏。此时智能指针是 “最优解”—— 它会在生命周期结束时(如离开作用域、被销毁)自动调用 delete
,无需手动干预。
反例(裸指针的风险):
void func() {int* p = new int(10); // 动态分配内存if (some_condition) {return; // 提前返回,delete 被跳过,内存泄漏!}delete p; // 正常情况下的释放
}
正例(智能指针自动释放):
#include <memory> // 智能指针头文件void func() {std::unique_ptr<int> p = std::make_unique<int>(10); // 智能指针管理if (some_condition) {return; // 离开作用域时,p 自动调用 delete,无泄漏}
}
85.2 具体使用场景分类
根据内存的 “所有权管理需求”(是否需要共享、是否需要独占),C++ 标准库提供了三种核心智能指针,适用场景不同:
std::unique_ptr
:独占所有权(最常用)
核心特性:内存所有权 “唯一”,不允许拷贝(仅允许移动),生命周期结束时自动释放内存。
适用场景:
- 动态内存仅由一个指针管理(独占),无需共享。
- 作为函数的返回值(避免返回裸指针导致的所有权模糊)。
- 作为容器(如
std::vector
)的元素(避免容器销毁时遗漏释放元素内存)。 - 管理 “独占资源”(如文件句柄、网络连接等,需自定义删除器)。
示例 1:容器存储动态对象
// 错误:vector 存裸指针,销毁时不会 delete 元素,内存泄漏
std::vector<int*> bad_vec;
bad_vec.push_back(new int(10)); // 正确:vector 存 unique_ptr,容器销毁时自动释放所有元素
std::vector<std::unique_ptr<int>> good_vec;
good_vec.push_back(std::make_unique<int>(10));
示例 2:函数返回动态对象
// 错误:返回裸指针,调用者需手动 delete,易遗漏
int* create_int() { return new int(20); }// 正确:返回 unique_ptr,调用者无需管理释放
std::unique_ptr<int> create_int_safe() { return std::make_unique<int>(20);
}
std::shared_ptr
:共享所有权
核心特性:通过 “引用计数” 实现内存共享,多个 shared_ptr
可指向同一内存;当最后一个 shared_ptr
销毁时,才释放内存。
适用场景:
- 动态内存需要被多个对象 / 指针 “共享访问”(如多线程共享数据、容器中元素被外部引用)。
- 无法确定哪个指针是 “最后一个使用内存的指针”(避免提前
delete
导致野指针)。
示例:多对象共享同一动态内存
#include <memory>
#include <vector>void func() {// 创建 shared_ptr,引用计数 = 1std::shared_ptr<int> ptr = std::make_shared<int>(30);// 拷贝 ptr,引用计数 = 2std::shared_ptr<int> ptr2 = ptr;// 容器存储 ptr,引用计数 = 3std::vector<std::shared_ptr<int>> vec;vec.push_back(ptr);// ptr 销毁,引用计数 = 2;ptr2 销毁,引用计数 = 1;vec 销毁,引用计数 = 0 → 内存释放
}
std::weak_ptr
:解决 “循环引用” 问题
核心特性:是 shared_ptr
的 “弱引用”,不增加引用计数,仅用于 “观察”shared_ptr
管理的内存,无法直接访问内存(需先转换为 shared_ptr
)。
适用场景:
- 打破
shared_ptr
的 “循环引用”(最核心用途)。 - 需 “安全观察” 共享内存,但不希望影响其生命周期(如缓存、观察者模式)。
问题场景:shared_ptr 循环引用导致内存泄漏
struct A;
struct B;struct A {std::shared_ptr<B> b_ptr; // A 持有 B 的 shared_ptr~A() { std::cout << "A 销毁" << std::endl; }
};struct B {std::shared_ptr<A> a_ptr; // B 持有 A 的 shared_ptr → 循环引用~B() { std::cout << "B 销毁" << std::endl; }
};void func() {std::shared_ptr<A> a = std::make_shared<A>();std::shared_ptr<B> b = std::make_shared<B>();a->b_ptr = b; // A 引用 B,B 的计数 = 2b->a_ptr = a; // B 引用 A,A 的计数 = 2// 函数结束时,a 和 b 销毁,A 和 B 的计数均变为 1 → 内存泄漏(析构函数不执行)
}
解决方案:用 weak_ptr 打破循环
struct A {std::shared_ptr<B> b_ptr;~A() { std::cout << "A 销毁" << std::endl; }
};struct B {std::weak_ptr<A> a_ptr; // 改为 weak_ptr,不增加 A 的引用计数~B() { std::cout << "B 销毁" << std::endl; }
};void func() {std::shared_ptr<A> a = std::make_shared<A>();std::shared_ptr<B> b = std::make_shared<B>();a->b_ptr = b; // B 的计数 = 2b->a_ptr = a; // A 的计数仍为 1(weak_ptr 不增加)// 函数结束:a 销毁 → A 的计数 = 0 → A 销毁 → A 的 b_ptr 销毁 → B 的计数 = 1 → b 销毁 → B 的计数 = 0 → B 销毁
}
85.3 绝对不适合使用智能指针的场景
智能指针虽好,但并非所有指针场景都需要它。以下情况应使用裸指针:
- 指向栈内存的指针:栈内存由编译器自动管理,无需智能指针(如
int x = 10; int* p = &x;
)。 - 仅用于 “观察” 内存,不管理生命周期:若指针仅作为 “访问入口”,不负责释放(如函数参数传递栈内存地址)。
- 与 C 语言库交互的指针:C 库返回的指针(如
malloc
分配)需用free
释放,智能指针默认调用delete
,需自定义删除器(虽可行,但裸指针更直观)。 - 性能极致敏感的场景:智能指针(尤其是
shared_ptr
)有轻微的引用计数开销,若在高频循环(如每秒百万次调用)中,裸指针更优(需确保手动管理无错)。
85.4 总结:智能指针的使用决策流程
- 是否需要动态内存?→ 否(栈内存):用裸指针;是(
new
分配):进入下一步。 - 是否需要共享内存所有权?→ 否(独占):用
std::unique_ptr
(首选,性能最优);是(共享):进入下一步。 - 是否存在循环引用风险?→ 是:用
std::weak_ptr
配合std::shared_ptr
;否:用std::shared_ptr
。
遵循此流程,可最大化利用智能指针的安全性,同时避免不必要的开销。
八十六、智能指针如何计数那些情况会加1?如何清零?
在 C++ 中,智能指针(如 std::shared_ptr
)的引用计数机制用于管理动态内存,当引用计数变为 0 时,会自动释放所指向的资源。以下是引用计数会增加 1 的常见情况:
-
创建智能指针时
当用原始指针初始化
std::shared_ptr
时,计数初始化为 1:int* raw_ptr = new int(10); std::shared_ptr<int> ptr1(raw_ptr); // 计数 = 1
-
拷贝构造智能指针时
用一个
shared_ptr
初始化另一个shared_ptr
时,计数加 1:std::shared_ptr<int> ptr2(ptr1); // 计数 = 2(ptr1 和 ptr2 指向同一资源)
-
赋值操作时
当一个
shared_ptr
赋值给另一个shared_ptr
时,目标指针的计数加 1,原指针的计数减 1(若原指针计数变为 0,则释放资源):std::shared_ptr<int> ptr3; ptr3 = ptr1; // 计数 = 3(ptr1、ptr2、ptr3 指向同一资源)
-
作为函数参数按值传递时
当
shared_ptr
作为函数参数按值传递时,会触发拷贝构造,计数加 1:void func(std::shared_ptr<int> ptr) {// 函数内 ptr 的计数比外部多 1 } func(ptr1); // 调用时计数临时加 1,函数结束后自动减 1
-
作为函数返回值按值返回时
返回
shared_ptr
时,会产生临时拷贝,计数加 1(若返回值被接收,则计数保持;否则临时对象销毁后计数减 1):std::shared_ptr<int> get_ptr() {return std::shared_ptr<int>(new int(20)); // 返回时计数加 1 } auto ptr4 = get_ptr(); // 接收返回值,计数保持为 1
需要注意的是,std::weak_ptr
不会增加引用计数,它仅作为观察者存在,用于避免循环引用问题。
八十七、如何判断基类的派生类中?是那个类型?(考察强制转化)
在 C++ 中,要判断一个基类指针(或引用)指向的是哪个派生类对象,可以使用以下两种主要方式:
87.1 dynamic_cast 类型转换(推荐)
dynamic_cast
是 C++ 专门用于多态类型转换的运算符,它可以在运行时检查转换的有效性。
使用条件:基类必须包含至少一个虚函数(即存在多态性)。
用法示例:
#include <iostream>// 基类(含虚函数,支持多态)
class Base {
public:virtual ~Base() {} // 虚析构函数,确保多态释放
};// 派生类1
class DerivedA : public Base {};// 派生类2
class DerivedB : public Base {};int main() {Base* base_ptr = new DerivedA(); // 基类指针指向派生类对象// 判断是否为 DerivedA 类型if (DerivedA* a_ptr = dynamic_cast<DerivedA*>(base_ptr)) {std::cout << "指向的是 DerivedA 类型" << std::endl;}// 判断是否为 DerivedB 类型else if (DerivedB* b_ptr = dynamic_cast<DerivedB*>(base_ptr)) {std::cout << "指向的是 DerivedB 类型" << std::endl;}else {std::cout << "指向的是其他类型" << std::endl;}delete base_ptr;return 0;
}
原理:
- 如果转换成功,
dynamic_cast
返回派生类指针(非空); - 如果转换失败,返回
nullptr
(对于指针类型)或抛出bad_cast
异常(对于引用类型)。
87.2 typeid 运算符(获取类型信息)
typeid
可以获取对象的实际类型信息(需包含 <typeinfo>
头文件),返回一个 std::type_info
对象,可用于比较类型。
用法示例:
#include <iostream>
#include <typeinfo> // 需包含此头文件class Base {
public:virtual ~Base() {} // 基类必须有虚函数,否则 typeid 只能识别编译时类型
};class DerivedA : public Base {};
class DerivedB : public Base {};int main() {Base* base_ptr = new DerivedB();// 比较类型信息if (typeid(*base_ptr) == typeid(DerivedA)) {std::cout << "实际类型是 DerivedA" << std::endl;}else if (typeid(*base_ptr) == typeid(DerivedB)) {std::cout << "实际类型是 DerivedB" << std::endl;}// 也可以直接打印类型名(依赖编译器实现,可能不是可读名称)std::cout << "类型名:" << typeid(*base_ptr).name() << std::endl;delete base_ptr;return 0;
}
注意:
- 若基类没有虚函数,
typeid
只会识别指针 / 引用的编译时类型(即基类类型),无法获取实际派生类类型; typeid
返回的类型名(name()
方法)因编译器而异(例如 GCC 和 MSVC 的输出格式不同),不建议直接用于字符串比较以外的场景。
八十七、Vector可以直接用std::move去拷贝大资源吗?
是的,std::vector
可以安全地使用 std::move
来 “转移” 大资源,而不是进行深拷贝,这是高效处理大型容器的推荐做法。
原理:
std::vector
内部管理着动态分配的内存(存储元素的连续缓冲区)。当使用 std::move(v)
时,会触发 vector 的移动构造函数或移动赋值运算符,此时:
- 源 vector 的内部数据指针会被 “转移” 给目标 vector,而非复制数据本身。
- 源 vector 会被置为空(内部指针为
nullptr
,大小为 0),不再拥有资源。
这与拷贝操作(深拷贝整个缓冲区)相比,效率极高(O (1) 时间复杂度),尤其适合包含大量元素的大 vector。
示例代码:
#include <iostream>
#include <vector>
#include <utility> // 包含 std::moveint main() {// 创建一个包含大量元素的 vector(模拟大资源)std::vector<int> large_vec(1000000, 42); // 100万个元素std::cout << "移动前 - large_vec 大小: " << large_vec.size() << std::endl;// 使用 std::move 转移资源std::vector<int> new_vec(std::move(large_vec)); std::cout << "移动后 - new_vec 大小: " << new_vec.size() << std::endl;std::cout << "移动后 - large_vec 大小: " << large_vec.size() << std::endl; // 源 vector 被清空return 0;
}
输出结果:
移动前 - large_vec 大小: 1000000
移动后 - new_vec 大小: 1000000
移动后 - large_vec 大小: 0
注意事项:
- 源对象状态:移动后源 vector 会处于 “有效但未定义” 的状态(通常为空),不应再使用它访问原数据(但可以安全地赋值新数据或销毁)。
- 适用场景:
- 传递大型 vector 给函数参数(避免拷贝)。
- 从函数返回大型 vector(编译器通常会自动优化为移动,无需显式
std::move
)。 - 交换两个 vector 的内容(
std::swap
内部会使用移动语义)。
- 元素的移动语义:如果 vector 存储的是自定义类型,确保该类型实现了移动构造函数(或编译器生成默认移动构造),否则可能退化为深拷贝。
总结:
对于 std::vector
包含大资源的场景,强烈推荐使用 std::move
,它能避免昂贵的深拷贝,大幅提升性能。这是 C++11 移动语义带来的重要优化手段。
八十八、什么时候可以用std::move呢?
std::move
是 C++11 引入的移动语义核心工具,其作用是将对象强制转换为右值引用,从而触发移动构造 / 赋值(而非拷贝),实现资源的高效转移。以下是适合使用 std::move
的典型场景:
88.1 转移容器或大型对象的所有权
当需要将一个容器(如 std::vector
、std::string
)或包含动态资源的大型对象(如自定义类)的所有权从一个变量转移到另一个变量时,std::move
可以避免昂贵的深拷贝。
std::vector<int> src(1000000); // 大型容器
std::vector<int> dest = std::move(src); // 转移资源,O(1) 操作
// src 此时为空(有效但未定义状态),资源由 dest 接管
88.2 函数参数传递:避免不必要的拷贝
当向函数传递大型对象,且不再需要在调用方保留原对象时,用 std::move
触发移动构造,减少拷贝开销。
void process(std::vector<int> data) { // 处理数据
}int main() {std::vector<int> large_data(1000000);process(std::move(large_data)); // 传递右值,触发移动构造// large_data 已失效,不应再使用
}
88.3 函数返回值:优化返回大型对象
虽然编译器通常会对返回值进行 RVO(返回值优化),但在无法优化的场景(如根据条件返回不同对象),std::move
可避免拷贝。
std::vector<int> create_data(bool flag) {std::vector<int> a(1000), b(2000);if (flag) {return std::move(a); // 转移 a 的资源} else {return std::move(b); // 转移 b 的资源}
}
88.4 在容器中插入临时对象或即将废弃的对象
向容器(如 std::vector
、std::map
)插入元素时,对不再使用的对象使用 std::move
,可避免拷贝。
std::vector<std::string> vec;
std::string str = "hello world";// 插入时转移 str 的资源(str 之后会被清空)
vec.push_back(std::move(str));
88.5 实现移动构造函数和移动赋值运算符
在自定义类中实现移动语义时,需用 std::move
转移成员变量的资源
class MyBuffer {
private:std::vector<int> data;
public:// 移动构造函数MyBuffer(MyBuffer&& other) : data(std::move(other.data)) {} // 转移 other 的 data 资源// 移动赋值运算符MyBuffer& operator=(MyBuffer&& other) {if (this != &other) {data = std::move(other.data); // 转移资源}return *this;}
};
88.6 交换两个对象(实现高效 swap)
std::swap
的内部实现通常会使用 std::move
转移资源,避免三次拷贝。
template <typename T>
void swap(T& a, T& b) {T temp = std::move(a); // 移动 a 到 tempa = std::move(b); // 移动 b 到 ab = std::move(temp); // 移动 temp 到 b
}
88.7 不适合使用 std::move
的场景
-
对临时对象使用:临时对象本身就是右值,无需
std::move
(编译器会自动识别)。std::string get_str(); std::string s = std::move(get_str()); // 多余,get_str() 已返回右值
-
对仍需使用的对象使用:移动后源对象会处于 “有效但未定义” 状态(通常为空),若后续还需访问源对象,会导致未定义行为。
std::string s = "test"; std::string t = std::move(s); std::cout << s; // 危险!s 可能已被清空
-
对基本类型使用:
int
、double
等基本类型的移动与拷贝效率相同,使用std::move
无意义。int a = 10; int b = std::move(a); // 等价于 b = a,无性能提升
88. 8核心原则
std::move
本身不移动任何数据,它只是 “允许” 编译器使用移动语义。只有当你确定不再需要源对象时,才使用 std::move
,此时移动语义能最大化性能(避免拷贝)。
八十九、还有啥方法可以实现大规模拷贝吗?
除了 std::move
,C++ 中还有其他实现资源高效转移或避免不必要拷贝的方法,这些方法通常与移动语义相关,或通过其他机制减少拷贝开销。以下是主要方式:
89.1 返回值优化(RVO/NRVO,编译器自动优化)
这是编译器层面的优化,当函数返回一个局部对象时,编译器会直接在调用方的内存空间构造对象,完全避免拷贝或移动,效率比 std::move
更高。
std::vector<int> create_large_vector() {std::vector<int> vec(1000000); // 局部大型容器return vec; // 编译器会优化为直接在调用方构造,无拷贝/移动
}int main() {auto vec = create_large_vector(); // 无拷贝,直接使用构造好的对象
}
特点:
- 无需手动干预,编译器自动触发(C++17 起成为强制优化)。
- 适用场景:函数返回局部对象时,优先依赖此优化,而非显式
std::move
(std::move
可能会阻止 RVO)。
89.2 std::swap
交换资源
std::swap
可通过交换两个对象的内部资源指针实现高效 “转移”,避免深拷贝。其内部通常会利用移动语义(本质是三次移动操作)。
#include <algorithm> // std::swapstd::vector<int> a(1000000), b;
std::swap(a, b); // 交换内部资源指针,O(1) 操作
// 交换后,b 拥有原 a 的数据,a 变为空(原 b 的状态)
适用场景:需要交换两个对象的资源,且后续可能继续使用原对象(但内容已交换)。
89.3 emplace 系列函数(直接在容器中构造对象)
容器的 emplace_back
、emplace
等函数可直接在容器内存中构造对象,避免临时对象的拷贝 / 移动。
#include <vector>
#include <string>struct Person {std::string name;int age;Person(std::string n, int a) : name(std::move(n)), age(a) {}
};int main() {std::vector<Person> people;// 直接在容器中构造 Person,避免临时对象people.emplace_back("Alice", 30); // 对比:push_back 会先构造临时对象,再移动/拷贝到容器people.push_back(Person("Bob", 25));
}
特点:
- 直接传递构造函数参数,在容器内部完成构造,比
push_back
更高效。 - 适用于向容器添加元素时,避免中间临时对象的开销。
89.4 右值引用参数(函数接口设计)
通过将函数参数定义为右值引用(T&&
),可接收右值并直接转移其资源,无需显式 std::move
。
void process_data(std::vector<int>&& data) {// 直接使用 data,其资源已被转移(调用时需传入右值)
}int main() {std::vector<int> vec(1000000);process_data(std::move(vec)); // 传入右值,触发资源转移
}
特点:
- 强制调用方传入右值(通常通过
std::move
转换),明确表示 “资源会被转移”。 - 常用于函数接口设计,避免参数的拷贝。
89.5 自定义资源管理(指针 / 智能指针)
对于自定义类型,可通过内部管理资源指针(如 std::unique_ptr
)实现高效转移,无需依赖移动语义。
#include <memory>class LargeResource {
private:std::unique_ptr<int[]> data; // 管理动态数组size_t size;
public:LargeResource(size_t s) : data(new int[s]), size(s) {}// 无需手动实现移动构造,unique_ptr 本身支持移动
};int main() {LargeResource res(1000000);LargeResource res2 = std::move(res); // 转移 unique_ptr 管理的资源
}
特点:
- 借助智能指针(
unique_ptr
/shared_ptr
)的移动语义,间接实现资源转移。 - 适用于自定义类的资源管理,避免手动处理内存。
89.6 std::forward
(完美转发,保留值类别)
std::forward
主要用于模板中保留参数的左值 / 右值属性,间接实现资源的高效传递(避免不必要的拷贝)。
template <typename T>
void wrapper(T&& arg) {process(std::forward<T>(arg)); // 完美转发,若 arg 是右值则触发移动
}// 调用时:
std::vector<int> vec;
wrapper(std::move(vec)); // 转发为右值,process 会移动资源
特点:
- 不直接转移资源,但确保参数在传递过程中保持原有值类别,使移动语义能正确触发。
- 主要用于泛型编程(模板函数)。
89.7 总结
- 优先依赖编译器优化(RVO):函数返回局部对象时,无需任何操作,编译器自动避免拷贝。
- 容器操作优先用
emplace
:向容器添加元素时,emplace
比push_back
更高效。 std::swap
适合交换场景:比手动移动更简洁。- 右值引用和
std::forward
:用于接口设计和泛型编程,确保移动语义正确触发。
这些方法各有适用场景,但核心目标都是减少不必要的深拷贝,其中编译器优化(RVO)和 emplace
系列函数是日常开发中最常用的高效手段。
九十、std::emplace_back
和 std::emplace
?
std::emplace_back
和 std::emplace
都是 C++11 引入的容器成员函数,用于直接在容器中构造元素(避免临时对象的拷贝 / 移动),但它们的使用场景和作用目标不同,主要区别在于操作的位置和适用的容器类型。
核心区别:操作位置与容器支持
特性 | emplace_back | emplace |
---|---|---|
操作位置 | 仅在容器末尾构造元素 | 可在容器指定位置(迭代器位置)构造元素 |
支持的容器 | 序列式容器(如 vector 、deque 、list ) | 所有支持迭代器插入的容器(如 vector 、map 、set 等) |
功能本质 | 等价于 emplace(end(), args...) | 通用的位置插入接口 |
90.1 emplace_back
:仅在容器末尾构造元素
- 适用容器:
vector
、deque
、list
、forward_list
等支持在末尾插入的序列式容器。 - 作用:在容器的末尾直接构造一个元素,参数会直接传递给元素的构造函数。
- 与
push_back
的对比:push_back
需要先构造临时对象(或传入已构造的对象),再将其拷贝 / 移动到容器中。emplace_back
直接在容器的内存空间中构造元素,完全避免临时对象的开销。
示例:
#include <vector>
#include <string>struct Person {std::string name;int age;Person(std::string n, int a) : name(std::move(n)), age(a) {} // 构造函数
};int main() {std::vector<Person> people;// emplace_back:直接在容器末尾构造 Person,传递构造参数people.emplace_back("Alice", 30); // 无需手动构造临时对象// 对比 push_back:需要先构造临时对象(或移动已有对象)people.push_back(Person("Bob", 25)); // 先构造临时对象,再移动到容器
}
90.2 emplace
:在指定位置构造元素
- 适用容器:几乎所有支持插入操作的容器(
vector
、list
、map
、set
、unordered_map
等)。 - 作用:在容器的指定迭代器位置直接构造一个元素,参数传递给元素的构造函数。
- 与
insert
的对比:insert
需要传入已构造的对象(或临时对象),会触发拷贝 / 移动。emplace
直接在指定位置构造元素,避免临时对象。
示例:
#include <vector>
#include <string>int main() {std::vector<std::string> words = {"apple", "banana"};// emplace:在迭代器指定位置(第2个元素前)构造元素auto it = words.begin() + 1; // 指向 "banana" 的位置words.emplace(it, "orange"); // 在 "banana" 前插入 "orange"// 结果:words = ["apple", "orange", "banana"]
}
对于关联容器(如 map
),emplace
还能避免键的重复构造:
#include <map>
#include <string>int main() {std::map<int, std::string> dict;// emplace:直接构造键值对(key=1, value="one")dict.emplace(1, "one"); // 比 insert({1, "one"}) 更高效// 无需先构造 pair 对象,直接传递构造参数
}
90.3 关键总结
- 位置差异:
emplace_back
只能在容器末尾插入(等价于emplace(end(), args)
)。emplace
可以在任意迭代器位置插入,更灵活。
- 效率差异:
- 两者都比
push_back
/insert
高效,因为避免了临时对象的拷贝 / 移动。 - 对于需要在非末尾位置插入的场景,
emplace
是唯一选择。
- 两者都比
- 使用建议:
- 向容器末尾插入时,优先用
emplace_back
(比emplace
更简洁)。 - 向指定位置插入时,必须用
emplace
。 - 对于自定义类型或大型对象,优先使用这两个函数替代
push_back
/insert
。
- 向容器末尾插入时,优先用
本质上,emplace_back
是 emplace
的一个特例(固定在末尾插入),而 emplace
是更通用的插入接口。
九十一、对迭代器的理解?
在 C++ 中,迭代器(Iterator)是一种抽象的数据访问机制,它提供了一种统一的方式遍历容器(如 vector
、list
、map
等)中的元素,而无需暴露容器的内部实现细节。可以将迭代器理解为 “泛化的指针”,但比指针更灵活,能适配不同的数据结构。
91.1 迭代器的核心作用
- 统一遍历接口:无论容器是数组式(
vector
)、链表式(list
)还是树形(map
),迭代器都提供一致的操作(如++
移动到下一个元素,*
访问元素)。 - 隔离容器实现:用户无需关心容器内部如何存储数据(连续内存 / 节点指针),只需通过迭代器操作元素。
- 支持算法复用:C++ 标准算法库(如
std::sort
、std::find
)通过迭代器操作,可适配各种容器。
91.2 迭代器的基本操作
所有迭代器都支持以下基础操作(不同类型的迭代器支持的操作范围不同):
操作 | 含义 |
---|---|
*it | 访问迭代器指向的元素(解引用) |
it->mem | 访问元素的成员(等价于 (*it).mem ) |
++it | 移动迭代器到下一个元素(前置递增) |
it++ | 移动迭代器到下一个元素(后置递增) |
it1 == it2 | 判断两个迭代器是否指向同一位置 |
it1 != it2 | 判断两个迭代器是否指向不同位置 |
91.3 迭代器的类型(按功能划分)
C++ 标准将迭代器分为 5 类,功能从弱到强依次为:
- 输入迭代器(Input Iterator)
- 只能单向读取(从容器开头到结尾),支持
++
、*
、==
、!=
。 - 示例:
istream_iterator
(从输入流读取数据)。
- 只能单向读取(从容器开头到结尾),支持
- 输出迭代器(Output Iterator)
- 只能单向写入,支持
++
、*
。 - 示例:
ostream_iterator
(向输出流写入数据)。
- 只能单向写入,支持
- 前向迭代器(Forward Iterator)
- 可单向读写,支持输入 / 输出迭代器的所有操作,且可重复遍历(同一迭代器可多次使用)。
- 示例:
forward_list
的迭代器、unordered_map
的迭代器。
- 双向迭代器(Bidirectional Iterator)
- 可双向读写(支持
--
操作移动到上一个元素),包含前向迭代器的所有功能。 - 示例:
list
、map
、set
的迭代器。
- 可双向读写(支持
- 随机访问迭代器(Random Access Iterator)
- 支持随机访问(如
it + n
直接跳到第 n 个元素),包含双向迭代器的所有功能,还支持<
、>
、+=
、-=
等操作。 - 示例:
vector
、deque
、string
的迭代器(本质是对指针的封装)。
- 支持随机访问(如
91.4 容器与迭代器的对应关系
不同容器支持的迭代器类型由其内部结构决定:
容器类型 | 迭代器类型 | 特点(为什么支持该类型) |
---|---|---|
vector 、deque | 随机访问迭代器 | 元素在连续内存中,支持 O (1) 随机访问 |
list 、map 、set | 双向迭代器 | 元素通过指针链接,只能双向顺序访问 |
forward_list | 前向迭代器 | 单向链表,只能向前遍历 |
unordered_map | 前向迭代器 | 哈希表存储,元素无序,只能单向遍历 |
91.5 迭代器的使用示例
#include <iostream>
#include <vector>
#include <list>
#include <algorithm> // 用于 std::findint main() {// 1. vector 支持随机访问迭代器std::vector<int> vec = {1, 2, 3, 4, 5};auto vec_it = vec.begin(); // 获得起始迭代器std::cout << *vec_it << std::endl; // 输出:1vec_it += 2; // 随机访问:直接跳到第3个元素std::cout << *vec_it << std::endl; // 输出:3// 2. list 支持双向迭代器std::list<int> lst = {10, 20, 30};auto lst_it = lst.begin();++lst_it; // 向前移动std::cout << *lst_it << std::endl; // 输出:20--lst_it; // 向后移动(随机访问迭代器不支持)std::cout << *lst_it << std::endl; // 输出:10// 3. 用迭代器遍历容器for (auto it = vec.begin(); it != vec.end(); ++it) {std::cout << *it << " "; // 输出:1 2 3 4 5}// 4. 迭代器配合算法使用auto find_it = std::find(vec.begin(), vec.end(), 3);if (find_it != vec.end()) {std::cout << "\n找到元素:" << *find_it << std::endl; // 输出:3}return 0;
}
91.6 注意事项
-
迭代器失效:当容器发生修改(如
vector
的push_back
可能导致内存重分配,erase
删除元素)时,迭代器可能失效(指向错误位置),使用前需确认有效性。std::vector<int> vec = {1, 2, 3}; auto it = vec.begin(); vec.push_back(4); // 可能导致内存重分配,it 失效 // *it 操作未定义!
-
const 迭代器:
const_iterator
只能读取元素,不能修改,用于遍历const
容器:const std::vector<int> cvec = {1, 2, 3}; std::vector<int>::const_iterator cit = cvec.begin(); // *cit = 4; // 错误:const 迭代器不能修改元素
-
范围 for 循环的本质:范围 for 循环是迭代器的语法糖,编译器会自动转换为迭代器遍历:
for (int x : vec) { ... } // 等价于: for (auto it = vec.begin(); it != vec.end(); ++it) {int x = *it; ... }
91.7 总结
迭代器是 C++ 中连接容器与算法的桥梁,它通过统一的接口屏蔽了不同容器的实现差异,使得通用算法(如排序、查找)可以无缝适配各种数据结构。理解迭代器的类型和特性,是高效使用 C++ 容器和算法的基础。
九十二、单例中用static 和call_once有啥区别?
在 C++ 单例模式中,static
局部变量和 std::call_once
是两种实现线程安全初始化的常用方式,核心目标都是保证单例实例 “只被初始化一次”,但两者的底层机制、适用场景和行为特性有显著区别。
92.1 基础实现对比
先看两种方式的典型代码实现,直观感受差异:
(1)static
局部变量方式(C++11 后推荐)
利用 C++11 标准对 “局部静态变量初始化” 的线程安全保证:
#include <iostream>class Singleton {
public:// 禁止拷贝和移动Singleton(const Singleton&) = delete;Singleton& operator=(const Singleton&) = delete;Singleton(Singleton&&) = delete;Singleton& operator=(Singleton&&) = delete;// 获取单例实例static Singleton& getInstance() {// 局部静态变量:C++11 后保证线程安全初始化,仅执行一次static Singleton instance; return instance;}private:// 私有构造函数,禁止外部实例化Singleton() { std::cout << "Singleton 初始化" << std::endl; }
};
(2)std::call_once
方式
借助 <mutex>
头文件中的 std::call_once
和 std::once_flag
:
#include <iostream>
#include <mutex> // 需包含 mutex 头文件class Singleton {
public:// 禁止拷贝和移动(同上面)Singleton(const Singleton&) = delete;Singleton& operator=(const Singleton&) = delete;Singleton(Singleton&&) = delete;Singleton& operator=(Singleton&&) = delete;// 获取单例实例static Singleton& getInstance() {// once_flag 用于标记初始化是否完成static std::once_flag flag; // call_once 保证初始化函数只被执行一次(线程安全)std::call_once(flag, &Singleton::initInstance); return *instance_ptr;}private:Singleton() { std::cout << "Singleton 初始化" << std::endl; }// 初始化函数:实际创建实例static void initInstance() {instance_ptr = new Singleton(); // (可选)注册析构函数,程序结束时释放单例static Destructor destructor; }// 静态指针存储实例static Singleton* instance_ptr; // 嵌套类:用于程序结束时销毁单例class Destructor {public:~Destructor() {if (Singleton::instance_ptr) {delete Singleton::instance_ptr;Singleton::instance_ptr = nullptr;}}};
};// 初始化静态成员
Singleton* Singleton::instance_ptr = nullptr;
92.2 核心区别分析
维度 | static 局部变量方式 | std::call_once 方式 |
---|---|---|
线程安全机制 | 依赖 C++11 标准的 “局部静态变量初始化线程安全”:编译器会自动插入锁和同步机制,保证初始化过程唯一且线程安全。 | 依赖 std::once_flag 和 std::call_once :由标准库实现同步,通过原子操作标记初始化状态,确保传入的函数只执行一次。 |
初始化时机 | 懒汉式(Lazy Initialization):第一次调用 getInstance() 时才初始化。 | 懒汉式:第一次调用 getInstance() 时,由 call_once 触发初始化函数执行。 |
实现复杂度 | 极简:无需手动管理指针、锁或析构,编译器自动处理。 | 较复杂:需手动维护 static 指针、once_flag ,且需额外处理析构(如嵌套析构类)。 |
异常处理 | 若初始化抛出异常,下次调用 getInstance() 会重新尝试初始化(因为初始化未完成)。 | 若初始化函数抛出异常,once_flag 仍会标记为 “已执行”,后续调用 call_once 不会再次执行初始化,可能导致获取实例失败(需手动处理异常)。 |
适用场景 | 单例构造简单、无复杂初始化逻辑,追求代码简洁。 | 初始化逻辑复杂(需拆分到单独函数),或需要更精细控制初始化过程(如多步骤初始化)。 |
兼容性 | 依赖 C++11 及以上标准(部分旧编译器可能不严格遵守线程安全保证)。 | 同样依赖 C++11 及以上,但 call_once 的行为在标准中定义更明确,兼容性更稳定(尤其对复杂初始化)。 |
92.3 关键细节补充
(1)关于 static
局部变量的线程安全
C++11 标准明确规定:局部静态变量的初始化在多线程环境下是线程安全的,即多个线程同时首次调用 getInstance()
时,只会有一个线程执行初始化,其他线程会阻塞等待初始化完成,且初始化仅执行一次。
这种机制由编译器实现(如插入内部锁),无需用户手动加锁,因此代码极其简洁。但需注意:
- 仅保证 “初始化阶段” 的线程安全,若单例实例本身有状态修改,仍需额外同步(如
mutex
)。 - 部分非常旧的编译器(如 GCC 4.3 之前)可能未完全实现该特性,需确认编译器版本。
(2)关于 std::call_once
的行为
std::call_once
的核心是 std::once_flag
(一个不可复制的对象),它会记录初始化是否 “成功完成”:
- 若初始化函数正常返回,
once_flag
标记为 “已完成”,后续调用不再执行。 - 若初始化函数抛出异常,
once_flag
不会标记为 “已完成”,下次调用会再次尝试执行初始化(这一点与直觉可能相反,需注意异常处理)。
因此,call_once
更适合初始化逻辑可能抛出异常,但需要 “重试” 的场景(需在初始化函数中处理异常,避免无限重试)。
92.4 如何选择?
- 优先用
static
局部变量:绝大多数单例场景(构造简单、无复杂初始化),代码简洁、不易出错,且性能足够好(现代编译器对局部静态变量的初始化优化已非常成熟)。 - 用
std::call_once
:当初始化逻辑复杂(需拆分为多步操作)、需要在初始化阶段做更多控制(如日志、资源检查),或需要明确处理初始化异常时。
总结:两者都是线程安全的单例初始化方式,但 static
局部变量是 “编译器自动托管”,call_once
是 “手动显式控制”。根据初始化逻辑的复杂度和异常处理需求选择即可。