C++面经

Ikko Lv4

构造函数初始化列表与构造函数体内赋值的区别

先看两种写法:

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 里。这样可以控制对象创建,常用于单例模式、工厂模式,或者禁止外部直接实例化对象。

  1. 构造函数和析构函数是否可以为虚函数

构造函数能不能是虚函数

不能。

为什么不能

虚函数依赖虚函数表指针 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 的析构,导致资源泄露。

什么时候必须写虚析构函数

只要这个类打算作为基类,并且可能通过基类指针删除派生类对象,就要把析构函数写成虚函数。

面试总结版

构造函数不能是虚函数,因为对象构造时虚表机制还没完全建立。
析构函数可以是虚函数,而且如果类要作为多态基类使用,析构函数一般必须是虚函数,否则通过基类指针释放派生类对象时会出现析构不完整的问题。

  1. 说明 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 的核心含义是改变存储周期或链接属性。
对局部变量来说,它让变量从栈上短生命周期变成静态存储区的整个程序生命周期,并且只初始化一次。
对全局变量和函数来说,它让符号变成内部链接,只在当前编译单元可见。
对类成员来说,静态成员属于类本身而不属于对象,因此所有对象共享一份。

  1. 介绍 STL 里的 vector

vector 本质上是动态数组,底层是一段连续内存。

特点
• 支持随机访问,O(1)
• 尾插尾删效率高,均摊 O(1)
• 中间插入删除效率低,通常 O(n)
• 内存连续,所以缓存友好

常见成员函数

vector v;
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;
v.push_back(1);

可能此时:
• size = 1
• capacity = 1 或更大

扩容机制

当容量不够时,vector 会申请更大内存,把原来的元素搬过去,再释放旧空间。

这就是为什么 push_back 虽然偶尔会很慢,但均摊复杂度是 O(1)。

通常扩容会按倍数增长,但标准没有强制必须 2 倍,具体实现跟编译器有关。

为什么 vector 插入中间慢

因为插入一个元素后,后面的元素都要整体后移。

为什么迭代器会失效

因为扩容后,底层内存地址变了,原来的迭代器、指针、引用都可能指向旧地址。

例如:

vector v = {1,2,3};
int* p = &v[0];
v.push_back(4); // 可能扩容

扩容后 p 可能失效。

面试总结版

vector 是 STL 中的动态数组,底层使用连续内存实现,支持 O(1) 随机访问。
尾插均摊 O(1),中间插入删除 O(n)。
它有 size 和 capacity 两个概念,容量不足时会扩容并搬迁数据,因此扩容后迭代器、引用和指针可能失效。

  1. 智能指针介绍

C++ 常见智能指针有 3 个:
• unique_ptr
• shared_ptr
• weak_ptr

它们的目的都是:自动管理堆对象,减少内存泄漏。

1)unique_ptr

独占所有权,同一时刻只能有一个指针拥有对象。

unique_ptr p = make_unique(10);

特点:
• 不能拷贝
• 可以移动

unique_ptr p1 = make_unique(10);
unique_ptr p2 = std::move(p1);

适合场景:
• 对象只有唯一拥有者
• 替代裸指针管理资源

2)shared_ptr

共享所有权,多个指针可以共同管理同一对象。
底层有引用计数。

shared_ptr p1 = make_shared(10);
shared_ptr p2 = p1;

此时引用计数加 1。
最后一个 shared_ptr 销毁时,对象才释放。

3)weak_ptr

weak_ptr 不拥有对象,不增加引用计数。
主要用来观察 shared_ptr 管理的对象,解决循环引用问题。

shared_ptr sp = make_shared(10);
weak_ptr wp = sp;

想访问时要用:

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. 如果智能指针放到多线程中,如何访问共享对象

这个问题别答偏。
重点不是“智能指针一定线程安全”,而是:

先分清两层安全性

1)控制块引用计数是否线程安全
对于 shared_ptr,引用计数的增减通常是线程安全的。

也就是说:

多个线程各自拷贝一份 shared_ptr,对象不会因为计数竞争被提前释放。

2)被管理对象本身是否线程安全
不一定。
智能指针只保证“谁来释放对象”这一层,不保证对象内部数据访问安全。

比如:

shared_ptr<vector> sp = make_shared<vector>();

多个线程同时 sp->push_back(),还是有数据竞争。

正确做法

场景1:多个线程共享读
如果只读,通常没问题。

场景2:多个线程读写
要加同步机制,比如:
• mutex
• shared_mutex
• 原子变量
• 读写锁

例如:

shared_ptr sp = make_shared(0);
mutex m;

void func() {
lock_guard lock(m);
(*sp)++;
}

如果对象生命周期也可能变化

线程里最好先拷贝一份 shared_ptr,确保对象在当前使用期间不会被释放。

shared_ptr local = global_sp;
if (local) {
local->do_something();
}

如果全局保存的是 weak_ptr,就先 lock():

if (auto sp = wp.lock()) {
sp->do_something();
}

面试总结版

在多线程中,shared_ptr 的引用计数增减通常是线程安全的,但它管理的对象本身并不是线程安全的。
所以如果多个线程共享访问对象,仍然需要用 mutex 等同步机制保护对象内部状态。
另外在线程中使用共享对象时,通常会先拷贝一份 shared_ptr,以保证对象在当前线程使用期间不会被提前释放。

  1. 讲解一下动态绑定和静态绑定

静态绑定

也叫早绑定。
在编译期就确定调用哪个函数。

典型情况:
• 普通函数
• 重载函数
• 非虚成员函数

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 实现。

  1. 算法题: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 topK(vector& nums, int k) {
priority_queue<int, vector, greater> minHeap;

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 nums = {3, 2, 1, 5, 6, 4};
int k = 2;
vector ans = topK(nums, k);
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 个最大值。

Comments
On this page
C++面经