跳转到内容

04 · 类与对象

定位:构造/析构、拷贝/赋值/移动、初始化列表是“C++ 六大默认函数”的考点,必考。 学完标准:能答全“六大函数什么时候被调用”;能解释为什么初始化列表更快;空类大小、explicit、delete/default 张口就来。


1. 类的六个特殊成员函数(Rule of 0/3/5)

Section titled “1. 类的六个特殊成员函数(Rule of 0/3/5)”

编译器会自动生成(当需要且未被自定义时):

  1. 默认构造函数
  2. 析构函数
  3. 拷贝构造函数
  4. 拷贝赋值运算符
  5. 移动构造函数(C++11)
  6. 移动赋值运算符(C++11)

经验法则:

  • Rule of 0:成员都用标准库类型(string/vector/智能指针),什么都不用自己写。
  • Rule of 3(C++11 前):自定义了析构/拷贝构造/拷贝赋值其中任意一个,三个都要自己写(否则浅拷贝→double free)。
  • Rule of 5:涉及动态资源 + 需要移动语义时,析构/拷贝构造/拷贝赋值/移动构造/移动赋值五个都写(或都 delete)。

面试金句:一旦类管理裸资源(裸指针、文件句柄),默认的浅拷贝会产生两次析构同一块内存(double free),所以必须深拷贝或禁用拷贝。


2.1 构造函数初始化顺序(必背)

Section titled “2.1 构造函数初始化顺序(必背)”
顺序:先基类(按继承顺序)→ 再成员(按【声明顺序】,不是初始化列表顺序)→ 最后构造函数体
析构:完全相反:自身析构体 → 成员析构(逆声明顺序)→ 基类析构
struct Base { Base(){ puts("Base构造"); } ~Base(){ puts("Base析构"); } };
struct Member {
Member(int){ puts("Member构造"); }
~Member(){ puts("Member析构"); }
};
struct Derived : Base {
Member m1{1}; // 声明顺序:m1 在前
Member m2{2}; // 声明顺序:m2 在后
Derived() : Base(), m2(2), m1(1) { // 列表里写反没关系!
puts("Derived构造体");
}
~Derived(){ puts("Derived析构体"); }
};
int main(){ Derived d; }
// 输出顺序:
// Base构造 → Member构造(1) → Member构造(2) → Derived构造体
// Derived析构体 → Member析构(2) → Member析构(1) → Base析构

:初始化列表里 m2(2) 写在前面也没用,实际构造顺序按声明顺序 m1 先。编译器会警告“初始化顺序不匹配”。

2.2 为什么用初始化列表?(高频)

Section titled “2.2 为什么用初始化列表?(高频)”
struct T {
Member m;
// 方式A:列表初始化 —— 直接以 42 构造 m,只调一次构造
T() : m(42) {}
};
struct U {
Member m;
// 方式B:体内赋值 —— 先构造 m,再把 42 转成临时 Member 后赋值,多一步
U() { m = 42; }
};

必须用初始化列表的三种成员

  1. const 成员(const 必须初始化,不能赋值);
  2. 引用成员(引用必须初始化);
  3. 没有默认构造函数的成员对象(Member m; 没有无参构造 → 必须先 : m(42))。

性能:列表初始化直接以目标值构造成员(一次构造);体内 m = 42 要先构造临时对象再赋值(构造 + 赋值,多一步)。对重量级成员是浪费。

  • 无参数、无返回值、不可重载。
  • 对象离开作用域/delete 时自动调用,用于释放资源。
  • 析构里不要抛异常(否则可能 terminate;异常安全要求析构 noexcept)。 !!这里不太懂
  • 基类析构要 virtual(第 5 章重点)。

3. 拷贝构造 vs 赋值运算符(必考区分)

Section titled “3. 拷贝构造 vs 赋值运算符(必考区分)”
struct S { S(); S(const S&); S& operator=(const S&); };
S a; // 默认构造
S b = a; // 拷贝构造(新对象 b 从 a 构造)★
S c(a); // 拷贝构造 ★
b = c; // 拷贝赋值(b 已存在,赋新值)★
void f(S s); // 按值传参 → 拷贝构造
S g() { S t; return t; } // 返回临时 → 拷贝构造(实际常被优化省略)

判断口诀“有新对象 = 拷贝构造,老对象赋值 = 拷贝赋值”。看运算符左边,是“创建”还是“已存在”。

struct Buffer {
int* data;
Buffer() : data(new int[100]) {}
~Buffer(){ delete[] data; }
// 不写拷贝构造 → 默认浅拷贝:拷贝指针本身
};
Buffer a;
Buffer b = a; // b.data 和 a.data 指向同一块内存!
// 析构时:a 和 b 各自 delete[] data → 同一块内存删两次 → double free 崩溃
  • 浅拷贝:只复制指针值(成员逐个拷贝)。
  • 深拷贝:为拷贝对象新分配资源并复制内容,让两者各自独立。
  • 管理裸资源的类必须实现深拷贝(或禁用拷贝)。
