C++类和对象-初始化和清理
1 构造函数与析构函数:对象生命周期的起点与终点
在 C++ 面向对象编程中,对象的初始化与销毁过程是自动进行的。为此,C++ 提供了两个特殊的成员函数:
构造函数(Constructor):在对象创建时调用,用于初始化数据成员或申请资源。
析构函数(Destructor):在对象销毁前调用,用于释放资源或进行清理。
对象的生命周期可理解为:构造 → 使用 → 析构。若初始化不当,可能产生未定义行为;若未正确释放资源,则可能引发内存泄漏或野指针问题。
1.1 构造函数(Constructor)
1.1.1 构造函数的语法与特点
类名() {
// 初始化代码
}构造函数具有以下特点:
无返回值(不写 void)
函数名与类名相同
可以带参数,因此可以重载
在对象创建时自动调用,并且只调用一次
1.1.2 构造函数示例
class Person {
public:
Person() {
cout << "Person 构造函数调用" << endl;
}
};1.2 析构函数(Destructor)
1.2.1 析构函数的语法与特点
~类名() {
// 清理代码
}析构函数特点:
无返回值
函数名与类名相同,但前面加 ~
不可带参数,因此不能重载
对象销毁前自动调用,且只调用一次
1.2.2 析构函数示例
class Person {
public:
~Person() {
cout << "Person 析构函数调用" << endl;
}
};1.3 构造与析构的基本示例
class Person {
public:
Person() {
cout << "Person 构造函数调用" << endl;
}
~Person() {
cout << "Person 析构函数调用" << endl;
}
};
void test01() {
Person p; // 创建对象时调用构造,退出 test01() 时调用析构
}2 构造函数的分类与调用方式
构造函数根据参数特征与使用目的可以分为不同类型。
按参数数量分类
无参(默认)构造函数
有参构造函数
按用途分类
普通构造函数
拷贝构造函数(用于以已有对象初始化新对象)
此外,在实际编码中,构造函数存在三种常见的调用方式:
括号法
显示法(= 号构造)
隐式转换法
掌握这些调用方式有助于控制对象初始化的行为及避免潜在的歧义。
2.1 构造函数示例类
class Person {
public:
Person() {
age = 0;
cout << "无参构造" << endl;
}
Person(int a) {
age = a;
cout << "有参构造" << endl;
}
Person(const Person& p) {
age = p.age;
cout << "拷贝构造" << endl;
}
~Person() {
cout << "析构" << endl;
}
int age;
};2.2 三种常见构造调用方式
2.2.1 括号法(直接构造)
void test01() {
// 调用无参构造
Person p1;
// 调用有参构造
Person p2(18);
// 调用拷贝构造
Person p3(p2);
}输出示例:
无参构造
有参构造
拷贝构造
析构
析构
析构括号法是最直接、最清晰的构造方式。
2.2.2 显式法(= 号构造)
void test02() {
// Person(20) 产生匿名对象 → 再用其初始化 p1
Person p1 = Person(20);
// 拷贝构造
Person p2 = p1;
}输出:
有参构造
拷贝构造
析构 // 匿名对象析构
析构
析构注意:Person p = Person(20); 不是赋值,而是构造,本质等同于 Person p(20);
2.2.3 隐式转换法(单参数构造触发类型转换)
当构造函数只有一个参数时,可发生隐式类型转换:
void test03() {
// 等价于 Person p(30)
Person p = 30;
}输出:
有参构造
析构注意:若不希望发生隐式转换,可在构造函数前加 explicit 关键字,如:
explicit Person(int a);2.3 匿名对象(临时对象)
Person(100); // 创建匿名对象,使用后立即析构输出:
有参构造
析构匿名对象常用于立即使用、无需命名、生命周期受限于语句结束的场景,例如临时参数封装。
2.4 括号陷阱(最易误解点)
Person p1(); // 注意!这不是构造对象,而是“函数声明”编译器将其解析为:声明一个返回类型为 Person 的函数 p1()。
因此创建对象时推荐不写多余小括号:
Person p1; // 正确,调用无参构造3 拷贝构造函数的调用时机
拷贝构造函数用于将一个已存在对象的数据复制到新对象中,C++ 在以下三种场景下会自动调用拷贝构造。
拷贝构造函数的典型定义形式为:
Person(const Person& p) {
age = p.age;
cout << "拷贝构造调用" << endl;
}3.1 使用一个已存在对象初始化新对象
当用一个对象直接初始化另一个对象时,会调用拷贝构造。
class Person {
public:
Person(int a) : age(a) {
cout << "有参构造调用" << endl;
}
Person(const Person& p) {
age = p.age;
cout << "拷贝构造调用" << endl;
}
int age;
};
void test01() {
// 调用有参构造
Person p1(10);
// 调用拷贝构造
Person p2 = p1;
}执行顺序输出:
有参构造调用
拷贝构造调用3.2 函数参数以值传递方式传入对象
当对象作为形参以值方式传递给函数时,会调用拷贝构造。
// 形参 p 由实参拷贝而来
void func(Person p) {
}
void test02() {
Person p1(20);
// 此处调用拷贝构造
func(p1);
}输出:
有参构造调用
拷贝构造调用因为 func 函数需要生成形参 p 的独立副本,因此调用拷贝构造。
3.3 函数以值方式返回局部对象
当函数返回局部对象时,会调用拷贝构造用于生成返回值。
Person createPerson() {
Person p1(30);
// 返回时触发拷贝构造(现代编辑器可能有优化)
return p1;
}
void test03() {
// 接收返回值仍可能触发拷贝构造
Person p = createPerson();
}可能输出:
有参构造调用
拷贝构造调用注:现代编译器会进行返回值优化(RVO),因此实际运行中可能省略拷贝构造,但语义上它仍属于拷贝构造调用场景。
4 默认构造规则与编译器自动生成行为
在 C++ 中,如果程序员未显式编写构造函数,编译器会根据类的情况自动生成相应的构造函数与析构函数。然而,一旦程序员显式定义了某些构造函数,编译器的自动生成行为会发生变化。
理解这些规则有助于避免以下问题:
对象无法创建
拷贝行为不符合预期
遗漏资源管理导致内存泄漏或重复释放
4.1 编译器自动生成行为对照表
核心点:一旦类中出现用户定义的构造函数,编译器将不再自动生成默认构造。
4.2 示例 1:未定义任何构造函数
class Person {
public:
int age;
};
void test01() {
Person p;
}由于未定义任何构造函数,编译器自动生成:
默认构造 Person()
拷贝构造 Person(const Person&)
析构函数 ~Person()
4.3 示例 2:只定义有参构造(默认构造不会自动生成)
class Person {
public:
Person(int a) {
age = a;
}
int age;
};
void test02() {
Person p1(10);
// 错误:没有默认构造函数
// Person p2;
}此时,如果想支持无参创建,必须手动补上:
class Person {
public:
Person() {
age = 0;
}
Person(int a) {
age = a;
}
int age;
};4.4 示例 3:定义拷贝构造(默认构造不会自动生成)
class Person {
public:
Person(const Person& p) {
age = p.age;
}
int age;
};
void test03() {
// 错误:未生成默认构造
// Person p1;
Person p2(p2);
}如需默认构造,同样需要显式补齐:
class Person {
public:
Person() {
age = 0;
}
Person(const Person& p) {
age = p.age;
}
int age;
};4.5 示例 4:定义析构函数时的编译器行为
class Person {
public:
~Person() {
// 自定义清理逻辑
}
int age;
};如果类中包含复杂资源(如 new 分配内存),且只写了析构函数而未写拷贝构造,则可能引发 浅拷贝问题(将在深拷贝章节详细说明)。
4.6 实战建议
当类中出现以下成员时,应该显式定义一套构造管理函数:
指针成员(需要深拷贝)
文件句柄、网络连接等非托管资源
动态资源生命周期受控情况
常规推荐实现的“三/五法则”:
默认构造
拷贝构造
拷贝赋值运算符
(现代 C++)移动构造
(现代 C++)移动赋值运算符
5 深拷贝与浅拷贝
当类中包含 指针成员变量 时,默认的拷贝构造函数与赋值行为会执行 浅拷贝(Shallow Copy)。浅拷贝仅复制指针地址,而不复制指针指向的堆内存。这样会导致多个对象共享同一块内存区域,从而带来严重问题。
5.1 浅拷贝问题分析
浅拷贝_default behavior:
指针变量的地址被拷贝,而不是数据本身
多个对象共享同一块堆内存
析构阶段会多次 delete 同一内存,造成 double free 错误
修改一个对象会影响另一个对象的数据内容,导致难以排查的逻辑 bug
示例说明浅拷贝行为:
#include <iostream>
#include <cstring>
class Person {
public:
char* name;
// 使用默认构造分配内存
Person(const char* initName) {
name = new char[strlen(initName) + 1];
strcpy(name, initName);
}
// 由于未定义拷贝构造,编译器自动生成浅拷贝:仅复制指针地址
~Person() {
delete[] name;
}
};
int main() {
Person p1("Tom");
Person p2 = p1;
// 修改 p2 的内容
strcpy(p2.name, "Jack");
// 输出结果:p1 也会变成 Jack,因为两者共享同一块堆内存
std::cout << p1.name << std::endl;
return 0;
}运行结果:
Jack这清楚展示了浅拷贝的风险。
5.2 深拷贝解决方案
深拷贝的关键:在拷贝构造函数中为指针分配新内存,并复制内容,而不是直接拷贝指针地址。
#include <iostream>
#include <cstring>
class Person {
public:
char* name;
// 构造函数
Person(const char* initName) {
name = new char[strlen(initName) + 1];
strcpy(name, initName);
}
// 深拷贝构造函数
Person(const Person& other) {
// 分配新内存,而不是复制指针
name = new char[strlen(other.name) + 1];
strcpy(name, other.name);
}
// 析构函数
~Person() {
delete[] name;
}
};
int main() {
Person p1("Tom");
Person p2 = p1;
strcpy(p2.name, "Jack");
// 输出:Tom,不受影响
std::cout << p1.name << std::endl;
return 0;
}深拷贝保证每个对象拥有独立内存,不会出现共享与冲突问题。
5.3 配套的 拷贝赋值运算符(Rule of Three)
如果类定义了自定义拷贝构造函数,那么往往也需要自定义赋值运算符和析构函数,这被称为“三法则(Rule of Three)”。
class Person {
public:
char* name;
Person(const char* str) {
name = new char[strlen(str) + 1];
strcpy(name, str);
}
// 深拷贝构造
Person(const Person& other) {
name = new char[strlen(other.name) + 1];
strcpy(name, other.name);
}
// 深拷贝赋值运算符
Person& operator=(const Person& other) {
// 避免自赋值,例如 p = p
if (this == &other) {
return *this;
}
delete[] name;
name = new char[strlen(other.name) + 1];
strcpy(name, other.name);
return *this;
}
~Person() {
delete[] name;
}
};5.4 小结
当类中存在堆内存资源时,必须构造深拷贝,否则必然引发资源共享、数据污染以及重复释放的问题。
6 初始化列表(Initializer List)
在 C++ 中,构造函数除了可以在函数体内对成员变量赋值之外,还可以使用 初始化列表 来完成成员变量的初始化工作。初始化列表在构造函数形参列表与函数体之间,通过 : 引出,对成员逐一进行初始化。
6.1 初始化列表的必要性
初始化列表不仅仅是语法糖,它对于以下几类成员来说是必须使用或者更高效的方式:
const 成员变量
常量一旦创建后必须被初始化,不能再被赋值,因此只能通过初始化列表初始化。
引用类型成员变量
引用必须在创建时绑定到具体对象,也只能通过初始化列表绑定。
自定义类型成员(包含无默认构造的类类型)
对于类成员,如果其类型本身没有默认构造函数,则必须通过初始化列表传入构造参数进行初始化。
6.2 基本示例
#include <iostream>
class Person {
public:
int m_A;
int m_B;
int m_C;
// 使用初始化列表进行成员初始化
Person(int a, int b, int c)
: m_A(a), m_B(b), m_C(c) {
}
};
int main() {
Person p(1, 2, 3);
std::cout << p.m_A << " " << p.m_B << " " << p.m_C << std::endl;
return 0;
}初始化列表效果等价于在构造函数内部赋值,但语义上更直接。
6.3 初始化列表提升效率的原因
当在构造函数函数体中对成员赋值时:
Person(int a, int b) {
m_A = a;
m_B = b;
}执行顺序为:
成员变量先被默认构造
然后再执行赋值
而使用初始化列表:
Person(int a, int b) : m_A(a), m_B(b) {}执行顺序为:
成员变量直接被构造为目标值,没有多余的默认构造和赋值过程
因此在效率上更优,特别是当成员类型为较大对象或复杂自定义类型时优势更明显。
6.4 const 和引用成员必须使用初始化列表
class Demo {
public:
const int refA;
int& refB;
// 必须使用初始化列表初始化 const 和引用类型
Demo(const int& x, int& y)
: refA(x), refB(y) {
}
};若不用初始化列表,将会产生编译错误。
6.5 成员初始化顺序规则
一个常被忽略的重要细节:
成员变量的初始化顺序由其在类中声明的顺序决定,而不是初始化列表中的书写顺序。
#include <iostream>
using namespace std;
class Example {
public:
int x;
int y;
// 初始化列表中 y 在前 x 在后
// 但真实初始化顺序以成员声明顺序为准:先初始化 x,再初始化 y
Example() : y(10), x(y) {
}
};
int main() {
Example e;
cout << "x = " << e.x << endl;
cout << "y = " << e.y << endl;
return 0;
}尽管 x(y) 写在后面,实际初始化顺序仍然是:
x 被初始化(未定义行为,因为 y 尚未构造)
y 被初始化为 10
结果可能导致不可预期的值,因此应始终遵守:
class Example {
public:
int x;
int y;
// 和声明顺序一致
Example() : x(10), y(x) {
}
};6.6 小结
初始化列表不仅是可选语法,而是构造对象成员时的优选方式,在语义、效率和类型兼容性上均有优势。
7 类对象作为成员时的构造与析构顺序
在 C++ 中,一个类对象内部可以包含其他类的对象成员(称为 组合关系 Composition)。当外部类对象创建与销毁时,其成员对象也会随之自动创建与销毁。
这体现的是 C++ 面向对象中的生命周期托管机制:成员对象的生命周期完全被包含它的类对象所管理。
7.1 构造与析构的执行顺序规则
当类 A 中包含类 B 的成员对象 b 时:
构造顺序
1)先调用 成员对象 B 的构造函数
2)再调用 类 A 自身的构造函数
析构顺序(构造顺序逆序执行)
1)先调用 类 A 自身的析构函数
2)再调用 成员对象 B 的析构函数
可以概括为:
先构造的后销毁,后构造的先销毁
这是 C++ RAII(资源获取即初始化,Resource Acquisition Is Initialization)哲学的关键体现。
7.2 成员对象构造顺序由声明顺序决定,而不是初始化列表顺序
成员对象的构造顺序取决于它们在类中出现的声明顺序,而不是初始化列表中书写的顺序。
class A {
public:
// 即使初始化列表中顺序为 y, x
// 构造顺序仍然是 x -> y
A() : y(), x() {
}
private:
B x; // x 先构造
B y; // y 后构造
};也就是说,类中成员的声明顺序是严格的构造顺序控制点。
7.3 构造与析构顺序运行示例
#include <iostream>
using namespace std;
class B {
public:
B() {
cout << "B 构造函数执行" << endl;
}
~B() {
cout << "B 析构函数执行" << endl;
}
};
class A {
public:
A() {
cout << "A 构造函数执行" << endl;
}
~A() {
cout << "A 析构函数执行" << endl;
}
private:
B b;
};
int main() {
A a;
return 0;
}输出:
B 构造函数执行
A 构造函数执行
A 析构函数执行
B 析构函数执行顺序完全符合规则:先成员后本类构造,先本类后成员析构。
7.4 为何必须先构造成员对象
原因在于:
类 A 的构造函数中可能访问成员对象 b
如果 b 未初始化就访问,会导致 未定义行为
因此 C++ 要求 先把成员对象构造好,确保可用性
这就像你造车时:
先造好发动机(B)
再组装整车(A)
7.5 更复杂情况:多个成员对象 + 初始化列表
class Engine {
public:
Engine() { cout << "Engine init" << endl; }
~Engine() { cout << "Engine destroy" << endl; }
};
class Wheel {
public:
Wheel() { cout << "Wheel init" << endl; }
~Wheel() { cout << "Wheel destroy" << endl; }
};
class Car {
public:
Car() : wheel(), engine() {
cout << "Car init" << endl;
}
~Car() {
cout << "Car destroy" << endl;
}
private:
Engine engine; // 先构造 engine
Wheel wheel; // 再构造 wheel
};输出:
Engine init
Wheel init
Car init
Car destroy
Wheel destroy
Engine destroy对比:
7.6 与指针成员的区别
如果成员是对象本体,生命周期由类自动管理:
B b; // 构造/析构自动管理但如果成员是对象指针,则不会自动构造和释放:
B* pb; // 只存指针,资源需程序员手动管理因此:
8 静态成员
在 C++ 中,类的成员变量和成员函数在默认情况下是属于对象实例的,每创建一个对象都会获得自己的成员副本。然而,在某些情况下,我们希望不同对象之间共享相同的数据或功能,此时就可以使用 static(静态)成员。
静态成员属于类本身,而不是具体对象。无论创建多少对象,静态成员都只存在一份拷贝。
8.1 静态成员的分类
静态成员分为两类:
静态成员与普通成员的根本区别:
静态成员属于类,不属于对象
静态成员不存储在对象内存中
静态成员在编译阶段分配空间
因此,静态成员常用于:
计数(统计对象数量)
全局变量替代(提供更好的封装)
多对象共享状态同步
8.2 静态成员变量
静态成员变量具有以下特征:
所有对象共享同一份数据
在编译阶段分配内存(存储于全局/静态区)
必须在类外进行初始化
8.2.1 示例:静态成员变量共享性验证
#include <iostream>
using namespace std;
class Person {
public:
static int m_A;
private:
static int m_B;
};
// 类外初始化
int Person::m_A = 10;
int Person::m_B = 10;
void test01() {
// 通过对象访问
Person p1;
p1.m_A = 100;
cout << "p1.m_A = " << p1.m_A << endl;
Person p2;
p2.m_A = 200;
cout << "p1.m_A = " << p1.m_A << endl;
cout << "p2.m_A = " << p2.m_A << endl;
// 通过类名访问
cout << "Person::m_A = " << Person::m_A << endl;
// Person::m_B 无法访问,因为其为 private
}
int main() {
test01();
return 0;
}运行输出表明:m_A 在所有对象间共享同一份存储空间。
8.3 静态成员函数
静态成员函数同样属于类,而不是对象。其主要特征:
所有对象共享同一个函数实例
可以通过对象或类名访问
只能访问静态成员变量,无法访问非静态成员变量
原因在于:
非静态成员函数内部隐含 this 指针,表示调用该函数的对象地址
而静态成员函数不依赖对象存在,不生成 this
8.3.1 示例:静态成员函数访问规则
#include <iostream>
using namespace std;
class Person {
public:
static void func() {
cout << "func 调用" << endl;
m_A = 100;
// m_B = 100; // 错误,无法访问非静态成员
}
static int m_A;
int m_B;
private:
static void func2() {
cout << "func2 调用" << endl;
}
};
int Person::m_A = 10;
void test02() {
Person p1;
p1.func();
Person::func();
// Person::func2(); // 无法访问,private 限制
}
int main() {
test02();
return 0;
}8.4 静态成员的内存结构与对象模型关系
静态成员不存储在对象实例中,也就是说:
sizeof(Person)不会包含 static 成员的空间。
静态成员实际存储于:
静态存储区 / 数据段 (.data / .bss)
这使静态成员具有类似全局变量的生命周期,但又具备类作用域的封装性,因此既安全又结构清晰。
8.5 工程实践中静态成员常见用途
静态成员是实现类级别状态共享的核心手段。
本文为原创内容,遵循 CC BY-NC-ND 4.0 协议。转载请注明来源 kixyu,禁止商业使用及修改演绎。