2.3构造函数与析构函数

4.8k words

2.3构造函数与析构函数


构造函数与析构函数:通俗版解释


1. 构造函数:对象的“出生证明”

  • 作用:在对象创建时,自动初始化数据成员。
  • 特点
    • 名字与类名相同(如Dog())。
    • 无返回值(连void都没有)。
    • 可以重载(多个构造函数,按参数不同区分)。
    • 创建对象时,构造函数会被自动调用!!

例子


1
2
3
4
5
6
7
8
9
10
11
class Dog {
private:
string name;
int age;
public:
Dog() { name = "无名"; age = 0; } // 默认构造函数
Dog(string n, int a) { name = n; age = a; } // 带参数的构造函数
};

**Dog d1; // 调用默认构造函数:name="无名", age=0**
Dog d2("旺财", 3); // 调用带参数的构造函数:name="旺财", age=3

2. 成员初始化表:高效初始化

  • 用途:初始化常量(const)、引用(&)或没有默认构造函数的成员对象。
  • 语法:在构造函数后用冒号列出初始化项。
  • 执行顺序:按成员变量定义的顺序初始化,与初始化表顺序无关。

例子

1
2
3
4
5
6
7
class Cat {
private:
const int ID; // 常量必须初始化
string& name; // 引用必须绑定
public:
Cat(int id, string& n) : ID(id), name(n) {} // 必须用初始化表
};

3. 析构函数:对象的“临终遗言”

  • 作用:对象销毁前自动调用,释放额外申请的资源(如动态内存)。
  • 特点
    • 名字是~类名(如~Dog())。
    • 无参数,不能重载。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
class String {
private:
char* str;
public:
String(const char* s) {
str = new char[strlen(s)+1]; // 动态申请内存
strcpy(str, s);
}
~String() {
delete[] str; // 必须释放内存,否则内存泄漏
str = nullptr; // 可选,防止野指针
}
};

4. 成员对象的构造与析构顺序

  • 构造顺序:先构造成员对象,再构造自身。!!!
  • 析构顺序:先析构自身,再析构成员对象。!!!

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Engine {
public:
Engine() { cout << "引擎启动\\n"; }
~Engine() { cout << "引擎关闭\\n"; }
};

class Car {
private:
Engine engine; // 成员对象
public:
Car() { cout << "汽车组装完成\\n"; }
~Car() { cout << "汽车报废\\n"; }
};

int main() {
Car myCar;
}
// 输出:
// 引擎启动 → 汽车组装完成 → 汽车报废 → 引擎关闭

5. 显式调用析构函数?危险!

  • 问题:手动调用析构函数不会销毁对象,但会释放资源(暂时归还对象额外申请的资源),导致对象处于无效状态。
  • 例子
1
2
3
String s("Hello");
s.~String(); // 释放内存,但s对象还在!
s.print(); // 崩溃!因为str已被释放。

6. 为什么需要构造函数和析构函数?

  • 构造函数:确保对象出生时状态合法(如年龄不为负数)。
  • 析构函数:防止资源泄漏(如内存、文件句柄)。

总结

  • 构造函数:对象的“出生仪式”,负责初始化。
  • 析构函数:对象的“告别仪式”,负责清理。
  • 成员初始化表:专门处理常量、引用和复杂成员对象的初始化。
  • 顺序规则:成员对象先构造后析构,自身后构造先析构。

比喻

  • 构造函数像装修队,房子建好后帮你布置家具。
  • 析构函数像搬家公司,房子拆之前帮你清空家具。
  • 成员初始化表像装修清单,规定必须先装水电才能刷墙。

三种常见的初始化成员变量方式

在 C++ 中,就地初始化初始化列表构造函数体内初始化是三种常见的初始化成员变量的方式。它们有不同的使用场景、先后顺序以及相应的限制。下面我们详细分析这三种初始化方式。

1. 就地初始化(In-Class Initialization)

就地初始化是指在类定义内部直接为成员变量提供默认值。它在类定义时进行初始化,适用于普通成员变量(非引用或常量):

1
2
3
4
5
6
class A {
int x = 10; // 普通成员变量的就地初始化
const int y = 20; // 常量成员的就地初始化
public:
A() {} // 构造函数
};

特点

  • 就地初始化为成员变量提供默认值。
  • 只适用于普通成员变量,不适用于引用类型常量类型(常量成员需要在构造函数初始化列表中初始化)。
  • 如果构造函数没有显式为成员变量提供值,那么成员变量会使用就地初始化提供的默认值。

优点

  • 简洁,避免在构造函数中显式赋值。
  • 适用于大多数普通成员变量的初始化。

注意

  • 就地初始化的优先级比构造函数体内的赋值高,但在初始化列表之前。

2. 初始化列表(Member Initialization List)

初始化列表是在构造函数的参数后通过 : 来初始化成员变量。它用于初始化所有类型的成员(包括常量和引用及没有默认构造函数的类类型成员变量)。

1
2
3
4
5
6
7
class A {
int x;
const int y;
int& z;
public:
**A() : x(10), y(20), z(x) {} // 通过初始化列表初始化成员**
};

特点

  • 用于初始化成员变量,尤其是常量引用类型成员。
  • 常量类型的成员变量必须在初始化列表中进行初始化,不能在构造函数体内进行赋值。
  • 引用类型的成员变量也必须在初始化列表中进行初始化。
  • 没有默认构造函数的类类型成员变量也必须在初始化列表中进行初始化。如果类中的成员变量是其他类的对象,并且这个类没有默认构造函数(即没有无参构造函数),那么在创建包含它的类的对象时,必须在初始化列表中调用该成员变量所属类的合适构造函数来进行初始化。例如:
