🏠专栏介绍:浅尝C++专栏是用于记录C++语法基础、STL及内存剖析等。
🚩一些备注:之前的文章有点杂乱,这里将前面的知识点重新组织了,避免了过多冗余的废话。
🎯每日努力一点点,技术变化看得见。
文章目录
- 类的6个默认成员函数概述
- 构造函数
- 概念
- 特性
- 析构函数
- 概念
- 特性
- 拷贝构造函数
- 概念
- 特性
- 赋值运算符重载
- 运算符重载
- 赋值运算符重载
- 前置++与后置++
- const成员
- 取地址及const取地址操作符重载
类的6个默认成员函数概述
如果我们写一个不含任何成员函数、成员变量的类,这个类什么成员都没有,则称这个类为空类。
class Date{};
空类真的什么都没有吗?并不是!任何类在什么都不写的情况下,编译器会自动生成下图所示的6个默认成员函数。
上面说的默认成员函数,就是用户没有显示实现,编译器会生成的成员函数称为默认成员函数。
构造函数
概念
首先,我们来看一个日期类代码↓↓↓
class Date
{
public:
void Init(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 d1;
d1.Init(2024, 5, 5);
d1.Print();
return 0;
}
上面代码中,我们在实例化一个Date对象d1后,如果想对它的3个成员变量进行初始化,需要显示调用Init函数进行初始化。我们在定义一个类型/一个对象时,经常都需要对它进行初始化,如果每次都需要显示调用初始化函数显然有点麻烦。
那能否在对象创建的时候,直接将初始值设置进去呢?这就需要谈谈C++类和对象中的析构函数了。
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特性如下:
- 函数名与类名相同
- 无返回值
- 对象实例化时编译器自动调用对应的构造函数
- 构造函数可以重载【也就是说,一个类可以有多个构造函数】
下面定义一个提供默认构造函数的日期类,演示上面的4个特性↓↓↓
#include <iostream>
using namespace std;
class Date
{
public:
//无参构造函数 --> 函数名与类名相同,无返回值
Date()
{}
//有参构造函数 --> 与无参构造函数构成重载
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024,5,5);//编译器在对象创建时自动调用有参构造函数
Date d2;//编译器在对象创建时自动调用无参构造函数
return 0;
}
上面代码中需要特别注意的是,在调用无参构造函数时,需要在对象的后面添加括号。否则编译器会将这种代码看作函数的声明。
void test()
{
Date d3();//编译器会将该行代码看作函数名为d3,返回值为Date,没有的参数的函数声明
Date d4;//调用无参构造不用在对象后面加括号
}
- 如果类没有定义构造函数,则C++编译器会自动生成一个无参的默认构造函数。但只要用户显示定义构造函数数(不管是有参构造还是无参构造),编译器将不再生成默认构造函数。
#include <iostream>
using namespace std;
class Date
{
public:
//用户定义了构造函数,编译器将不再提供默认构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2024,5,5);//代码执行正确
Date d2;//error!!
return 0;
}
上面代码中由于用户自定义了构造函数,则编译器不再提供无参的默认构造函数。此时创建d2时将会报错,因为此时并没有默认构造函数。
- 在用户不是实现构造函数时,编译器实现的默认构造函数的作用:对于内置类型(C++语言提供的默认类型,如int/char等),不会对它们做任何处理;对于自定义类型(使用class/struct/union等定义的类型),会调用它们的默认构造函数(也称为无参构造函数)。
#include <iostream>
using namespace std;
class Time
{
public:
Time()
{
cout << "Time()正在被调用" << endl;
_hour = _minute = _second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
cout << _t._hour << "/" << _t._minute << "/" << _t._second << endl;
}
private:
//基本类型(内置类型)
int _year;
int _month;
int _day;
//自定义类型
Time _t;
};
int main()
{
Date d;
d.Print();
return 0;
}
上面代码执行结果如上图所示。可以看到Date提供的默认构造函数对C++内置类型没有做任何操作,所以它们都是一些随机值;对于自定义类型,Date会调用它们的默认构造函数。
★ps:C++11中为了解决默认构造函数不会初始化内置类型的问题,打了如下补丁:内置类型的成员变量在声明时可以给出默认值。给出默认值后,类在创建时会给内置类型的成员变量赋予默认值
#include <iostream>
using namespace std;
class Time
{
public:
Time()
{
cout << "Time()正在被调用" << endl;
_hour = _minute = _second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
cout << _t._hour << "/" << _t._minute << "/" << _t._second << endl;
}
private:
//基本类型(内置类型)
int _year = 0;
int _month = 0;
int _day = 0;
//自定义类型
Time _t;
};
int main()
{
Date d;
d.Print();
return 0;
}
7. 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认函数只能有一个。
★ps:我们不能在同一个类中同时提供无参构造函数和全缺省的构造函数。
下面代码中由于同时提供了无参构造和全缺省的构造函数,在main定义调用默认构造的类对象时,编译器无法确定调用无参构造还是全缺省的构造函数。(程序存在二义性)
#include <iostream>
using namespace std;
class Date
{
public:
Date()
{
_year = 2024;
_month = 6;
_day = 1;
}
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
Time _t;
};
int main()
{
Date d1;
return 0;
}
析构函数
概念
上面我们介绍了用于对象创建时的成员函数,那有没有用于对象销毁的成员函数呢?
析构函数:与构造函数功能相反,析构函数用于清理对象的存储空。但析构函数不是完成对对象本身的销毁,它主要用于销毁对象申请的堆空间,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
特性
析构函数是特殊的成员函数,其特征如下:
- 析构函数名是在类名前加上字符
~
。 - 无参数无返回值类型。
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构
函数不能重载 - 对象生命周期结束时,C++编译系统系统自动调用析构函数。
析构函数主要用于清理对象申请的堆空间,对于局部变量的释放工作,由编译器完成。下面代码中演示了析构函数的定义和调用情况↓↓↓
#include <iostream>
using namespace std;
class Stack
{
public:
Stack(int n = 5)
{
_arr = (int*)malloc(sizeof(int) * n);
_size = 00;
_capacity = 0;
}
//析构函数
~Stack()
{
cout << "~Stack()被调用" << endl;
free(_arr);
_arr = nullptr;
_size = _capacity = 0;
}
private:
int* _arr;
int _size;
int _capacity;
};
int main()
{
Stack s;//在s的声明周期结束时,s的析构函数将被自动调用
return 0;
}
5. 关于编译器自动生成的析构函数,是否会完成一些事情呢?下面的程序我们会看到,编译器生成的默认析构函数,对自定类型成员变量调用它的析构函数。
#include <iostream>
using namespace std;
class Time
{
public:
~Time()
{
cout << "~Time()被调用" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
private:
//基本类型(内置类型)
int _year = 0;
int _month = 0;
int _day = 0;
//自定义类型
Time _t;
};
int main()
{
Date d;//在d生命周期结束后,将会调用析构函数,d的默认析构函数会调用自定义类型的析构函数
return 0;
}
6. 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如上面定义的Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如上面定义的Stack类。
拷贝构造函数
概念
在程序中,优势需要对每个对象做备份。那在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
特性
拷贝构造函数也是特殊的成员函数,其特征如下:
- 拷贝构造函数是构造函数的一个重载形式。
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
//拷贝构造
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year = 0;
int _month = 0;
int _day = 0;
};
int main()
{
Date d1(2024,6,1);
Date d2(d1);
d1.Print();
d2.Print();
return 0;
}
- 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
如果使用值传递调用拷贝函数,在给拷贝构造函数传参时也是一次拷贝,这次拷贝需要调用拷贝构造函数;调用拷贝构造又是值传递,拷贝构造函数传参时也是一次拷贝,这次拷贝需要调用拷贝构造函数…(引发无穷递归调用)
3. 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。(被拷贝对象的各个成员变量存什么值,新创建的对象的各个成员变量也存什么值)。
注意:在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的。
#include <iostream>
using namespace std;
class Time
{
public:
Time()
{}
Time(const Time& t)
{
cout << "Time(const Time& t)被调用" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
private:
//基本类型(内置类型)
int _year = 0;
int _month = 0;
int _day = 0;
//自定义类型
Time _t;
};
int main()
{
Date d1;
Date d2(d1);
return 0;
}
★ps:编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,还需要自己显式实现吗?当然像日期类这样的类是没必要的。那么下面的类呢?验证一下试试?
#include <iostream>
using namespace std;
class Stack
{
public:
Stack(int n = 5)
{
_arr = (int*)malloc(sizeof(int) * n);
_size = 00;
_capacity = 0;
}
//析构函数
~Stack()
{
cout << "~Stack()被调用" << endl;
free(_arr);
_arr = nullptr;
_size = _capacity = 0;
}
private:
int* _arr;
int _size;
int _capacity;
};
int main()
{
Stack s1;
Stack s2(s1);
return 0;
}
为什么上面的代码会引发错误呢?
由于编译器自动生成的构造函数是按字节挨个拷贝的,导致s2中_arr和s1的_arr指向同一块内存空间。当s2生命周期结束后会调用了析构函数将这片内存空间释放,s1在生命周期结束也会释放这片空间。一块内存空间被重复释放从而导致错误。这种现象称为浅拷贝。
因此,如果类中有向堆申请空间,需要我们自己重新编写拷贝构造函数。在新创建的对象的构造函数中开辟一片新的内存空间,将待拷贝对象堆中的数据挨个拷贝进来。这种重写拷贝的行为称为深拷贝。
#include <iostream>
#include <cstring>
using namespace std;
class Stack
{
public:
Stack(int n = 5)
{
_arr = (int*)malloc(sizeof(int) * n);
_size = 00;
_capacity = 0;
}
//重写拷贝构造函数
Stack(const Stack& s)
{
_arr = (int*)malloc(sizeof(int) * s._capacity);
memcpy(_arr, s._arr, sizeof(int) * s._capacity);
_size = s._size;
_capacitu = s._capacity;
}
//析构函数
~Stack()
{
cout << "~Stack()被调用" << endl;
free(_arr);
_arr = nullptr;
_size = _capacity = 0;
}
private:
int* _arr;
int _size;
int _capacity;
};
int main()
{
Stack s1;
Stack s2(s1);
return 0;
}
★ps:注意:类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。
5.拷贝构造函数典型调用场景:
①使用已存在对象创建新对象
②函数参数类型为类类型对象
③函数返回值类型为类类型对象
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)
{
cout << "拷贝构造" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year = 0;
int _month = 0;
int _day = 0;
};
Date func(Date d)
{
return d;
}
int main()
{
Date d1;
Date d2 = func(d1);
return 0;
}
上面代码中一共调用了3次构造函数,如下图所示。但由于第2次和第3次拷贝连续发生,可能会被编译器优化成一次。(编译器具体如何优化,随编译器的不同而不同)
★ps:为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用。
赋值运算符重载
运算符重载
如果我们想实现两个日期类的比较,我们可以使用一个isequal函数来实现↓↓↓
bool isequal(Date& d1, Date& d2)
{
return d1._year == d2._year && d1._month == d2._month && d1._day == d2._day;
}
int main()
{
Date d1(2023, 1, 1);
Date d2(2024, 5, 1);
cout << isequal(d1, d2) << endl;
}
虽然上面的代码能够实现我们需要的功能。但我们在比较两个内置类型是否相等时,都会使用==
运算符。为了让自定义类型能和内置类型一样使用常用的运算符,C++引入运算符重载。
运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:operator+需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
我们来看一下运算符重载如何实现两个日期类对象的比较↓↓↓
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
//运算符重载
//返回值+operator+需要重载的运算符+参数列表
bool operator==(const Date& d)
{
return _year == d._year && _month == d._month && _day == d._day;
}
private:
int _year = 0;
int _month = 0;
int _day = 0;
};
int main()
{
Date d1(2023,1,1);
Date d2(2024,5,1);
cout << (d1 == d2) << endl;
return 0;
}
注意:
①不能重载C++中不存在的运算符:比如operator@
②重载操作符必须有一个类类型参数
③内置类型(如char、int等)的运算符不能被重载
④作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this指针
⑤.*
、 ::
、 sizeof
、 ?:
、 .
注意以上5个运算符不能重载。
★ps:由于运算符重载的左操作数必须用this指针传入,故运算符重载函数只能在类内实现,而不能定义在类外。在类外定义如下下代码是不能编译通过的,因为编译器不会给它传入隐藏的this指针。
bool operator==(const Date& d2)
{
return _year == d2._year && _month == d2._month && _day == d2._day;
}
赋值运算符重载
1.赋值运算符重载格式↓↓↓
参数类型:const T&,传递引用可以提高传参效率,const可以防止在重载函数中修改传入的对象
返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
返回*this :因为要满足连续赋值,所以要返回自身(用于将自身给下一个对象赋值)
注意:函数体内需要检测是否自己给自己赋值
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
//赋值运算符重载
Date& operator=(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year = 0;
int _month = 0;
int _day = 0;
};
int main()
{
Date d1(2023,1,1);
Date d2 = d1;
d1.Print();
d2.Print();
return 0;
}
2. 赋值运算符只能重载成类的成员函数不能重载成全局函数
★ps:原因:赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
- 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。
既然编译器生成的默认赋值运算符重载函数已经可以完成字节序的值拷贝了,还需要自己实现吗?当然像日期类这样的类是没必要的。那么下面的类呢?验证一下试试?
#include <iostream>
using namespace std;
class Stack
{
public:
Stack(int n = 5)
{
_arr = (int*)malloc(sizeof(int) * n);
_size = 00;
_capacity = 0;
}
~Stack()
{
cout << "~Stack()被调用" << endl;
free(_arr);
_arr = nullptr;
_size = _capacity = 0;
}
private:
int* _arr;
int _size;
int _capacity;
};
int main()
{
Stack s1;
Stack s2 = s1;
return 0;
}
这里发生的错误和拷贝构造函数一样。由于编译器自动形成operator=函数是按直接进行拷贝的,导致两个Stack指向同一片内存空间,该内存空间被重复释放导致出错。因此,上面的代码应该修改成这样↓↓↓
#include <iostream>
#include <cstring>
using namespace std;
class Stack
{
public:
Stack(int n = 5)
{
_arr = (int*)malloc(sizeof(int) * n);
_size = 00;
_capacity = 0;
}
Stack& operator=(const Stack& s)
{
_arr = (int*)malloc(sizeof(int) * s._capacity);
memcpy(_arr, s._arr, size(int) * s._size);
_size = s._size;
_capacity = s._capacity;
}
~Stack()
{
cout << "~Stack()被调用" << endl;
free(_arr);
_arr = nullptr;
_size = _capacity = 0;
}
private:
int* _arr;
int _size;
int _capacity;
};
int main()
{
Stack s1;
Stack s2 = s1;
return 0;
}
如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要实现。
★ps:注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。
#include <iostream>
using namespace std;
class Time
{
public:
Time()
{}
Time& operator=(const Time& t)
{
cout << "Time& operator=(const Time& t)" << endl;
_hour = t._hour;
_minute = t._minute;
_second = t._second;
return *this;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
private:
//基本类型(内置类型)
int _year = 0;
int _month = 0;
int _day = 0;
//自定义类型
Time _t;
};
int main()
{
Date d1;
Date d2;
d2 = d1;
return 0;
}
★ps:如下代码是拷贝构造而不是赋值。因为此时d2并未构造,编译器会将Date d2 = d1;
转化成Date d2 = Date(d1);
完成拷贝构造。
void test()
{
Date d1(2024,5,1);
Date d2 = d1;
}
前置++与后置++
通过上面的了解,我们可以理解下面的代码↓↓↓
class NumSet
{
public:
Numset(int num = 0)
{
_num = num;
}
NumSet& operator++()
{
++_num;
return *this;
}
private:
int _num;
}
上面代码实现的是前置++。对于重载++运算符,如果重载函数的参数列表没有参数,则是实现的是前置++。完成前置++要将自身返回给调用处(也就是++后的结果)。
那如何实现后置++呢?C++中规定,如果要实现后置++,要在对应函数的参数列表添加一个int类型的占位参数。(这里没有为什么,这只是C++的规定)
class NumSet
{
public:
Numset(int num = 0)
{
_num = num;
}
NumSet& operator++(int)
{
Numset tmp = Numset(*this);
++_num;
return tmp;
}
private:
int _num;
}
如果我们在调用后置++时,编译器默认会给operator++函数传递一个整型数,从而调用后置++。我们可以使用NumSet n; n.operator++(0);
显示调用后置++。
后置–和前置–与上面的代码类似,这里不再介绍。
const成员
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
下面有几个问题,这里一起讨论一下:
- const对象可以调用非const成员函数吗?
解答:const对象的不能修改对象的成员变量,但非const成员函数能修改成员变量。这答案很明显,const对象不能调用非const成员函数,因为这属于权限放大。 - 非const对象可以调用const成员函数吗?
解答:非const对象的成员变量是可以修改的,const成员函数不可以修改成员函数。这里属于权限的缩小,因此非const对象可以调用const成员函数。 - const成员函数内可以调用其它的非const成员函数吗?
解答:这属于权限放大,故const成员函数内不可以调用其它的非const成员函数 - . 非const成员函数内可以调用其它的const成员函数吗?
解答:这属于权限缩小,故非const成员函数内可以调用其它的const成员函数。
取地址及const取地址操作符重载
这两个操作符的重载与上面介绍的其他运算符重载类似,这里不做赘述,直接给出代码示例↓↓↓
class Date
{
public :
Date* operator&()
{
return this;
}
const Date* operator&() const
{
return this;
}
private:
int _year = 0;
int _month = 0;
int _day = 0;
};
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载。
🎈欢迎进入浅尝C++专栏,查看更多文章。
如果上述内容有任何问题,欢迎在下方留言区指正b( ̄▽ ̄)d