class Buffer {
int* data;
size_t size;
public:
Buffer(size_t n) : data(new int[n]), size(n) {}
// 拷贝构造:深拷贝
Buffer(const Buffer& o) : data(new int[o.size]), size(o.size) {
std::copy(o.data, o.data + o.size, data);
}
// 拷贝赋值:先分配新内存,成功后再释放旧资源(还要自赋值检查)
Buffer& operator=(const Buffer& o) {
if (this != &o) { // 自赋值检查
int* tmp = new int[o.size]; // 先分配新内存:new 失败时旧对象不受影响
std::copy(o.data, o.data + o.size, tmp);
delete[] data; // 成功后再释放旧资源
data = tmp;
size = o.size;
}
return *this; // 返回引用,支持链式 a=b=c
}
~Buffer(){ delete[] data; }
};

现代写法:拷贝赋值可以用 copy-and-swap 惯用法(传值 + swap),更简洁且异常安全——面试提到是加分项。 !!展开说说


4. 移动语义(本章先建立直觉,第 8 章深入)

Section titled “4. 移动语义(本章先建立直觉,第 8 章深入)”
  • 拷贝:复制内容,源对象不变。
  • 移动:把源对象的资源“偷”过来(搬走指针),源对象置空。成本 O(1) 于资源指针。
  • 什么时候触发:参数是右值(临时对象、std::move(x) 结果)时,优先选移动。
class Buffer {
int* data; size_t size;
public:
Buffer(Buffer&& o) noexcept // 移动构造
: data(o.data), size(o.size) {
o.data = nullptr; o.size = 0; // 源对象置空(防双释放)
}
Buffer& operator=(Buffer&& o) noexcept {
if (this != &o) {
delete[] data; // 释放旧资源
data = o.data; size = o.size;
o.data = nullptr; o.size = 0;
}
return *this;
}
};

为什么移动后源对象“不能用”std::move 只是转型,但移动构造把源指针置空了,源对象处于“有效但未指定”状态,可以析构、可以重新赋值,但内容不可依赖。

面试点:移动构造/移动赋值应尽量 noexcept——否则 vector 扩容时若类可拷贝会退化为拷贝(异常安全考虑),性能优势就没了;类不可拷贝时仍会移动。 !!没懂


5. 空类、static 成员、friend、explicit、delete/default

Section titled “5. 空类、static 成员、friend、explicit、delete/default”

5.1 空类 sizeof 为什么是 1?(高频)

Section titled “5.1 空类 sizeof 为什么是 1?(高频)”
class Empty {};
sizeof(Empty) == 1;

原因:C++ 要求不同对象地址必须不同,空对象也得占至少 1 字节来区分。

class WithVirtual { virtual void f(); };
sizeof(WithVirtual) == 8; // 64位:一个 vptr 占 8 字节
class EmptyA{}; class EmptyB{}; class Both : EmptyA, EmptyB {};
sizeof(Both) == 1; // 不同类型的空基类可以共享同一地址(EBO);只有同类型的两个子对象必须地址不同
class Game {
static int instanceCount; // 声明:不占对象空间,属于类
};
int Game::instanceCount = 0; // 定义(类外必须定义一次)

!!sizeof(Game) == 1吗

  • static 成员变量属于类、所有对象共享一份,存放在数据段。
  • static 成员函数没有 this 指针,只能访问 static 成员。
  • C++17 起可以用 inline static int x = 0; 在类内直接定义。

!!怎么用

  • 友元函数/友元类:可以访问该类的私有成员。
  • 适度使用(运算符重载常需要,如 operator<< 输出流)。

5.4 explicit(单个参数构造函数必加)

Section titled “5.4 explicit(单个参数构造函数必加)”
class Vec2 { public: Vec2(float x) { /* ... */ } };
void setPos(Vec2 v);
setPos(1.0f); // 隐式转换:1.0f → Vec2(1.0f)。常是 bug 来源!
// 加 explicit 后:
class Vec2 { public: explicit Vec2(float x) { } };
setPos(1.0f); // 编译错误!禁止隐式转换
setPos(Vec2(1.0f)); // 必须显式构造

面试点:vector<bool> 的 operator[] 返回 proxy 也是因为不能隐式转 bool 引用。explicit 同样可以修饰运算符。 !!啥意思

class NoCopy {
public:
NoCopy() = default; // 手动要回默认构造函数(自定义了别的构造后默认被吞掉)
NoCopy(const NoCopy&) = delete; // 禁止拷贝
NoCopy& operator=(const NoCopy&) = delete;
};
  • =default:显式要求编译器生成默认版本。例如自定义构造函数后默认构造函数不再自动生成,但拷贝构造/拷贝赋值仍会自动生成;=default 用来把默认构造要回来或明确意图。
  • =delete:禁用某函数;常见用途:禁拷贝、禁隐式类型转换(删掉对应构造)、禁 new(删 operator new)。!!详细说说例子

