编辑
引言
在上一篇博客中,我们学习了C++类与对象的基础内容。这一次,我们将深入探讨C++类的关键特性,包括构造函数、析构函数、拷贝构造函数、赋值运算符重载、以及取地址运算符的重载。这些内容是理解面向对象编程的关键,也帮助我们更好地掌握C++内存管理的细节和编码的高级技巧。
编辑
一、类的默认成员函数
在C++中,编译器会为每个类自动生成一些成员函数,即使你没有显式地编写这些函数。这些默认成员函数帮助我们快速完成一些常见的操作。通常情况下,一个类在没有显式定义某些函数时,编译器会为其自动生成六个默认成员函数(需要注意的是这6个中最重要的是前4个,最后两个取地址重载不重要,我们稍微了解⼀下即可):
1.默认构造函数:当没有显式编写构造函数时,编译器会自动生成一个默认构造函数,用来初始化对象。 2.析构函数:在没有定义析构函数时,编译器会自动生成一个析构函数,用来在对象被销毁时释放资源。 3.拷贝构造函数:如果没有编写拷贝构造函数,编译器会生成一个默认的拷贝构造函数,用来通过已有对象创建新的对象。 4.赋值运算符重载:当我们没有定义赋值运算符(=)时,编译器会生成一个默认的赋值运算符,用来将一个对象的值赋给另一个对象。 5.取地址运算符重载:允许使用 & 来获取对象的地址,编译器会为每个类自动生成取地址运算符。 6.const 取地址运算符重载:当对象是 const 时,编译器也会生成对应的取地址运算符。
编辑
接下来我们会具体讨论这些函数,了解它们的作用以及在什么情况下我们需要自行实现这些函数。
二、构造函数
2.1 构造函数的作用
构造函数是一个用于初始化对象的特殊成员函数。它的名字与类名相同,并且在创建对象时会被自动调用。构造函数的主要任务是确保对象在被创建时有一个明确的初始状态。可以将它理解为对象的"出生",从它开始,对象拥有了完整的、可用的状态。
构造函数的功能类似于我们在C语言中为结构体编写的初始化函数,但更为方便,因为它可以自动调用,而不需要每次手动去调用一个初始化函数。
2.2 构造函数的特点
1.函数名与类名相同:构造函数的名字必须和类名一致。
2.没有返回值:构造函数不需要返回类型,也不能有返回值。
3.自动调用:对象创建时,系统自动调用构造函数初始化对象。
4.支持重载:可以根据不同参数列表定义多个构造函数。
5.默认构造函数:
- 如果没有定义构造函数,编译器会自动生成一个无参的默认构造函数。
- 一旦定义了任何构造函数,编译器就不会自动生成默认的无参构造函数。
6.默认构造的多种情况:
- 无参构造、全缺省构造(所有参数都有默认值)、编译器自动生成的构造都属于默认构造。
- 这三者不能同时存在,因为都满足“可以不传实参调用”的条件。
7.初始化行为:
- 自动生成的构造函数对内置类型成员变量的初始化没有要求。
- 自定义类型成员变量需要调用其默认构造函数初始化,否则需用初始化列表。
补充说明:
- 内置类型是指C++语言本身提供的基本数据类型,如
int
、char
、double
和指针等。- 自定义类型是指通过
class
或struct
等关键字定义的类型。
2.3 构造函数的类型
C++中,构造函数可以有多个类型,主要包括:
- 无参构造函数:用于初始化一个对象,没有需要用户提供的参数。例如:
class Date { public: Date() { _year = 1; _month = 1; _day = 1; } private: int _year; int _month; int _day; };
- 在这个例子中,无参构造函数会将日期的年、月、日初始化为1。
- 带参构造函数:用于在创建对象时指定初始值。例如:
class Date { public: Date(int year, int month, int day) { _year = year; _month = month; _day = day; } private: int _year; int _month; int _day; };
- 这样用户在创建对象时,可以通过传递参数来指定对象的初始状态:
Date d(2025, 5, 10);
- 全缺省构造函数:带有所有默认参数的构造函数,也可以作为无参构造函数使用。例如:
class Date { public: Date(int year = 1, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } private: int _year; int _month; int _day; };
- 这种方式使得在创建对象时,既可以不传递参数,也可以只传递部分参数,从而提高了代码的灵活性。
2.4 初始化列表
什么是初始化列表?
初始化列表是构造函数的一种特殊语法,用于在对象创建时为其成员变量赋初值。它的语法是在构造函数的参数列表之后,冒号(
:
)后面跟随成员变量的初始化代码。初始化列表让成员变量在对象创建时直接被初始化,而不是先默认初始化再赋值。
class Point { public: Point(int x, int y) : _x(x), _y(y) {} // 这里是初始化列表 private: int _x; int _y; };
在这个例子中,Point
类有两个成员变量 _x
和 _y
。构造函数使用初始化列表将传入的参数 x
和 y
直接赋值给 _x
和 _y
。
为什么要用初始化列表?
- 提高效率:初始化列表可以避免成员变量被先默认初始化再赋值,减少不必要的操作。
- 必须使用的情况:对于常量(
const
)或引用(&
)类型的成员变量,它们必须在对象创建时通过初始化列表进行赋值。
2.5 构造函数的调用时机
构造函数在以下几种情况下被调用:
- 定义对象时:
Date d1;
,会调用无参构造函数。 - 通过参数列表创建对象:
Date d2(2025, 12, 25);
,会调用带参构造函数。 - 在容器中创建对象时:例如,向
std::vector
中添加元素,容器会使用构造函数创建新对象。
三、析构函数
3.1 析构函数的作用
析构函数是用于销毁对象的特殊成员函数。它的名字是在类名前加上波浪号~
,没有参数且没有返回值。析构函数的主要任务是释放对象在生命周期中占用的资源,例如动态分配的内存、打开的文件句柄等。
析构函数和构造函数形成了一个完整的生命周期管理机制,确保对象的创建和销毁过程一致性和安全性。
3.2 析构函数的特点
1.函数命名:析构函数的名字是在类名前加上
~
,例如,类Stack
的析构函数为~Stack()
。2.无参且无返回值:析构函数没有参数,也不需要返回类型。
3.自动调用:当对象超出其作用域或被显式删除(使用
delete
)时,析构函数会被自动调用。4.唯一性:一个类只能有一个析构函数。如果没有显式定义,系统会自动生成一个默认析构函数。
5.编译器生成的析构行为:
- 对内置类型成员不做处理。
- 对自定义类型成员,编译器生成的析构函数会自动调用这些成员的析构函数。
6.显式定义的析构函数:
- 自定义类型成员的析构函数总会自动调用,无论析构函数是自动生成还是显式定义。
- 如果类没有动态资源管理需求,可以使用编译器生成的默认析构函数。
7.手动编写析构函数的必要性:
- 当类中有动态资源(如堆内存)时,一定要自己编写析构函数来释放资源,否则会导致内存泄漏。
8.析构顺序:在局部作用域中,多个对象按定义的逆序进行析构(后定义的先析构)。
例如:
class Stack { public: Stack(int n = 4) { _array = new int[n]; _capacity = n; _top = 0; } ~Stack() { delete[] _array; } private: int* _array; size_t _capacity; size_t _top; };
在这个例子中,~Stack()
析构函数用于释放构造函数中动态分配的内存。如果没有这个析构函数,当对象销毁时,动态分配的内存无法释放,就会导致内存泄漏。
3.3 析构函数的调用时机
析构函数在以下情况下会被调用:
- 对象离开作用域:例如,在
main()
函数中定义的局部对象在函数结束时会被自动销毁。 - 对象被显式删除:当通过
delete
销毁一个对象时,析构函数会被调用。 - 容器销毁其元素:当
std::vector
或其他容器销毁其持有的对象时,它们也会调用相应对象的析构函数。
3.4 析构函数的重要性
析构函数对于管理动态内存和其他系统资源非常重要。例如,如果类中包含指向堆内存的指针,而我们没有实现自定义的析构函数,则该指针所指向的内存不会被释放,从而导致内存泄漏。因此,任何涉及到动态内存分配的类,几乎都需要实现一个自定义的析构函数。
四、拷贝构造函数
4.1 拷贝构造函数的作用
拷贝构造函数用于通过已有对象创建新对象。拷贝构造函数的主要目的是使新对象具有与原对象相同的状态。
比如说,你有一个日历日期对象 Date
,想要再创建一个新的 Date
,内容和原来的日期一样,这时就需要用到拷贝构造函数。
#include <iostream> using namespace std; class Date { public: // 普通构造函数,用年、月、日来创建日期对象 Date(int year, int month, int day) : _year(year), _month(month), _day(day) {} // 拷贝构造函数,用一个已有的Date对象d来创建新的Date对象 Date(const Date& d) : _year(d._year), _month(d._month), _day(d._day) {} // 打印日期的方法 void Print() const { cout << _year << "/" << _month << "/" << _day << endl; } private: int _year; // 年 int _month; // 月 int _day; // 日 }; int main() { Date date1(2024, 10, 21); // 用普通构造函数创建日期对象 Date date2 = date1; // 用拷贝构造函数创建一个新的Date对象,内容和date1一样 // 打印两个日期对象 date1.Print(); // 输出:2024/10/21 date2.Print(); // 输出:2024/10/21 return 0; }
这里,拷贝构造函数Date(const Date& d)
通过已有的对象d
来初始化新的对象d2
。
4.2 拷贝构造函数的特点
1.构造函数重载:拷贝构造函数是构造函数的一种重载。
2.参数要求:第一个参数必须是类类型对象的引用,不能用传值方式,否则会引发无限递归。可以有多个参数,但第一个必须是引用,后面的参数要有默认值。
3.调用场合:拷贝构造在传值传参和传值返回时都会被调用。
4.默认生成:如果没有显式定义,编译器会生成默认的拷贝构造,对内置类型执行浅拷贝,对自定义类型调用其拷贝构造。
5.使用场景:
- 纯内置类型成员时,默认拷贝构造即可。
- 有指向资源的成员时,需要自定义深拷贝。
- 含自定义类型成员时,编译器自动调用成员的拷贝构造。
6.实现建议:如果类有析构函数处理资源,通常也需要自己写拷贝构造。
7.传值返回与引用:
- 传值返回会调用拷贝构造。
- 传引用返回不会拷贝,但要确保返回对象在函数结束后仍存在。
编辑
4.3 拷贝构造函数的调用时机
拷贝构造函数通常在以下几种情况下调用:
- 对象按值传递时:
void Func(Date d) { d.Print(); } Date d1(2024, 5, 12); Func(d1); // 调用拷贝构造函数
- 在将对象
d1
传递给函数Func
时,d1
按值传递,因此会调用拷贝构造函数。 - 对象按值返回时:
Date CreateDate() { Date d(2024, 5, 12); return d; // 返回时调用拷贝构造函数 }
- 在函数返回对象时,会调用拷贝构造函数。
- 用已有对象初始化新对象时:
Date d1(2024, 5, 12); Date d2 = d1; // 调用拷贝构造函数
4.4 浅拷贝与深拷贝
- 浅拷贝:复制对象时,只复制指针的地址,新旧对象共享同一块内存。如果一个对象释放了这块内存,另一个对象就会出问题。
- 深拷贝:为新对象分配独立的内存,并复制原对象的数据。这样新旧对象各自有自己的内存,不会互相影响。
示例代码:实现深拷贝
以下是一个 Stack
类的示例,实现了深拷贝:
#include <iostream> #include <cstring> // 用于memcpy using namespace std; class Stack { public: // 构造函数,初始化栈 Stack(int n = 4) { _array = new int[n]; // 分配内存 _capacity = n; _top = 0; } // 拷贝构造函数,实现深拷贝 Stack(const Stack& other) { _array = new int[other._capacity]; // 分配新内存 _capacity = other._capacity; _top = other._top; memcpy(_array, other._array, sizeof(int) * _top); // 复制数据 } // 析构函数,释放内存 ~Stack() { delete[] _array; } private: int* _array; // 指向栈的数组 size_t _capacity; // 栈的容量 size_t _top; // 栈顶位置 };
代码要点
构造函数
Stack(int n = 4)
:
- 初始化一个栈对象,默认容量为4。使用
new
分配内存来存放整数数组,_capacity
用来存储栈的容量,_top
表示栈顶的位置(初始为0)。拷贝构造函数
Stack(const Stack& other)
:
- 深拷贝实现:当用一个已有的
Stack
对象创建新的Stack
对象时,这个构造函数会被调用。首先,为新对象分配一块和原对象_capacity
大小相同的内存。然后,将原对象的_capacity
和_top
的值复制给新对象。使用memcpy
函数,将原对象_array
中的数据复制到新对象的_array
中。这一步是深拷贝的关键,因为它确保了新对象和原对象有独立的内存空间。析构函数
~Stack()
:
- 释放分配的内存,防止内存泄漏。对于每个
Stack
对象,析构函数在对象生命周期结束时自动调用。为什么要用深拷贝?
当类中包含指针成员(如动态分配的内存)时,必须使用深拷贝,否则会出现多个对象共享同一块内存的情况。这可能导致程序出错或崩溃,特别是在析构时释放内存时。如果类只包含内置类型成员(如
int
、double
),那么默认的浅拷贝就足够了。
五、赋值运算符重载
5.1 运算符重载
C++支持运算符重载,使得自定义类型可以像内置类型一样使用运算符。例如,可以为自定义类重载+
、-
、=
等运算符,使这些类对象能与内置类型有类似的操作体验。运算符重载的目的是提高代码的可读性和简洁性,让代码更自然地表达程序的意图。
5.2 赋值运算符重载
默认情况下,C++对对象进行赋值时,编译器会执行“浅拷贝”,即按成员逐个复制。这在类中仅包含内置类型成员时没问题,但如果类中有指针成员,浅拷贝会导致两个对象共享同一块内存资源,可能会引发内存管理问题,例如重复释放同一块内存。因此,针对有动态内存分配的类,我们需要重载赋值运算符,以实现“深拷贝”。
赋值运算符重载的实现示例
#include <cstring> // 为了使用memcpy函数 class Stack { public: // 构造函数,初始化栈,默认容量为4 Stack(int n = 4) { _array = new int[n]; // 为栈分配内存 _capacity = n; // 设置栈的容量 _top = 0; // 初始化栈顶位置为0,表示栈为空 } // 赋值运算符重载,用于将一个已有的Stack对象赋值给另一个Stack对象 Stack& operator=(const Stack& other) { if (this != &other) { // 检查是否自赋值(避免自己给自己赋值) delete[] _array; // 释放已有的内存资源,防止内存泄漏 _array = new int[other._capacity]; // 分配新的内存空间 _capacity = other._capacity; // 复制原对象的容量 _top = other._top; // 复制原对象的栈顶位置 memcpy(_array, other._array, sizeof(int) * _top); // 复制原对象的数据 } return *this; // 返回当前对象的引用,以支持链式赋值 } // 析构函数,释放栈的内存资源 ~Stack() { delete[] _array; // 释放分配的内存,防止内存泄漏 } private: int* _array; // 动态数组,用于存储栈的元素 size_t _capacity; // 栈的最大容量 size_t _top; // 当前栈顶位置(表示栈中的元素个数) };
在这个示例中,重载了赋值运算符以确保在赋值时正确处理动态内存,并避免内存泄漏或重复释放的错误。自赋值检查 (if (this != &other)
) 可以避免在赋值给自己时发生内存问题。
5.3 日期类实现
Date.h
#pragma once #include<iostream> #include<stdbool.h> #include<assert.h> using namespace std; class Date { // 友元声明,使得非成员函数可以访问私有成员 friend ostream& operator<<(ostream& out, const Date& d); // 重载输出运算符 << friend istream& operator>>(istream& in, Date& d); // 重载输入运算符 >> public: // 构造函数,提供默认参数用于初始化年、月、日 Date(int year = 2000, int month = 1, int day = 1); // 构造函数声明,默认值在这里给出 // 打印日期 void Print(); // 检查日期是否合法 bool CheckDate() { if (_month < 1 || _month > 12 || _day < 1 || _day > GetMonthDay(_year, _month)) { return false; // 如果月份或日期超出合理范围,返回false } else { return true; // 日期有效,返回true } } // 获取指定月份的天数 int GetMonthDay(int year, int month) { // 定义每个月份的天数,数组下标从1开始,0元素为-1占位 static int monthDayArray[13] = { -1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; // 判断是否为闰年,且月份为2月,如果是,返回29天 if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0))) { return 29; } return monthDayArray[month]; // 否则返回对应月份的天数 } // 比较运算符重载,比较日期大小 bool operator<(const Date& d); bool operator>(const Date& d); bool operator<=(const Date& d); bool operator>=(const Date& d); bool operator==(const Date& d); bool operator!=(const Date& d); // 日期增加指定天数,影响当前对象 Date& operator+=(int day); // 日期增加指定天数,返回新的日期对象 Date operator+(int day); // 日期减少指定天数,影响当前对象 Date& operator-=(int day); // 日期减少指定天数,返回新的日期对象 Date operator-(int day); // 前置自增运算符,增加一天,返回增加后的引用 Date& operator++(); // 后置自增运算符,增加一天,返回增加前的对象(使用int区分后置) Date operator++(int); // 前置自减运算符,减少一天,返回减少后的引用 Date& operator--(); // 后置自减运算符,减少一天,返回减少前的对象(使用int区分后置) Date operator--(int); // 计算两个日期之间的差距,返回天数 int operator-(const Date& d); private: int _year; // 年 int _month; // 月 int _day; // 日 }; // 重载输出运算符,用于输出日期对象 ostream& operator<<(ostream& out, const Date& d); // 重载输入运算符,用于输入日期对象 istream& operator>>(istream& in, Date& d);
Date.cpp
#define _CRT_SECURE_NO_WARNINGS #include "Date.h" // 构造函数 - 初始化年、月、日 Date::Date(int year, int month, int day) { _year = year; _month = month; _day = day; // 检查日期是否有效,如果无效则输出提示信息 if (!CheckDate()) { cout << "日期非法->"; cout << *this; // 输出当前日期对象 } } // 打印日期 void Date::Print() { cout << _year << "/" << _month << "/" << _day << endl; } // 重载小于运算符 - 比较日期大小 bool Date::operator<(const Date& d) { if (_year < d._year) { return true; // 当前年份小于比较对象 } else if (_year == d._year && _month < d._month) { return true; // 年相等,当前月份小于比较对象 } else if (_year == d._year && _month == d._month && _day < d._day) { return true; // 年月相等,当前日期小于比较对象 } return false; // 不满足以上条件,返回false } // 重载大于运算符 - 实现为取小于运算符的否定 bool Date::operator>(const Date& d) { return !(*this < d); } // 重载小于等于运算符 bool Date::operator<=(const Date& d) { return *this < d || *this == d; // 小于或等于则返回true } // 重载大于等于运算符 bool Date::operator>=(const Date& d) { return !(*this < d); // 取小于的反面 } // 重载等于运算符 - 检查年、月、日是否均相等 bool Date::operator==(const Date& d) { return _year == d._year && _month == d._month && _day == d._day; } // 重载不等于运算符 bool Date::operator!=(const Date& d) { return !(*this == d); } // 重载+=运算符 - 日期加上指定天数 Date& Date::operator+=(int day) { if (day < 0) { return *this -= -day; // 如果天数为负数,则调用-= } _day += day; // 增加天数 while (_day > GetMonthDay(_year, _month)) { _day -= GetMonthDay(_year, _month); // 减去当前月份的天数 ++_month; // 进入下一个月 if (_month == 13) { _year++; // 如果月份是13,进入下一年 _month = 1; // 月份重置为1 } } return *this; // 返回当前对象的引用 } // 重载+运算符 - 返回新的日期对象 Date Date::operator+(int day) { Date tmp(*this); // 创建副本,避免修改当前对象 tmp += day; // 使用+=实现 return tmp; } // 重载-=运算符 - 日期减去指定天数 Date& Date::operator-=(int day) { if (day < 0) { return *this += -day; // 如果天数为负数,则调用+= } _day -= day; // 减去天数 while (_day <= 0) { --_month; // 进入上一个月 if (_month == 0) { _year--; // 如果月份是0,进入上一年 _month = 12; // 月份设为12 } _day += GetMonthDay(_year, _month); // 补足当前月的天数 } return *this; // 返回当前对象的引用 } // 重载-运算符 - 返回新的日期对象,日期减去指定天数 Date Date::operator-(int day) { Date tmp(*this); // 创建副本 tmp -= day; // 使用-=实现 return tmp; } // 重载前置++运算符 - 日期加1天,返回自身 Date& Date::operator++() { *this += 1; return *this; } // 重载后置++运算符 - 日期加1天,返回旧值 Date Date::operator++(int) { Date tmp(*this); // 创建副本,保存当前状态 *this += 1; // 增加1天 return tmp; // 返回旧的对象 } // 重载前置--运算符 - 日期减1天,返回自身 Date& Date::operator--() { *this -= 1; return *this; } // 重载后置--运算符 - 日期减1天,返回旧值 Date Date::operator--(int) { Date tmp(*this); // 创建副本,保存当前状态 *this -= 1; // 减少1天 return tmp; // 返回旧的对象 } // 重载-运算符 - 返回两个日期之间的天数差 int Date::operator-(const Date& d) { Date max = *this; // 假定当前日期为较大者 Date min = d; int flag = 1; // 标志符号,表示方向 if (*this < d) { max = d; min = *this; flag = -1; // 如果当前日期小于比较日期,调整符号 } int n = 0; // 用于计数两个日期之间的天数 while (min != max) { ++min; // 将较小的日期逐步增加 ++n; // 计数增加 } return n * flag; // 返回带有方向的天数差 } // 重载输出运算符 - 输出日期信息 ostream& operator<<(ostream& out, const Date& d) { out << d._year << "年" << d._month << "月" << d._day << "日" << endl; return out; } // 重载输入运算符 - 输入日期信息 istream& operator>>(istream& in, Date& d) { while (1) { cout << "请依次输入年月日:>"; in >> d._year >> d._month >> d._day; if (d.CheckDate()) { break; // 如果日期有效,退出循环 } else { cout << "日期非法,请重新输入"; // 如果无效,要求重新输入 } } return in; }
test.cpp
#define _CRT_SECURE_NO_WARNINGS #include<iostream> #include"Date.h" using namespace std; int main() { Date d1(2024, 10, 21); d1.Print(); //Date d2 = d1 + (-100); //d2.Print(); //d1 += -100; //d1.Print(); //++d1; //d1.Print(); //d1++; //d1.Print(); Date d2(2024, 12, 16); //cout << d1 - d2 << endl; cout << d2 - d1 << endl; cout << d1; operator<<(cout, d1); cin >> d1>>d2; cout << d1 << d2 << endl; return 0; }
六、取地址运算符重载
6.1 const
成员函数
const
成员函数用于保证函数内部不能修改对象成员变量。例如:
class Date { public: Date(int year = 1, int month = 1, int day = 1) : _year(year), _month(month), _day(day) {} void Print() const { // 该函数不能修改对象的状态 cout << _year << "-" << _month << "-" << _day << endl; } private: int _year; int _month; int _day; };
6.2 取地址运算符重载
可以重载取地址运算符 &
来控制取对象地址的行为,例如不希望外部获取对象的地址:
class Date { public: Date* operator&() { return nullptr; // 返回空指针,隐藏真实地址 } const Date* operator&() const { return nullptr; } private: int _year; int _month; int _day; };
通过这篇博客,我们讨论了C++类的默认成员函数、构造函数、析构函数、拷贝构造函数、赋值运算符重载和取地址运算符的重载。理解这些概念是学习C++面向对象编程的基础,也是管理内存、资源安全的关键。
如果有任何疑问,欢迎在评论区留言交流!