一、什么是对象生命周期?
对象生命周期可以简单理解为:
对象从创建开始,
经过初始化、使用,
最终被销毁和释放资源的整个过程。
在 C++ 中,一个对象通常会经历下面几个阶段:
1. 分配内存
2. 调用构造函数,完成初始化
3. 对象被使用
4. 调用析构函数,释放资源
5. 对象占用的内存被回收
例如:
#include <iostream>
using namespace std;
class Student {
public:
Student() {
cout << "Student 构造函数" << endl;
}
~Student() {
cout << "Student 析构函数" << endl;
}
};
int main() {
Student stu;
cout << "正在使用 stu 对象" << endl;
return 0;
}
输出结果大致是:
Student 构造函数
正在使用 stu 对象
Student 析构函数
这里的 stu 是局部对象。
当程序执行到:
Student stu;
时,会调用构造函数。
当 main() 函数结束时,stu 离开作用域,会自动调用析构函数。
二、构造函数
构造函数是在对象创建时自动调用的特殊成员函数,主要作用是初始化对象成员。
构造函数特点:
1. 函数名和类名相同。
2. 没有返回值类型。
3. 对象创建时自动调用。
4. 一个类可以有多个构造函数。
三、默认构造函数和带参数构造函数
1. 默认构造函数
默认构造函数指不需要传入参数就可以调用的构造函数。
#include <iostream>
using namespace std;
class Student {
public:
Student() {
cout << "调用默认构造函数" << endl;
}
};
int main() {
// 创建对象时自动调用默认构造函数
Student stu;
return 0;
}
这里:
Student stu;
会自动调用:
Student();
2. 带参数构造函数
带参数构造函数可以在创建对象时传入初始数据。
#include <iostream>
#include <string>
using namespace std;
class Student {
private:
string name;
int age;
public:
// 带参数构造函数
Student(const string& n, int a) {
name = n;
age = a;
cout << "调用带参数构造函数" << endl;
}
void print() const {
cout << "姓名:" << name
<< ",年龄:" << age << endl;
}
};
int main() {
// 创建对象时传入姓名和年龄
Student stu("Tom", 20);
stu.print();
return 0;
}
输出结果:
调用带参数构造函数
姓名:Tom,年龄:20
四、构造函数初始化列表
构造函数除了可以在函数体中给成员变量赋值,也可以使用初始化列表。
例如:
#include <iostream>
#include <string>
using namespace std;
class Student {
private:
string name;
int age;
public:
// 使用初始化列表初始化成员变量
Student(const string& n, int a)
: name(n), age(a) {
cout << "调用构造函数" << endl;
}
void print() const {
cout << "姓名:" << name
<< ",年龄:" << age << endl;
}
};
int main() {
Student stu("Jack", 22);
stu.print();
return 0;
}
其中:
: name(n), age(a)
就是构造函数初始化列表。
它表示对象创建时,直接使用 n 初始化 name,使用 a 初始化 age。
1. 为什么推荐使用初始化列表?
初始化列表的优点主要有:
1. 写法更清晰。
2. 对某些成员变量必须使用初始化列表。
3. 通常比先默认构造、再赋值更高效。
例如下面这些成员通常必须通过初始化列表初始化:
const 成员变量
引用成员变量
没有默认构造函数的成员对象
示例:
#include <iostream>
using namespace std;
class Test {
private:
const int value;
int& ref;
public:
// const 成员和引用成员必须在初始化列表中初始化
Test(int v, int& r)
: value(v), ref(r) {
}
void print() const {
cout << "value = " << value << endl;
cout << "ref = " << ref << endl;
}
};
int main() {
int num = 100;
Test t(10, num);
t.print();
return 0;
}
2. 成员变量初始化顺序要注意什么?
成员变量的实际初始化顺序,不是由初始化列表的书写顺序决定的,而是由成员变量在类中声明的顺序决定的。
例如:
#include <iostream>
using namespace std;
class Test {
private:
int a;
int b;
public:
// 虽然这里写的是 b(a),a(10)
// 但实际初始化顺序仍然是先 a,再 b
Test()
: b(a), a(10) {
}
void print() const {
cout << "a = " << a << endl;
cout << "b = " << b << endl;
}
};
int main() {
Test t;
t.print();
return 0;
}
上面的写法不推荐,因为 b(a) 执行时,a 还没有完成初始化,容易出现问题。
正确写法应该和成员声明顺序一致:
Test()
: a(10), b(a) {
}
面试时可以这样回答:
成员变量的初始化顺序由它们在类中声明的顺序决定,而不是初始化列表中的书写顺序。为了避免成员使用未初始化数据,初始化列表的顺序最好和成员声明顺序保持一致。
五、析构函数
析构函数是在对象销毁时自动调用的特殊成员函数,主要作用是释放对象占用的资源。
析构函数特点:
1. 函数名是在类名前加 ~。
2. 没有返回值。
3. 没有参数。
4. 一个类只能有一个析构函数。
5. 对象销毁时自动调用。
1. 析构函数基本示例
#include <iostream>
using namespace std;
class Student {
public:
Student() {
cout << "Student 构造函数" << endl;
}
~Student() {
cout << "Student 析构函数" << endl;
}
};
int main() {
{
Student stu;
cout << "stu 正在作用域内使用" << endl;
}
// stu 离开花括号作用域后,自动调用析构函数
cout << "stu 已经销毁" << endl;
return 0;
}
输出结果大致为:
Student 构造函数
stu 正在作用域内使用
Student 析构函数
stu 已经销毁
这说明:局部对象离开作用域时,会自动调用析构函数。
六、栈对象和堆对象的生命周期
C++ 中常见对象创建方式有两种:
栈上创建对象
堆上创建对象
1. 栈对象
栈对象一般是普通局部对象。
#include <iostream>
using namespace std;
class Test {
public:
Test() {
cout << "Test 构造函数" << endl;
}
~Test() {
cout << "Test 析构函数" << endl;
}
};
void func() {
// t 是栈对象
Test t;
cout << "func 函数中正在使用 t" << endl;
// func 结束后,t 自动析构
}
int main() {
func();
return 0;
}
这里的 t 是栈对象。
特点是:
创建时自动调用构造函数。
离开作用域时自动调用析构函数。
不需要手动 delete。
2. 堆对象
堆对象通常通过 new 创建。
#include <iostream>
using namespace std;
class Test {
public:
Test() {
cout << "Test 构造函数" << endl;
}
~Test() {
cout << "Test 析构函数" << endl;
}
};
int main() {
// 在堆区创建对象
Test* p = new Test();
cout << "正在使用堆对象" << endl;
// 手动释放堆对象
delete p;
// 避免悬空指针
p = nullptr;
return 0;
}
特点是:
new 时调用构造函数。
delete 时调用析构函数。
如果忘记 delete,可能造成内存泄漏。
面试时可以这样回答:
栈对象由系统自动管理,离开作用域时会自动调用析构函数。堆对象通过 new 创建,需要通过 delete 手动释放,delete 时会调用析构函数。如果只 new 不 delete,就可能产生内存泄漏。
七、拷贝构造函数
拷贝构造函数用于:使用一个已经存在的对象来创建一个新对象。
常见形式:
ClassName(const ClassName& other);
1. 拷贝构造函数示例
#include <iostream>
#include <string>
using namespace std;
class Student {
private:
string name;
int age;
public:
// 普通构造函数
Student(const string& n, int a)
: name(n), age(a) {
cout << "普通构造函数" << endl;
}
// 拷贝构造函数
Student(const Student& other)
: name(other.name), age(other.age) {
cout << "拷贝构造函数" << endl;
}
void print() const {
cout << "姓名:" << name
<< ",年龄:" << age << endl;
}
};
int main() {
Student s1("Tom", 20);
// 使用 s1 创建 s2,调用拷贝构造函数
Student s2(s1);
s2.print();
return 0;
}
输出结果:
普通构造函数
拷贝构造函数
姓名:Tom,年龄:20
2. 拷贝构造函数常见调用时机
拷贝构造函数常见调用时机包括:
1. 用一个对象创建新对象。
2. 按值传递对象参数。
3. 函数按值返回对象时,可能调用拷贝构造或移动构造。
情况一:用已有对象创建新对象
Student s1("Tom", 20);
// 调用拷贝构造函数
Student s2(s1);
情况二:按值传参
#include <iostream>
using namespace std;
class Test {
public:
Test() {
cout << "普通构造函数" << endl;
}
Test(const Test& other) {
cout << "拷贝构造函数" << endl;
}
};
void show(Test t) {
cout << "进入 show 函数" << endl;
}
int main() {
Test t;
// 按值传参,可能触发拷贝构造
show(t);
return 0;
}
因此,如果对象比较大,函数参数通常建议写成:
const Test& t
这样可以避免不必要的对象拷贝。
3. 返回对象时一定会调用拷贝构造吗?
不一定。
例如:
#include <iostream>
using namespace std;
class Test {
public:
Test() {
cout << "普通构造函数" << endl;
}
Test(const Test& other) {
cout << "拷贝构造函数" << endl;
}
};
Test createTest() {
Test t;
return t;
}
int main() {
Test result = createTest();
return 0;
}
从概念上说,函数返回对象可能涉及拷贝构造或移动构造。
但是现代 C++ 编译器通常会进行返回值优化,也就是 RVO 或 NRVO,直接在目标位置构造对象,从而省略不必要的拷贝。
所以实际运行时,你可能看不到拷贝构造函数输出。
面试时可以这样回答:
函数按值返回对象时,从语义上可能涉及拷贝构造或移动构造,但现代编译器通常会进行返回值优化,减少甚至省略对象拷贝。
八、构造函数和析构函数调用顺序
1. 同一作用域中局部对象的构造和析构顺序
对象构造顺序是按照定义顺序进行。
对象析构顺序与构造顺序相反。
#include <iostream>
using namespace std;
class Test {
private:
string name;
public:
Test(const string& n)
: name(n) {
cout << name << " 构造" << endl;
}
~Test() {
cout << name << " 析构" << endl;
}
};
int main() {
Test t1("t1");
Test t2("t2");
Test t3("t3");
return 0;
}
输出结果:
t1 构造
t2 构造
t3 构造
t3 析构
t2 析构
t1 析构
可以简单记忆:
构造:先创建的先构造。
析构:后创建的先析构。
2. 类成员对象的构造和析构顺序
如果一个类中包含其他类对象作为成员,那么创建外部对象时,会先构造成员对象,再执行当前类构造函数体。
销毁时则相反:先执行当前类析构函数体,再析构成员对象。
#include <iostream>
using namespace std;
class Engine {
public:
Engine() {
cout << "Engine 构造" << endl;
}
~Engine() {
cout << "Engine 析构" << endl;
}
};
class Car {
private:
// Engine 是 Car 的成员对象
Engine engine;
public:
Car() {
cout << "Car 构造" << endl;
}
~Car() {
cout << "Car 析构" << endl;
}
};
int main() {
Car car;
return 0;
}
输出结果:
Engine 构造
Car 构造
Car 析构
Engine 析构
可以这样记忆:
成员对象构造:先成员,后当前类。
成员对象析构:先当前类,后成员。
3. 父类和子类的构造、析构顺序
当子类继承父类时,创建子类对象需要先构造父类部分,再构造子类部分。
销毁子类对象时,先析构子类部分,再析构父类部分。
#include <iostream>
using namespace std;
class Base {
public:
Base() {
cout << "Base 构造" << endl;
}
~Base() {
cout << "Base 析构" << endl;
}
};
class Derived : public Base {
public:
Derived() {
cout << "Derived 构造" << endl;
}
~Derived() {
cout << "Derived 析构" << endl;
}
};
int main() {
Derived d;
return 0;
}
输出结果:
Base 构造
Derived 构造
Derived 析构
Base 析构
简单记忆:
构造:先父后子。
析构:先子后父。
如果基类可能通过父类指针删除子类对象,基类析构函数应声明为 virtual,避免只调用父类析构函数。
九、静态对象和全局对象的生命周期
1. 静态局部对象
静态局部对象只会初始化一次,生命周期直到程序结束。
#include <iostream>
using namespace std;
class Test {
public:
Test() {
cout << "Test 构造" << endl;
}
~Test() {
cout << "Test 析构" << endl;
}
};
void func() {
// 第一次执行 func 时创建
// 之后再次进入 func,不会重复构造
static Test t;
cout << "执行 func" << endl;
}
int main() {
func();
func();
return 0;
}
输出结果大致为:
Test 构造
执行 func
执行 func
Test 析构
这里的 t 在程序结束时才析构。
2. 全局对象
定义在所有函数外部的对象叫全局对象。
#include <iostream>
using namespace std;
class Test {
public:
Test() {
cout << "全局对象构造" << endl;
}
~Test() {
cout << "全局对象析构" << endl;
}
};
// 全局对象
Test globalTest;
int main() {
cout << "进入 main 函数" << endl;
return 0;
}
一般来说:
程序启动时,全局对象会先构造。
程序结束时,全局对象会析构。
实际工程中,不建议过度依赖全局对象,因为多个全局对象之间的初始化顺序可能带来复杂问题。
十、构造函数和析构函数的常见注意点
1. 构造函数可以是虚函数吗?
构造函数不能是虚函数。
因为对象还在构造过程中,对象的完整类型和虚函数机制还没有完全建立,无法通过虚函数机制实现多态调用。
面试时可以这样回答:
构造函数不能是虚函数。因为虚函数依赖对象内部的虚函数表指针,而对象在构造阶段还没有完全构造完成,无法安全地通过虚函数机制调用构造函数。
2. 析构函数为什么可以是虚函数?
析构函数可以是虚函数。
如果一个类作为基类使用,并且可能通过基类指针删除派生类对象,那么基类析构函数应该写成虚函数。
class Base {
public:
virtual ~Base() {
}
};
这样:
Base* p = new Derived();
delete p;
才能先正确调用 Derived 的析构函数,再调用 Base 的析构函数。
3. 构造函数中能调用虚函数吗?
语法上可以调用,但通常不建议依赖多态行为。
因为在基类构造函数执行时,派生类部分还没有构造完成。此时调用虚函数,通常只会调用当前构造阶段对应类的版本,而不会表现出预期的派生类多态行为。
析构函数中调用虚函数也有类似问题。
因此,一般不建议在构造函数和析构函数中依赖虚函数实现多态逻辑。
十一、面试高频问题整理
1. 构造函数和析构函数分别有什么作用?
构造函数在对象创建时自动调用,主要用于初始化成员变量和申请资源。
析构函数在对象销毁时自动调用,主要用于释放对象占用的资源,例如动态内存、文件句柄、锁或网络连接等。
2. 构造函数可以重载吗?析构函数可以重载吗?
构造函数可以重载,因为一个类可以有多个不同参数列表的构造函数。
析构函数不能重载,因为一个类只能有一个析构函数,并且析构函数没有参数。
3. 拷贝构造函数什么时候调用?
常见情况包括:
使用已有对象创建新对象。
对象按值传递给函数参数。
函数按值返回对象时,可能发生拷贝或移动。
不过现代编译器通常会进行返回值优化,减少不必要的拷贝。
4. 为什么拷贝构造函数参数通常写成 const 引用?
常见写法是:
ClassName(const ClassName& other);
使用引用可以避免传参时再次复制对象。
使用 const 可以保证不会修改原对象,并且允许传入常量对象。
5. 栈对象和堆对象有什么区别?
栈对象通常是局部对象,离开作用域时会自动析构和释放。
堆对象通过 new 创建,需要通过 delete 手动释放。忘记 delete 可能导致内存泄漏。
6. 对象构造和析构顺序是什么?
局部对象按照定义顺序构造,按照相反顺序析构。
成员对象先构造,再执行当前类构造函数;析构时先执行当前类析构函数,再析构成员对象。
继承关系中,构造时先父类后子类;析构时先子类后父类。
7. 为什么基类析构函数通常要写成 virtual?
因为可能通过基类指针删除派生类对象。
如果基类析构函数不是虚函数,delete 时可能只调用基类析构函数,而派生类资源无法正确释放。
十二、总结
构造函数、析构函数和对象生命周期是 C++ 面试中的基础高频内容。
构造函数负责对象创建时的初始化,析构函数负责对象销毁时的资源释放。
局部对象一般创建在栈区,离开作用域后自动析构。通过 new 创建的堆对象需要手动 delete,否则可能造成内存泄漏。
拷贝构造函数用于用已有对象创建新对象。对象按值传参和按值返回时,也可能涉及拷贝构造或移动构造,但现代编译器通常会优化掉不必要的拷贝。
对象构造和析构顺序需要重点记忆:
同一作用域:
构造按定义顺序,析构按相反顺序。
成员对象:
构造先成员后当前类,析构先当前类后成员。
继承关系:
构造先父后子,析构先子后父。
最后可以简单记忆:
构造函数:对象出生时初始化。
析构函数:对象销毁时释放资源。
栈对象:离开作用域自动销毁。
堆对象:需要 delete 手动销毁。
拷贝构造:用旧对象创建新对象。
初始化列表:对象创建时直接初始化成员。
构造:先父后子、先成员后当前类。
析构:先子后父、先当前类后成员。