C++ 第一阶段:对象、内存与生命周期
目标:面向 Windows C++ 客户端 / Qt / CEF / 基础软件 岗位,掌握第一阶段最核心的对象模型,并达到中级 C++ 面试可表达的程度。
1. 对象、内存、生命周期:先把三个概念分开
C++ 中最容易混淆的是:
text
对象 ≠ 内存
例如:
cpp
MyClass* p = new MyClass;
概念上可以拆成:
text
获得存储空间
↓
在这块空间中构造 MyClass 对象
而:
cpp
delete p;
概念上是:
text
调用析构函数
↓
结束对象生命周期
↓
释放动态存储空间
所以:
构造函数负责初始化对象,不等于负责给对象本身申请内存。
析构函数负责对象销毁前的清理,不等于"直接释放对象那块内存"。
面试:new/delete 和 malloc/free 的核心区别
text
malloc/free
→ 管理原始内存
→ 不自动调用构造/析构
new/delete
→ 参与 C++ 对象创建和销毁
→ new 会初始化对象
→ delete 会先析构,再释放存储
注意:
"
new最终一定调用 WindowsHeapAlloc"不是 C++ 标准规定,这是实现层问题。
2. 栈、堆与存储期
工程里常说:
text
栈对象
堆对象
但标准层面更准确的是:
text
automatic storage duration
dynamic storage duration
例如:
cpp
void Test()
{
MyClass a;
}
a 是自动存储期对象,离开作用域时生命周期自动结束。
cpp
MyClass* p = new MyClass;
这里:
text
p
通常是局部指针变量;
text
p 指向的 MyClass
才是动态存储期对象。
所以不要说:
p在堆上。
更准确:
p是局部指针,p指向的对象具有动态存储期。
3. 指针、引用、所有权不是一回事
cpp
int value = 10;
int* p = &value;
int& ref = value;
区别:
| 项目 | 指针 | 引用 |
|---|---|---|
| 可以为空 | 可以 | 普通引用必须绑定对象 |
| 可以改为指向其他对象 | 可以 | 不能重新绑定 |
| 语义 | 地址关系 | 对象别名 |
| 是否天然拥有对象 | 否 | 否 |
最重要的一点:
指针和引用都不天然表示资源所有权。
例如:
cpp
QObject* obj;
HWND hwnd;
FILE* file;
看到这些成员时,必须继续判断:
text
谁创建?
谁销毁?
当前对象是否拥有它?
只是观察,还是负责释放?
这就是客户端工程里真正重要的生命周期判断。
4. 空指针、悬空指针、野指针
空指针
cpp
int* p = nullptr;
明确表示当前不指向有效对象。
悬空指针
cpp
int* p = new int(10);
delete p;
此时:
text
p 变量还存在
但原对象已经销毁
继续:
cpp
*p = 20;
属于 未定义行为(UB)。
野指针
cpp
int* p;
未初始化就使用:
cpp
*p = 10;
同样危险。
工程中裸指针至少应优先初始化为:
cpp
nullptr
delete 后设 nullptr 能解决全部问题吗?
不能。
cpp
int* p1 = new int(10);
int* p2 = p1;
delete p1;
p1 = nullptr;
此时:
text
p2 仍然是悬空指针
所以真正重要的不是"释放后置空",而是:
明确资源所有权,避免多个地方误以为自己负责释放。
5. const:看清楚限制的是谁
指向 const 的指针
cpp
const int* p = &value;
不能:
cpp
*p = 20;
但可以:
cpp
p = nullptr;
限制的是:
text
不能通过 p 修改它指向的对象
const 指针
cpp
int* const p = &value;
可以:
cpp
*p = 20;
不能:
cpp
p = nullptr;
限制的是:
text
p 自己不能改指向
常见函数参数
cpp
void Print(const std::string& text);
目的:
text
引用 → 避免复制
const → 不允许函数修改原对象
对于 int/double/pointer 这种小对象,通常直接按值传递即可。
6. 构造、析构与初始化列表
构造函数
cpp
class Date
{
public:
Date(int year, int month)
: m_year(year),
m_month(month)
{
}
private:
int m_year;
int m_month;
};
构造函数的核心职责:
让刚进入生命周期的对象处于合法、合理的初始状态。
初始化列表为什么重要
cpp
Date::Date(int year)
: m_year(year)
{
}
这里是:
text
直接初始化成员
而:
cpp
Date::Date(int year)
{
m_year = year;
}
函数体执行时,成员已经完成了自己的初始化过程,然后才进行赋值。
哪些成员必须通过初始化列表处理
典型:
cpp
const int m_value;
int& m_ref;
因为:
text
const 成员不能先构造再赋值
引用必须在初始化时绑定
另外,没有默认构造函数的类类型成员,也必须显式构造。
初始化顺序
真正初始化顺序由:
text
成员在类中的声明顺序
决定,而不是初始化列表的书写顺序。
cpp
class A
{
public:
A()
: m_second(10),
m_first(m_second)
{
}
private:
int m_first;
int m_second;
};
实际上:
text
先 m_first
再 m_second
因此这里可能读取未初始化的 m_second。
7. 拷贝构造与拷贝赋值
判断方法非常简单:
text
目标对象正在创建
→ 拷贝构造
目标对象已经存在
→ 拷贝赋值
例如:
cpp
Buffer a;
Buffer b = a; // 拷贝构造
Buffer c;
c = a; // 拷贝赋值
为什么 Buffer b = a; 不是赋值?
因为:
text
b 原来不存在
这一行是在:
text
定义并初始化 b
所以调用拷贝构造。
8. 默认拷贝不等于 memcpy
面试不要说:
编译器默认拷贝就是把对象按字节复制一遍。
更准确:
隐式生成的拷贝构造会对基类和非静态成员逐个执行对应的拷贝。
例如:
cpp
class A
{
int x;
std::string name;
};
概念上:
text
复制 x
调用 string 的拷贝逻辑复制 name
对于:
cpp
char* data;
复制的是:
text
指针值
而不是指针指向的内存。
这就是浅拷贝问题的根源。
9. 深拷贝与浅拷贝
cpp
class Buffer
{
private:
char* m_data;
};
默认成员级拷贝可能得到:
text
a.m_data ─┐
├──→ 同一块内存
b.m_data ─┘
两个对象析构时:
text
delete[] 同一个地址两次
导致:
text
Double Free
属于 UB。
深拷贝
深拷贝的本质:
新对象创建独立资源,再复制资源内容。
text
a → Resource A
b → Resource B
内容可以相同,但生命周期互相独立。
10. 为什么资源类经常禁止拷贝
例如:
cpp
class FileSession
{
private:
FILE* m_file;
char* m_buffer;
};
如果它表达:
text
独占文件
独占缓冲区
那么"复制对象"本身就没有合理语义。
C++11 以后:
cpp
FileSession(const FileSession&) = delete;
FileSession& operator=(const FileSession&) = delete;
VS2008 / C++03 老项目常见:
cpp
private:
FileSession(const FileSession&);
FileSession& operator=(const FileSession&);
所以:
text
C++11+
→ = delete
VS2008 / C++03
→ private 声明等传统方式
11. 移动语义:转移所有权,不是复制资源
如果资源很大:
text
100 MB buffer
深拷贝意味着:
text
重新申请 100 MB
复制 100 MB
但如果源对象马上就不再需要资源,可以:
text
直接把资源所有权转交给新对象
这就是移动语义。
12. std::move 本身不会移动任何资源
cpp
std::move(a);
它本身不会:
text
释放资源
复制资源
把 a 置空
调用移动构造
它主要做的是:
把表达式转换成可以匹配移动操作的右值类别。
例如:
cpp
Buffer&& ref = std::move(a);
这里只是:
text
右值引用 ref 绑定到 a
没有创建新 Buffer 对象,所以:
text
没有移动构造
没有资源转移
13. 真正移动发生在移动构造 / 移动赋值
移动构造
cpp
Buffer b(std::move(a));
典型实现:
cpp
Buffer::Buffer(Buffer&& other) noexcept
: m_data(other.m_data),
m_size(other.m_size)
{
other.m_data = nullptr;
other.m_size = 0;
}
过程:
text
b 接管 a 的资源
↓
a 放弃所有权
注意:
a对象还存在,只是状态改变了。
移动赋值
cpp
b = std::move(a);
这里:
text
b 已经存在
所以它可能已经持有旧资源。
必须:
text
先释放 b 的旧资源
↓
再接管 a 的资源
↓
让 a 进入可安全析构状态
否则会资源泄漏。
14. moved-from 对象是什么状态
错误理解:
text
move 后原对象不存在
正确:
text
原对象仍然存在
仍然会析构
只是状态发生变化
对标准库类型,常见表述:
text
valid but unspecified state
即:
text
对象仍然有效
但不能随意假定具体值
对于我们自己的 FileSession,如果明确写:
cpp
other.m_file = nullptr;
other.m_buffer = nullptr;
那就是我们自己定义:
text
移动后为空状态
15. const 为什么可能让 std::move 最终走拷贝
cpp
const Buffer a;
Buffer b(std::move(a));
std::move(a) 保留 const 属性。
常见移动构造:
cpp
Buffer(Buffer&&);
不能绑定到:
text
const Buffer&&
如果类还有:
cpp
Buffer(const Buffer&);
那么可能最终调用:
text
拷贝构造
所以:
std::move不保证一定发生移动。
16. 为什么移动构造常加 noexcept
cpp
Buffer(Buffer&&) noexcept;
第一层意义:
text
这个函数承诺不向外抛异常
第二层与 STL 有关:
cpp
std::vector<Buffer>
扩容时,如果移动可能抛异常,而拷贝可用,为了异常安全,实现通常可能倾向拷贝。
所以对于只做:
text
指针/句柄转移
的移动构造,若确实不会抛异常,应标记:
cpp
noexcept
17. RVO / NRVO
cpp
Buffer Make()
{
Buffer temp;
return temp;
}
不能说:
text
一定调用移动构造
可能发生:
text
NRVO
→ temp 直接构造到调用者最终对象位置
→ 没有拷贝
→ 也没有移动
所以一般:
cpp
return temp;
不要写:
cpp
return std::move(temp);
后者可能妨碍 NRVO。
18. new[]/delete[] 必须匹配
cpp
int* p = new int[10];
delete[] p;
错误:
cpp
delete p;
属于 UB。
同样:
cpp
new T
必须匹配:
cpp
delete
19. 三类高频内存错误
Double Free
cpp
delete p;
delete p;
同一资源重复释放。
Use-After-Free
cpp
delete p;
p->Run();
对象生命周期已结束,还继续使用。
Memory Leak
cpp
char* p = new char[1024];
// 丢失 p,没有 delete[]
资源失去释放机会。
这些问题在 Windows 客户端崩溃、堆损坏、长时间运行内存上涨中都很常见。
20. Windows 工程:跨 DLL 分配释放
历史 Windows C++ 项目里要特别注意:
text
DLL A 分配
DLL B / EXE 释放
如果两边 CRT 版本或运行库配置不同,可能产生堆不匹配问题。
常见设计:
cpp
Handle* CreateHandle();
void DestroyHandle(Handle* p);
也就是:
text
谁创建
谁销毁
注意:
这是 Windows / CRT 工程实践,不是 C++ 标准规定。
21. FileSession:第一阶段综合理解
cpp
class FileSession
{
public:
FileSession(
const std::string& path,
std::size_t size
);
~FileSession();
FileSession(const FileSession&) = delete;
FileSession& operator=(const FileSession&) = delete;
FileSession(FileSession&& other) noexcept;
FileSession& operator=(FileSession&& other) noexcept;
private:
FILE* m_file = nullptr;
char* m_buffer = nullptr;
std::size_t m_bufferSize = 0;
};
你需要能解释:
text
为什么析构?
为什么禁止拷贝?
为什么移动后源对象还存在?
为什么 std::move 不等于移动?
为什么移动赋值要先处理旧资源?
为什么 noexcept 对移动重要?
如果这些可以连续解释清楚,第一阶段基本达标。
22. 高频面试回答
指针和引用有什么区别?
指针本身是对象,可以为空,也可以重新指向其他对象;引用表示对已有对象的绑定,初始化后不能重新绑定。两者都不天然代表资源所有权,是否负责生命周期要看接口设计。
拷贝构造和拷贝赋值区别?
核心看目标对象是否已经存在。拷贝构造是用已有对象创建新对象;拷贝赋值是两个对象都已经存在,再复制状态。资源类的赋值还需要处理目标对象已有的旧资源。
什么是移动语义?
移动语义用于转移资源所有权,避免昂贵的深拷贝。移动构造或移动赋值接管源对象的资源,再把源对象调整到可安全析构的状态。移动的是资源,不是让源对象消失。
std::move 做了什么?
std::move本身不移动资源,它主要进行类型转换,让表达式可以匹配移动操作。真正的资源转移发生在移动构造或移动赋值中。
默认拷贝是不是 memcpy?
不能这样说。语言语义上是对基类和成员逐个执行对应的拷贝。某些平凡类型编译器可能优化成字节复制,但这是实现层。裸指针成员只复制地址,因此会产生浅拷贝的所有权风险。
为什么移动构造经常 noexcept?
对只转移指针、句柄的移动操作,如果确实不会抛异常,应声明
noexcept。这不仅表达异常保证,也会影响vector等容器迁移元素时是否更愿意使用移动。
23. 第一阶段验收
必须能回答:
text
1. 对象和内存是什么关系?
2. 指针、引用、所有权有什么区别?
3. const T* 和 T* const 区别?
4. 构造和析构什么时候发生?
5. 初始化列表和函数体赋值区别?
6. 为什么初始化顺序看声明顺序?
7. 拷贝构造和拷贝赋值区别?
8. 为什么裸指针默认拷贝可能 double free?
9. 移动构造和移动赋值区别?
10. std::move 为什么不等于移动?
11. moved-from 对象还存在吗?
12. const 对象 std::move 后为什么可能还是拷贝?
13. noexcept 为什么影响 STL?
14. return local 为什么不一定移动?
15. new/delete 和 malloc/free 核心区别?
能稳定解释这些问题,第一阶段就已经达到中级 C++ 面试要求。
总结
第一阶段最重要的不是记函数签名,而是形成这条判断链:
text
对象在哪里?
↓
生命周期多长?
↓
它是否拥有资源?
↓
拷贝时资源如何处理?
↓
移动时所有权如何变化?
↓
对象销毁时谁负责清理?
最终记住四句话:
对象不等于内存。
指针不等于所有权。
默认拷贝不等于深拷贝。
std::move不等于真正移动。