C++面经
构造函数初始化列表与构造函数体内赋值的区别
先看两种写法:
class A {
public:
A(int x) : a(x) {} // 初始化列表
private:
int a;
};
class A {
public:
A(int x) {
a = x; // 构造函数体内赋值
}
private:
int a;
};
本质区别
初始化列表是“直接构造”成员。
构造函数体内赋值,是成员先被默认构造,再被赋值。
对于内置类型比如 int,看起来差别不大。
但对于类对象,差别很明显:
class B {
public:
B(int x) {}
};
class A {
public:
A(int x) : b(x) {} // 直接调用 B(int)
private:
B b;
};
如果写成:
class A {
public:
A(int x) {
b = B(x); // 先默认构造 b,再赋值
}
private:
B b;
};
这要求 B 必须能默认构造,还多一次赋值,效率和语义都差一些。
哪些情况必须用初始化列表
1)const 成员
class A {
public:
A(int x) : a(x) {}
private:
const int a;
};
const 对象一旦构造完就不能再赋值,所以只能在初始化列表里初始化。
2)引用成员
class A {
public:
A(int& x) : ref(x) {}
private:
int& ref;
};
引用必须在定义时绑定,不能先空着后面再赋值。
3)没有默认构造函数的成员对象
class B {
public:
B(int x) {}
};
class A {
public:
A(int x) : b(x) {}
private:
B b;
};
初始化顺序
注意:成员真正的初始化顺序,取决于声明顺序,不取决于初始化列表顺序。
class A {
public:
A() : b(2), a(1) {}
private:
int a;
int b;
};
这里还是先初始化 a,再初始化 b,因为声明里 a 在前。
面试总结版
初始化列表是在对象创建时直接构造成员,构造函数体内赋值则是成员先默认构造再赋值。
对类对象来说,初始化列表通常更高效,而且 const 成员、引用成员、以及没有默认构造函数的成员对象必须使用初始化列表。
另外成员初始化顺序由声明顺序决定,不由初始化列表里的书写顺序决定。
⸻
构造函数是否可以放到 private 里面
可以。
有什么用
1)单例模式
限制外部随便创建对象。
class Singleton {
private:
Singleton() {}
public:
static Singleton& getInstance() {
static Singleton instance;
return instance;
}
};
外部不能 Singleton s;,只能通过 getInstance() 获取。
2)工厂模式
不让用户直接构造,只允许通过静态接口创建。
class A {
private:
A() {}
public:
static A create() {
return A();
}
};
面试总结版
构造函数可以放在 private 里。这样可以控制对象创建,常用于单例模式、工厂模式,或者禁止外部直接实例化对象。
⸻
- 构造函数和析构函数是否可以为虚函数
构造函数能不能是虚函数
不能。
为什么不能
虚函数依赖虚函数表指针 vptr。
而对象在执行构造函数时,本身还没有完全构造好,虚表机制还没稳定建立,所以没法通过虚函数机制去调用“虚构造函数”。
虚函数调用的前提是对象已经完整建立并且虚表指针已经就绪,而构造函数本身就是在建立对象,因此构造函数不能是虚函数。
⸻
析构函数能不能是虚函数
可以,而且父类经常应该写成虚析构函数。
例如:
class Base {
public:
virtual ~Base() {
cout << “Base析构\n”;
}
};
class Derived : public Base {
public:
~Derived() {
cout << “Derived析构\n”;
}
};
Base* p = new Derived();
delete p;
如果基类析构函数不是虚函数,就可能只调用 Base 的析构,不调用 Derived 的析构,导致资源泄露。
什么时候必须写虚析构函数
只要这个类打算作为基类,并且可能通过基类指针删除派生类对象,就要把析构函数写成虚函数。
面试总结版
构造函数不能是虚函数,因为对象构造时虚表机制还没完全建立。
析构函数可以是虚函数,而且如果类要作为多态基类使用,析构函数一般必须是虚函数,否则通过基类指针释放派生类对象时会出现析构不完整的问题。
⸻
- 说明 static 的原理
static 在 C++ 里要分场景讲
⸻
1)修饰局部变量
void func() {
static int x = 0;
x++;
cout << x << endl;
}
特点:
• 生命周期是整个程序运行期间
• 作用域仍然只在函数内部
• 只初始化一次
第一次调用 func() 时初始化,以后不会重复初始化。
⸻
2)修饰全局变量 / 函数
static int g_val = 10;
static void test() {}
这里的 static 表示内部链接,即只在当前源文件可见,别的 .cpp 文件访问不到。
⸻
3)修饰类成员变量
class A {
public:
static int count;
};
int A::count = 0;
特点:
• 属于类,不属于某个对象
• 所有对象共享一份
• 需要在类外定义一次(C++17 前常见说法,inline static 例外)
⸻
4)修饰类成员函数
class A {
public:
static void func() {}
};
特点:
• 属于类,不属于对象
• 没有 this 指针
• 只能访问静态成员,不能直接访问非静态成员
因为非静态成员需要依赖具体对象,而静态成员函数没有 this。
⸻
原理层面怎么说
static 的核心含义是改变存储周期或链接属性。
对局部变量来说,它让变量从栈上短生命周期变成静态存储区的整个程序生命周期,并且只初始化一次。
对全局变量和函数来说,它让符号变成内部链接,只在当前编译单元可见。
对类成员来说,静态成员属于类本身而不属于对象,因此所有对象共享一份。
⸻
- 介绍 STL 里的 vector
vector 本质上是动态数组,底层是一段连续内存。
特点
• 支持随机访问,O(1)
• 尾插尾删效率高,均摊 O(1)
• 中间插入删除效率低,通常 O(n)
• 内存连续,所以缓存友好
⸻
常见成员函数
vector
v.push_back(1);
v.pop_back();
v.size();
v.capacity();
v.empty();
v.clear();
v.begin();
v.end();
⸻
size 和 capacity 的区别
• size():当前已有元素个数
• capacity():当前已分配容量
vector
v.push_back(1);
可能此时:
• size = 1
• capacity = 1 或更大
⸻
扩容机制
当容量不够时,vector 会申请更大内存,把原来的元素搬过去,再释放旧空间。
这就是为什么 push_back 虽然偶尔会很慢,但均摊复杂度是 O(1)。
通常扩容会按倍数增长,但标准没有强制必须 2 倍,具体实现跟编译器有关。
⸻
为什么 vector 插入中间慢
因为插入一个元素后,后面的元素都要整体后移。
为什么迭代器会失效
因为扩容后,底层内存地址变了,原来的迭代器、指针、引用都可能指向旧地址。
例如:
vector
int* p = &v[0];
v.push_back(4); // 可能扩容
扩容后 p 可能失效。
面试总结版
vector 是 STL 中的动态数组,底层使用连续内存实现,支持 O(1) 随机访问。
尾插均摊 O(1),中间插入删除 O(n)。
它有 size 和 capacity 两个概念,容量不足时会扩容并搬迁数据,因此扩容后迭代器、引用和指针可能失效。
⸻
- 智能指针介绍
C++ 常见智能指针有 3 个:
• unique_ptr
• shared_ptr
• weak_ptr
它们的目的都是:自动管理堆对象,减少内存泄漏。
⸻
1)unique_ptr
独占所有权,同一时刻只能有一个指针拥有对象。
unique_ptr
特点:
• 不能拷贝
• 可以移动
unique_ptr
unique_ptr
适合场景:
• 对象只有唯一拥有者
• 替代裸指针管理资源
⸻
2)shared_ptr
共享所有权,多个指针可以共同管理同一对象。
底层有引用计数。
shared_ptr
shared_ptr
此时引用计数加 1。
最后一个 shared_ptr 销毁时,对象才释放。
⸻
3)weak_ptr
weak_ptr 不拥有对象,不增加引用计数。
主要用来观察 shared_ptr 管理的对象,解决循环引用问题。
shared_ptr
weak_ptr
想访问时要用:
if (auto tmp = wp.lock()) {
// 对象还活着
}
⸻
循环引用问题
class B;
class A {
public:
shared_ptr pb;
};
class B {
public:
shared_ptr pa;
};
A 和 B 互相持有 shared_ptr,引用计数都不会变成 0,对象无法释放。
解决办法:一边改成 weak_ptr。
⸻
面试总结版
unique_ptr 表示独占所有权,不能拷贝只能移动;
shared_ptr 表示共享所有权,底层通过引用计数管理对象生命周期;
weak_ptr 不拥有对象,通常配合 shared_ptr 使用,用来打破循环引用或安全观察对象是否仍然存在。
⸻
- 如果智能指针放到多线程中,如何访问共享对象
这个问题别答偏。
重点不是“智能指针一定线程安全”,而是:
先分清两层安全性
1)控制块引用计数是否线程安全
对于 shared_ptr,引用计数的增减通常是线程安全的。
也就是说:
多个线程各自拷贝一份 shared_ptr,对象不会因为计数竞争被提前释放。
2)被管理对象本身是否线程安全
不一定。
智能指针只保证“谁来释放对象”这一层,不保证对象内部数据访问安全。
比如:
shared_ptr<vector
多个线程同时 sp->push_back(),还是有数据竞争。
⸻
正确做法
场景1:多个线程共享读
如果只读,通常没问题。
场景2:多个线程读写
要加同步机制,比如:
• mutex
• shared_mutex
• 原子变量
• 读写锁
例如:
shared_ptr
mutex m;
void func() {
lock_guard
(*sp)++;
}
⸻
如果对象生命周期也可能变化
线程里最好先拷贝一份 shared_ptr,确保对象在当前使用期间不会被释放。
shared_ptr
if (local) {
local->do_something();
}
如果全局保存的是 weak_ptr,就先 lock():
if (auto sp = wp.lock()) {
sp->do_something();
}
⸻
面试总结版
在多线程中,shared_ptr 的引用计数增减通常是线程安全的,但它管理的对象本身并不是线程安全的。
所以如果多个线程共享访问对象,仍然需要用 mutex 等同步机制保护对象内部状态。
另外在线程中使用共享对象时,通常会先拷贝一份 shared_ptr,以保证对象在当前线程使用期间不会被提前释放。
⸻
- 讲解一下动态绑定和静态绑定
静态绑定
也叫早绑定。
在编译期就确定调用哪个函数。
典型情况:
• 普通函数
• 重载函数
• 非虚成员函数
class Base {
public:
void func() { cout << “Base\n”; }
};
class Derived : public Base {
public:
void func() { cout << “Derived\n”; }
};
Base* p = new Derived();
p->func(); // 调 Base::func
因为 func 不是虚函数,编译时看 p 的类型是 Base*,就定死了。
⸻
动态绑定
也叫晚绑定。
在运行期根据对象真实类型决定调用哪个函数。
前提:
• 通过基类指针或引用调用
• 函数是虚函数
class Base {
public:
virtual void func() { cout << “Base\n”; }
};
class Derived : public Base {
public:
void func() override { cout << “Derived\n”; }
};
Base* p = new Derived();
p->func(); // 调 Derived::func
这里运行时会通过虚表找到真正该调用的函数。
⸻
底层原理
每个含虚函数的类通常有一张虚函数表 vtable,对象里有一个虚表指针 vptr。
调用虚函数时,根据对象实际类型的 vptr 去查表,再跳转到对应函数。
⸻
面试总结版
静态绑定是在编译期确定函数调用目标,典型是普通函数和非虚函数;
动态绑定是在运行期根据对象真实类型决定调用哪个函数,依赖虚函数机制实现。
动态绑定是 C++ 多态的核心,底层通常通过 vtable 和 vptr 实现。
⸻
- 算法题:Top K 如何用堆实现
这个很经典。
⸻
问题
给一个数组,找出最大的 Top K 个数。
例如:
nums = [3,2,1,5,6,4], k = 2
答案是 [6,5]
⸻
方法:维护一个大小为 k 的小根堆
思路
• 先把前 k 个元素放进小根堆
• 之后遍历剩下元素:
• 如果当前元素比堆顶大,说明它更应该进入 Top K
• 弹出堆顶,插入当前元素
• 最后堆里剩下的就是 Top K
为什么用小根堆?
因为堆顶是当前 Top K 中最小的那个,最容易被替换。
⸻
复杂度
• 建堆和维护:O(n log k)
• 空间复杂度:O(k)
这个比全排序 O(n log n) 更适合 k 远小于 n 的情况。
⸻
C++ 实现
#include
#include
#include
using namespace std;
vector
priority_queue<int, vector
for (int x : nums) {
if (minHeap.size() < k) {
minHeap.push(x);
} else if (x > minHeap.top()) {
minHeap.pop();
minHeap.push(x);
}
}
vector<int> res;
while (!minHeap.empty()) {
res.push_back(minHeap.top());
minHeap.pop();
}
return res;
}
int main() {
vector
int k = 2;
vector
for (int x : ans) {
cout << x << “ “;
}
return 0;
}
⸻
如果问“堆排序实现 Top K”怎么答
严格说:
• 完整堆排序 是先建大根堆,再弹出前 k 次,复杂度通常可写成 O(n + k log n)。
• 但面试中更常说的 Top K 堆解法,一般是大小为 k 的小根堆,复杂度 O(n log k),更优。
你可以主动补一句:
如果是求 Top K,通常不需要把整个数组排完,更优的方法是维护一个大小为 k 的小根堆,复杂度 O(n log k)。如果用完整堆排序,也可以先建大根堆,再取前 k 个最大值。
⸻
- Title: C++面经
- Author: Ikko
- Created at : 2026-03-24 15:40:54
- Updated at : 2026-03-24 16:04:40
- Link: http://ikko-debug.github.io/2026/03/24/mianjing/
- License: This work is licensed under CC BY-NC-SA 4.0.