文章目录
- 前言
- 类的6个默认成员函数
- 1.构造函数
- 1.1 构造函数特性
- 1.1.1 函数名与类名相同
- 1.1.2 无返回值
- 1.1.3 对象实例化时编译器自动调用对应的构造函数
- 1.1.4 构造函数可以重载
- 1.1.5 默认构造只能有一个
- 1.1.6 默认构造的必要性
- 1.2 构造函数的初始化列表
- 2.析构函数
- 2.1 析构函数特性
- 2.1.1 函数名与类名相同但是在类名前加上字符 “~”
- 2.1.2 无参数无返回值类型
- 2.1.3 对象生命周期结束时编译器自动调用析构函数
- 2.1.4 析构函数只能有一个,析构函数不能重载
- 2.1.5 默认析构函数的必要性
- 构造和析构的总结
- 3.拷贝构造函数
- 3.1 拷贝构造特性
- 3.1.1 拷贝构造函数的参数只有一个且必须是同类对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用
- 3.1.2 拷贝构造函数是构造函数的一个重载形式
- 3.1.3 默认拷贝构造的必要性
- 3.1.4 默认拷贝构造的行为
- 3.2 深拷贝
- 4.赋值运算符重载
- 4.1 运算符重载
- 4.1.1 运算符重载的使用
- 4.2 赋值重载的特性
- 4.2.1 赋值运算符重载细节
- 4.2.2 赋值运算符只能重载成类的成员函数不能重载成全局函数
- 4.2.3 默认赋值重载函数的必要性
- 4.2.4 默认赋值重载函数的行为
- 4.3 深拷贝
- 5.取地址重载(普通对象)
- 5.1取地址重载的特性
- 5.1.1 默认取地址重载的必要性
- 5.1.2 默认取地址重载的行为
- 6.取地址重载(const对象)
- 6.1 const修饰的成员函数
- 全文重点提炼
前言
通过00【C++ 入门基础】前言得知,C++是为了解决C语言在面对大型项目的局限而诞生:
C语言面对的现实工程问题(复杂性、可维护性、可扩展性、安全性)
C语言面临的具体问题:
1.struct 数据公开暴露,函数数据分离,逻辑碎片化。(复杂性、安全性)
2.修改数据结构,如 struct 新增字段,可能导致大量相关函数需要修改。(可维护性)
3.添加新功能常需修改现有函数或结构体,易引入错误。(可扩展性)
4.资源(内存、文件句柄)需手动管理,易泄漏或重复释放。(安全性)
前文《06【C++ 初阶】类和对象(上篇) — 初步理解/使用类》中C++的类通过封装和抽象,使我们的类,更加贴近现实,独立性更强,解决了:
- struct 数据公开暴露,函数数据分离(复杂性、安全性)。
- 面向过程耦合高(可维护性)
也就是我们上面所说的C语言面临的第1和第2点问题,接下来的内容,我们要深化类的抽象,使其更加贴近我们的内置类型,使用难度再降低,同时解决资源管理的安全性问题(通过RAII),即进一步解决C语言的复杂性和安全性问题:
“1. struct 数据公开暴露,函数数据分离(复杂性、安全性)。”
“4. 资源(内存、文件句柄)需手动管理,易泄漏或重复释放。(安全性)”
我们的代码中,经常会出现内存泄漏的情况(比如忘记初始化,忘记销毁等)。
我们C++类的解决方法,就是让初始化和销毁自动进行(RAII),即规定特殊的成员函数—构造和析构,它们在类对象的创建和销毁处自动调用,如果我们将资源的初始化和销毁对应的写在构造和析构中,就可以保证资源的初始化和销毁自动进行。
类的6个默认成员函数
在上一篇中,我们提了一嘴,如果一个类中没有成员,那么它称为空类,如class Date {};
但是它真的空吗?-- 如空。
其实任何类在什么都不写时,编译器会自动为类生成以下6个默认成员函数。(这里我们先不讨论C++11)
我们的六大成员函数的功能,是:
“生命周期管理(构造/析构)、对象复制控制(拷贝构造/赋值)、资源转移优化(移动构造/赋值)。”
六大成员函数共同构建了C++对象的确定性生命周期框架——从诞生(构造)到复制传递(拷贝/移动),最终到消亡(析构),确保所有对象行为可预测、资源可管控,这是C++高效性与安全性的基石。
所以如果我们的用户没有显式实现,但是代码又有使用的话,我们的编译器就会在需要时自动添加这些函数的默认形式(即默认成员函数),为的是保证基本的构造确定性原则,即确保所有的对象行为时可预测的。
1.构造函数
是一个特殊的成员函数,由编译器自动在对应的位置调用,用来初始化对象的内存空间,初始化类中资源的函数,其名字与类名相同,创建类对象时像给函数传参一样去调用,编译器会遵循逻辑自动的调用构造函数,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。
了解编译器行为可以让我们更加理解对象的结构:(看完特性之后回顾更佳)
编译器在编译时根据内存对齐规则计算类的大小和成员偏移量,并生成汇编代码用于运行时在内存上开辟对象空间(如堆或栈分配)。随后,在构造函数调用时(通过汇编call函数地址),对象的地址作为函数参数,和成员的偏移量一起被用于类内的成员变量寻址。初始化列表中对特定成员的初始化操作会和成员变量的地址一起,被编译成汇编指令,嵌入到构造函数的代码体中,在函数执行时完成成员初始化。然而,这仅保证被初始化列表覆盖的成员被正确设置;未初始化的成员可能保留垃圾值。所以只要调用了构造函数,那么就会对对象的内存区域做初始化,就实现了一次对象的内存开辟和成员初始化。
此外,对于有基类或虚函数的类,编译器还会插入基类构造函数调用和虚表设置代码。
1.1 构造函数特性
1.1.1 函数名与类名相同
class Date
{
public:Date(){} //最简单的构造函数
};
1.1.2 无返回值
我们的构造函数不需要返回值。
1.1.3 对象实例化时编译器自动调用对应的构造函数
class date
{
public:// 1.无参构造函数(默认构造函数)date(){}private:int _year;int _month;int _day;
};
void testdate()
{date d1; // 当我们用一个类去实例化对象时,它会自动调用默认构造函数(无参构造)// 注意:如果通过无参构造函数创建对象时,对象后面不要跟括号!否则就成了函数声明// 以下代码的函数:声明了d3函数,该函数无参,返回一个日期类型的对象//date d3(); // warning c4930: “date d3(void)”: 未调用原型函数(是否是有意用变量定义的?)//d3(); // 甚至可以定义完之后当成函数去调用.
}
//对于d3函数的定义:
//Date d3()
//{
// Date da;
// return da;
//}int main()
{date d3();testdate();return 0;
}
1.1.4 构造函数可以重载
class Date
{
public:// 1.无参构造函数Date(){}// 2.带参构造函数Date(int year, int month, int day){_year = year;_month = month;_day = day;}
private:int _year;int _month;int _day;
};
void TestDate()
{Date d1; // 当我们用一个类去实例化对象时,它会自动调用默认构造函数(无参构造)Date d2(2015, 1, 1); // 当我们用一个类去实例化对象时,我们想调用函数一样传参,会显示的调用对应参数的构造函数(有参构造)// 以上调用的默认构造和我们实现的有参构造,其实是形成函数重载的,函数重载的条件: 相同作用域中相同函数名的函数的参数数量/类型/类型顺序不同.// 注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明// 以下代码的函数:声明了d3函数,该函数无参,返回一个日期类型的对象//date d3(); // warning c4930: “date d3(void)”: 未调用原型函数(是否是有意用变量定义的?)//d3(); // 甚至可以定义完之后当成函数去调用.
}
//对于d3函数的定义:
//Date d3()
//{
// Date da;
// return da;
//}int main()
{Date d3();TestDate();return 0;
}
1.1.5 默认构造只能有一个
因为无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。
所以如果我们定义了多个默认构造,那么就会编译报错。
class Date
{
public:// 1.无参构造函数Date(){_year = 1900;_month = 1;_day = 1;}// 2.带参构造函数Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}
private:int _year;int _month;int _day;
};void Test()
{Date d1; //“Date::Date”: 对重载函数的调用不明确
}
1.1.6 默认构造的必要性
如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数。 为的是保证构造确定性,保证类的行为可预测。
反之如果我们手动去实现了任意构造函数(默认构造或者普通带参的构造),那么编译器将不再自动生成默认构造。这又是为了保证我们的:程序员掌控,即提供给程序员最大的掌控力,如果我们手动的实现了(如构造函数),那么编译器将不再干预任何操作,它默认程序员会将一切处理好。
class Date
{
public:/*// 如果用户显式定义了构造函数,编译器将不再生成Date(int year, int month, int day){_year = year;_month = month;_day = day;}*/void Print(){cout << _year << "-" << _month << "-" << _day << endl;}
private:int _year;int _month;int _day;
};
int main()
{// 将Date类中构造函数屏蔽后,代码可以通过编译,因为编译器生成了一个无参的默认构造函数// 将Date类中构造函数放开,代码编译失败,因为一旦显式定义任何构造函数,编译器将不再生成// 无参构造函数,放开后报错:error C2512: “Date”: 没有合适的默认构造函数可用Date d1;return 0;
}
默认构造的必要性:
如果我们显示的实现了构造函数,编译器将不再给我们生成默认构造函数,但是我们还是避免不了还是有必须要使用默认构造的场景,所以如果我们自己实现了构造函数,那么大概率还要去重载一个默认构造函数。
注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。
必须要使用默认构造的场景(不可以显示传参的场景):
A.动态分配的自定义类的数组:
Date* arr = new Date[5]; // 数组每个元素都需默认构造,报错: 类 "Date" 不存在默认构造函数
B.容器类(STL)的隐式构造:
std::vector<Date> vec;
vec.resize(3); // 扩容时会自动给新对象初始化,所以 -> 需要默认构造 ,报错: “Date::Date”: 没有合适的默认构造函数可用
C.类对象作为成员变量:
class Time
{int time;
};
class Date
{
public:Date(int year, int month, int day){_year = year;_month = month;_day = day;}
private:Time _t;int _year;int _month;int _day;
};
int main()
{Date d;return 0;
}
我们知道:
- 编译器会在,类对象被创建空间的那一刻马上调用它的构造函数,为对象做初始化。
- 在定义类的时候,其实是在声明该类中的成员变量(包括对象),只有实例化对象时,创建好该类的空间了,我们才说是对该类中的成员的定义,而成员定义过后,就需要调用其默认构造函数去为其初始化。
- 构造函数是对类对象的初始化,其实就是对类对象中的成员变量做初始化。
那么其实当我们对Date类实例化的时候,编译器为我们Date类对象d创建好了空间,那么作为Date类中的成员Time类对象_t也就被顺便创建好空间了,然后我们要初始化类对象d,就要调用Date类的构造去初始化它的成员变量,那么也就要对它其中的成员变量_t做初始化,而对于一个类对象做初始化,就又要调用它的构造函数(也就是Time类的构造函数)。
当一个类包含另一个类作为成员变量时,我们称作为成员变量的那个类对象为成员对象,类的构造函数不会直接实现成员类对象的构造逻辑,而是通过调用成员对象的默认构造函数来完成对它的初始化。
那么如果我们的成员对象没有默认构造函数,即我们自己实现了成员对象的构造,但是又没有重载其默认构造的情况,就会报错:
class Hour
{
public:Hour(int _a){a = _a;}int a;int b;int c;
};class Date
{
public:Date(){ //“Hour”: 没有合适的默认构造函数可用}Hour _h;};
int main()
{Date d;return 0;
}
疑问1:
- 类的构造函数如何调用自己的成员对象的默认构造去为它初始化?难道用自己的构造去调用成员对象的构造吗?
- 类的构造函数对自己其他的内置类的成员变量是否有初始化?
- 如果对于内置类的成员变量也有初始化,那么类中所有成员的初始化是否有顺序?
- 为什么成员对象没有默认构造函数,却会在类的构造函数处报错?
答案都在构造函数的初始化列表中。
1.2 构造函数的初始化列表
初始化列表,是构造函数的一部分,是用来对类的所有成员变量初始化的,以确保类的所有成员变量在进入函数体时之前都已经被初始化了(构造确定性)。
初始化列表的行为:
-
类对于自己的成员类,通过初始化列表去调用成员对象的构造函数,让成员类初始化自己的变量,
因为我们的初始化列表也是类的构造函数的一部分,
所以说是类的构造函数调用了成员类的构造函数也没有错。 -
C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,
如:int/char…,自定义类型就是我们使用class/struct/union等自己定义的类型。
C++11之前类的初始化列表不会对内置类型的成员变量做初始化,
C++11 中针对内置类型成员不初始化的缺陷,打了补丁,
即:
内置类型成员变量在类中声明时可以给默认值,然后编译器会隐式的在初始化列表处用该值为内置类型也初始化。 -
初始化顺序是声明顺序,我们编译器在编译阶段会为每个类维护一个声明顺序表,
它提供了成员变量的初始化的顺序,而我们的初始化列表中提供了成员变量初始化的方法。
以上行为,无论是我们自己实现的构造函数,还是编译器自动生成的默认构造函数,编译器都会在初始化列表处自动的去做,如果没有对应的自动调用的条件,如类中有引用的成员,或者类的成员对象成员没有默认构造函数,都会报错,这么做是为了维持构造确定性,让所有变量在进入构造函数体之前,都经过初始化列表的强制初始化,防止函数体对没有初始化的变量操作。
初始化列表对于内置类型的成员变量:
class Time
{
public:Time(){cout << "Time()" << endl;_hour = 0;_minute = 0;_second = 0;}
private:int _hour;int _minute;int _second;
};
class Date
{
public:Date()//显示写的初始化列表是在这个位置,//不显示写它也会存在,编译器会自动在里面插入对应变量的初始化方法,对有缺省值的内置类型成员做初始化,也为有默认构造函数的自定义类成员做初始化.//自动生成://:_t()//,_year(1970)//,_month(1)//,_day(1){cout << "Date()" << endl;cout << _year << " " << _month << " " << _day;}
private:// 基本类型(内置类型)int _year = 1970;int _month = 1;int _day = 1;// 自定义类型Time _t;
};
int main()
{Date d; //输出: //Time()//Date()//1970 1 1return 0;
}
初始化列表,是可能会自动调用我们类中内置类的构造的,自动调用就需要默认构造,也是一种不可以传参的场景,又证明了我们默认构造函数的必要性。
注意:无论是实例化一个类对象的时候,其默认构造的自动调用;还是类构造函数的初始化列表对于它的对象成员的默认构造的自动调用,都是编译器的自动行为,都是编译器通过在AST中自动插入对应的隐式节点,并在最后生成汇编,去实现的。
编译器自动生成的默认构造和构造的初始化列表保证了:
- 所有自定义类成员在进入构造函数体前已调用其默认构造函数
- 所有内置类型成员完成默认初始化(即使是没有操作)
- 所有const/引用/无默认构造的成员获得有效初始状态(因为类中可能会有const/引用/无默认构造的成员变量)
总之,默认构造和我们初始化列表的配合,就是为了遵循C++的一个核心原则:构造确定性,即保证了所有类对象的初始化是可预测的。但程序员仍需警惕:内置类型的默认初始化不保证数据安全!
疑问1解答:
- 初始化列表,也是构造函数的一部分,所以说是我们类的构造函数去调用成员对象的默认构造也没错,只不过通常交给编译器去自动调用的。
- 无论是类中用户实现的构造函数还是编译器自动生成的默认构造函数,其对内置类型都有做初始化,只是并不对内置类执行操作罢了。
- 内置类成员和成员对象成员,都依据类中的声明顺序,再根据我们的初始化列表中的方法,去给成员变量初始化。
- 因为类构造函数的初始化列表处,要调用成员对象的默认构造,这是初始化列表和默认构造的无声默契,遵循了构造确定性原则。
2.析构函数
析构函数也是类的一个特殊的成员函数,也是会被编译器自动调用的成员函数。
与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
了解编译器行为可以让我们更加理解对象的结构:(看完特性之后回顾更佳)
和之前的构造函数一样,它也是编译器自动在对象(在AST中的)应该被析构的位置插入的函数调用节点,当调用执行它的函数体时(机器码),执行其中的命令,对管理的资源进行释放(如果有),利用对象的地址和成员变量的偏移量寻址,然后将地址作为参数,去调用它所有的成员对象成员的析构函数,因为要防止成员对象有管理的需要手动释放的资源导致内存泄漏,而对于内置类,析构函数不用操作,只是释放其使用权,直接将内存使用权交换给系统。
2.1 析构函数特性
2.1.1 函数名与类名相同但是在类名前加上字符 “~”
class Date
{
public:~Date() //显示实现析构函数.{}Date(){_year = 1900;_month = 1;_day = 1;}private:int _year;int _month;int _day;
};
int main()
{Date d; return 0;
}
2.1.2 无参数无返回值类型
析构函数不需要被显示调用,由编译器在对象生命周期结束时自动调用(栈对象离开作用域、堆对象被delete、全局对象程序退出等)(编译器自动行为,是RAII实现的关键)。程序员无法传递参数,也不需要传参,不需要返回值。
2.1.3 对象生命周期结束时编译器自动调用析构函数
class Date
{
public:~Date(){cout << "析构函数: ~Date();";}Date(){cout << "构造函数: Date();";_year = 1900;_month = 1;_day = 1;}private:int _year;int _month;int _day;
};
int main()
{Date d; //构造函数: Date();析构函数: ~Date();//类对象的定义处,申请对应类大小的内存空间,然后编译器自动调用构造函数.return 0;
} //在域的结束位置,变量的生命周期结束,编译器自动调用析构函数.
2.1.4 析构函数只能有一个,析构函数不能重载
- 对象销毁路径唯一,不需要通过参数指定不同销毁方式;
- 还有就是析构无法传参,所以不能重载,因为我们函数重载的条件,就是需要参数不同最终来生成不同的符号表。
2.1.5 默认析构函数的必要性
一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数,那么关于编译器自动生成的默认析构函数,会做什么呢?
因为我们的类中可能会管理资源,所以编译器需要在对象将销毁的时候自动的去调用它的析构函数去释放,这就是我们RAII的实现方式;而成员对象中也有可能会管理资源,比如它申请了一片堆的空间(需要手动释放),如果只调用了类的析构,而不调用其成员对象的析构,就会内存泄漏,所以我们的析构函数需要去自动调用其成员对象的析构函数。
而对于内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可。
下面的程序我们会看到,编译器生成的默认析构函数,对它的成员对象成员调用它的析构函数:
class Time
{
public:~Time(){cout << "~Time()" << endl;}
private:int _hour;int _minute;int _second;
};
class Date
{
private:// 基本类型(内置类型)int _year = 1970;int _month = 1;int _day = 1;// 自定义类型Time _t;
};
int main()
{Date d;return 0;
}
程序运行结束后输出:~Time()
在main方法中根本没有直接创建Time类的对象,为什么最后会调用Time类的析构函数?
因为:main方法中创建了Date对象d,而d中包含4个成员变量,其中_year, _month, _day三个是内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可;
而_t是Time类对象,所以在d销毁时,要将其内部包含的Time类的_t对象销毁,所以要调用Time类的析构函数。但是:main函数中不能直接调用Time类的析构函数,实际要释放的是Date类对象,所以编译器会调用Date类的析构函数,而Date没有显式提供,则编译器会给Date类生成一个默认的析构函数,目的是在其内部调用Time类的析构函数,即当Date对象销毁时,要保证其内部每个自定义对象都可以正确销毁main函数中并没有直接调用Time类析构函数,而是显式调用编译器为Date类生成的默认析构函数
所以当前阶段,如果类中有资源申请,那么就显示的写析构去释放资源,如果类中没有资源管理,我们可以不写,让编译器自动生成,这样即使有成员对象管理了资源,系统生成的默认析构也可以自动去调用其成员对象的析构函数。
构造和析构的总结
构造函数和析构函数,是C++类生命周期管理的手段,会被编译器自动调用,无论是栈上的临时对象还是堆上的动态开辟的对象,编译器都会在这个对象的内存被开辟的后一刻,插入该对象的构造函数和构造函数需要的一些参数,然后在我们对象快要销毁的前一刻,插入该对象的析构函数,释放类中对应的资源,(编译器插入的函数、参数,其实都是汇编指令)
- 对于临时对象来说,创建的时候就是执行到创建临时变量的汇编代码处,销毁的时候就是所在函数的域将要结束的时候;
- 对于动态开辟在堆(这里是指New的)对象,创建就是执行到我们的New函数的汇编代码处,销毁就是执行到delete函数的汇编代码处。
自动添加的方式:
其实自动调用构造或者析构,都是编译器在自己的抽象语法树中自动插入对应函数的节点,最后根据语法树生成汇编代码中带有对应的函数了。
3.拷贝构造函数
拷贝构造函数是为了让自定义类可以像内置类一样的初始化,让类的使用更贴近内置类型,使类的抽象程度进一步加深。
内置类型可以做:
//1.内置类同类型变量的初始化
int a = 100;
int b = a;//2.内置类型变量传参、内置类型变量返回
int func(int a)
{return a++;
}
内置类型可以做像类似于上面这种,用一个类型的变量去拷贝出另一个同类型的变量的行为,我们的自定义类,也当然要有,实现原理就是当自定义类有这种初始化形式出现时,让编译器自动的调用拷贝构造函数,实现直接用一个类对象去拷贝出另一个类对象的效果(深化抽象),其实底层经过了复杂赋值过程,正因为它也是在做初始化操作,所以它也是我们的构造函数。
了解编译器行为可以让我们更加理解对象的结构:(看完特性之后回顾更佳)
拷贝构造最终也是一条函数跳转,当出现拷贝行为时,如果没有手动实现拷贝构造函数,那么编译器就会在对应的AST树位置插入拷贝构造节点,最终生成的汇编将拷贝对象的地址和被拷贝的对象的地址作为我们拷贝构造函数的参数,根据类中变量的声明顺序和变量在对象中的偏移量,去生成执行成员对象成员变量的拷贝构造和对内置变量去按照内存值一一拷贝的汇编代码。
拷贝构造函数的经典使用场景:
- 使用已存在对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
class Date
{
public:Date(int year, int minute, int day){cout << "Date(int,int,int):" << this << endl;}Date(const Date& d){cout << "Date(const Date& d):" << this << endl;}~Date(){cout << "~Date():" << this << endl;}
private:int _year;int _month;int _day;
};
Date Test(Date d)
{Date temp(d);return temp;
}
int main()
{Date d1(2022, 1, 13);Test(d1);return 0;
}
3.1 拷贝构造特性
拷贝构造其实就是我们构造函数的一个函数重载,为的就是实现自定义类之间的同类初始化问题。
其特征如下:
3.1.1 拷贝构造函数的参数只有一个且必须是同类对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用
我们刚才说了,拷贝是针对所有自定义类型同类之间的拷贝问题,所以拷贝构造的接收参数,无疑是要接受一个同类型的变量,但是还不能传值传参,因为我们的传值传参的过程,也是一次同类型之间的拷贝场景之一,也会触发编译器的自动调用拷贝构造的行为,所以如果传值给我们的拷贝构造,就会无限套娃:
而传引用我们知道,其实就是传一个指针,我们的指针只是一个变量,就不会触发拷贝构造,也就不会无穷递归:
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}// Date(const Date d) // 错误写法:编译报错,会引发无穷递归Date(const Date& d) // 正确写法{cout << "Date(const Date d)" << endl;_year = d._year;_month = d._month;_day = d._day;}
private:int _year;int _month;
int _day;
};
int main()
{Date d1;Date d2(d1);return 0;
}
3.1.2 拷贝构造函数是构造函数的一个重载形式
拷贝构造也是和类的名字同名,不需要返回值,唯一不同的就是参数不同,而同名不同参的函数,是构成重载的,所拷贝构造其实是我们构造函数的重载。
3.1.3 默认拷贝构造的必要性
若未显式定义,编译器会生成默认的拷贝构造函数。
表象上:
为的是,就算我们没有手动实现拷贝构造,这个类也可以由编译器自动去生成,然后实现像我们的内置类
int a = b;
这样的初始化方式,即保证了构造确定性,确保所有的对象行为是可预测的。
这种行为更加的深化了抽象,使我们的类更加贴近内置类型的使用,让自定义类型可以使用同类型变量去初始化。
深层里:
拷贝构造,其实涉及了类对象的身份与状态管理,处理资源复制控制,和析构函数(负责结束时的资源释放)和构造函数(负责开始时的资源创建)一起,共同构成对资源的安全管理。
3.1.4 默认拷贝构造的行为
为了语义完整性,所以:
- 编译器自动生成的默认构造函数要实现完整的拷贝,所以会在初始化列表的位置自动调用成员对象的拷贝构造。
- 且编译器自动生成的默认构造函数也会在初始化列表的位置对内置类型的成员做拷贝初始化。
我们的拷贝构造既然是构造,就也有初始化列表,但是我们知道,编译器自动生成的默认构造函数的初始化列表,是不会对我们的内置类做操作的。
为什么这里我们编译器自动生成的默认拷贝构造的初始化列表,会对内置类的成员做初始化?
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}void print(){cout << _year << " " << _month << " " << _day << endl;}
private:int _year;int _month;int _day;
};
int main()
{Date d;d.print(); //1900 1 1Date d2(d); //编译器会自动生成默认拷贝d2.print(); //1900 1 1return 0;
}
原因:
默认构造: 它不知道应该用哪个值来初始化内置类型成员!是0吗?是42吗?还是没有具体值?编译器无法猜测用户的意图,所以语言标准规定默认构造不进行值初始化(保持未定义),除非成员在类内声明时有缺省值(C++11及以后)或在初始化列表中显式列出。这是为了效率和灵活性。
拷贝构造: 它有明确的初始化信息源——要复制的那个对象(other)!编译器知道需要精确复制other的成员。因此,对于内置类型成员,唯一合理的初始化方式就是other.member的值直接复制过来。这个信息是明确已知且必须使用的。
疑问,但是我们由编译器自动生成的默认拷贝构造函数一定好吗?深拷贝部分揭晓。
为了程序员掌控力原则,所以:
- 一但我们显示的实现了拷贝构造函数,那么编译器将默认我们会将一切处理好,也会不会为我们自动的去调用成员对象的拷贝构造了,也不会对内置类做操作了。
class Hour
{
public:Hour(){a = 1;b = 2;c = 3;}Hour(const Hour & h){}int a;int b;int c;
};
int main()
{Hour h1;Hour h2(h1);cout << h2.a; // -858993460return 0;
}
为了构造确定性原则,所以:
- 我们显示的实现了拷贝构造函数,编译器没有生成默认拷贝,它虽然不会去调用其成员对象的拷贝构造,但是会去调用它的默认拷贝构造。
总结就是:
1.因为语意完整性原则,默认构造函数要强制完整的拷贝,所以需要自动调用成员对象的拷贝构造,且要对内置类做拷贝.
2.因为程序员掌控力原则,一旦我们手动的写了拷贝构造,编译器将不再介入,所以它不会自动的去调用成员对象的拷贝构造.
3.因为构造确定性原则,所有值在进入构造函数函数体的时候,必须经过初始化,所以我们自定义的拷贝构造的初始化列表中就算没有显示的写,它也会自动调用我们成员对象的默认构造函数.
3.2 深拷贝
前面我们已经知道,编译器生成的默认拷贝构造函数已经可以完成自动调用成员对象的拷贝构造,并且还可以对内置类成员做拷贝,既然已经这么完善,为什么还需要我们手动的去实现呢?
看一下下面的场景:
// 这里会发现下面的程序会崩溃掉?
typedef int DataType;
class Stack
{
public:Stack(size_t capacity = 10){_array = (DataType*)malloc(capacity * sizeof(DataType));if (nullptr == _array){perror("malloc申请空间失败");return;}_size = 0;_capacity = capacity;}void Push(const DataType& data){// CheckCapacity();_array[_size] = data;_size++;}~Stack(){if (_array){free(_array);_array = nullptr;_capacity = 0;_size = 0;}}
private:DataType* _array;size_t _size;size_t _capacity;
};
int main()
{Stack s1;s1.Push(1);s1.Push(2);s1.Push(3);s1.Push(4);Stack s2(s1);return 0;
}
以上代码会崩溃,因为代码中使用了拷贝初始化,所以Stack类由编译器自动生成了默认的拷贝构造函数,但是由于我们Stack内部管理的资源是从堆中申请的空间,实际上默认拷贝构造拷贝的只是我们的指针,这种单纯对成员变量的值的拷贝,叫做浅拷贝。
浅拷贝只拷贝值,没有将我们类中真正管理的资源拷贝下来,所以就会导致重复释放资源而崩溃。
但是我们编译器自动生成的拷贝构造又只能是浅拷贝,所以,如果我们想要拷贝构造可以将类管理的资源也一起拷贝复制下来,我们需要自己去实现拷贝构造函数。
typedef int DataType;
class Stack
{
public:Stack(size_t capacity = 10){_array = (DataType*)malloc(capacity * sizeof(DataType));if (nullptr == _array){perror("malloc申请空间失败");return;}_size = 0;_capacity = capacity;}Stack(const Stack& s){_array = (DataType*)malloc(s._capacity * sizeof(DataType));_capacity = s._capacity;for (int i = 0; i < s._size; i++){_array[i] = s._array[i];}_size = s._size;}void Push(const DataType& data){// CheckCapacity();_array[_size] = data;_size++;}~Stack(){if (_array){free(_array);_array = nullptr;_capacity = 0;_size = 0;}}
private:DataType* _array;size_t _size;size_t _capacity;
};
int main()
{Stack s1;s1.Push(1);s1.Push(2);s1.Push(3);s1.Push(4);Stack s2(s1);return 0;
}
所以总结就是:类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。
4.赋值运算符重载
4.1 运算符重载
运算符重载,也是我们C++为了增加类的抽象程度,使类的使用更贴近内置类,使类的可读性增加的手段。
重载我们知道,就是让同一个名字的函数,根据不同的参数,可以实现不同的逻辑,那么运算符重载,其实就是给我们一般的运算符去赋予不同的逻辑,让我们自定义的类,也可以使用运算符(如+、-、*、<<等)。
4.1.1 运算符重载的使用
运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}// bool operator==(Date* this, const Date& d2)// 这里需要注意的是,左操作数是this,指向调用函数的对象bool operator==(const Date & d2){return _year == d2._year&& _month == d2._month&& _day == d2._day;}
private:int _year;int _month;int _day;
};
注意:
不能通过连接其他符号来创建新的操作符:比如operator@
重载操作符必须有一个类类型参数
用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不 能改变其含义
作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
“.*” “::” “sizeof 和 typeid” “?:” “.” 注意以上5个运算符不能重载。这个经常在笔试选择题中出现。
运算符重载除了重载成类的成员函数,还可以重载成全局的,只要保证函数的参数中有一个类类型即可,不过有一个例外:赋值运算符重载不可以重载成全局,详细看后面。
运算符重载通过为类定义类似内置类型的操作语义,让用户能够使用熟悉的运算符(如+、-、*、<<等)来操作对象,从而隐藏底层实现细节。这使得代码更贴近问题领域的自然表达,用户无需关心具体实现,只需理解操作在领域内的抽象含义。
赋值运算符重载:
赋值运算符重载,就是给类实现一个赋值“=”的功能。
那么赋值和拷贝有什么区别呢?
拷贝构造函数是从无到有创建新对象。
赋值是将一个对象的状态拷贝给一个已经存在的对象。
4.2 赋值重载的特性
4.2.1 赋值运算符重载细节
- 参数类型:const T&,传递引用可以提高传参效率
- 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
- 检测是否自己给自己赋值
- 返回*this :要符合连续赋值的含义
a = b = c;
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}Date(const Date& d){_year = d._year;_month = d._month;_day = d._day;}Date& operator=(const Date& d){if (this != &d){_year = d._year;_month = d._month;_day = d._day;}return *this;}
private:int _year;int _month;int _day;
};
4.2.2 赋值运算符只能重载成类的成员函数不能重载成全局函数
赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的
赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
class Date
{
public:Date(int year = 1900, int month = 1, int day = 1){_year = year;_month = month;_day = day;}int _year;int _month;int _day;
};
// 赋值运算符重载成全局函数,注意重载成全局函数时没有this指针了,需要给两个参数
Date& operator=(Date& left, const Date& right)
{if (&left != &right){left._year = right._year;left._month = right._month;left._day = right._day;}return left;
}
// 编译失败:
// error C2801: “operator =”必须是非静态成员
4.2.3 默认赋值重载函数的必要性
用户没有显式实现时,编译器会生成一个默认赋值运算符重载
我们的运算符重载有很多,那么为什么偏偏是赋值重载,被列为我们的六大成员函数之一,而且我们没有手动实现,编译器还会自动生成呢?
表面上:
编译器自动生成默认构造,可以让我们就算没有写赋值重载,也可以使用赋值运算符,这种行为加深了类的抽象,不过后续我们知道,编译器生成的总是浅拷贝,不一定总是好。
深层里:
因为赋值操作和拷贝构造一样,涉及对象身份与状态管理,是处理资源复制控制的部分,非常重要,赋值与析构函数(负责结束时的资源释放)和拷贝构造(负责开始时的资源创建)共同构成对资源的安全管理。
4.2.4 默认赋值重载函数的行为
如果代码中有使用赋值,且用户没有显示实现赋值运算符重载,我们的编译器会自动生成一个赋值运算符重载。
为了保证语义的完整性:
赋值运算符重载,也会和拷贝构造一样,对我们内置类的成员变量做逐字节的拷贝,并且会调用成员对象的赋值重载;
在维持语义完整方面,赋值运算符重载函数和拷贝构造函数的区别是:
我们的赋值重载并不是构造函数,它是对一个已经存在的对象赋值,所以编译器生成的默认赋值运算符,它没有初始化列表,所以它并不是在初始化列表处去自动调用其成员对象的赋值运算符,而是直接在函数体中调用。
class Time
{
public:Time(){_hour = 1;_minute = 1;_second = 1;}Time& operator=(const Time& t){if (this != &t){_hour = t._hour;_minute = t._minute;_second = t._second;}return *this;}
private:int _hour;int _minute;int _second;
};
class Date
{
private:// 基本类型(内置类型)int _year = 1970;int _month = 1;int _day = 1;// 自定义类型Time _t;
};
int main()
{Date d1;Date d2;d1 = d2;return 0;
}
为了保证程序员掌控原则:
如果我们显示实现了,那么编译器不会再生成,默认程序员会将一切处理好,也同时给于程序员最大操作权限。
class Time
{
public:Time(){_hour = 1;_minute = 1;_second = 1;}Time& operator=(const Time& t){cout << "Time& operator=(const Time& t)" << endl;if (this != &t){_hour = t._hour;_minute = t._minute;_second = t._second;}return *this;}
private:int _hour;int _minute;int _second;
};
class Date
{
public:Date& operator=(const Date& d){if (this != &d){_year = d._year;_month = d._month;//_day = d._day; //我们自己实现赋值重载,但是不给_day和_t做赋值,编译器也不会自动帮我们做,因为程序员掌控原则,它默认我们会全部完成.//_t = d._t;}return *this;}void print(){cout << _year << " " << _month << " " << _day << endl;}// 基本类型(内置类型)int _year = 1970;int _month = 1;int _day = 1;// 自定义类型Time _t;
};
int main()
{Date d1;d1._day = 666;Date d2;d1 = d2; //没有打印d1.print(); //1970 1 666d2.print(); //1970 1 1return 0;
}
由于我们的赋值重载,是对已经存在的对象做赋值,那么也就无需讨论构造确定性原则了,因为对象已经存在是给它赋值,而不需要构造。当然不讨论不代表不遵循,这个已经存在的对象,肯定也是由构造函数或者默认构造函数去构造的。
为了保证语义的完整性,我们的赋值运算符重载,也会和拷贝构造一样,对我们内置类的成员变量做逐字节的拷贝,并且会调用成员对象的赋值重载,只不过它没有初始化列表,是编译器直接通过函数体调用的;
为了保证程序员掌控原则,如果我们显示实现了,那么编译器不会再生成,默认程序员会将一切处理好,也同时给于程序员最大操作权限;
由于赋值重载,是对已经存在的对象做赋值,所以无需讨论构造确定性原则。
4.3 深拷贝
由于我们的赋值场景(赋值重载),和拷贝构造函数一样,是处理资源复制控制的重要部分,所以一旦有使用到,但是我们又没有实现,编译器会自动生成,而编译器自动生成的又只能做简单的浅拷贝,所以就需要我们手动去实现深拷贝。
// 这里会发现下面的程序会崩溃掉?
typedef int DataType;
class Stack
{
public:Stack(size_t capacity = 10){_array = (DataType*)malloc(capacity * sizeof(DataType));if (nullptr == _array){perror("malloc申请空间失败");return;}_size = 0;_capacity = capacity;}void Push(const DataType& data){// CheckCapacity();_array[_size] = data;_size++;}~Stack(){if (_array){free(_array);_array = nullptr;_capacity = 0;_size = 0;}}
private:DataType* _array;size_t _size;size_t _capacity;
};
int main()
{Stack s1;s1.Push(1);s1.Push(2);s1.Push(3);s1.Push(4);Stack s2;s2 = s1;return 0;
}
实现赋值重载的深拷贝:
typedef int DataType;
class Stack
{
public:Stack(size_t capacity = 10){_array = (DataType*)malloc(capacity * sizeof(DataType));if (nullptr == _array){perror("malloc申请空间失败");return;}_size = 0;_capacity = capacity;}Stack& operator=(const Stack& s){if (this != &s){_array = (DataType*)malloc(s._capacity * sizeof(DataType));if (nullptr == _array){perror("malloc申请空间失败");exit(1);}_capacity = s._capacity;for (int i = 0; i < s._size; i++){_array[i] = s._array[i];}_size = s._size;}return *this;}void Push(const DataType& data){// CheckCapacity();_array[_size] = data;_size++;}~Stack(){if (_array){free(_array);_array = nullptr;_capacity = 0;_size = 0;}}void print(){for (int i = 0; i < _size; i++){cout << _array[i] << " ";}cout << endl;}
private:DataType* _array;size_t _size;size_t _capacity;
};
int main()
{Stack s1;s1.Push(1);s1.Push(2);s1.Push(3);s1.Push(4);Stack s2;s2 = s1;s1.print();s2.print();return 0;
}
5.取地址重载(普通对象)
取地址运算符重载,即是对“&”的重载,运算符“&”的本意,是取地址;而operator&本质是函数,返回一个地址,我们可以决定如果有用户对该类取地址,我们的返回逻辑。
class Date
{
public:Date* operator&() {return &_year; //返回_year成员的地址.}private:int _year; // 年int _month; // 月int _day; // 日
};
5.1取地址重载的特性
5.1.1 默认取地址重载的必要性
如果有使用,但是我们没有显示实现,编译器会自动生成取地址重载。
我们知道运算符可以重载,那么为什么取地址运算符也需要重载,而且还是六个默认成员函数之一?
表面上:
为了让我们就算没有显示写,也可以使用编译器自动生成的 默认取地址重载,实现对类对象的取地址。
深层里:
C++的所有变量,无论是内置类型还是自定义类型,它都切实的存在内存中,C++作为系统级语言,其核心特性之一就是允许直接操作内存地址,因此,获取对象地址是基本操作。
5.1.2 默认取地址重载的行为
编译器自动生成的取地址运算符重载,返回的是这个类对象的地址:
class Date
{
public://编译器自动生成的就像:Date* operator&() {return this;}private:int _year; // 年int _month; // 月int _day; // 日
};
编译器默认实现的取地址重载,作用是返回当前对象在内存中的物理地址(即 this 指针的值)。 在绝大多数情况下,这正是我们期望的行为,所以我们通常不需要自己重载它。
6.取地址重载(const对象)
还有就是对const类对象的取地址重载,它和我们的取地址重载一样,只不过返回的是const类变量的地址:
6.1 const修饰的成员函数
当对象被const修饰,那么当它调用自己的成员函数的时候,其实就是给我们成员函数传入的隐式this指针,带上了const:const Date* this;
表示指针指向的内容不可以被修改。
而一般我们实现的成员函数,它默认都是接受的普通对象的this,所以我们要去为const类的对象添加一个对应参数类型的成员函数,那么我们就要重载它:(C++规定,在成员函数的参数列表后面加const,表示该函数匹配的是const类型对象的this指针,这样该函数就可以匹配const类的对象)
class Date
{
public:Date* operator&(){return this;}const Date* operator&()const //在函数的参数列表之后加const{return this;}
private:int _year; // 年int _month; // 月int _day; // 日
};
一个类可以是普通类也可以被const修饰,所以函数接收的参数不同:
Date* const this
、const Date* const this
,因为参数不同,函数名相同,所以它们其实是构成函数重载的。
注意:既然我们知道了,被const修饰的对象调用自己的成员函数的时候,传入的this指针类型不同(一个是普通指针,一个是const类指针),而我们平常实现的一些成员函数,比如构造函数:
Date::Date(int year, int month, int day)
{_year = year;_month = month;_day = day;
}
它默认都是接受的普通对象的this指针,那么const对象,传入const类型的this指针,是不是我们自己实现的普通成员函数就不可以匹配了?
没错!
像我们自己实现的赋值运算符重载,const类对象就会因为参数类型不匹配而调用不到。
const类对象传入的this指针是:const Date* const this
普通对象传入的this指针是:Date* const this
我们实现的针对普通对象的成员函数,经过编译器的自动添加this指针之后:
Date& Date::operator=(Date* const this, const Date& d)
{if (this != &d){_year = d._year;_month = d._month;_day = d._day; }return *this;
}
所以我们的const类对象时不可以调用到我们普通类的成员函数的,那么我们还是一样,给这个构造函数的参数列表之后添加const类,然后就可以匹配到我们const类对象了吧?
const Date& Date::operator=(const Date& d) const
{if (this != &d){_year = d._year; //由于正在通过常量对象访问“_year”,因此无法对其进行修改_month = d._month; //由于正在通过常量对象访问“_month”,因此无法对其进行修改_day = d._day; //由于正在通过常量对象访问“_day”,因此无法对其进行修改}return *this;
}
为什么呢?
- 因为const的对象传入的const类的this指针,意义就是不可以通过this指针修改我们类中的成员函数。
- 我们成员函数中所有操作成员变量的操作,都是通过this指针的去操作的。
但是有一个例外,我们的构造函数,即便是const类型,也可以匹配到,它会在使用构造初始化对象的时候,暂时的屏蔽掉const属性,为成员变量赋值,然后初始化完成之后,恢复const属性,因为一个对象初始化,是必须要经过我们构造函数的,所以这是编译器给构造函数的特权:
class Date
{
public:Date(int year = 1, int month = 2, int day = 3){_year = year;_month = month;_day = day;}void print(){cout << _year << " " << _month << " " << _day << endl;}void print() const{cout << _year << " " << _month << " " << _day << endl;}private:int _year; // 年int _month; // 月int _day; // 日
};int main()
{Date d1;d1.print();const Date d2;d2.print();return 0;
}
但是如果想给构造函数手动添加const修饰this指针,是不允许的,不可以使用const修饰构造函数的this!!
全文重点提炼
- 资源管理需要手动,经常忘记释放,C++类的解决方式是将资源的初始化和释放自动进行。
- 六大成员函数:
“生命周期管理(构造/析构)、对象复制控制(拷贝构造/赋值)、资源转移优化(移动构造/赋值)。”
六大成员函数共同构建了C++对象的确定性生命周期框架——从诞生(构造)到复制传递(拷贝/移动),最终到消亡(析构),确保所有对象行为可预测、资源可管控,这是C++高效性与安全性的基石。
- C++规定:如果用户没有在类中显式实现,编译器会生成六个默认成员函数,为的是保证基本的构造确定性原则,即确保所有的对象行为是可预测的。
- 类的构造函数和析构函数,是会在我们类对象创建的时候自动执行,去自动的初始化我们对象的内存中的成员变量的。
- 自动调用的原理,其实就是编译器在需要的地方(如对象创建时需要调用构造、销毁时需要调用析构、赋值时需要调用赋值重载),在AST抽象语法树中自动的去插入、调用对应的成员函数的隐式节点。
- 而自动生成默认成员函数的原理,就是编译器根据AST树,去自动生成代码,而前面编译器在AST抽象语法树中自动插入了对应默认成员函数的隐式节点,所以最后根据这个语法树生成的代码中,也就有对应的默认成员函数逻辑的汇编指令了。
构造函数:
- 构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
- 构造函数虽然名称叫构造,但其实构造函数并不是开空间创建对象的,而是初始化对象的。
- 构造函数是我们给类初始化的手段,它有两部分,一个部分是初始化列表,一个部分是给我们用显示赋值的区域,也就是构造函数的函数体。
编译器在编译时根据内存对齐规则计算类的大小和成员偏移量,并生成汇编代码用于运行时在内存上开辟对象空间(如堆或栈分配)。随后,在构造函数调用时(通过call指令),对象的地址和成员的偏移量被用于寻址。初始化列表中对特定成员的初始化操作会和成员变量的地址一起,被编译成汇编指令,嵌入到构造函数的代码体中,在函数执行时完成成员初始化。然而,这仅保证被初始化列表覆盖的成员被正确设置;未初始化的成员可能保留垃圾值。所以只要调用了构造函数,那么就会对对象的内存区域做初始化,就实现了一次对象的内存开辟和成员初始化。
- 编译器通过在类对象空间开辟后插入类的构造函数节点,实现自动初始化;
也通过在类域中按照成员变量的声明顺序插入其成员类的默认构造函数节点,实现对成员对象的自动初始化。 - 构造函数的特性:
类名和构造函数名相同;
没有返回值;
对象的空间创建好之后由编译器自动调用构造函数;
如果我们没有显示实现,编译器会自动生成默认构造函数,当我们显示实现,编译器将不会生成默认构造;
编译器自动生成默认构造,默认构造不需要传参,编译器自动生成是因为有必要的不传参场景;
构造函数可以函数重载;
但是默认构造只能有一个,因为多个会有调用歧义;
- 编译器自动生成的默认构造和构造的初始化列表保证了:
- 所有类对象成员在进入构造函数体前已调用其默认构造函数
- 所有内置类型成员完成默认初始化(即使是没有操作)
- 所有const/引用/无默认构造的成员获得有效初始状态(因为类中可能会有const/引用/无默认构造的成员变量)
总之,默认构造和我们初始化列表的配合,就是为了遵循C++的一个核心原则:构造确定性原则,即保证了所有类对象的初始化是可预测的。但程序员仍需警惕:内置类型的默认初始化不保证数据安全!
- 为了遵循程序员掌控原则,一旦我们手动实现了构造函数,编译器就不再自动生成了,这时候就需要我们去手动的重载一个默认构造函数,以应对需要默认构造的场景。
析构函数:
- 析构函数也是类的一个特殊的成员函数,也是会被编译器自动调用的成员函数。
- 与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
- 编译器对析构函数:
和之前的构造函数一样,它也是编译器自动在对象应该被析构的位置(在AST中的)插入的函数调用节点,当调用执行它的函数体时(机器码),执行其中的命令,对管理的资源进行释放(如果有),利用对象的地址和成员变量的偏移量寻址,然后将地址作为参数,去调用它所有的成员对象成员的析构函数,因为要防止成员对象有管理的需要手动释放的资源导致内存泄漏,而对于内置类,析构函数不用操作,只是释放其使用权,直接将内存使用权交换给系统。
- 析构函数的特性:
函数名与类名相同但是在类名前加上字符 “~”
无参数无返回值类型
对象生命周期结束时编译器自动调用析构函数
析构函数只能有一个,析构函数不能重载
- 默认析构函数的必要性:
类中可能会管理资源,所以编译器需要在对象将销毁的时候自动的去调用它的析构函数去释放,这就是我们RAII的实现方式;而成员对象中也有可能会管理资源,比如它申请了一片堆的空间(需要手动释放),如果只调用了类的析构,而不调用其成员对象的析构,就会内存泄漏,所以我们的析构函数需要去自动调用其成员对象的析构函数。
而对于内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可。
拷贝构造函数:
- 拷贝构造函数是为了让自定义类可以像内置类一样的初始化,让类的使用更贴近内置类型,使类的抽象程度进一步加深。
- 编译器对拷贝构造:
拷贝构造最终也是一条函数跳转,当出现拷贝行为时,如果没有手动实现拷贝构造函数,那么编译器就会在对应的AST树位置插入拷贝构造节点,最终生成的汇编将拷贝对象的地址和被拷贝的对象的地址作为我们拷贝构造函数的参数,根据类中变量的声明顺序和变量在对象中的偏移量,去生成执行成员对象成员变量的拷贝构造和对内置变量去按照内存值一一拷贝的汇编代码。
- 拷贝构造特性:
拷贝构造函数的参数只有一个且必须是同类对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用;
拷贝构造函数是构造函数的一个重载形式;
- 默认拷贝构造的必要性:
表象上:
为的是,就算我们没有手动实现拷贝构造,这个类也可以由编译器自动去生成,然后实现像我们的内置类
int a = b;
这样的初始化方式,即保证了构造确定性,确保所有的对象行为是可预测的。
这种行为更加的深化了抽象,使我们的类更加贴近内置类型的使用,让自定义类型可以使用同类型变量去初始化。
深层里:
拷贝构造,其实涉及了类对象的身份与状态管理,处理资源复制控制,和析构函数(负责结束时的资源释放)和构造函数(负责开始时的资源创建)一起,共同构成对资源的安全管理。
- 默认拷贝构造的行为:
1.因为语意完整性原则,默认构造函数要强制完整的拷贝,所以需要自动调用成员对象的拷贝构造,且要对内置类做拷贝.
2.因为程序员掌控力原则,一旦我们手动的写了拷贝构造,编译器将不再介入,所以它不会自动的去调用成员对象的拷贝构造.
3.因为构造确定性原则,所有值在进入构造函数函数体的时候,必须经过初始化,所以我们自定义的拷贝构造的初始化列表中就算没有显示的写,它也会自动调用我们成员对象的默认构造函数.
-
深拷贝:
浅拷贝只拷贝值,没有将我们类中真正管理的资源拷贝下来,所以就会导致重复释放资源而崩溃。
但是我们编译器自动生成的拷贝构造又只能是浅拷贝,所以,如果我们想要拷贝构造可以将类管理的资源也一起拷贝复制下来,我们需要自己去实现拷贝构造函数。 -
总结就是:类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。
赋值运算符重载函数:
- 运算符重载函数:
运算符重载,也是我们C++为了增加类的抽象程度,使类的使用更贴近内置类,使类的可读性增加的手段。
重载我们知道,就是让同一个名字的函数,根据不同的参数,可以实现不同的逻辑,那么运算符重载,其实就是给我们一般的运算符去赋予不同的逻辑,让我们自定义的类,也可以使用运算符(如+、-、*、<<等)。 - 赋值运算符重载,就是给类实现一个赋值“=”的功能。
- 赋值和拷贝的区别:
拷贝构造函数是从无到有创建新对象。
赋值是将一个对象的状态拷贝给一个已经存在的对象。
- 赋值重载的特性:
赋值运算符重载细节:
- 参数类型:const T&,传递引用可以提高传参效率
- 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
- 检测是否自己给自己赋值
- 返回*this :要符合连续赋值的含义
a = b = c;
赋值运算符只能重载成类的成员函数不能重载成全局函数
- 默认赋值重载函数的必要性:
表面上:
编译器自动生成默认构造,可以让我们就算没有写赋值重载,也可以使用赋值运算符,这种行为加深了类的抽象,不过后续我们知道,编译器生成的总是浅拷贝,不一定总是好。
深层里:
因为赋值操作和拷贝构造一样,涉及对象身份与状态管理,是处理资源复制控制的部分,非常重要,赋值与析构函数(负责结束时的资源释放)和拷贝构造(负责开始时的资源创建)共同构成对资源的安全管理。
- 默认赋值重载函数的行为:
为了保证语义的完整性,我们的赋值运算符重载,也会和拷贝构造一样,对我们内置类的成员变量做逐字节的拷贝,并且会调用成员对象的赋值重载,只不过它没有初始化列表,是编译器直接通过函数体调用的;
为了保证程序员掌控原则,如果我们显示实现了,那么编译器不会再生成,默认程序员会将一切处理好,也同时给于程序员最大操作权限;
由于赋值重载,是对已经存在的对象做赋值,所以无需讨论构造确定性原则。
- 深拷贝:
由于我们的赋值场景(赋值重载),和拷贝构造函数一样,是处理资源复制控制的重要部分,所以一旦有使用到,但是我们又没有实现,编译器会自动生成,而编译器自动生成的又只能做简单的浅拷贝,所以就需要我们手动去实现深拷贝。
取地址重载函数:
- 取地址运算符重载,即是对“&”的重载,运算符“&”的本意,是取地址;而operator&本质是函数,返回一个地址,我们可以决定如果有用户对该类取地址,我们的返回逻辑。
- 默认取地址重载的必要性:
表面上:
为了让我们就算没有显示写,也可以使用编译器自动生成的 默认取地址重载,实现对类对象的取地址。
深层里:
C++的所有变量,无论是内置类型还是自定义类型,它都切实的存在内存中,C++作为系统级语言,其核心特性之一就是允许直接操作内存地址,因此,获取对象地址是基本操作。
-
默认取地址重载的行为:
编译器默认实现的取地址重载,作用是返回当前对象在内存中的物理地址(即 this 指针的值)。 在绝大多数情况下,这正是我们期望的行为,所以我们通常不需要自己重载它。 -
对于const类的取地址重载函数:
因为const类对象在类中的this指针也是const修饰的,所以一般我们实现的普通函数无法匹配,需要重载对应的const类型,但是我们的构造函数是例外,编译器会暂时屏蔽掉const属性,使用构造函数为对象初始化,初始化结束之后,再恢复const属性。
本文章为作者的笔记和心得记录,顺便进行知识分享,有任何错误请评论指点:)。