类的六个默认成员函数
如果一个类中什么成员都没有,简称为空类。
那么空类真的是什么都没有吗?其实并不是这样的,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。
默认成员函数:用户没有显式定义,编译器会自动生成的成员函数称为默认成员函数。
构造函数
概念
构造函数是一个特殊的成员函数,名字和类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只能调用一次。
特性
构造函数是特殊的成员函数,需要注意的是构造函数虽然名字叫做构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特征如下:
1.函数名与类名相同。 2.无返回值。 3.对象实例化时编译器自动调用对应的构造函数。 4.构造函数可以重载。
class Date
{
public:
//构造函数可以重载
//全缺省的构造函数
/*Date(int year=2023, int month=7, int day=28)
{
_year = year;
_month = month;
_day = day;
}*/
//无参构造函数
Date()
{}
//带参的构造函数
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 d1();
Date d1;
d1.Print();
Date d2(2023, 7, 27);
d2.Print();
return 0;
}
注意:其中我们在创建d1对象时,不能写成Date d1()。因为这样会与函数声明冲突,编译器无法区分开来。
5.如果类中没有显式定义构造函数,则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 d1;
return 0;
}
在上述代码,如果将Date类中构造函数屏蔽后发现代码可以通过编译,这是因为编译器生成了一个无参的默认构造函数,而一旦将Date类中构造函数放开(取消屏蔽),代码就会报错,报错信息是:没有合适的默认构造函数(显示定义的构造函数需要传参,而实例化d1时没有进行传参)。这是由于类中一旦显示定义,编译器将不再自动生成默认构造函数。
6.关于编译器生成的默认构造函数,很多人可能会有疑问:不实现构造的情况下,编译器会生成默认的构造函数,但是看起来默认构造函数又没有什么用?假设d1调用了编译器生成的默认构造函数,而_year,_month,_day依旧是随机值,如图
其实不然,我们知道c++把类型分为内置类型和自定义类型。内置类型就是语言提供的数据类型,如:int/char等等,自定义类型就是我们使用class/struct/union等自己定义的类型,看看下面程序就会发现编译器生成的默认构造函数就会对自定义类型成员_t调用它的默认构造函数。
class Time
{
public:
Time()
{
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
//内置类型
//c++11对于内置类型不初始化的缺陷打了补丁、
//即:内置类型成员变量在类中声明时可以给默认值,用于编译器生成的默认构造函数
int _year = 1970;
int _month = 1;
int _day = 1;
//自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
总结:当我们在类中未显式定义时,编译器自动生成的默认构造函数对于内置类型不做处理(有些编译器也会处理,那是个性化行为),自定义类型会去调用它的默认构造。因此,一般情况下,有内置类型成员时,就需要自己写构造函数,不能由编译器自己生成,如果全是自定义类型成员时,可以考虑让编译器自己生成。
7.无参的构造函数和全缺省的构造函数也都称为默认构造函数,并且默认构造函数只能有一个(即无参的和全缺省的不会同时出现)。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的默认构造函数,都可以认为是默认构造函数(不传参就可以调用的函数)。
析构函数
概念
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
特性
析构函数是特殊的成员函数,其特性如下:
1.析构函数是在类名前加上字符~。 2.无参数无返回值类型。 3.一个类只能有一个析构函数。若为显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载。 4.对象生命周期结束时,系统会自动调用析构函数
那么关于编译器自动生成的析构函数,是否会完成一些事呢?我们下面来运行一段程序
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;
}
我们发现编译器生成的默认析构函数对内置类型未进行处理,而自定义类型进行了处理,我们由此得出_year,_month,_day三个内置类型成员销毁时不需要资源清理,最后系统直接将其内存回收即可;而_t是Time类对象,所以d销毁时要将其内部包含的Time类_t对象销毁,所以要调用Time类的析构函数。编译器会调用Date类的析构函数,而Date没有显示提供,则编译器会给Date类生成一个默认的析构函数,目的是在其内部调用Time类的析构函数,即当Date类销毁,保证其内部每个自定义对象都可以正确销毁。
总结:系统默认生成的析构函数对内置类型不做处理,自定义类型会去调用它的析构函数
拷贝构造函数
概念
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),再用已存在的类类型对象创建新对象时由编译器自动调用。
特性
拷贝构造函数也是特殊的成员函数,其特征如下:
1.拷贝构造函数是构造函数的一个重载形式。 2.拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归。
在谈及为什么会引发无穷递归之前,我们先来看个例子,并请读者先行调试下这段代码。
class Date
{
public:
Date(int year = 1990, 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;
}
private:
int _year = 1970;
int _month = 1;
int _day = 1;
};
void func(Date d)
{
//.....
}
void func(int i)
{
//.....
}
int main()
{
Date d1(2023, 11, 18);
func(d1);
func(10);
return 0;
}
调试的过程中我们发现执行“func(d1)”这条语句时,首先会调用Date类的拷贝构造,等它执行完毕,才会去调用func函数。而执行“func(10)”这条语句时,会直接调用func函数。我们于是明白c++对于内置类型是直接拷贝传参调用函数,而对于自定义类型必须先调用拷贝构造完成传参,然后才能调用函数。
这也就不难解释拷贝构造函数直接传参会产生无穷递归,因为对于自定类型调用函数要先传参,传参会形成一个新的拷贝构造,调用新的拷贝构造又要传参,传参又会形成新的拷贝构造,依次循环往复。
3.若类中未显式定义,编译器会生成默认的拷贝构造函数。默认的拷贝构造函数对象按内存存储,按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
Time(const Time& t)
{
_hour = t._hour;
_minute = t._minute;
_second = t._second;
cout << "Time::Time(const 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 d1;
//Date类中并没有显示定义拷贝构造函数,编译器会给Date类生成默认拷贝构造函数
Date d2(d1);
return 0;
}
注意:在编译器生成的默认的拷贝构造函数中,内置类型是按照字节方式浅拷贝,而自定义类型是调用其拷贝构造函数完成拷贝
4.编译器生成的默认的拷贝构造函数已经可以完成字节序的值拷贝了,还需要自己显示实现吗?我们来看个例子,并先请读者执行下下面这段代码
class Stack
{
public:
Stack(int capacity = 4)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == nullptr)
{
perror("malloc fail");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(const int& data)
{
//checkcapacity();
_a[_size] = data;
_size++;
}
~Stack()
{
if (_a)
{
free(_a);
_a = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
int* _a;
int _capacity;
int _size;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Push(3);
s1.Push(4);
Stack s2(s1);
return 0;
}
我们惊奇地发现会产生报错
经过探究,我们明白s2对象使用了s1的拷贝构造,而stack类没有显示定义拷贝构造函数,则编译器会给Stack类生成一份默认的拷贝构造函数,默认的拷贝构造函数是按照值拷贝的,即将s1的内容原封不动地拷贝到s2中。因此s1和s2指向同一块内存空间。当程序退出时s2和s1要销毁。s2先销毁,s2销毁时调用析构函数,已经将空间释放,但是s1并不知道,到s1销毁时,会将空间再释放一次,一块内存空间进行多次释放会造成程序崩溃。还可能会引发s1修改造成s2也修改的问题。
拷贝函数典型调用场景
总结:如果类中没有涉及到资源申请时,拷贝构造函数是否写都可以,一旦涉及到资源申请时,拷贝构造函数是一定要写的,否则就是浅拷贝。
画一张图,区分浅拷贝和深拷贝
为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用
赋值运算符重载
运算符重载
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号
函数原型:返回值类型 operator操作符(参数列表)
注意:1.不能通过连接其他符号来创建新的操作符:比如operator@ 。 2.重载操作符必须有一个类类型参数。(自定义类型) 3.用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义。 4.作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为 隐藏的this指针。 5. .* :: sizeof ?: . 注意以上5个运算符不能重载。
下面我们来看个例子
我们可以将运算符重载函数放在类里面,以此来作为类中的一个成员函数,此时该函数的第一个形参默认为this指针。
class Date
{
public:
Date(int year = 2023, int month = 11, int day = 18)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
// 运算符重载函数
bool operator==(const Date& d)
{
return _year == d._year
&&_month == d._month
&&_day == d._day;
}
private:
int _year;
int _month;
int _day;
};
我们也可以将运算符重载函数放在类外面,使之成为全局的函数。但此时类外无法直接访问类内部的成员变量,这时我们可以用友元函数(后续会介绍),或者将成员变量的访问限定符设为public(这样做会破环封装性),这样类外就可以直接访问类的成员变量。还需要注意的是由于类外定义的函数没有隐藏的this指针,这时我们必须将函数的形参设置为两个。
class Date
{
public:
Date(int year = 2023, int month = 11, int day = 18)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
int _year;
int _month;
int _day;
};
// 运算符重载函数
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year
&&d1._month == d2._month
&&d1._day == d2._day;
}
赋值运算符重载
1.赋值运算符重载格式
参数类型:const T&,传递引用可以提高传参效率
返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
检测是否自己给自己赋值
返回*this :为了要复合连续赋值
其中我们以日期类为例
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)
{
this->_year = d._year;
this->_month = d._month;
this->_day = d._day;
}
return *this;
}
private:
int _year;
int _month;
int _day;
};
operator=()中只有一个参数是因为operator=是成员函数,因此它的第一个形参是隐藏的this指针,使用传引用传参是为了提高传参效率。而返回类型为Date,不是void,是为了支持连续赋值,例如有三个Date类对象d1,d2,d3,使用的返回类型是Date就可以进行“d1=d2=d3”这样的操作,d3将值赋给d2,然后“d2=d3”表达式返回d2的值赋给d1,从而实现连续赋值。
为什么返回要加引用呢?其实我们从前面学习知道,对于临时变量返回值不能加引用,因为当函数调用结束,临时变量一旦出了作用域就销毁不在了,函数对应的栈空间返还给操作系统,如果返还的空间进行了资源清理,可能就造成错误。而上述代码返回的是*this,我们易知this指向的是对象,函数结束后它不会销毁,因此可以用引用返回以此提高返回的效率。
2.赋值运算符只能重载成类的成员函数不能重载成全局函数
原因:赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
3.用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。
既然编译器生成的默认赋值运算符重载函数已经可以完成字节序的值拷贝了,我们还需要自己实现吗?当然像日期类这样的类是没必要的。那么下面这个类呢,读者不妨试试。
class Stack
{
public:
Stack(int capacity = 10)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == nullptr)
{
perror("malloc fail");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(const int& data)
{
//checkcapacity();
_a[_size] = data;
_size++;
}
~Stack()
{
if (_a)
{
free(_a);
_a = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
int* _a;
int _capacity;
int _size;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Push(3);
s1.Push(4);
Stack s2;
s2=s1;
return 0;
}
我们发现会报错
经过探究我们发现:s1对象调用构造函数创建,在构造函数中,默认申请了10个元素的空间,然后存了四个元素1,2,3,4。s2对象调用构造函数创建,在构造函数中,默认申请了10个元素的空间,没有存储元素。由于Stack没有显示实现赋值运算符重载,编译器会以浅拷贝的方式实现一份默认的赋值运算符重载。执行这句“s2=s1”代码时,即当s1给s2赋值时,编译器会将s1中的内容原封不动拷贝到s2中,这样会导致两个问题:
- s2原来的空间丢失了,存在内存泄漏。
- s1和s2共享同一份内存空间,最后销毁时会使同一块内存空间释放两次,而使程序崩溃。
总结:如果类中未涉及到资源管理,赋值运算符是否实现都可以,一旦涉及到资源管理则必须要实现。
我们已经学完了运算符重载和构造函数,再来对它们做个区分 1. 已经存在的两个对象之间复制拷贝------赋值运算符重载函数 2. 用一个已经存在的对象初始化另外一个对象------拷贝构造函数
前置++和后置++重载
前置++
Date& operator++()
{
_day+=1;
return *this;
}
前置++是返回+1之后的结果,this指向的对象函数结束后不会销毁,故以引用方式返回提高效率。
后置++
Date operator++(int)
{
//temp是临时对象因此只能传值返回,不能传引用
Date temp(*this);
_day+=1;
return temp;
}
后置++返回的是+1之前的旧值,它相比前置++增加了一个int类型的参数,这样做不是为了接收具体的值,它仅仅是一个占位符,为了与前置++构成重载方便区分,并且调用后置++时参数不用传递,编译器会自动传递。
日期类的实现
(后续放链接)
注意:在重载<<(流插入运算符)时,如果我们将operator<<()写成成员函数时,使用时就要写作“d1<<cout”或“d1.operator(cout)”,这是因为this指针是成员函数的第一个参数,所以Date类对象默认占用了第一个参数,就是做了左操作数。这样的写法与我们的使用习惯不符合,因此我们将operator<<()写到类外,作为全局函数,写成“void operator<<(ostream& out,const Date& d)”,为了支持连续插入,例如“cout<<d2<<d3<<d1”我们将ostream作为返回值来做进一步优化,还有由于出了作用域对象没有销毁,我们使用引用返回来提高返回效率,最终优化成为“ostream& operator<<(ostream& out,const Date& d)”
const成员
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
我们来看一段代码
class Date()
{
public:
Date(int year,int month,int day)
{
_year=year;
_month=month;
_day=day;
}
void Print()
{
cout<<"Print()"<<endl;
cout<<"year:"<<_year<<endl;
cout<<"month:"<<_month<<endl;
cout<<"day:"<<_day<<endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023,11,18);
d1.Print();
const Date d2(2023,11,18); //会报错
d2.Print();
}
我们发现会报错,错误信息是不能将"this"指针从“const Date”转换为"Date &"。
这里就涉及了指针的权限问题(权限不可以放大,但是可以缩小或平移),当const对象去调用非const成员函数时,就会把const Date*传给Date*,权限就会放大,因此不可行。而当非const对象去调用const成员函数时,就会把Date*传给const Date*,权限缩小,这样是可行的。如图所示
有时成员函数不加const还会发生一些古怪的错误
class Date()
{
public:
Date(int year,int month,int day)
{
_year=year;
_month=month;
_day=day;
}
bool operator<(const Date& x)
{
//.....
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023,11,18);
const Date d2(2023,11,19);
d1<d2; //可行
d2<d1; //程序报错
}
我们发现d1作为左操作数,程序正常运行,而d2作为左操作数,程序报错,是由于d2作为左操作数对应operator<的第一个参数,而作为成员函数operator<第一个参数是this指针,类型为Date*,而d2类型为const Date*,d2传给this指针涉及到权限放大的问题,会报错。
总结:只要成员函数内部不修改成员变量都应该加上const,这样const对象和普通对象都可以调用