1
2
3
4
5
6
7
8
9
10
11
12
class AnotherClass {
public:
AnotherClass(int data) : data(data) {} // 有参构造函数
private:
int data;
};
class MyClass {
public:
MyClass(int value) : anotherObj(value) {} // 在初始化列表中初始化AnotherClass类型的成员变量anotherObj
private:
AnotherClass anotherObj;
};
  • 成员的初始化顺序总是按照在类定义中的声明顺序,而不是在初始化列表中的顺序。

优点

  • 允许对常量成员、引用成员以及普通成员进行初始化。
  • 能够在对象创建时直接为常量成员和引用成员提供正确的初始化。

缺点

  • 无法对默认值提供修改,必须在构造函数初始化列表中进行初始化。

3. 构造函数体内初始化(In Constructor Body)

在构造函数体内进行初始化,通常是通过赋值语句对成员变量赋值。

在 C++ 类中,除了必须在初始化列表中初始化的成员变量(引用成员变量、常量成员变量、没有默认构造函数的类类型成员变量)外,其他类型的成员变量均可以在构造函数体内进行初始化。比如对 const 和引用类型的成员不能使用此方式进行初始化。

1
2
3
4
5
6
7
8
9
class A {
int x;
const int y;
public:
A() {
x = 10; // 普通成员变量
// y = 20; // 错误!不能在构造函数体内为 const 成员赋值
}
};

特点

  • 可以为所有类型的成员变量赋值,通常用于普通成员。
  • 引用成员和常量成员不能在构造函数体内赋值(因为它们必须在初始化列表中初始化)。
  • 构造函数体内的初始化仅发生在对象构造后,已经为成员提供了默认值(如果有的话)。

优点

  • 适用于普通成员变量,可以根据需要动态计算初始化值。

缺点

  • 无法初始化引用类型和常量类型的成员变量。
  • 相比初始化列表,效率略低,因为它们会在默认初始化后再次赋值。

@初始化顺序

  • 不同成员变量的初始化顺序是它们在类中声明的顺序
  • 同一成员变量的初始化顺序:就地初始化≥初始化列表≥构造函数

例如:

1
2
3
4
5
6
7
class A {
int x;
const int y;
int& z;
public:
A() : z(x), y(20) {} // y 先初始化,z 后初始化
};

结论:各方法的区别与适用情况

初始化方式 可用成员类型 使用场景 优点 缺点
就地初始化 普通成员变量 简单的默认值初始化 代码简洁,适合普通成员变量 不能用于引用、常量类型成员
初始化列表 所有类型的成员 需要初始化常量或引用类型成员 支持所有成员类型,初始化顺序明确,效率高 语法相对复杂,无法提供动态默认值
构造函数体内初始化 所有类型的成员 需要动态计算初始化值 灵活,适用于普通成员变量,赋值可以更复杂 无法初始化引用和常量成员,效率较低

在实际编程中,推荐使用初始化列表来初始化类的成员,特别是对于常量和引用类型成员。就地初始化适合简单的成员变量初始化,构造函数体内初始化则主要用于普通成员的后续赋值。

析构函数

析构函数(Destructor)是类的一种特殊成员函数,用于在对象生命周期结束时执行清理操作。当对象的生命周期结束(即对象被销毁)时,析构函数会自动被调用。

1. 析构函数的基本定义

析构函数的名称与类名相同,但前面有一个波浪符号 ~。析构函数没有返回类型,也没有参数。

1
2
3
4
5
6
7
8
class MyClass {
public:
~MyClass() {
// 析构函数的清理操作
std::cout << "对象被销毁了!" << std::endl;
}
};

2. 析构函数的特点

  • 自动调用:析构函数会在对象的生命周期结束时自动调用,通常是在对象超出作用域或被显式销毁时。
  • 没有参数
  • 没有返回值
  • 每个类只有一个析构函数:一个类最多只能有一个析构函数。
  • 无法被重载:与构造函数不同,析构函数不能有重载版本。
  • 无法手动调用:析构函数是由编译器自动调用的,程序员不能显式地调用析构函数。

3. 析构函数的作用

析构函数的主要作用是进行资源的释放和清理工作,尤其是当类涉及动态内存分配或者拥有外部资源时。常见的用途包括:

  • 释放通过 new 动态分配的内存
  • 关闭打开的文件句柄。
  • 释放网络连接或数据库连接等资源。
  • 清理其他需要手动管理的资源(例如 mutexsocket 等)。

4. 析构函数的自动调用时机

析构函数的调用时机有几个典型情况:

  • 局部对象:当一个局部对象超出作用域时,析构函数会被调用。
1
2
3
void function() {
MyClass obj; // obj 在函数结束时会被销毁,析构函数会被调用
}
  • 动态分配的对象:如果对象通过 new 创建,那么在调用 delete 时析构函数会被调用。
1
2
MyClass* p = new MyClass(); // 动态创建对象
delete p; // 释放对象并调用析构函数
  • 全局和静态对象:全局对象或静态对象在程序结束时会调用析构函数。

⚠️必考!!!!!!!!!!!!!!!!!

必须手动创建析构函数的情况!!!

Comments