C++ 构造函数完全指南:从入门到进阶
构造函数是 C++ 类设计中最重要的部分之一。它控制着对象的"诞生方式",直接影响代码的安全性、性能和可维护性。很多人觉得构造函数无非就是"初始化变量",但 C++ 的构造函数体系远比这复杂------从默认构造、拷贝构造、移动构造,到初始化列表、委托构造、explicit 关键字,每一个知识点都是面试和工程实践的重点。
1. 构造函数是什么?为什么需要它?
构造函数是在对象创建时自动调用的特殊成员函数,负责初始化对象的状态。
cpp
class Widget {
int id;
std::string name;
public:
Widget(int i, const std::string& n) {
id = i; // 这是赋值,不是初始化
name = n; // 这也是赋值
}
};
特点:
- 函数名与类名相同
- 没有返回类型(连
void都没有) - 可以重载(多个构造函数,参数不同)
- 对象创建时自动调用,不能手动调用
2. 初始化列表:赋值的"坑"与"正道"
看下面这段代码的问题:
cpp
class Widget {
const int id; // const 成员
std::string& name; // 引用成员
std::string desc;
public:
Widget(int i, std::string& n, const std::string& d) {
id = i; // 错误!const 成员不能赋值
name = n; // 错误!引用成员不能赋值
desc = d; // 正确但低效:先默认初始化,再赋值
}
};
初始化列表就是为解决这个问题而生的:
cpp
Widget(int i, std::string& n, const std::string& d)
: id(i), name(n), desc(d) {
// 构造函数体可以为空,或者做其他工作
}
为什么初始化列表更好?
- const 成员和引用成员必须用初始化列表
- 效率更高:成员在初始化列表中直接构造,在函数体内是默认构造后再赋值(多一次操作)
- 成员初始化顺序只取决于声明顺序,与初始化列表顺序无关
cpp
class Example {
int a;
int b;
public:
Example(int x) : b(x), a(b) {}
// 危险!a 先初始化(按声明顺序),但此时 b 还未初始化,a 的值未定义
};
最佳实践:能用初始化列表就用初始化列表,且顺序与成员声明顺序一致。
3. 默认构造函数:没有参数的那个
默认构造函数是不传参数就能调用的构造函数。
cpp
class Widget {
public:
Widget() { /* ... */ } // 默认构造函数
};
什么时候编译器会帮你生成?
只有在你没有定义任何构造函数时,编译器才会自动生成一个合成的默认构造函数。
cpp
class A {
int x; // 默认构造:x 的值是未定义的(内置类型不初始化)
};
class B {
int x = 0; // 类内初始值,默认构造时 x 为 0
};
class C {
B b; // 包含类类型成员,默认构造会调用 B 的默认构造
};
规则:
- 如果类有内置类型成员且没有类内初始值,合成的默认构造函数不初始化它们(值未定义)
- 如果类包含类类型成员,会调用它们的默认构造函数
- 一旦你定义了任何构造函数,编译器就不再生成默认构造函数
cpp
class Widget {
public:
Widget(int x) {} // 自定义构造函数
};
Widget w; // 错误!没有默认构造函数了
解决 :用 = default 显式要求编译器生成:
cpp
class Widget {
public:
Widget() = default; // 让编译器生成
Widget(int x) {}
};
4. 析构函数:对象的"清理工"
cpp
class Widget {
int* data;
public:
Widget() : data(new int[100]) {}
~Widget() { delete[] data; } // 析构函数
};
特点:
- 函数名是
~类名 - 没有参数,不能重载(一个类只有一个析构函数)
- 对象生命周期结束时自动调用
- 如果类作为基类,析构函数应该是虚的
cpp
class Base {
public:
virtual ~Base() = default; // 基类必须有虚析构
};
Base* p = new Derived();
delete p; // 如果析构不虚,Derived 的析构不会被调用
5. 拷贝构造函数与拷贝赋值:对象的"克隆"
5.1 拷贝构造函数
cpp
class Widget {
std::string name;
public:
Widget(const Widget& other) : name(other.name) {
std::cout << "Copy constructed\n";
}
};
Widget w1;
Widget w2(w1); // 调用拷贝构造
Widget w3 = w1; // 也是拷贝构造(不是赋值!)
调用时机:
- 用一个对象初始化另一个对象
- 函数传值(传参时复制)
- 函数返回值(可能被 RVO/NRVO 优化掉)
5.2 拷贝赋值运算符
cpp
class Widget {
std::string name;
public:
Widget& operator=(const Widget& other) {
name = other.name; // 注意:对象已经存在,这里是赋值
return *this;
}
};
Widget w1, w2;
w2 = w1; // 调用拷贝赋值(两个对象都已存在)
区别:拷贝构造是"从无到有",拷贝赋值是"覆盖已有"。
5.3 浅拷贝与深拷贝
cpp
// 浅拷贝(危险的)
class Shallow {
int* data;
public:
Shallow(int v) : data(new int(v)) {}
// 默认拷贝构造:只复制指针值,不复制内存
// 析构时会 double free!
};
// 深拷贝(正确的)
class Deep {
int* data;
public:
Deep(int v) : data(new int(v)) {}
Deep(const Deep& other) : data(new int(*other.data)) {} // 分配新内存
~Deep() { delete data; }
};
当你管理堆内存时,必须自己写深拷贝的拷贝构造和拷贝赋值。
6. 移动构造函数与移动赋值(C++11):性能的大杀器
移动语义让"转移所有权"成为可能,避免了不必要的深拷贝。
cpp
class Buffer {
char* data;
size_t size;
public:
// 移动构造
Buffer(Buffer&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr; // 把原对象置空
other.size = 0;
}
// 移动赋值
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data; // 释放自己的旧资源
data = other.data; // 接管对方资源
size = other.size;
other.data = nullptr; // 置空原对象
other.size = 0;
}
return *this;
}
~Buffer() { delete[] data; }
};
为什么用 noexcept?
移动操作标记 noexcept 后,标准库容器(如 std::vector)才能在扩容时安全使用移动而非拷贝,大幅提升性能。
7. 三/五法则:资源管理的黄金准则
三法则 (C++98):如果需要自定义析构函数、拷贝构造、拷贝赋值中的任何一个,大概率三个都需要。
五法则 (C++11 扩展):加上移动构造 和移动赋值。
cpp
class Resource {
int* data;
public:
// 五件套
Resource() : data(new int(0)) {}
~Resource() { delete data; }
Resource(const Resource& other) : data(new int(*other.data)) {}
Resource& operator=(const Resource& other) { /* 深拷贝 */ return *this; }
Resource(Resource&& other) noexcept : data(other.data) { other.data = nullptr; }
Resource& operator=(Resource&& other) noexcept { /* 移动交换 */ return *this; }
};
零法则 :如果类的所有成员都正确管理自己的资源(使用 std::string、std::vector、智能指针等),你不需要写任何特殊成员函数,编译器生成的默认版本就是正确的。
cpp
class Good {
std::string name;
std::vector<int> data;
std::unique_ptr<Config> config;
// 不需要写析构、拷贝、移动,编译器生成的都是对的
};
8. explicit 关键字:防止隐式转换的坑
cpp
class MyString {
public:
MyString(int n) {} // 可以用 int 构造
};
void print(const MyString& s) { /* ... */ }
print(42); // 这也能编译!42 隐式转换成 MyString
加 explicit 阻止隐式转换:
cpp
class MyString {
public:
explicit MyString(int n) {}
};
print(MyString(42)); // 必须显式构造
// print(42); // 编译错误!
准则 :除非有明确的理由支持隐式转换,单参数构造函数都应该加 explicit。
9. 委托构造函数(C++11)
一个构造函数可以调用同类中另一个构造函数,减少重复代码:
cpp
class Widget {
int a, b, c;
public:
Widget() : Widget(0, 0, 0) {} // 委托给三参数版本
Widget(int x) : Widget(x, x, x) {}
Widget(int x, int y, int z) : a(x), b(y), c(z) {
// 真正的初始化逻辑只写一次
}
};
注意:一旦使用了委托构造,初始化列表中就不能再有其他成员初始化了。
10. 继承体系中的构造函数
10.1 派生类构造时发生了什么?
cpp
class Base {
public:
Base(int x) { std::cout << "Base(" << x << ")\n"; }
};
class Derived : public Base {
public:
Derived(int x, int y) : Base(x) { // 显式调用基类构造
std::cout << "Derived(" << x << "," << y << ")\n";
}
};
Derived d(1, 2);
// 输出:Base(1)
// Derived(1,2)
构造顺序:基类 -> 成员(按声明顺序)-> 派生类构造体。
10.2 继承构造函数(C++11)
cpp
class Base {
public:
Base(int x) {}
Base(int x, double y) {}
};
class Derived : public Base {
public:
using Base::Base; // 继承基类的所有构造函数
};
11. 面试常考清单
11.1 初始化列表和构造函数体内赋值的区别?
答案要点:
- 初始化列表是真正的初始化,函数体内是赋值
- const 成员和引用成员必须用初始化列表
- 初始化列表效率更高(类类型成员少一次默认构造)
- 初始化顺序只取决于成员声明顺序
11.2 什么情况下编译器不会自动生成默认构造函数?
答案要点:当你自定义了任何构造函数时,编译器不再生成默认构造函数。
11.3 拷贝构造和拷贝赋值的区别?
答案要点:拷贝构造是"用一个对象初始化另一个新对象",拷贝赋值是"把一个对象的值赋给另一个已存在的对象"。
11.4 什么是深拷贝?为什么需要它?
答案要点:当类管理堆内存等资源时,浅拷贝只复制指针值,导致两个对象指向同一内存,析构时 double free。深拷贝会分配新内存并复制数据。
11.5 移动构造相比拷贝构造的优势是什么?
答案要点:移动构造直接"偷走"临时对象的资源(把指针"挪"过来),避免了深拷贝的开销。对于堆内存、大容器,性能提升巨大。
11.6 explicit 关键字的作用?
答案要点:阻止单参数构造函数的隐式类型转换,要求必须显式调用,避免意外的隐式转换和临时对象。
11.7 什么是三/五法则和零法则?
答案要点:
- 三法则:如果定义了析构/拷贝构造/拷贝赋值之一,大概率三个都需要
- 五法则:C++11 加上移动构造和移动赋值
- 零法则:如果成员都正确管理资源(用 RAII 类型),不写任何特殊成员函数
11.8 为什么基类析构函数必须是虚的?
答案要点:通过基类指针删除派生类对象时,如果析构函数不虚,只会调用基类析构而不会调用派生类析构,导致资源泄漏。
12. 实践清单
一个设计良好的类,其构造函数应该:
- 使用初始化列表初始化所有成员
- 单参数构造加 explicit,除非有明确理由
- 遵循三/五法则或零法则
- 移动操作加 noexcept
- 基类析构加 virtual
- 能用
= default就让编译器生成
构造函数是对象生命周期的起点,设计好构造函数,就为类的安全性和性能打下了坚实的基础。