struct Vec2 {
float x, y;
Vec2 operator+(const Vec2& o) const { return {x+o.x, y+o.y}; }
bool operator==(const Vec2& o) const { return x==o.x && y==o.y; }
};

要点:

  • 推荐成员函数 vs 自由函数:二元运算符如 <<+(可交换)用自由函数。
  • ++a vs a++:前置返回引用(T& operator++()),后置返回值(T operator++(int))。 !!举个例子
  • operator<< 输出需要 friend。!!举例
  • 不能重载::: . .* ?: sizeof
  • 重载不改变运算符优先级和操作数个数

Q1:构造/析构顺序? 先基类再成员(按声明顺序)后自身;析构反过来。初始化列表顺序由声明顺序决定,与列表书写顺序无关。

Q2:拷贝构造和赋值运算符什么时候被调用? 有新对象创建 → 拷贝构造;对已存在对象赋值 → 拷贝赋值。按值传参/返回触发拷贝构造。

Q3:为什么用初始化列表? 直接初始化成员(一次构造),比“默认构造+赋值”省一次操作;const/引用/无默认构造成员必须用它。

Q4:浅拷贝有什么问题? 管理裸资源时两个对象共享同一块内存,析构 double free。

Q5:移动构造什么时候触发?为什么移动后源对象不能用? 参数是右值时(临时对象、move 结果);移动把源资源搬走并置空源指针,源对象内容不再有效。

Q6:移动构造为什么必须 noexcept? vector 扩容用 move_if_noexcept:移动可能抛异常且类可拷贝时会退化为拷贝;类不可拷贝时仍会移动。noexcept 让标准库放心用移动。

Q7:空类 sizeof 为什么是 1? 保证不同对象地址不同。

Q8:explicit 的作用? 禁止构造函数隐式转换,避免意外隐式转换产生错误对象。

Q9:=delete 和 =default? default 要回编译器默认实现;delete 禁用函数(禁拷贝、禁隐式转换)。

Q10:为什么析构函数不建议抛异常? 析构在栈展开时执行,抛异常可能 double throw → std::terminate 直接终止程序。

Q11:什么时候必须自己写拷贝/析构? 类持有裸资源(堆内存、文件、套接字、GPU 资源)。用 RAII 封装资源(string/vector/智能指针)就不用。

Q12:复制对象时编译器会优化掉拷贝构造吗? 会:返回局部对象时的 RVO/NRVO、按值传递临时对象时的拷贝省略(C++17 保证某些情况)。面试可提“拷贝省略”。


1. 说出类的六个默认特殊成员函数。

2. 拷贝构造和拷贝赋值如何快速区分?

3. 为什么 `const` 成员必须用初始化列表初始化?

4. 写出移动构造的模板(含 noexcept 和置空源对象)。

5. 空类大小?

我的原答:含虚函数的类大小(64位)?为什么?

6. static 成员变量为什么需要在类外定义?

我的原答:(C++17 怎么解决)

7. `explicit Vec2(float)` 后 `Vec2 v = 1.0f;` 会怎样?

8. 浅拷贝导致 double free 的完整过程讲一遍。

9. `Buffer b2 = std::move(b1);` 之后 b1 的状态是什么?

10. 为什么拷贝赋值要返回引用?

📖 答案提示(先自己答完再展开)
  1. 默认构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值。
  2. 左边是“创建新对象”→拷贝构造;“已存在对象”→拷贝赋值。
  3. const 成员只能初始化不能赋值;初始化列表是“初始化”,函数体是“赋值”。
  4. Buffer(Buffer&& o) noexcept : data(o.data), size(o.size){ o.data=nullptr; o.size=0; }
  5. 1;8(vptr);空对象要地址唯一,vptr 占 8 字节。
  6. static 成员属于类不随对象构造,需要类外单独定义分配存储;C++17 inline static 类内定义。
  7. 编译错误(禁止隐式转换)。
  8. a、b 共享同一 data → a 析构释放 → b 析构再释放同一地址 → double free 崩溃。
  9. “有效但未指定”:b1 指针被置空,可析构可重新赋值,内容不可依赖。
  10. 支持链式赋值 a = b = c,符合内建类型语义。

## 9. 进阶追问
  • copy-and-swap 惯用法怎么实现?(传值参数 + std::swap,异常安全)
  • 什么是三/五法则(Rule of 0/3/5)?给出一个违反的例子。
  • 为什么基类析构要 virtual?不做会怎样?(衔接第 5 章)
  • 空基类优化(EBO)是什么?std::tuple、标准库 allocator 怎么用它省内存?