专栏导读
🍁作者简介:余悸,在读本科生一枚,致力于 C++方向学习。
🍁收录于 C++专栏,本专栏主要内容为 C++初阶、 C++ 进阶、STL 详解等,持续更新中!
🍁相关专栏推荐: C语言初阶 、C语言进阶 、数据结构与算法 ( C语言描述)、 C++、 Linux、 Mysql
本文介绍C++的六大默认成员函数构造函数、析构函数、拷贝构造和赋值运算符重载等。掌握类和对象的各项操作,可以让程序更加高效和灵活,提高代码的可维护性和可读性。
文章目录
- 第3章、类和对象(中)
- 六大默认成员函数
- 构造函数
- 构造函数的函数名和返回值
- 构造函数的调用
- 构造函数的重载
- 系统生成的默认构造函数
- 系统生成的默认构造函数的作用
- 在内置类型的成员变量的声明中给缺省值
- 初始化列表初始化
- 单参数构造(C++98)、多参数构造(C++11)
- 析构函数
- 析构函数的函数名、参数和返回值
- 析构函数的特点
- 编译器生成的默认析构函数
- 拷贝构造
- 拷贝构造函数的参数
- 编译器默认生成的拷贝构造函数
- 拷贝构造函数的深浅拷贝
- 拷贝构造函数调用场景
- 赋值运算符重载
- 赋值运算符重载的注意事项
- 取地址操作符重载和const取地址操作符重载
第3章、类和对象(中)
六大默认成员函数
构造函数
构造函数主要完成初始化对象,类似C语言中的Init(初始化)函数。构造函数是用于创建对象时进行初始化的特殊成员函数。在C++中,每个对象都有一个与之相关联的构造函数,它负责初始化对象的状态。构造函数在对象实例化时自动被调用,可以对对象的成员变量进行初始化,从而确保对象在实例化后处于有效的状态。
如果没有定义构造函数,C++会提供一个默认构造函数,但它只能进行默认的初始化,可能不能满足实际需要。构造函数的设计可以避免在使用对象之前忘记进行初始化操作的问题,确保了程序的正确性和可靠性。同时,它还允许在对象创建时执行必要的操作,比如分配动态存储空间或打开文件等等。总之,构造函数是C++面向对象编程的重要特性,它可以保证对象被正确地初始化,并且在对象创建时执行必要的操作。
构造函数的函数名和返回值
在C++中,构造函数的函数名必须与类名完全一致。构造函数无需明确返回值类型,因为它们实际上没有返回值,也不需要使用void表示无返回值,因为它们的作用是初始化对象而不是返回值。
构造函数的调用
对象实例化时,编译器自动调用相应的构造函数。
以下情况会自动调用构造函数:
- 创建类的对象时,如 Person p(“Alice”, 20);
- 创建类的对象数组时,如 Person people[3];这将创建包含3个Person对象的数组,并自动调用默认构造函数。
- 通过new运算符动态分配内存创建对象时,如 Person *p = new Person(“Alice”, 20);
- 当对象作为参数传递给函数时,如 void doSomething(Person p) { … },这会通过调用复制构造函数创建一个副本。
- 当使用类模板实例化对象时,如 vector v;,即使没有明确声明构造函数,C++编译器也会使用默认构造函数进行初始化。
需要注意的是,构造函数只会在对象创建时自动调用一次。如果需要重新初始化对象,可以在对象已经被创建之后,在代码中调用相应的构造函数进行重新初始化。但是,这是一种不常用的做法,应该尽量避免。
构造函数的重载
和其他函数一样,构造函数也可以进行重载。重载构造函数允许创建对象时使用不同的参数进行初始化。但我们需要注意的是:
虽然全缺省和无参的构造函数构成重载,但在调用时存在二义性,是不正确的重载。
例如,可以定义一个带有不同参数的默认构造函数和一个带有参数的构造函数来重载一个类的构造函数。下面的例子展示了一个重载构造函数的例子:
class Date {
int year;
int month;
int day;
public:
// 默认构造函数
Date() {
year = 1900;
month = 1;
day = 1;
}
// 带一个参数的构造函数(仅年份)
Date(int _year) {
year = _year;
month = 1;
day = 1;
}
// 带三个参数的构造函数(年月日)
Date(int _year, int _month, int _day)
{
year = _year;
month = _month;
day = _day;
}
void print()
{
cout << year << "-" << month << "-" << day << endl;
}
};
这个例子中,我们定义了一个默认构造函数、一个带一个参数的构造函数和一个带三个参数的构造函数。
现在,我们可以使用三种不同的方式来构造一个Date对象:
Date d1; // 调用默认构造函数,日期为1900-01-01
Date d2(2022); // 调用带一个参数的构造函数,日期为2022-01-01
Date d3(2023, 5, 10); // 调用带三个参数的构造函数,日期为2023-05-10
当我们构造Date对象时,编译器会根据传递的参数来自动调用对应的构造函数,从而正确初始化对象。
重载构造函数提供了更大的灵活性,允许以不同的方式初始化同一类的对象。
系统生成的默认构造函数
不传参就可以调用的构造函数,就是默认构造函数,有 编译器默认生成的、全缺省的和显式)无参的构造函数
如果在类中没有显式定义构造函数,则编译器会为该类生成一个默认的构造函数。
需要注意的是,如果我们定义了一个带参数的构造函数,但没有定义默认构造函数,则编译器不会生成默认构造函数,这意味着如果尝试创建没有传递参数的对象,将会产生编译错误。推荐在定义类时显式提供一个默认构造函数或全缺省构造函数,以避免这种问题。
系统生成的默认构造函数的作用
系统生成的默认构造函数的作用是保证对象的数据成员都被正确地初始化,从而使得对象的状态处于有效且定义良好的状态。
如果一个类没有自定义的构造函数,编译器会自动生成一个默认构造函数,并对其中的数据成员进行初始化。默认情况下,基本数据类型不做处理(随机值)【有些编译器也会处理,是编译器的个性化行为,标准未规定要处理】,而自定义类型的默认值将调用其默认构造函数进行初始化。
在内置类型的成员变量的声明中给缺省值
C++11中针对内置类型不处理初始化为随机值的问题,打了补丁可以在内置类型成员变量在类中声明可以给缺省值,甚至可以给动态开辟的缺省值,缺点是不能判断空间是否开辟成功。注意这里的默认值是缺省值,不是初始化。初始化是要等对象调用时才叫初始化。
class Date
{
private:
int _year=1;
int _month=2;
int _day=3;
int* arr = (int*)malloc(sizeof(int) * 5);
};
这个特性只能用于解决默认构造函数初始化为随机值的问题。这个特性不能解决对象的多种初始化方式(这也是构造函数支持重载的原因),构造函数该写还是得自己写。
初始化列表初始化
在C++中,初始化列表用于在创建对象时初始化其成员变量。它是在构造函数的函数体执行之前执行的。使用初始化列表比在构造函数中对成员变量进行赋值更高效,因为它避免了先构造再赋值的操作。
不使用初始化列表来初始化成员变量,则会使用默认构造函数初始化,这可能会导致一些不必要的开销或行为。
注意:
-
对象的每个成员变量是在初始化列表部分进行初始化,而函数体内的行为是对成员函数赋值。
-
如果没有在初始化列表里显示初始化某个成员变量,对于内置类型,有缺省值用缺省值,无缺省值初始化为随机值;对于自定义类型将会调用它的默认构造函数,没有找到默认构造就会报错。
-
引用、const、无默认构造函数的自定义类型必须通过初始化列表初始化。
-
成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关。
总之,C++中的初始化列表是一个高效的初始化对象成员的方式,尽量使用使用初始化列表进行初始化,在类中尽量提供默认构造函数(最好是全缺省的默认构造函数)
单参数构造(C++98)、多参数构造(C++11)
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;
};
int main()
{
//单参数的构造,构造+拷贝,编译器直接优化为构造C++98
Date d1 = 1998;
//临时对象具有常性 调用构造
const Date& d2 = 1999;
//多参数的构造C++11
Date d3 = { 2022,10,16 };
return 0;
}
在构造时,支持等号加参数的构造形式,实际上发生的是隐式类型转换。1998由int类型隐式类型转换为Date型的临时对象(用2022构造了一个临时对象),该临时对象再将它的值拷贝构造给d1。但是编译器会将这一行为优化为直接构造。
1999会隐式类型转换为Date类型的临时对象,具有常属性。d2是这个临时对象的引用,所以需要加上const。这里只发生一次构造,涉及临时对象的引用,所以编译器并没有优化空间。使用explicit关键字修饰构造函数,会不会再发生(禁止)隐式类型转换。
析构函数
析构函数是一种可以用于清理工作的特殊成员函数,它在对象被销毁时自动调用,可以用于释放内存或执行一些其他清理操作。需要注意在析构函数中释放动态分配的内存,以避免内存泄漏。
析构函数的函数名、参数和返回值
- 析构函数的函数名在C++中是以~字符开头的类名,没有参数,也没有返回类型或值。
- 析构函数没有参数,因为它的目的是在对象被销毁时清理可能已分配的资源,这些资源通常是在构造函数中分配的。
- 在析构函数中,不需要返回值。这是因为析构函数的唯一作用就是在对象被销毁时执行清理工作,不需要返回任何东西。
- 创建对象时,构造函数被调用,当对象被销毁(生命周期结束)时,析构函数被调用。
析构函数的特点
一个类只能有一个析构函数(不能重载)。若未显式定义,系统会自动生成默认的析构函数。
-
系统自动调用:析构函数是在对象生命周期结束时系统自动调用的,且不能手动调用。当对象超出其作用域、被删除、释放内存或程序终止时,析构函数将自动执行。
-
没有参数:析构函数不能有任何参数,因为系统在调用析构函数时不会传递任何参数给它。
-
无返回值:析构函数没有返回值,因为它们是类型为void的函数。
-
逆序调用:当一个对象被销毁时,析构函数将按照与构造函数相反的顺序调用。即后被构造的对象将先被销毁,先被构造的对象将后被销毁。
总之,析构函数是在对象生命周期结束时自动执行的,并负责清理对象使用的所有资源。析构函数通常不能手动调用,且没有参数和返回值。它们是与构造函数相似的特殊函数,被用来确保对对象的正确处理。
编译器生成的默认析构函数
如果一个类没有显式定义析构函数,编译器会生成默认析构函数。默认析构函数的作用是释放对象所有成员变量分配的资源,对自定义类型调用他的析构函数。对于内置类型,没有需要处理资源(不做处理)。
拷贝构造
拷贝构造函数(copy constructor)是一种特殊的构造函数,用于以一个对象作为模板来创建一个新的对象。当对象按值传递或以值返回时,拷贝构造函数会自动调用。建议加const,即Date (const Date& d);
拷贝构造函数的函数名和构造函数相同,无返回值,在参数上和构造函数构成重载。
拷贝构造函数的参数
拷贝构造函数采用一个参数,该参数是该类的一个常引用。在函数体中,通过将新对象的各个值设置为原始对象的各个值来创建新对象。因此,拷贝构造函数的目的是初始化新对象,使其与原始对象相等。
如果使用传值传参的方式进行拷贝构造,在传值的过程中实参需要拷贝一份数据给形参,这个过程需要调用拷贝构造。形成层层传值引发对象的拷贝的递归(无穷递归)调用。
编译器默认生成的拷贝构造函数
若未显式定义,C++编译器在生成默认的拷贝构造函数,会自动执行浅拷贝(Shallow Copy)操作,,默认的构造函数对于内置类型按照字节拷贝。对于自定义类型则调用它的拷贝构造函数。这意味着,拷贝构造函数仅仅是对成员变量逐一进行赋值。如果类中有指针类型的成员变量,那么会导致浅拷贝的问题,即两个指针会指向同一块内存地址。
这通常会导致许多问题,包括悬挂指针和内存泄漏等问题。因此,拷贝构造函数应该进行深拷贝(Deep Copy)操作,即为新对象分配一块新的内存地址,并将指针指向这块内存地址。这样,就可以避免悬挂指针和内存泄漏等问题。
如果类中存在指针类型的成员变量,那么需要手动编写拷贝构造函数,以实现深拷贝操作。
拷贝构造函数的深浅拷贝
通过默认的拷贝构造函数构造的对象,按字节完成拷贝。这种拷贝被称为浅拷贝(值拷贝)。
int main()
{
Date d1(2022,9,24);
Date d2(d1);
return 0;
}
对于内置类型,使用浅拷贝即可,系统默认生成的就可以做到,所以我们不用动手写拷贝构造函数。注意这里有d1,d2两个对象,当main函数生命周期结束时,这两个对象均会发生一次析构,d2先析构,d1后析构。(后定义的先销毁,类似栈的后进先出原则)
但是浅拷贝对于占用“资源”的成员变量时(例如成员变量中有动态开辟或fopen的资源),指针虽然复制了,但是所指向的内容却没有复制,析构时存在同一块空间被释放两次的问题。需要进行深拷贝。深拷贝的拷贝构造函数必须自己手动实现。
class Stack
{
public:
Stack(int capacity=100)//构造函数
{
_capacity = capacity;
_top = 0;
_arr = (int*)malloc(sizeof(int) * 5);
if (_arr == nullptr)
{
perror("malloc fail");
exit(-1);
}
}
//Stack(const Stack& st)//浅拷贝,栈这个类不能用浅拷贝
//{
// _capacity = st._capacity;
// _top = st._top;
// _arr = st._arr;
//}
Stack(const Stack& st)//深拷贝
{
_capacity = st._capacity;
_top = st._top;
_arr = (int*)malloc(sizeof(int) * st._top);
if (_arr == nullptr)
{
perror("malloc fail");
exit(-1);
}
memcpy(_arr, st._arr,sizeof(int)*st._top);
}
~Stack()//析构函数
{
_capacity = 0;
_top = 0;
free(_arr);
_arr = nullptr;
}
private:
int* _arr;
int _top;
int _capacity;
};
栈这个类因为成员变量中有动态开辟的空间,所以要用深拷贝。
拷贝构造函数调用场景
-
使用已存在的对象创建新对象
-
函数参数类型为类的类型对象(传值调用,实参拷贝给形参)
-
函数返回值类型为类的类型对象(传值返回)
赋值运算符重载
在C++中,赋值运算符是默认提供的(默认成员函数),但是对于自定义的类或结构体等复杂类型,需要自行重载赋值运算符。
赋值运算符的重载函数应该返回一个引用类型(对标原生赋值操作符 - 连续赋值操作),并以class类型或struct类型的对象作为参数,如下所示:
class MyClass {
public:
MyClass& operator = (const MyClass& other) {
// 重载代码
return *this;
}
};
在重载函数体内部,需要将右侧的对象赋值给左侧的对象,通常使用深拷贝的方式。例如:
class MyClass {
public:
int* data;
int size;
MyClass& operator = (const MyClass& other) {
if (this != &other) {
// 释放已有的资源
delete[] data;
// 深拷贝数据
size = other.size;
data = new int[size];
for (int i = 0; i < size; i++) {
data[i] = other.data[i];
}
}
return *this;
}
};
上面实现了一个自定义的MyClass类,其中包含一个指向int数组的指针和一个表示数组大小的int变量。在赋值运算符重载函数中,使用了深拷贝的方式来进行对象的赋值,以保证数据的安全性和正确性。
赋值运算符重载的注意事项
-
参数是const T&,传引用可以减少一次拷贝构造。
-
返回值是*this的引用,引用返回,减少一次拷贝构造,有返回值是为了支持函数的链式访问。
-
务必检查下是否支持自己给自己赋值。
-
赋值运算符重载必须是类中的默认成员函数,不能写在全局。
-
系统默认生成的赋值运算符重载会完成值拷贝。
取地址操作符重载和const取地址操作符重载
-
取地址操作符是&,而const取地址操作符则是const &。这两个操作符都可以被重载。
-
取地址操作符重载用于在自定义类型中返回该类型的地址,通常用于对象指针的初始化。
-
const取地址操作符则用于在常量类型或常量对象上获取其地址(禁止对该地址写入操作),通常可用于实现只读访问模式。在重载中,需要将函数声明为const并添加&符号。
Date* operator&()
{
return this;
//return nullptr;
}
const Date* operator&()const
{
return this;
//return nullptr;
}
这两个默认成员函数一般情况下不用自己显式定义,除非想让别人通过取地址操作符获取到特定值(自己在重载函数内部写)或屏蔽类地址。
本节结束,希望可以帮助到读者,如果对你有帮助请关注、点赞、评论支持一下。