“C 使得射中自己的脚变得很容易;C++ 则使得这变得更难,但当你射中时,它会把你的整条腿都打断。”
1. 虚析构函数的作用
当你通过基类指针删除派生类对象,且基类析构函数不是 virtual 时,会导致派生类的析构函数不被调用。
注意:只要类中包含任何虚函数,就应该将析构函数声明为 virtual。这样在执行 delete 基类指针时,会先调用派生类的析构函数,再调用基类的析构函数,从而引发完整的资源释放。
2. delete 与 delete[] 的区别
使用 new 分配的内存必须用 delete 释放,使用 new[] 分配的数组必须用 delete[] 释放。
注意:delete[] 会读取数组前的元数据(通常包含数组长度)以确定需要调用多少次析构函数。如果混用,会导致未定义行为(UB),通常表现为程序崩溃或内存损坏。
3. 悬挂指针 (Dangling Pointers)
指向已释放内存的指针称为悬挂指针。对悬挂指针的解引用会导致严重的运行错误。
注意:在释放内存后立即将指针置为 nullptr 是一种良好的习惯。但在多线程环境下,这并不能完全解决问题,此时优先使用智能指针(std::unique_ptr, std::shared_ptr)来管理生命周期。
4. C 语言的关键字 static 和 C++ 的关键字 static 有什么区别
在 C 中 static 用来修饰局部静态变量和外部静态变量、函数。而 C++中除了上述功能外,还用来定义类的成员变量和函数。即静态成员和静态成员函数。 注意:编程时 static 的记忆性,和全局性的特点可以让在不同时期调用的函数进行通信,传递信息,而 C++的静态成员则可以在多个对象实例间进行通信,传递信息。
5. C中的 malloc 和C++中的 new 有什么区别
malloc 和 new 有以下不同: (1) new、delete 是操作符,可以重载,只能在 C++中使用。 (2) malloc、free 是函数,可以覆盖,C、C++中都可以使用。 (3) new 可以调用对象的构造函数,对应的 delete 调用相应的析构函数。 (4) malloc 仅仅分配内存,free 仅仅回收内存,并不执行构造和析构函数 (5) new、delete 返回的是某种数据类型指针,malloc、free 返回的是 void 指针。 注意:malloc 申请的内存空间要用 free 释放,而 new 申请的内存空间要用 delete 释放,不要混用。因为两者实现的机理不同。
6. 写一个“标准”宏 MIN
1
#define MIN(a,b) ((a)<=(b)?(a):(b))
注意:在调用时一定要注意这个宏定义的副作用,如下调用:
MIN(++*p, x) 会被展开为 ((++*p)<=(x)?(++*p):(x))。
此时 p 指针就可能自加了两次,违背了 MIN 的本意。
7. 令人困惑的解析 (Most Vexing Parse)
C++ 有时会将对象初始化解析为函数声明。例如 TimeKeeper t(Timer()); 会被解析为一个名为 t 的函数。
注意:这是由于 C++ 的语法歧义造成的。解决方法是使用 C++11 的大括号初始化 {},如 TimeKeeper t{Timer{}};。
8. unsigned 减法溢出
无符号数减法在结果为负时会发生回绕(wrap-around),变成一个巨大的正数。
注意:这常发生在循环中,如 for (unsigned int i = 10; i >= 0; --i)。当 i 为 0 后继续执行 --i,会变成其最大可能值,导致死循环。除非明确需要模运算语义,否则优先使用 int 或 size_t。
9. 初始化顺序依赖
类的成员变量初始化顺序是按照它们在类中声明的顺序,而不是在初始化列表中的顺序。
注意:如果一个成员的初始化依赖于另一个成员,必须确保声明顺序正确。例如 class A { int x; int y; A(int v): y(v), x(y+1) {} }; 是错误的,因为 x 会先于 y 初始化。
10. std::vector<bool> 的特殊性
std::vector<bool> 是 STL 中的一个特殊化版本,为了节省空间,它内部使用位(bit)来存储。
注意:它并不完全符合 STL 容器的规范,例如你不能获取它的元素的引用(bool&),auto x = v[0] 返回的是一个代理对象。如果需要常规行为,请使用 std::vector<char>。
11. 迭代器失效 (Iterator Invalidation)
在遍历容器的过程中修改容器(如插入或删除元素)可能会导致迭代器失效。
注意:对于 std::vector,插入操作触发扩容时所有迭代器失效;删除操作会使指向被删元素及其后所有元素的迭代器失效。在循环中操作时应非常小心,通常需要利用 erase 的返回值更新迭代器。
12. std::endl 与 \n 的效率差异
std::endl 不仅输出换行符,还会显式调用 flush() 清空缓冲区。
注意:在频繁输出的循环中使用 std::endl 会显著降低 I/O 性能。绝大多数情况下应该使用 \n。
13. auto 的类型推导规则
auto 在进行类型推导时会忽略引用修饰符和顶层 const。
注意:如果你需要推导出引用类型,必须显式写为 const auto& 或 auto&。
14. Lambda 捕获 this 的风险
在类成员函数中捕获 [=] 实际上是按值捕获了 this 指针。
注意:如果该 Lambda 是异步执行的,而此时原对象已被销毁,执行 Lambda 会导致访问非法内存。在 C++17 中可以使用 [*this] 来捕获对象的副本以保证安全。
15. 对象切片 (Object Slicing)
当派生类对象按值传递给基类参数时,派生类特有的部分会被“切掉”,只保留基类部分。
注意:这会导致多态失效。为了保持多态性,应该始终通过引用或指针传递基类类型参数(如 const Base&)。
16. sizeof和strlen的区别
sizeof 和 strlen 有以下不同:
- sizeof是一个操作符,strlen是库函数
- sizeof的参数可以是数据的类型,也可以是变量,而strlen只能以结尾’\0’的字符串作为参数。
- 编译器在编译时就计算出了sizeof的结果。而strlen函数必须在运行的时候才能计算出来。并且sizeof计算的是数据类型占内存的大小,而strlen计算的是字符串实际的长度。
- 数组做sizeof的参数不退化,传递给strlen就退化为指针。
17. 数组指针和数组地址
1
2
3
4
5
6
7
8
9
10
11
#include<stdio.h>
void main(void)
{
int a[5] = {};
int *ptr=(int*)(&a + 1);
printf("%d,%d" *(a + 1), *(ptr - 1));
return;
}
//输出结果:2,5
//数组名 a 可以作数组的首地址,而&a 是数组的指针
18. C、C++程序编译的内存分配情况
C、C++中内存分配方式可以分为三种: (1) 从静态存储区域分配: 内存在程序编译时就已经分配好,这块内存在程序的整个运行期间都存在。速度快、不容易出错,因为有系统会善后。例如全局变量,static 变量等。 (2) 在栈上分配: 在执行函数时,函数内局部变量的存储单元都在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。 (3) 从堆上分配: 即动态内存分配。程序在运行的时候用 malloc 或 new 申请任意大小的内存,程序员自己负责在何时用 free 或 delete 释放内存。动态内存的生存期由程序员决定,使用非常灵活。如果在堆上分配了空间,就有责任回收它,否则运行的程序会出现内存泄漏,另外频繁地分配和释放不同大小的堆空间将会产生堆内碎块。 一个 C、C++程序编译时内存分为 5 大存储区:堆区、栈区、全局区、文字常量区、程序代码区
19. 拷贝构造函数和赋值运算符的区别
拷贝构造函数生成新的类的对象,而赋值运算符不能。 由于拷贝构造函数时直接直接构造一个新的类对象,所以在初始化这个对象之前不用检验元对象是否和新建对象相同。而赋值运算符则需要这个操作,另外赋值运算如果原来的对象中有内存分配要先把内存释放掉。
注:当类中有指针类型的成员变量时,一定要重写拷贝构造函数和赋值运算符,不要使用默认的
20.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream.h>
class A
{
virtual void g()
{
cout << "A::g" << endl;
}
private:
virtual void f()
{
cout << "A::f" << endl;
}
};
class B : public A
{
void g()
{
cout << "B::g" << endl;
}
virtual void h()
{
cout << "B::h" << endl;
}
};
typedef void( *Fun )( void );
void main()
{
B b;
Fun pFun;
for(int i = 0 ; i < 3; i++)
{
pFun = ( Fun )*( ( int* ) * ( int* ) ( &b ) + i );
pFun();
}
}
输出:
B::g
A::f
B::h
其中:
(int)(&b) 获取对象b的地址,强制转换为int (假设虚表指针位于对象的起始处)
*(int)(&b) 解引用这个指针,得到虚函数表的地址(vptr指向vtable)
(int)* (int)(&b) + i 将虚表地址转为int后,加上偏移量i, 得到第i个虚函数条目的地址
*(…)取出该地址中存储的函数地址
(Fun) 将取出的地址转换为函数指针类型
pFun()函数调用,因为这里的函数指针已经指向函数
21. 类成员函数的重写、重载、隐藏的区别
(1)重写和重载主要有以下几点不同。范围的区别:被重写的和重写的函数在两个类中,而重载和被重载的函数在同一个类中。参数的区别:被重写函数和重写函数的参数列表一定相同,而被重载函数和重载函数的参数列表一定不同。virtual 的区别:重写的基类中被重写的函数必须要有 virtual 修饰,而重载函数和被重载函数可以被virtual 修饰,也可以没有。
(2)隐藏和重写、重载有以下几点不同。与重载的范围不同:和重写一样,隐藏函数和被隐藏函数不在同一个类中。参数的区别:隐藏函数和被隐藏的函数的参数列表可以相同,也可不同,但是函数名肯定要相同。当参数不相同时,无论基类中的参数是否被 virtual 修饰,基类的函数都是被隐藏,而不是被重写。说明:虽然重载和覆盖都是实现多态的基础,但是两者实现的技术完全不相同,达到的目的也是完全不同的,覆盖是动态态绑定的多态,而重载是静态绑定的多态。
22. 简述多态实现的原理
编译器发现一个类中有虚函数,便会立即为此类生成虚函数表vtable。虚函数表的各表项为指向对应虚函数的指针。编译器还会在此类中隐含插入一个指针vptr(vc编译器会让它插入在类的第一个位置上)指向虚函数表。调用此类的构造函数时,在类的构造函数中,编译器会隐含执行vptr与vtable的关联代码,将vptr指向对应的vtable,将类与此类的 vtable 联系了起来。另外在调用类的构造函数时,指向基础类的指针此时已经变成指向具体的类的 this 指针,这样依靠此 this 指针即可得到正确的 vtable。
23.左值和右值
(1) 按值传递
1
2
3
4
5
6
void func1(int x) {} // x是左值(函数内的局部变量)
int a = 10;
func1(a); // ✅ a是左值,复制a到x
func1(20); // ✅ 20是右值,复制到x
func1(a + 1);// ✅ 表达式结果是右值
(2) 左值引用参数
1
2
3
4
5
6
void func2(int& x) {} // 只能接受左值
int a = 10;
func2(a); // ✅ a是左值
// func2(20); ❌ 不能接受右值
// func2(a+1); ❌ 不能接受右值表达式
(3) const左值引用参数
1
2
3
4
5
6
void func3(const int& x){}
int a = 10;
func3(a); // ✅ 左值
func3(20); // ✅ 右值
func3(a + 1); // ✅ 右值表达h式
(4) 右值引用参数(C++)
1
2
3
4
5
6
7
8
9
10
11
12
void func4(int&& x) {} // 只接受右值
int a = 10;
// func4(a); ❌ 不能接受左值
func4(20); // ✅ 字面量是右值
func4(a + 1); // ✅ 表达式结果是右值
func4(std::move(a)); // ✅ std::move转为右值
注:std::move 是一个将左值转换为右值引用的工具,它可以让我们在需要时将左值当作右值使用。
```cpp
std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = std::move(v1); // 使用 std::move 将 v1 的资源移动到 v2,避免了拷贝操作
(5) 右值引用参数(C++)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 重载版本1:处理左值
void process(int& x) {
cout << "左值版本" << endl;
}
// 重载版本2:处理右值
void process(int&& x) {
cout << "右值版本" << endl;
}
int a = 10;
process(a); // 输出:"左值版本"
process(20); // 输出:"右值版本"
process(std::move(a)); // 输出:"右值版本"
(6) 完美转发模式
1
2
3
4
5
6
7
8
9
10
11
template<typename T>
void wrapper(T&& arg) //注意这里时万能引用
{
// arg在函数内部是左值
// 但需要保持外部传入是的左右值性质
other_func(std::forward<T>(arg)); // 完美转发
//other_func函数是其他函数,如果参数表是左值引用,std::forward就传出左值
//如果参数表是右值引用,std::forward就传出右值
}
万能引用的两个条件:1.必须有类型推导(模板或auto) 2.必须是 T&& 形式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
auto&& var1 = x; //万能引用
template<typename T>
void f(T&& param); // ✅ 万能引用
void f(int&& param); // ❌ 右值引用,不是万能引用
//实际应用中
vector<pair<string, int>> v;
// 传统方式:创建临时对象 + 拷贝/移动
v.push_back(make_pair("hello", 42));
// 完美转发:直接构造,零拷贝
v.emplace_back("hello", 42); // 直接调用pair的构造函数
24.指针数组和数组指针
(1)指针(的)数组
1
2
3
4
5
6
7
8
int a = 10, b = 20, c = 30;
int *ptr_array[3]; // 指针数组:包含3个int指针
ptr_array[0] = &a;
ptr_array[1] = &b;
ptr_array[2] = &c;
int *ptr_array2[] = {&a, &b, &c};
(2)数组(的)指针
1
2
3
4
5
6
7
int arr[5] = {1, 2, 3, 4, 5};
int (*arr_ptr)[5]; // 数组指针:指向包含5个int的数组
arr_ptr = &arr; // 注意:取整个数组的地址
printf("%d\n", (*arr_ptr)[0]); // 输出: 1
printf("%d\n", (*arr_ptr)[2]); // 输出: 3
25.如何避免野指针
“野指针”产生原因及解决办法如下: (1) 指针变量声明时没有被初始化。解决办法:指针声明时初始化,可以是具体的地址值,也可让它指向 NULL。 (2) 指针 p 被 free 或者 delete 之后,没有置为 NULL。解决办法:指针指向的内存空间被释放后指针应该指向 NULL。 (3) 指针操作超越了变量的作用范围。解决办法:在变量的作用域结束前释放掉变量的地址空间并且让指针指向 NULL。
26.构造函数能否为虚函数
构造函数不能是虚函数。而且不能在构造函数中调用虚函数,因为那样实际执行的是父类的对应函数,因为自己还没有构造好。析构函数可以是虚函数,而且,在一个复杂类结构中,这往往是必须的。 析构函数也可以是纯虚函数,但纯虚析构函数必须有定义体,因为析构函数的调用是在子类中隐含的。
27.四种智能指针
static_cast静态类型转换:(const) (1)void* 转换为任意类型的指针 (2)任意类型的指针转换为void* (3)编译器允许的跨类型转换,比如char类型转换为int类型,double转换为int型 (4)做基类与派生类的转换,派生类转换为基类是安全的,基类转换为派生类是不安全的,因为往往子类的内容比父类多。
dynamic_cast动态类型转换:(转换基类和派生类) (1)用于面向对象中的多态的应用场景,用于基类指针和派生类指针,或者基类引用和派生类引用的安全转换
28.空类
(1)class A{}; sizeof(A)是多少?
答案:1个字节(不是0),C++标准的规定,不同的对象必须有不同的地址,如果大小为0的话,那么A a,b;的地址&a和&b就有可能相同,这将导致指针运算(如 p++)失效。
(2)编译器默认生成了什么函数?
答案:在 C++11 之后,编译器在需要的时候会生成 6 个默认函数:
1. 默认构造函数 (A())
2. 析构函数 (~A())
3. 拷贝构造函数 (A(const A&))
4. 拷贝赋值运算符 (A& operator=(const A&))
5. 移动构造函数 (A(A&&))
6. 移动赋值运算符 (A& operator=(A&&))
(3)
sizeof(Hold) 和 sizeof(Inherit) 分别是多少?(假设 32 位系统,int 为 4 字节)
1
2
3
4
5
6
7
8
9
class Empty {};
struct Hold {
Empty e;
int i;
};
struct Inherit : public Empty {
int i;
};
答案:
sizeof(Hold) = 8 字节(部分编译器可能是 5 或 8,通常因为内存对齐,1 + 3(padding) + 4 = 8)。
sizeof(Inherit) = 4 字节。
原理 (EBO):
作为成员:Hold中,Empty必须占用至少 1 字节,加上内存对齐,整个结构体变大。
作为基类:C++ 允许编译器进行空基类优化。当一个类继承自空类时,编译器可以将空基类的 1 字节优化掉,使其不占用派生类的空间。因为基类子对象可以和派生类第一个成员共享地址,而不违反“不同对象不同地址”的规则(因为它们类型不同)。
应用场景:STL 源码中大量使用 EBO。例如std::vector的内存分配器Allocator通常是空类,为了不让 vector 变大,通常通过继承Allocator来实现,而不是将其作为成员变量。
(4)类内添加虚函数后大小还是1吗?
1
2
3
class A {
virtual void func() {}
};
答案:一旦有了虚函数,对象内部就需要存储一个 vptr (虚表指针)。大小:32 位系统是 4 字节,64 位系统是 8 字节。
29.
结语:C++ 是一门需要终身学习的语言。掌握这些细节不仅能减少 Debug 时间,更能让你写出高性能且安全的代码。本指南将随开发经验的积累持续更新。
参考文献
- Scott Meyers. Effective C++.
- Marshall Cline. C++ FAQs.
- cppreference.com