文章目录
class Date
{
};
可以看到,上面那个类没有任何成员,是一个空类,但是它真的什么都没有吗?
其实一个类在我们不写的情况下,也会生成6个默认的成员函数,分别是:构造函数,析构函数,拷贝构造函数,赋值运算符重载,取地址运算符重载,对const对象取地址运算符的重载
构造函数
概念
构造函数是特殊的成员函数,它的主要功能就是初始化对象。和我们之前c语言中自己实现的init函数类似。但是有一点不同的是,init是在我们创建完后才自己调用,而构造函数是创建对象的时候由编译器自动调用,并且在生命周期中只调用这一次。
特征:
- 函数名与类名相同。
- 无返回值。
- 对象实例化时编译器自动调用对应的构造函数。
- 构造函数可以重载。
- 不同于其他成员函数,构造函数不能被声明成const的。(构造函数的作用就是为了初始化对象的成员参数,如果被声明为const则会认为自己无法修改调用对象的值,也就剥夺了构造函数的作用) 当我们需要创建类的一个const对象时,知道构造函数完成初始化过程,对象才能真正取得其“常量”属性,因此,构造函数在const对象的构造过程中可以向其写值,并且构造函数不必(实则不能)被声明为const。
关于合成的默认构造函数和默认构造函数之间的区别【C++ Primer】
先来明晰两个定义:
合成的默认构造函数: 当类没有声明任何构造函数时,编译器自动合成的默认构造函数,是当用户未提供显式初始值时用来构建对象的构造函数。
默认构造函数:不带“合成的”这三个字也就是指是我们自己定义的,是当用户未提供显式初始值时用来构建对象的构造函数。
其实两者起到的作用是一样的,大家都是默认构造函数,只是来源不同导致叫法不同,书中将编译器创建的默认构造函数称为合成的默认构造函数,我们自己定义的默认构造函数称为默认构造函数。
合成的默认构造函数按照如下规则初始化类的数据成员:
- 如果存在类内的初始值,用它来初始化成员
- 否则,默认初始化该成员
如果我们要自己定义一个默认构造函数,那我们有两种方法:
1.定义一个无参的构造函数。
2.定义所有参数都有缺省值(默认值)的构造函数【全缺省的构造函数】。
class Date
{
public:
//无参构造函数
Date()
{
_year = 0;
_month = 1;
_day = 1;
}
//全缺省的构造函数
//ps:本例中只是为了演示定义默认构造函数的方法,在实际编程中
//默认构造函数只能出现一个,全缺省的构造函数和无参构造函数不能同时出现
//因为编译器会无法识别此时到底该调用哪一个。
Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//有参构造函数,也就是书上说的一般构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
//调用默认构造函数
Date d3();
//如果要调用默认构造函数后面不能加上括号,加上了则变成了函数声明
Date d2(2020, 4, 19);
//调用有参数的
return 0;
}
再次重申:切记一个类只能有一个默认构造函数!也就是说上面提到的两个方法你只能选其中的一种。
某些类不能依赖于编译器合成的默认构造函数
第一个原因
如果我们没有显式创建构造函数,编译器会自动构建一个默认构造函数,但如果我们已经显式定义了构造函数,则编译器不会再生成默认构造函数。那么除非我们再定义一个默认的构造函数,否则类将没有默认的构造函数。
这条规则的依据是:如果一个类在某种情况下需要控制对象初始化(我们显式定义构造函数),那么该类很可能在所有情况下都需要控制。
第二个原因
且,合成的默认构造函数可能执行错误的操作。如果定义在块中的内置类型或复合类型(比如数组和指针)的对象被默认初始化,则他们的值将是未定义的。 该标准同样适用于默认初始化的内置类型成员。因此,含有内置类型或复合类型成员的类应该在类的内部初始化这些成员,或者定义一个自己的默认构造函数。 否则,用户在用合成的默认构造函数创建类的对象时就可能得到未定义的值。
类中有其他类类型成员也一样:
如果真的非常想使用合成的默认构造函数又不想得到未定义的值,则可以将成员全部赋予类内的初始值,这个类才适合于使用编译器合成的默认构造函数。
第三个原因
编译器不能为有些类合成默认构造函数,例如,如果类中包含一个其他类类型的成员,且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。
从例子中可以看出,A类的构造函数可以正常工作,但是当使用Date类的合成默认构造函数创建一个对象时,由于Date类中有其他类类型的成员(A类型的成员a),且其所在类(A类0)没有默认构造函数(只有一般构造函数),导致编译器无法初始化该成员(a)。
构造函数初始化
列表初始化
我们定义变量时习惯于立即对其初始化,而非先定义、再赋值(以防忘记赋值导致某些时候使用未初始化的变量):
string a = "hello!"; // 定义并立即初始化
string b; // 先定义,执行默认初始化,b为空string对象
b = "world!"; // 再赋值
跟变量一样,就对象的数据成员而言,如果没有在构造函数的初始值列表中显式地初始化成员,则该成员会在构造函数体之前执行默认初始化。
执行默认初始化分两种情况:
第一种,被忽略的成员有类内初始值(本例中的_month,_day):
class Date
{
public:
Date(int year):_year(year){
}
int getye()
{
return this -> _year;
}
int getmoth()
{
return this -> _month;
}
int getday()
{
return this -> _day;
}
private:
int _year;
int _month = 3;
int _day = 24;
};
int main(int argc, char const *argv[]) {
Date a(2020);
cout << a.getye() << endl;
cout << a.getmoth() << endl;
cout << a.getday() << endl;
return 0;
}
从结果可知,没有在初始值列表中显式初始化的数据成员,如果其具有类内初始值,会隐式地使用类内初始值初始化。
第二种情况,被忽略的成员没有类内初始值(本例中的_month,_day):
class Date
{
public:
Date(int year):_year(year){
}
int getye()
{
return this -> _year;
}
int getmoth()
{
return this -> _month;
}
int getday()
{
return this -> _day;
}
private:
int _year;
int _month;
int _day;
};
int main(int argc, char const *argv[]) {
Date a(2020);
cout << a.getye() << endl;
cout << a.getmoth() << endl;
cout << a.getday() << endl;
return 0;
}
从结果可知,没有在初始值列表中显式初始化的数据成员,如果其也没有类内初始值,则其值是未定义的,试图拷贝或以其他形式访问此类值将引发错误。
综述:
- 构造函数不应该轻易覆盖掉类内的初始值,除非新赋的值与原值不同。
- 构造函数使用类内初始值不失为一种好的选择,因为这样能确保为成员赋予了一个正确的值。
- 如果不能使用类内初始值(编译器不支持或其他原因),则所有构造函数都应该显式地初始化每一个内置类型的成员。
构造函数里面的语句到底是不是初始化?
这个构造函数内的赋值语句是初始化吗?
class Date
{
public:
Date(int year, int month, int day)
{
//这个构造函数内的赋值语句是初始化吗?
_year = year;
_month = month;
_day = day;
}
int getyear()
{
return this -> _year;
}
private:
int _year;
int _month;
int _day;
};
乍一看很可能会觉得构造函数内的赋值语句是初始化,但是如果这样写呢?
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
_year = 2020;
}
int getyear()
{
return this -> _year;
}
private:
int _year;
int _month;
int _day;
};
往后面加上了一个_year = 2020,那这样还是初始化吗?总不可能是先用year初始化_year,再用2020来初始化它,这明显不成立,因为初始化只能一次,而函数体内的赋值可以多次, 所以我们可以将函数体内的赋值理解为赋初值,而非初始化。
有时我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。
当类的成员是以下三种时,必须通过构造函数初始值列表为它们提供初值:
- 引用成员变量
- const成员变量
- 未提供默认构造函数的类类型
随着构造函数体一开始执行,初始化就完成了(也就意味着例如下面的:_month的const属性已经成立,无法再在函数体内为_month赋值了)。
class Date
{
public:
Date(int i)
{
_year = i; // 正确
_month = i; // 错误:不能给const赋值
day = i; // 错误:ri没被初始化
}
private:
int _year;
const int _month;
int &_day;
};
因此我们初始化const或者引用类型的数据成员的唯一机会就是通过构造函数初始值列表为它们提供初值:
class Date
{
public:
Date(int& year, const int month, int day)
:_year(year),
_day(day),
_month(month)
{
}
int getye()
{
return this -> _year;
}
int getmoth()
{
return this -> _month;
}
int getday()
{
return this -> _day;
}
private:
int &_year;
const int _month;
int _day;
};
int main(int argc, char const *argv[]) {
int i = 2020;
Date a(i,3,14);
cout << a.getye() << endl;
cout << a.getmoth() << endl;
cout << a.getday() << endl;
return 0;
}
成员初始化的顺序
同时,这里还有一个容易出错的地方——成员初始化的顺序,可以看到,我这里初始化列表的顺序是year,day,month。但是实际上初始化的顺序和初始化列表中顺序毫无关联,初始化的顺序是按照参数在类中声明的顺序的, 也就是下面的year,month,day(如图)。
一般来说,初始值列表的初始化顺序不会影响什么,就如上题,结果依然符合我们的预期:
不过如果一个成员是用另一个成员来初始化的,那么这两个成员的初始化顺序就很关键了,具体是什么意思呢?举个例子:
class Date
{
public:
Date(const int& year, const int month)
:_year(year),
_day(month),
_month(_day)
{
}
int getye()
{
return this -> _year;
}
int getmoth()
{
return this -> _month;
}
int getday()
{
return this -> _day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
int i = 2020;
Date d2(i, 4);
cout << d2.getmoth() << endl;
cout << d2.getday() << endl;
return 0;
}
从形式上初始值列表的顺序来讲,先用形参month初始化成员_day,再用初始化成功的_day去初始化成员_month,完全符合逻辑!但实际上真的是这样吗? 我们来看看运行结果:
可以看到初始化成功的只有成员_day,实际上,初始化的顺序是按照参数在类中的声明顺序来的,也就是先用形参year初始化成员_year,再用成员_day初始化成员_month,但由于此时成员_day尚未被形参month初始化,因此成员_month值是未定义的,接下来用形参month初始化成员_day,从而生成了上图的结果。
综述:
最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员,而是用构造函数的参数作为成员的初始值。这样的好处是我们可以不必考虑成员的初始化顺序。
初始化列表的效率比在构造函数内初始化的效率高
在很多类中,赋值和初始化的区别事关底层效率问题:前者首先会用默认构造函数来构造对象,再通过重载后的赋值运算符进行赋值,后者会直接调用拷贝构造函数,减少了一次默认构造函数的时间。
析构函数
概念
析构函数也是一个特殊的成员函数,它的功能是清理对象,和之前一些自定义类型所写的destroy函数类似。
特征:
- 析构函数名是在类名前加上字符 ~。
- 无参数无返回值。
- 一个类有且只有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
- 对象生命周期结束时,C++编译系统系统自动调用析构函数
class A
{
public:
A(const char* str = "hello world", int num = 3)
{
_str = (char*)malloc(sizeof(str));
strcpy(_str, str);
_num = num;
cout << "constructor function" << endl;
}
~A()
{
free(_str);
_str = nullptr;
_num = 0;
cout << "destructor function" << endl;
}
char* getstr()
{
return this->_str;
}
int getnum()
{
return this->_num;
}
private:
char* _str;
int _num;
};
int main(int argc, char const *argv[]) {
A a = A();
cout << a.getstr() << endl;
cout << a.getnum() << endl;
return 0;
}
从结果可以看到,析构函数的执行在return语句之前。
同时,默认的析构函数和默认的构造函数效果一样,会去调用自定义类型的析构函数,而不会处理内置类型。
拷贝构造函数
C++也为我们准备了两种能够通过其他对象的值来初始化一个对象的默认成员函数,他们分别是拷贝构造函数和赋值运算符重载。
拷贝构造函数是构造函数的一个重载形式。
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
调用方法,用别的对象来为本对象赋值,同时因为不需要修改引用的对象则为它加上const属性。
int main()
{
Date d1;
Date d2 = d1;
Date d3(d1);
//这两种等价,都是拷贝构造函数,并且d2不是赋值运算符重载
}
注意:拷贝构造函数的参数必须是引用,否则会引发无限递归调用。
原因如下:如果不是通过引用的方式将实参传递给形参,而是传值的方式,将实参对象以传值方式传递给形参这个操作本身就是调用拷贝构造函数完成的,如此一来则会形成一个死循环。
以本例来讲:
当我们要用d1来初始化d2的时候,需要将d1先传递给形参d,再用形参d进行赋值,但是d1传递给d的时候又会再次调用一个拷贝构造函数,这个d又会给它的拷贝构造函数的形参d传参,这又会调用新的拷贝构造函数,就导致了一个无限循环, 所以要加上引用。
如果我们不去定义一个拷贝构造函数,编译器也会默认创建一个,并且对于这个类,他们所实现的功能是一模一样的。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝, 这种拷贝我们叫做浅拷贝,或者值拷贝。 如果是上面这个日期类,当然没问题,但如果涉及到了动态开辟的数据,就会有问题了。
假设在堆上开辟了某个大小的一个数据,默认的拷贝构造函数会按照字节序来拷贝这个数据,这一步其实没问题,问题就出在析构函数上。因为析构函数会在类的生命周期结束后将类中所有成员变量释放,但是这种浅拷贝的数据就会存在问题,因为他们指向的是同一块空间,而原对象和拷贝的对象会分别对它释放一次,就导致了重复释放同一块内存空间,引发错误。
所以对于动态开辟的数据,我们还需要使用深拷贝。
运算符重载
函数原型:返回值类型 operator操作符(参数列表)
例如两个日期类是否相等
//成员函数的操作符重载
bool operator==(const Date& d2)
{
return _year == d2._year
&& _month == d2._month
&& _day == d2._day;
}
//如果写成普通的函数
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
int main()
{
bool isSame = (d1 == d2)
//调用时等价于 isSame = d1.operator==(d2);
//或者 isSame = operator==(d1, d2);
}
- 不能通过连接其他符号来创建新的操作符:比如operator@
- 重载操作符必须有一个类类型或者枚举类型的操作数
- 用于内置类型的操作符,其含义不能改变,例如:内置的整型+,不 能改变其含义
- 作为类成员的重载函数时,其形参看起来比操作数数目少1,成员函数的操作符有一个默认的形参this,限定为第一个形参
- :: 、*、sizeof 、?、: 、. 注意以上5个运算符不能重载。
赋值运算符的重载
上面说过,有两种方法能够实现用其他类来拷贝一个类,一个是拷贝构造函数,一个是赋值运算符重载
int main()
{
Date d;
Date d1(d);
Date d2 = d;
//在声明的时候初始化,用d初始化d2,调用的是拷贝构造函数
d1 = d2;
//是在对象d1已经存在的情况下,用d2来为d1赋值,这才是赋值运算符重载
//声明阶段的“=”都自动调用了拷贝构造函数,只有不是声明阶段的“=”才是赋值运算符重载
return 0;
}
Date& operator=(const Date& d)
{
if(this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
这里简单的实现了一个,需要注意的有几点
- 返回*this,因为可能存在连续赋值
- 检测是否是自己给自己赋值,如果是则忽略
- 因为不需要修改任何参数,所以参数都需要加上const,并且为了不花费多余的空间去拷贝数据,都采取引用
- 一个类如果没有显式定义赋值运算符重载,编译器也会生成一个,完成对象按字节序的值拷贝。
如果我们没有实现的话,编译器也会默认生成一个,但是这个默认的赋值重载运算符和上面的拷贝构造函数存在着同样的问题,它们都是浅拷贝。
取地址运算符重载、对const对象取地址运算符的重载
取地址运算符也有两个默认的成员函数,编译器默认生成,不需要我们定义,一般只有想让别人获取指定内容的时候才自己定义一个。
class Date
{
public:
Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//默认取地址重载
Date* operator&()
{
return this;
}
//const取地址重载
const Date* operator&()const
{
return this;
}
int _year;
int _month;
int _day;
};