C++ 类和对象2---(类的默认成员函数 , 构造函数 , 析构函数)
目录
1. 类的默认成员函数
1.1 定义
1.2 由来
2. 构造函数
2.1 定义
2.2 核心特性
2.3 默认构造函数(编译器自动生成的)
2.4 手动定义构造函数
1. 无参构造函数
2. 带参构造函数
2.5 补充
3. 析构函数
3.1 定义
3.2 语法规则
3.3 编译器生成的默认析构函数
3.4 手动定义析构函数
3.5 析构函数的“链式调用”
3.6 局部对象的析构顺序
3.7 补充
3.8 总结
4. 总结
1. 类的默认成员函数
1.1 定义
- 在 C++ 中,默认成员函数指的是:当类中没有显式定义某些特殊成员函数时,编译器会自动为该类生成的、用于支持对象基本操作的函数。这些函数是类的“默认配置”,无需开发者手动编写,就能让类的对象完成创建、销毁、复制、赋值等基础行为,是类与对象机制正常运行的基础。
- 默认成员函数共 6 个,分别是:构造函数、析构函数、拷贝构造函数、赋值运算符重载、普通取地址运算符重载、const 取地址运算符重载。
C++类的默认成员函数共有6个,各自的核心作用如下:
- 构造函数:对象创建时自动调用,初始化对象(如给成员变量赋值)。
- 析构函数:对象销毁时自动调用,清理资源(如释放动态内存)。
- 拷贝构造函数:用已存在的对象初始化新对象(如 Date d2(d1) )。
- 赋值运算符重载:两个已存在对象之间的赋值(如 d2 = d1 )。
- 取地址运算符重载:返回对象的地址(默认即可满足多数需求)。
- const 取地址运算符重载:返回 const 对象的地址(适配 const 场景)。
默认成员函数的核心作用是保障类对象能正常进行创建、销毁、复制、赋值等基础操作,让类在没有手动定义这些函数时,仍能满足最基本的使用需求,降低类的设计和使用门槛。
- 简单说,它们是类的“基础工具”,默认情况下由编译器自动生成,确保对象的生命周期管理和基本操作能正常运行;当类涉及资源(如动态内存)时,可能需要手动重写其中部分函数(如拷贝构造、析构)以避免问题。
1.2 由来
- 默认成员函数的由来,本质是C++为了让类能够像内置类型(如int、double)一样进行自然的操作(创建、销毁、复制等),同时兼顾类的封装特性而设计的机制。
- 在C语言中,结构体仅能存储数据,操作数据需要手动写函数,且无法自动处理初始化、清理等动作。而C++的类将数据和操作封装在一起,为了让类的对象在创建、销毁、赋值等场景下能“自动”完成必要的工作(比如初始化成员、释放资源),编译器就被设计为:当用户没有显式定义这些关键函数时,自动生成一套默认实现,确保类的基本功能可正常运行。
比如:
- 创建对象时需要初始化,于是有了默认构造函数;
- 销毁对象时需要清理资源,于是有了默认析构函数;
- 用一个对象复制出另一个新对象,于是有了默认拷贝构造函数;
- 两个对象之间赋值,于是有了默认赋值运算符重载。
这些默认函数的存在,让类在最基础的场景下无需手动编写代码就能工作,既保持了类的封装性,又降低了使用门槛,是C++类与对象机制得以顺畅运行的基础保障。
2. 默认构造函数
在 C++ 类的默认成员函数中,构造函数是对象创建时自动调用的特殊函数,其核心作用是初始化对象(包括给成员变量赋初值、分配资源等)。构造函数的核心分类标准是是否需要传参,而不是“谁写的”。默认构造函数:指不需要传任何参数就能调用的构造函数
2.1 定义
构造函数是与类同名的非静态成员函数,用于在对象创建时(即内存分配后)初始化对象。
2.2 核心特性
1. 自动调用:
- 对象创建时(如 Date d; 或 Date* d = new Date; ),编译器会自动调用构造函数,无需手动调用(也不能手动调用)。
2. 无返回值:
- 不同于普通函数,构造函数没有返回类型,甚至不能写 void 。
3. 可以重载:
- 一个类可以有多个构造函数,只要参数列表(参数类型、个数、顺序)不同,就构成重载。目的是支持对象在不同场景下的初始化(比如带参数初始化、无参数默认初始化)。
4. 与类名相同:
- 函数名必须和类名完全一致(包括大小写),例如 class Date 的构造函数只能叫 Date 。
5. 初始化对象:
- 核心作用是给对象的成员变量赋初值,或执行对象创建时的必要操作(如打开文件、分配动态内存等)。
2.3 默认构造函数(编译器自动生成的)
如果类中没有显式定义任何构造函数,编译器会自动生成一个默认构造函数(无参构造函数)。其默认实现规则如下:
- 对于内置类型成员(如 int , double , 指针等) : 默认不做初始化(即成员变量的值是随机的 , 比如 _year 可能是随机值)。
- 对于自定义类型成员(如类中包含另一个类的对象) :会自动调用该自定义类型的默认构造函数(即无参构造函数)。
示例:
class Time { public: Time() { // Time的默认构造函数 cout << "Time()" << endl; } }; class Date { private: int _year; // 内置类型 Time _t; // 自定义类型 }; int main() { Date d; // 创建 Date对象,会调用编译器生成的Date默认构造函数 // 结果:会打印"Time()"(因为_year不初始化,_t调用Time的默认构造) return 0; }2.4 手动定义构造函数
为什么需要手动定义构造函数?
默认构造函数的局限性很明显 :对内置类型成员不初始化 , 可能导致对象状态不确定(比如指针未初始化就使用 , 会引发崩溃)。因此,实际开发中通常需要手动定义构造函数 , 主要场景包括:
- 需要给内置类型成员赋初始值(如 Date 类必须初始化年月日);
- 对象创建时需要分配资源(如类中包含 char* 指针 , 需要在构造函数中 new 内存);
- 需要支持多种初始化方式(如无参默认值 , 带参指定值)。
注意:一旦手动定义了任何构造函数 , 编译器就不再生成默认构造函数。如果需要无参构造, 必须手动定义。
在 C++ 中,当编译器自动生成的默认构造函数无法满足需求时,就需要显式定义默认构造函数。具体场景如下:
1. 无参构造函数
手动定义的无参构造函数,就是开发者显式写出的 , 不需要参数就能调用的构造函数 , 格式是 类名() 。它的作用是在创建对象时(不需要传参) , 对对象的成员进行初始化。
关键特点:
1. 格式固定:函数名与类名相同 , 无参数 , 无返回值(连 void 都不能写)。class Date { private: int year; int month; int day; public: // 手动定义的无参构造函数 Date() { year = 2000; // 初始化成员 month = 1; day = 1; } };2. 调用方式:创建对象时不需要传参 , 直接写 类名 对象名; 。
示例:Date d; // 调用上面的无参构造函数,d的初始值是2000-1-13. 与编译器生成的默认构造的区别:
- 编译器自动生成的默认构造函数(当你没写任何构造时)对内置类型成员(如 int , double)不初始化(值是随机的) , 只初始化自定义类型成员。
- 手动定义的无参构造函数可以主动初始化所有成员(包括内置类型) , 更灵活。
4. 注意点:
- 一旦手动定义了任何构造函数(包括无参构造) , 编译器就不再生成默认构造函数。
- 一个类中只能有一个无参构造函数(否则会冲突 , 因为调用时无法区分)。
5. 什么时候用?
- 当你希望创建对象时(不需要传参) , 成员能被赋予特定的初始值(而不是随机值) , 就需要手动定义无参构造函数。
- 例如上面的 Date 类 , 用无参构造创建对象时 , 默认初始化为 2000-1-1 , 比编译器生成的“随机值”更合理。
对比一个错误案例:
如果只写了带参构造,没写无参构造,就不能无参创建对象:class Date { public: // 只写了带参构造 Date(int year, int month, int day) { _year = year; _month = month; _day = day; } }; Date d; // 报错!没有无参构造,编译器也不再生成默认构造此时如果想无参创建对象,就必须手动加一个无参构造函数。
2. 带参构造函数(全缺省构造函数)
手动定义的带参构造函数 , 是开发者显式写出的 , 需要传入参数才能调用的构造函数。它的核心作用是在创建对象时 , 通过外部传入的参数来初始化对象的成员 , 让对象的初始状态更灵活可控。
关键特点:
1. 格式 : 函数名与类名相同 , 有参数列表(至少1个参数) , 无返回值(不能写 void )。
示例:class Date { private: int year; int month; int day; public: // 手动定义的带参构造函数(3个参数) Date(int y, int m, int d) { year = y; // 用参数初始化成员 month = m; day = d; } };2. 调用方式:创建对象时必须传入对应参数 , 格式为 类名 对象名(参数1, 参数2, ...); 。
示例:Date d(2023, 10, 1); // 调用带参构造,d被初始化为2023-10-13. 与无参构造的区别:
- 无参构造:创建对象时不需要传参 , 成员初始化是固定的(比如默认值)。
- 带参构造:创建对象时必须传参 , 成员初始化由外部参数决定 , 更灵活(同一类可以创建出不同初始状态的对象)。
4. 特殊变种:全缺省构造函数
- 带参构造函数可以给参数设置默认值,当所有参数都有默认值时,就叫“全缺省构造函数”。
示例:
class Date { public: // 全缺省构造(带参,但可无参调用) Date(int y=2000, int m=1, int d=1) { year = y; month = m; day = d; } };调用方式灵活:
Date d1; // 无参调用,用默认值2000-1-1 Date d2(2023); // 传1个参数,月和日用默认值2023-1-1 Date d3(2023, 10); // 传2个参数,日用默认值2023-10-1 Date d4(2023, 10, 1); // 传3个参数,2023-10-15. 注意点:
- 一旦手动定义了带参构造函数(无论是否全缺省) , 编译器不再自动生成默认构造函数。
- 在 C++ 中 , 如果一个类显式定义了带参构造函数(无论参数数量和类型如何) , 编译器不会再自动生成默认无参构造函数。此时,如果你创建对象时不传递任何参数(试图调用无参构造),编译器会因为找不到匹配的无参构造函数而报错。例如:如果只写了上面的 Date(int y, int m, int d) , 就不能用 Date d; 创建对象(会报错 , 因为没有无参构造)。所以当我们想在创建对象时要想不传递任何参数,此时就可以写一个全缺省的构造函数
- 带参构造可以重载(一个类中可以有多个带参构造 , 只要参数个数或类型不同)。
示例:
class Date { public: Date(int y, int m, int d) { ... } // 3参数 Date(int y, int m) { ... } // 2参数(重载) Date(int y) { ... } // 1参数(重载) };调用时根据参数匹配对应的构造函数:
Date d1(2023, 10, 1); // 调用3参数 Date d2(2023, 10); // 调用2参数6. 什么时候用?
- 当你需要创建不同初始状态的对象时(比如不同日期、不同年龄的人) , 就需要带参构造函数。它能让对象的初始化更灵活 , 避免创建后再手动修改成员的麻烦。
例如 : 一个 Person 类:
class Person { private: string name; int age; public: // 带参构造,初始化姓名和年龄 Person(string n, int a) { name = n; age = a; } }; // 创建不同的人 Person p1("张三", 18); Person p2("李四", 20);这样就能直接创建出“张三(18岁)”和“李四(20岁)”两个不同的对象,非常直观。
下面我们来举个例子更直观的理解一下
class Time { public: Time(int hour) :_hour(hour) { cout << "Time()" << endl; } private: int _hour; };这个 Time 类(只定义了 Time(int hour) 这个带参构造函数),违背了“不传参就能调用”的默认构造函数原则(指不需要传任何参数就能调用的构造函数)。
具体来说:
因为我们显式定义了带参构造函数 Time(int hour) ,编译器就不会再自动生成默认构造函数了。这个类现在只有这一个构造函数,而它必须传入一个 int 参数才能调用,比如 Time t(10); 。
如果你尝试直接写 Time t; 来不传参创建对象,编译器会直接报错——因为找不到可以匹配的、不需要传参的构造函数。
简单总结就是:这个 Time 类没有默认构造函数,只能传参实例化对象。
class Time { public: Time(int hour = 10) :_hour(hour) { cout << "Time()" << endl; } private: int _hour; };但是当我们将这个构造函数写为全缺省时,它就是这个类的默认构造函数, 因为即使当我们在实例化对象没有传实参时, 也有全缺省的参数, 满足不需要传任何参数就能调用,因为所有参数都有默认值,所以可以不传参调用。
2.5 对默认构造函数的更深理解
构造函数的本质分类 :构造函数的核心分类标准是是否需要传参,而不是“谁写的”。
默认构造函数:指不需要传任何参数就能调用的构造函数,它包含三种情况:
1. 编译器自动生成的默认构造函数:当你没有定义任何构造函数时,编译器会自动生成一个空的默认构造函数。
2. 你自己写的无参构造函数:比如 Time() { ... } 。
3. 你自己写的全缺省构造函数:比如 Time(int hour = 0) { ... } ,因为所有参数都有默认值,所以可以不传参调用。带参构造函数(不属于默认构造函数):指必须传入参数才能调用的构造函数,比如 Time(int hour) { ... } 。
其实更准确的说法可以这样说:构造函数可以分为默认构造函数和非默认构造函数。
其中默认构造函数包含三类:1.编译器自动生成的
2.用户显式定义的无参构造函数
3.以及用户显式定义的全缺省构造函数
2.6 补充
为了更精确地区分“默认构造函数”的调用规则和成员初始化的细节。下面分两种情况详细说明:
1. 类中只有内置类型成员(无自定义类型成员)
当类中只包含内置类型成员(如 int , double , 指针等)时:
- 编译器会生成默认构造函数(如果没有显式定义任何构造函数)。
- 但这个默认构造函数是“无操作”的——它不会主动初始化内置类型成员(内置类型的默认初始化是“未定义的”,值可能是随机的)。
- 实例化对象时的行为:
- 如果用默认初始化(如 MyClass obj ) , 内置类型成员的值是不确定的(未初始化)。
- C++11引入了统一初始化语法,使用大括号 {} 的形式 , 它可以让初始化语义更加明确和统一。使用 {} 进行初始化时 , 对于内置类型明确表达了要将其初始化为合适的零值的意图 ;对于自定义类型 , 也能更好地适配不同的构造情况 , 比如针对没有默认构造函数的自定义类型也能实现合理的初始化方式。
示例:
class MyClass { public: int a; // 内置类型 double b; // 内置类型 }; int main() { MyClass obj1; // 默认初始化:a 和 b 的值是随机的(未定义) MyClass obj2{}; // 值初始化:a=0,b=0.0(内置类型被零初始化) return 0; }这里 MyClass 没有显式定义构造函数 , 编译器生成默认构造函数 , 但它不做任何初始化工作(内置类型的初始化需要显式处理)。
2. 类中包含自定义类型成员(嵌套类对象)
当类中包含自定义类型成员(如另一个类的对象)时:
- 1. 编译器仍会生成默认构造函数(如果没有显式定义任何构造函数)。
- 2. 默认构造函数会自动调用自定义类型成员的默认构造函数,完成嵌套对象的初始化。
也就是说,嵌套的自定义类型成员会被“自动初始化”(通过其自身的默认构造函数),无需手动干预。
示例:typedef int STDataType; class Stack { public: Stack(int n = 4) { cout << "Stack(int n = 4)" << endl; _a = (STDataType*)malloc(sizeof(STDataType) * n); if (!_a) { perror("malloc failed"); return; } _capacity = n; _top = 0; } private: STDataType* _a; size_t _capacity; size_t _top; }; // 使用两个栈实现队列 class MyQueue { private: Stack pushSt; Stack popSt; }; int main() { MyQueue q; Stack st; return 0; }在这个的代码中 , MyQueue 类没有显式定义构造函数 , 因此编译器会自动生成一个默认构造函数(属于默认成员函数的一种)。以下是关于 MyQueue 构造函数的详细分析:
1. 编译器自动生成的默认构造函数做了什么?
当类中没有显式定义任何构造函数时,编译器会为 MyQueue 生成一个默认构造函数,其行为是:
- 对于 MyQueue 的内置类型成员(如果有的话,比如 int 、指针等):不做任何初始化(值是随机的)。
- 对于 MyQueue 的自定义类型成员(这里是 Stack _pushst 和 Stack _popst ):调用它们的默认构造函数(即 Stack 类的默认构造函数)。
2. MyQueue 的自定义类型成员如何初始化?
MyQueue 的成员 _pushst 和 _popst 是 Stack 类型(自定义类型) , 因此 : 编译器生成的 MyQueue 默认构造函数 , 会自动调用 Stack 类的默认构造函数来初始化这两个成员。
而 Stack 类的构造函数是:Stack(int n = 4) { cout << "Stack(int n = 4)" << endl; _a = (STDataType*)malloc(sizeof(STDataType) * n); if (!_a) { perror("malloc failed"); return; } _capacity = n; _top = 0; }由于 Stack 的构造函数是全缺省构造函数(参数 n 有默认值 4 ) , 因此它也属于 Stack 类的默认构造函数(可以无参调用)。所以 , 当 MyQueue 的默认构造函数初始化 _pushst 和 _popst 时 , 会调用 Stack 的全缺省构造函数(Stack(int n = 4)) , 相当于:
MyQueue() { // 编译器自动生成的逻辑: _pushst.Stack::Stack(4); // 调用 Stack 的全缺省构造,n=4 _popst.Stack::Stack(4); // 同理 }3. 验证:运行时的初始化过程
在 main 函数中:MyQueue q; // 创建 MyQueue 对象,调用编译器生成的默认构造函数执行 q 的构造时 , 会依次初始化其成员 _pushst 和 _popst :
- _pushst 调用 Stack(int n = 4) , n 取默认值 4 , 因此 _pushst 的栈容量是 4 。
- _popst 同理 , 也会调用 Stack(int n = 4) , 栈容量是 4 。
4. 总结
- MyQueue 的构造函数是编译器自动生成的默认构造函数。
- 它的作用是:通过调用其自定义类型成员( _pushst 和 _popst )的默认构造函数( Stack 的全缺省构造) , 完成 MyQueue 对象的初始化。
- 如果 Stack 没有默认构造函数(比如Stack只定义了带参构造且无默认值) ,MyQueue 的默认构造函数会报错(因为无法初始化 Stack 成员)。
简单来说:编译器帮 MyQueue 自动生成了构造函数,这个构造函数会自动调用其成员 Stack 的构造函数完成初始化。
3. 析构函数
析构函数是 C++ 中类的第二个特殊成员函数 ,主要用于在对象生命周期结束时,释放对象占用的资源(如动态分配的内存、打开的文件、网络连接等 ) , 避免内存泄漏等问题。
3.1 定义
定义:析构函数是类的特殊成员函数 , 名称与类名相同 , 前面加 ~ 符号 , 没有返回值(连 void 都不能写 ) , 也不能有参数 , 一个类有且仅有一个析构函数(无法重载 , 因为参数列表固定为空)。
3.2 语法规则
- 1. 命名规则:
- 类名前加 ~, 例如 class Stack 的析构函数是 ~Stack() 。
- 2. 参数与返回值:
- 无参数 , 无返回值(连 void 都不加)。
- 不能重载(一个类只能有1个析构函数)。
- 3. 自动调用时机:
- 对象生命周期结束时触发 , 常见场景:
- 栈对象:作用域(如函数 , {} 代码块)结束时(如 Stack s; 在函数返回时调用 ~Stack())。
- 堆对象:delete 释放时(如 Stack* p = new Stack; delete p; 先调用 ~Stack() 再释放内存)。
- 全局/静态对象:程序结束(或静态作用域结束)时。
实例:
class Stack { public: // 构造函数 Stack(int n = 4) { _a = (STDataType*)malloc(sizeof(STDataType) * n); _capacity = n; _top = 0; } // 析构函数 ~Stack() { // 释放动态分配的内存 free(_a); _a = nullptr; _capacity = 0; _top = 0; } private: STDataType* _a; int _capacity; int _top; };
- 上述代码中 , 构造函数( Stack(int n=4) )负责“初始化资源”(如 malloc 申请内存) , 析构函数(~Stack())负责“销毁资源”(如 free 释放内存) , 避免资源泄漏。
- ~Stack 就是 Stack 类的析构函数 , 在 Stack 对象销毁时自动调用 , 用于释放构造函数中用 malloc 分配的堆内存。
3.3 编译器生成的默认析构函数
如果不手动定义析构函数 , 编译器会自动生成默认析构函数 , 但行为有明显“区分对待”:
- 1. 内置类型成员(如 int 、指针):
- 默认析构函数不做任何处理。
- 2. 自定义类型成员(如其他类的对象):
- 默认析构函数会自动调用成员的析构函数。
例如:
class A { public: ~A() { cout << "A 的析构函数" << endl; } }; class B { A a_obj; // 自定义类型成员 int num; // 内置类型成员 // 未显式定义析构函数 }; int main() { B b; return 0; // 销毁 b 时,编译器生成的 B 的析构函数会调用 a_obj 的析构函数 }上述代码中 , B 未显式定义析构函数 , 编译器生成的默认析构函数会调用 a_obj (A 类型 )的析构函数 , 但不会处理 num ( int 类型 )。不过 , 若 B 中有动态分配资源( 如指针指向堆内存 ), 仅靠编译器生成的析构函数无法释放 , 就需要显式定义析构函数来处理。
3.4 手动定义析构函数
“必须手动写析构”的场景:
- 当类持有“需要主动释放的资源”时(如动态内存 , 文件 , 锁) , 必须手动定义析构函数 , 否则会泄漏资源。
典型案例( Stack 类):
class Stack { STDataType* _a; // 动态分配的内存(需要释放) public: ~Stack() { free(_a); // 手动释放 malloc 申请的内存 _a = nullptr; // ... 其他清理(如重置 _top、_capacity) } };反面案例(内存泄漏):
如果不写 ~Stack() , _a 指向的内存永远不会释放 , 程序运行久了会“内存爆炸”。
“无需手动写析构”的场景
- 如果类不持有需要主动释放的资源(仅包含内置类型 , std::string / std::vector 等“智能”成员) , 无需手动定义析构函数 , 直接用编译器生成的默认析构即可。
示例(“无需析构”的类):
class Date { int year = 2025; double month = 8.0; // 只是声明,无动态资源,默认析构函数足够 };原理:
std::string / std::vector 等标准库类型的析构函数会自动释放资源(如 vector 析构时会 delete[] 内部数组) , 无需手动干预。
3.5 析构函数的“链式调用”
无论是否手动定义析构函数 , 自定义类型成员的析构函数一定会被调用 , 形成“链式析构”:
- 手动定义的析构函数执行时 , 会先执行自己的清理逻辑(如 free(_a) )。
- 然后自动调用所有自定义类型成员的析构函数(即使没手动写析构 , 编译器生成的默认析构也会做这一步)。
示例:
class Container { Stack s1; // 自定义类型成员 Stack s2; // 自定义类型成员 public: ~Container() { // 1. 执行自己的清理逻辑(如果有) cout << "Container 析构" << endl; // 2. 自动调用 s1.~Stack() 和 s2.~Stack() } };
3.6 局部对象的析构顺序
C++ 规定:局部作用域中 , 后定义的对象先析构(“栈式” 顺序,与构造顺序相反)。
示例:void func() { Stack s1; // 先构造 Stack s2; // 后构造 // 析构顺序:s2 先析构,s1 后析构 }
3.7 补充
- 内置类型(如 int , double , 指针等)没有析构函数。析构函数是面向自定义类型(类/结构体)的概念 , 用于在对象生命周期结束时释放资源(如动态内存等)。
- 对于内置类型,当它们的生命周期结束(如局部变量出作用域)时 , 系统会直接回收它们占用的内存 , 无需任何“析构”操作 , 因为它们本身不管理额外资源(仅存储值)。
关键区别:
- 1. 自定义类型:
- 析构函数由开发者定义(或编译器自动生成) , 用于释放对象持有的资源(如 new 分配的内存 , 打开的文件等) , 在对象生命周期结束时自动调用。
- 2. 内置类型:
- 没有析构函数 , 生命周期结束时直接被系统销毁(内存回收) , 不涉及任何资源释放逻辑(因为它们本身不持有需要手动释放的资源)。
- 举例:
#include <iostream> class MyClass { public: int* ptr; // 内置类型(指针) MyClass() { ptr = new int(10); // 自定义类型申请了动态内存(需要释放) std::cout << "构造:分配了动态内存" << std::endl; } ~MyClass() { delete ptr; // 析构函数释放动态内存(自定义逻辑) std::cout << "析构:释放了动态内存" << std::endl; } }; int main() { { int a = 5; // 内置类型,无析构 MyClass obj; // 自定义类型,有析构 } // 作用域结束 // a 直接被销毁(内存回收),无任何操作 // obj 调用析构函数,释放 ptr 指向的内存 return 0; }总结:
析构函数仅针对自定义类型 , 用于处理资源释放;内置类型无需析构 , 生命周期结束时由系统直接回收内存。
3.8 总结
- 是 “资源管理”的最后一道保障 , 尤其对动态内存 , 文件等资源 , 必须手动写析构释放。
- 编译器生成的默认析构 , 仅能自动处理“自定义类型成员的析构” , 对动态资源(如指针)无能为力。
- 记住“有资源申请(如 new / malloc ) , 就必须有对应的析构释放” , 否则必然泄漏。
结合 Stack 类的示例 , 理解析构函数如何“补全”资源管理的闭环——构造时 malloc 申请内存,析构时 free 释放,这就是 C++ 中最基础的“RAII 惯用法”(资源获取即初始化,资源释放即析构)。
总之 , 析构函数是保障 C++ 程序资源正确管理的重要机制 , 核心作用是在对象生命周期结束时清理资源 , 和构造函数协同工作 , 让对象的使用更安全、可靠。
4. 总结
构造函数与析构函数是类的“左右手”:
- 构造函数:对象创建时调用 , 负责初始化(分配资源、设置初始值), 让对象“可用”。
- 析构函数:对象销毁时调用 , 负责清理(释放资源、关闭连接), 让对象“收尾”。
二者配合保障对象从生到死的资源合理管理 , 避免泄漏(如动态内存、文件句柄 ), 是 C++ 类实现“自洽生命周期”的核心。
以上是关于类的默认成员函数中的构造函数和析构函数 , 后面还有4个类似的默认函数 , 我们在下篇文章中再说 , 感谢大家的观看!
二次复习:
1. 什么是默认构造函数?
答:默认构造函数的核心定义是不需要传入任何参数就能调用的构造函数,它的分类标准是 “是否需要传参”,而不是 “编译器生成还是用户手写”。它包含三种合法形式:
- 编译器自动生成的无参空构造函数;
- 用户自己写的无参构造函数;
- 用户写的所有参数都带默认值的全缺省构造函数。
只要符合 “无参调用” 的条件,都属于默认构造函数,和谁写的无关。
2. 编译器什么时候会自动生成默认构造函数?
答:当我们没有定义任何构造函数时,编译器才会自动生成一个空的默认构造函数,这个空构造函数什么都不做,只会按规则初始化成员变量。只要我们定义了任何构造函数(哪怕是带参的),编译器就不会再自动生成默认构造函数了。
3. 全缺省构造函数和无参构造函数可以同时存在吗?为什么?
答:不可以同时存在,否则会出现调用歧义。比如同时写了 Time() 和 Time(int hour=0) 这两个构造函数,当我们创建对象 Time t; 时,编译器无法判断应该调用哪个构造函数,会直接报编译错误。
4. 带参构造函数属于默认构造函数吗?
答:不属于。带参构造函数必须传入参数才能调用,不满足 “无参调用” 的条件,所以不属于默认构造函数。比如 Time(int hour) 就不是默认构造函数,创建对象时必须传参,不能写Time t;。果类中只写了带参构造函数,没有默认构造函数,这时不能无参创建对象,但不代表带参构造函数本身写错了,只是缺少了无参调用的入口,我们依旧可以传入参数正常创建对象。举个例子,写了 Time(int h) 这个带参构造,Time t(10); 这样传参实例化完全合法,只有 Time t; 这种无参写法才会编译报错。带参构造函数是合法标准语法,不存在错误;它和默认构造函数只是适用场景不同,根据业务需求选择使用即可。
5. 为什么默认构造函数很重要?
答:很多场景下编译器需要调用默认构造函数来初始化对象,比如创建对象数组、用 new[] 动态分配对象数组时,都需要调用默认构造函数。如果类没有可用的默认构造函数,这些操作就会编译报错,所以实际开发中,除非有特殊设计,一般都会保证类存在可用的默认构造函数。
深拷贝与浅拷贝
浅拷贝指仅拷贝对象本身的内存空间,对于对象内部指针指向的堆资源,只会拷贝指针地址,不会开辟新堆内存,新旧两个对象的指针会指向同一块堆内存。使用编译器默认生成的拷贝构造函数、赋值重载运算符时,默认行为都是浅拷贝,这会带来面试高频的双重释放问题:当两个对象生命周期结束调用析构函数,先后对同一块堆内存执行 delete 操作,程序直接崩溃;同时修改其中一个对象的堆数据,另一个对象的内容也会同步改变,数据无法相互隔离。深拷贝则会完整复制对象所有资源,除了拷贝对象自身成员,遇到指针管理的堆内存时,会主动申请一块同等大小的全新堆空间,再把原堆内存的数据完整复制过去,最终新旧对象的指针指向两块相互独立的堆内存。实现深拷贝需要手动重写拷贝构造函数和赋值运算符重载,自行完成堆内存的分配与数据复制,它的优势是两份数据完全隔离,修改互不影响,析构时各自释放自身持有的堆内存,不会出现重复释放的崩溃问题,缺点是会额外开辟堆内存,带来少量内存开销与拷贝耗时。二者核心区别集中在堆资源处理方式,浅拷贝共享堆内存、存在内存安全隐患,深拷贝独占堆内存、数据独立安全。面试中还可以补充开发规范,只要类内部存在动态申请的堆内存,就必须禁用默认浅拷贝逻辑,手动实现深拷贝,或者采用移动语义、智能指针等方案规避拷贝带来的内存问题。
我们首先区分对象本体和对象内部成员的存储位置:哪怕你在栈上定义一个类对象,这个对象本身存放在栈内存,但类里面如果有指针成员,指针变量自身是对象的栈内成员,指针存储的地址可以指向堆内存,这就是栈对象管理堆资源的核心场景,也是默认浅拷贝会出问题的根源。单纯由 int、char 这类内置类型构成成员的类,所有数据都跟着对象本体在栈上,就算使用默认拷贝构造做浅拷贝,拷贝后两份数据完全独立,修改互不干扰,析构时也不需要释放堆内存,不会产生 bug,这种情况不用手动写深拷贝。但如果类中包含动态内存相关的指针成员,构造函数里通过 new 为指针分配堆内存,此时哪怕该类的实例是栈变量,对象只是托管这片堆内存,编译器默认生成的拷贝构造只会复制指针里存储的堆地址,不会新开堆空间,也就是浅拷贝。拷贝出来的新对象和原对象的指针指向同一块堆内存,等到两个栈对象生命周期结束、调用析构函数时,会先后对同一个堆地址执行 delete,触发双重释放程序崩溃,同时修改其中一个对象指针指向的堆数据,另一个对象读取到的数据也会跟着变化,数据无法隔离,所以这种拥有堆资源指针成员的类,必须手动自定义拷贝构造函数实现深拷贝。深拷贝的逻辑就是在拷贝构造函数中,先根据源对象堆内存的大小 new 一块全新堆内存,再把源堆的数据完整复制到新堆空间,最后让新对象的指针指向这块新建堆内存,这样原对象和新对象各自持有独立堆资源,修改互不影响,析构释放各自的堆内存,彻底规避浅拷贝的内存隐患。简单总结,是否需要写深拷贝构造函数,和对象本身是栈变量还是堆变量无关,判断标准只看类内部有没有指针管理堆内存;没有堆资源只用默认浅拷贝即可,存在堆资源就必须实现深拷贝。
深拷贝要在哪两个函数里实现
有两处必须写深拷贝逻辑:拷贝构造函数、赋值运算符重载函数,两个缺一不可。
- 拷贝构造函数:创建新对象,并用已有对象初始化时触发,格式 类名 (const 类名&源对象);可以写在类内,也可以类外实现。
- 赋值运算符重载:两个已经存在的对象互相赋值 s2 = s1 时触发,格式 类名& operator=(const 类名& 源对象);同样支持类内 / 类外定义。
默认编译器生成的这两个函数都是浅拷贝,只复制指针地址,所以必须手动重写实现深拷贝。
深拷贝统一实现思路(两个函数通用逻辑)
- 获取源对象堆内存大小;
- 给当前对象的指针重新 new 一块同等大小堆内存;
- 循环把源对象堆里的数据完整复制过来;
- 当前对象指针指向这块全新堆空间。
效果:新旧对象各自持有独立堆内存,析构时各自释放,不会重复释放崩溃。
构造函数与析构函数调用时机
构造函数确实是对象创建时自动调用,不管是栈上局部对象、堆上 new 出来的对象还是全局对象,只要开辟出该对象的内存,就会立刻执行对应构造函数完成成员初始化,整个过程不需要我们手动调用。而析构函数的触发规则核心依靠对象的生命周期,栈上局部对象的生命周期严格绑定当前作用域,也就是对应的大括号范围,程序执行到大括号结束位置时,栈内所有局部对象会按照创建顺序逆序自动调用析构函数,释放对象占用的栈内存以及它管理的堆资源。除了栈局部对象之外还有两类特殊情况,第一类是使用 new 在堆上创建的对象,这类对象不会自动调用析构,堆内存不会自行回收,必须手动执行 delete,delete 指令执行时才会先调用对象的析构函数,再释放堆内存;第二类是全局对象、静态局部对象,全局对象在程序启动 main 函数之前调用构造,整个程序退出时才会调用析构,静态局部对象只会在第一次执行到定义代码时构造,等到整个程序运行结束才会执行析构,不受代码块大括号的约束。我们平时写函数内部的栈对象,遵循大括号作用域规则,一旦代码离开这对大括号,对象生命周期终结,自动执行析构,这也是栈对象不用手动释放、不会内存泄漏的原因,只有堆对象才需要开发者手动管理释放。面试作答可以补充区分两者的核心记忆点:栈对象生命周期跟着代码块大括号走,自动析构;堆对象生命周期由 new/delete 控制,不 delete 就不会触发析构,造成内存泄漏。
我们要在栈上创建类对象,直接在函数、代码块内部写 类名 变量名; 这种形式即可,对象整体内存分配在栈空间,离开当前大括号作用域就自动析构,全程不用手动释放;想要在堆上创建类对象,就必须搭配 new 关键字,写法是 类名* p = new 类名;,new 会在堆中开辟一块内存存放对象本体,返回这块堆内存的地址交给指针 p 接收,这块堆对象不会随代码块结束自动销毁,只有执行 delete p 的时候才会调用析构并释放堆内存。堆内存本身和类有没有指针成员没有必然绑定,堆对象只是把整个对象本体放在堆里,哪怕类全是 int、double 这类普通内置成员,也能用 new 把它创建在堆;反过来栈对象内部也完全可以拥有指针成员,栈对象本体存在栈,但它内部的指针能通过 new 指向一块独立堆内存,这也是之前浅拷贝、深拷贝问题的来源。开发里我们经常见到带指针成员的类,并不是因为对象本体在堆,而是类内部指针单独托管了堆资源,这才需要我们手动管理内存、实现深拷贝;单纯把对象本体放到堆只是改变对象自身的存储位置,不会额外产生需要维护的堆数据。最后总结区分两个概念,对象本体存储位置分栈、堆,由定义语法决定;指针成员指向的堆资源是对象管理的额外内存,二者互不干扰,不能混为一谈。
任何类都会存在析构函数,就算你一行代码都不写,编译器也会自动合成一份默认析构函数,不存在没有析构函数的类,栈对象、堆对象全都遵循这个规则。如果写的类内部没有指针、没有通过 new 申请堆内存,编译器生成的默认析构函数内部是空实现,什么清理逻辑都不会执行,即便栈对象离开作用域自动调用、堆对象 delete 时调用,也不会产生任何问题,完全不用我们手动自定义析构。但一旦类包含指针成员,并且在构造函数中用 new 为指针分配了堆内存,此时默认析构函数的空实现就会留下内存泄漏隐患,默认析构只会销毁对象本体的内存,不会主动调用 delete 释放指针指向的堆空间,这块堆内存会一直占用资源无法回收,这种场景下我们就必须手动编写自定义析构函数,在函数内部对所有动态申请的堆内存执行 delete 操作。这个要求和对象本身是栈对象还是堆对象无关:栈对象依靠作用域结束自动调用析构,堆对象依靠 delete 触发析构,两种对象最终都会执行析构函数,只要类持有堆资源,就必须手动实现析构来完成内存回收,否则就会出现内存泄漏。最后补充面试加分点,C++ 有三 / 五法则,只要手动实现了析构函数,往往意味着类管理了堆内存,此时拷贝构造、赋值运算符重载也需要同步自定义实现深拷贝,避免浅拷贝带来的双重释放崩溃问题。
从操作系统物理内存的底层视角来看,栈与堆本质都是计算机的物理内存,没有硬件层面的本质区别,操作系统只是通过虚拟地址空间做逻辑上的区域划分,给二者分配了不同的管理规则、地址范围、增长方向,这是你理解正确的地方,但二者的运行机制、分配释放方式、生命周期有着天壤之别。程序运行时每个线程都会拥有一块独立的栈内存,栈的大小在程序编译阶段就基本确定,分配内存依靠 CPU 压栈指令自动完成,定义局部变量、栈对象时直接移动栈指针就能开辟空间,速度极快,栈内存遵循后进先出规则,局部栈对象的生命周期绑定代码块大括号,离开作用域后栈指针回退自动回收内存,完全不需要开发者手动释放,同时栈内存向下增长,存储函数参数、局部变量、函数栈帧、返回地址这类临时数据。堆内存则属于进程全局共享的内存区域,没有固定大小限制,可以动态扩容,堆内存的申请与释放无法依靠 CPU 原生指令,必须调用操作系统提供的堆管理接口,C++ 里就是 new 和 delete,底层会调用 malloc、free,操作系统会维护一张堆内存空闲块表来管理空闲空间,分配时遍历链表寻找合适内存块,释放时合并相邻空闲内存,整体分配速度远慢于栈,堆内存向上增长,用来存放动态创建的对象、构造函数内 new 出来的数组与缓冲区,堆上的资源不会随代码块结束自动回收,开发者如果忘记 delete 就会造成内存泄漏。再补充二者核心使用场景与面试常考差异:短期临时数据、函数局部对象优先存栈;运行时才确定大小、生命周期跨代码块的数据放到堆;栈由系统自动管理无内存泄漏风险,但空间容量有限,超大数组直接定义在栈会触发栈溢出崩溃;堆由程序员手动管理,内存容量充足,但存在内存泄漏、野指针风险。最后结合你之前学的类知识做串联,栈对象只是对象本体存在栈,其内部指针成员依旧可以指向堆内存,栈和堆只是两块独立的内存区域,对象本体和它管理的资源可以分别处在不同区域。
栈上存放的只是一个指针变量,指针本身随栈对象出作用域自动销毁,但它存储的堆内存地址会跟着丢失,程序再也找不到这块堆内存,无法执行 delete 回收,最终形成内存泄漏。栈指针仅仅保存堆内存的地址值,销毁指针只会清除这个地址记录,不会连带释放地址对应的堆空间,操作系统也不会因为指针消失就自动回收堆内存,这块堆资源会持续占用进程内存,直到整个程序运行结束才会被系统回收,如果是长期运行的服务程序,持续产生这类泄漏会不断占用内存,最终引发内存耗尽崩溃。针对这种持有堆资源的类,正确处理方式分为两步,第一是在类的自定义析构函数内部编写 delete 语句释放指针指向的堆内存,只要对象生命周期结束,不管是栈对象出作用域自动调用析构,还是堆对象执行 delete 触发析构,都会自动释放堆内存,从根源避免泄漏;第二遵循 C++ 三法则,既然手动写了析构管理堆内存,就必须同步重写拷贝构造函数和赋值运算符重载实现深拷贝,防止默认浅拷贝带来的双重释放崩溃问题。除此之外还有更现代化的方案,使用 unique_ptr、shared_ptr 这类智能指针替代原生裸指针,智能指针会自动在生命周期结束时释放堆内存,不用手动写 delete,能同时规避内存泄漏和重复释放的风险,也是现在项目开发推荐的写法。
析构函数
析构函数只负责清理这个对象自己通过 new、new [] 手动申请出来的堆资源,比如类内指针成员指向的堆数组、堆缓冲区,我们在析构里写 delete/delete [] 回收这部分堆内存;除此之外,栈帧、对象本身的内存、操作系统底层内存管理这些内容,全都不归析构函数管控。
然后分两块讲清楚哪些不归它管: 第一,对象自身的内存回收和析构函数无关。如果这个对象是栈上局部对象,它的栈内存是在代码块大括号结束后,由 CPU 栈指针自动回退回收,整个栈帧的销毁是操作系统和 CPU 执行流程处理的,析构函数只是在栈内存回收之前被自动调用。第二,函数栈帧、线程栈、程序进程内存、操作系统底层的虚拟内存映射,这些都属于程序运行时系统层面管理的资源,也和析构函数无关,哪怕析构函数执行完毕,栈帧该怎么销毁、进程内存该怎么回收依旧由系统调度。
析构函数本质只是一段自定义清理代码,它的执行时机由对象生命周期决定,但它没有任何权限去操作底层内存分配回收机制。简单概括就是:析构只管对象内部我们手动开辟的堆资源,对象自身内存、栈帧、系统底层内存都由操作系统自动管理,不属于析构函数的工作内容。
正因为默认生成的析构函数内部是空逻辑,不会自动释放指针指向的堆内存,所以只要类里持有 new 出来的堆资源,我们就必须手动自定义析构函数,在里面执行 delete 释放堆内存,否则会产生内存泄漏;而类的成员变量如果全是栈内置类型,没有托管堆资源,默认空析构就完全够用,不需要我们手动编写。
