目录
本篇内容对六个默认成员函数进行了一个较为详细的分析,需要反复的复习并配合使用,其中构造,拷贝构造与运算符重载比较重要,在后面用的很多,后面还会有文章对这部分进行补充,例如友元函数和一些关键字。
前言:
本篇介绍的是有关于c++类中的六个默认成员函数(文章中附上了总结性的思维导图),为什么说是默认成员函数呢?因为大多数情况下,再一个类里面,如果没有写,编译器将会自动生成;如果写了某个,编译器将不会生成对应的默认成员函数;其次对于空类,六个默认成员函数不会生成,只会生成1byte的占位符。
一、构造函数
a.特点
注意:
1.不用写返回值不代表返回值是void。2.构造函数可重载说明一个类可以有多个构造函数。
3.第三条说明栈里面对象实例化需要初始化,可以在类里面用构造函数来初始化,再实例化对象就会自动调用初始化的构造函数了;无参的构造函数直接对对象实例化就自动调用;有参的构造函数在对象实例化时传参即可调用。
b.注意事项
1.首先明确什么是默认构造函数
class Date
{
public:
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
Date d1;
return 0;
}
上面的代码是没自己写构造函数,所以可以直接调用编译器默认生成的,在对象实例化的时候自动初始化,注意自动生成的是无参的构造。
class Date
{
public:
//是默认构造
Date()//无参的构造
{
_year = 1;
_month = 1;
_day = 1;
}
Date(int year, int month, int day)//带参的构造
{
_year = year;
_month = month;
_day = day;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
//无参的构造调用不能这样写,编译器无法分明是函数声明还是构造函数
Date();
//去调用无参的默认构造
Date d1;
//带参函数构造调用
Date d2(2024, 3, 27);
return 0;
}
第一个构造为无参的构造,我们将它看为默认构造函数;第二个构造函数为带参的构造,不是默认的构造函数,需要显示的调用。
它们构成函数重载,需要注意的是,调用无参的构造不能直接写成Date(),这样编译器识别不出这是函数声明还是构造函数的调用。
class Date
{
public:
//是默认构造
Date()//无参的构造
{
_year = 1;
_month = 1;
_day = 1;
}
Date(int year, int month, int day)//带参的构造
{
_year = year;
_month = month;
_day = day;
}
//是默认构造
Date(int year = 1, int month = 1, int day = 1)//将上方二者合而为一的全缺省的构造
{
_year = year;
_month = month;
_day = day;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
//无参的构造调用不能这样写,编译器无法分明是函数声明还是构造函数
Date();
//去调用默认构造
Date d1;
//带参函数构造调用
Date d2(2024, 3, 27);
return 0;
}
上面的第三个构造函数为全缺省的构造函数,将无参的与带参的构造函数结合到了一起,我们也认为它为默认构造函数。
但是需要考虑的是,全缺省的能和无参的一起使用吗???
答案是不能的,如果同时存在,那调用无参的构造函数应该去调用哪一个呢?会产生歧义,编译器会报对重载函数的调用不明确的错误;同样的,代码中第二个构造与第三个构造也不能同时存在,如果同时存在,那调用带参的构造函数时应该去调用谁呢?编译器会报构造函数已定义的错误。
2.默认构造函数对内置类型与自定义类型的处理
在我们调用默认生成的构造函数的时候,会发现,为什么用默认的构造函数初始化,对象中的成员变量为随机值,这有什么mao用???
这就关于我们默认成员函数的另一性质了,对于默认构造函数来说:
内置类型成员不做处理,自定义类型成员取调用它的默认构造(不用传参的构造)。
c++把类型分为内置类型(基本类型)与自定义类型:
内置类型:语言提供的类型,如int,char,double...以及这些类型的任意指针。
自定义类型:class,struct,union等自己定义的类型
后来c++11为了处理内置类型的初始化,增加了一条:
内置类型的声明位置可以给缺省值,注意不是初始化(因为给缺省值没有开空间),所以再对内置类型初始化时就不用给缺省值了(有些编译器会直接初始化为0,但是不要依赖)。
c.总结
二、析构函数
a.特点
在对象销毁时会自动调用析构函数,完成对象中资源的清理工作。
b.注意事项
1.什么时候写析构函数?
析构函数什么时候写呢?像我们后面要完成的日期类可以不写析构函数,因为对象为局部变量,出了作用域就销毁了;而对于像栈的实现,有动态开辟的空间,在堆区上开辟空间,要写析构函数清理资源,否则会造成内存泄露,后面会举例说明。
2.析构函数对内置类型与自定义类型的处理
与构造函数一样,内置类型不做处理,自定义类型会调用它的析构函数。
c.总结
三、拷贝构造
拷贝构造涉及内容比较深入,且注意点也很多,需要细细分析。
a.特点
b.特点的逐个分析以及注意事项
class Date
{
public:
/*Date()
{
_year = 1;
_month = 1;
_day = 1;
}
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
*/
Date(int year = 12, 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 = 1;
int _month = 1;
int _day = 1;
};
int main()
{
//调用默认(全缺省)构造函数并使用函数中的缺省值
Date d1;
//调用默认(全缺省)构造函数并传参
Date d2(2024, 3, 27);
//调用拷贝构造
Date d3(d2);
//Date d3=d2;
return 0;
}
1.为什么拷贝构造要使用传引用返回?
结合我们前面的知识,赋值其实就是拷贝(联想c++基础语法篇的隐式类型转换),对于类的对象来说,传值其实就是要去调用它的拷贝构造初始化,但是我们进行拷贝构造需要先传参,传参又是传值传参,传值传参又要调用拷贝构造......就陷入了死递归,且编译器会立刻报错。再根据我们之前c++语法讲解的使用传引用返回可以减少拷贝,所以我们在这里使用一个传引用返回就解决了这里的问题。(c++基础语法篇链接:http://t.csdnimg.cn/hCM1P)到这里有没有体会到知识融汇贯通的感觉呢?
2.为什么使用const修饰类类型对象的引用?
根据上面的代码,假设d2为const对象修饰的,我们在传参的时候传给类类型的引用d,此时d不是一个const修饰的引用,所以可以对其进行修改,这样我们就将权限放大了,而在任何时候权限只能缩小不能放大。
3.系统提供构造与拷贝构造的规则
搜先,由于拷贝构造是构造函数的重载,其实也就是构造的一种,所以我们如果自己写了拷贝构造,编译器就不会给我们提供任何默认的构造函数了,所以再想使用编译器生成的默认的无参构造函数初始化,就不行了,需要我们自己提供构造函数:
其次,如果我们提供了普通的有参的构造函数,且不是拷贝构造,那编译器将不再提供无参的默认构造,但是会提供拷贝构造(是浅拷贝),如下没有手写拷贝构造d3也能完成拷贝:
4.拷贝构造对内置类型与自定义类型的处理
对于拷贝构造而言,自定义类型的拷贝,需要调用拷贝构造;
内置类型可以直接拷贝,就像我们将d2拷贝给d3;或者需要深拷贝(默认生成的或者我们直接赋值的是浅拷贝,深拷贝需要我们自己写),因为拷贝是按字节去拷贝的,对于像动态开辟的变量,只是按字节拷贝,拷贝出来这两个动态变量在堆区上指向的位置却还是一样的,就像我们将栈的实现封装为stack这样的一个类,内置类型使用了动态开辟的变量,再进行拷贝,这样就会引发问题:
可以看到,拷贝出来的两个对象中的_a都指向了同一块空间(因为你只是负责拷贝字节,没法改变空间的指向啊),这样就会引发析构时需要析构两次,并且在这个场景下参与拷贝的两个对象插入删除数据会互相影响。
深拷贝:
再给st2开辟一块空间,再让st1按字节拷贝过去
以上是针对内置类型的深浅拷贝,下面是对于自定义类型的默认拷贝构造:
对于MYQueue这样的自定义类型,编译器默认生成的这三个构造函数的都可以用。
_size是内置类型,是值拷贝。
成员变量中的_pushST与_popST都是自定义类型(这里的stack没有封装成一个类,跟上面的stack不一样,这里是stack是自定义类型,会调用拷贝构造,但是这个自定义类型中有动态开辟的变量,所以要考虑深拷贝;而上面的stack是一个类,_a是它的成员,是一个内置类型,默认是浅拷贝(值拷贝),所以也需要考虑深拷贝。二者有些区别,注意区分),如果使用编译器默认生成的就是浅拷贝,因为它们都含有动态开辟的变量:
5.如果需要析构函数,则一般都需要拷贝构造与赋值重载
有了上面的知识,我们再来理解这句话就更加明白了,因为如果需要我们手写析构函数,一般来说是默认的析构函数不够用了,例如动态开辟的变量,所以为了防止刚刚提到的浅拷贝导致的双重释放,我们就要自己来写一个拷贝构造与赋值重载来防止浅拷贝。析构也通常用来清理拷贝构造函数产生的临时变量,所以拷贝构造后一般紧跟就是析构。
c.总结
四、运算符重载
下面要谈到赋值重载,就要先了解运算符重载。
a.特点
b.实现日期类的比较
class Date
{
public:
/*Date()
{
_year = 1;
_month = 1;
_day = 1;
}
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
*/
Date(int year = 12, 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;
}
//d1==d2
bool operator==(const Date& d)//这里使用引用是因为函数传参也需要拷贝,引用来减少拷贝
{
return _year == d._year//this->_year==d._year
&& _month == d._month
&& _day == d._day;
}
//d1<d2
bool operator<(const Date& d)
{
return (_year < d._year)
||((_year == d._year )&&( _month < d._month))
|| ((_year == d._year) && (_month == d._month) &&( _day < d._day));
}
//d1<=d2
bool operator<=(const Date& d)
{
return (*this < d) || (*this == d);
}
//d1>d2
bool operator>(const Date& d)
{
return !(*this <= d);
}
//d1>=d2
bool operator>=(const Date& d)
{
return !(*this < d);
}
//d1!=d2
bool operator!=(const Date& d)
{
return !(*this == d);
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
Date d1(2023, 3, 28);
Date d2(2024, 3, 28);
//调用时相当于d1.operator==(d2)
//调用也可以写为operator==(d1,d2),但一般不会这样写
cout<<(d1 == d2)<<endl;//0
cout<<(d1 < d2)<<endl;//1
cout<<(d1 > d2)<<endl;//0
cout<<(d1 >= d2)<<endl;//0
cout<<(d1 <= d2)<<endl;//1
cout<<(d1 != d2)<<endl;//1
//调用默认(全缺省)构造函数并使用函数中的缺省值
//Date d1;
//调用默认(全缺省)构造函数并传参
//Date d2(2024, 3, 27);
//调用拷贝构造
//Date d3(d2);
return 0;
}
注意使用时:
例如d1==d2,也可以写为operator==(d1,d2)(但是一般不会这样写),在调用的时候会转换为d1.operator==(d2),所以传的this指针也是第一个参数就是d1,第二个参数为d2。
这里比较返回bool值判断真假,在打印时需要注意,<<的优先级高于==等运算符,所以要加上括号。
进行比较的运算符的重载时,可以先写==与<或者其它的,然后其它的可以复用。
c.函数重载与运算符重载
运算符重载实际就是函数重载。
d.总结
五、赋值重载
我们来围绕日期类来进行举例说明,且下面的知识的梳理都是循序渐进的。
a.日期类实现赋值重载
class Date
{
public:
/*Date()
{
_year = 1;
_month = 1;
_day = 1;
}
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
*/
Date(int year = 12, 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;
}
//d1==d2
bool operator==(const Date& d)//这里使用引用是因为函数传参也需要拷贝,引用来减少拷贝
{
return _year == d._year//this->_year==d._year
&& _month == d._month
&& _day == d._day;
}
//d1<d2
bool operator<(const Date& d)
{
return (_year < d._year)
||((_year == d._year )&&( _month < d._month))
|| ((_year == d._year) && (_month == d._month) &&( _day < d._day));
}
//d1<=d2
bool operator<=(const Date& d)
{
return (*this < d) || (*this == d);
}
//d1>d2
bool operator>(const Date& d)
{
return !(*this <= d);
}
//d1>=d2
bool operator>=(const Date& d)
{
return !(*this < d);
}
//d1!=d2
bool operator!=(const Date& d)
{
return !(*this == d);
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
Date d1(2023, 3, 28);
Date d2(2024, 3, 28);
Date d3(2025, 3, 28);
//调用时相当于d1.operator==(d2)
//调用也可以写为operator==(d1,d2),但一般不会这样写
cout<<(d1 == d2)<<endl;//0
cout<<(d1 < d2)<<endl;//1
cout<<(d1 > d2)<<endl;//0
cout<<(d1 >= d2)<<endl;//0
cout<<(d1 <= d2)<<endl;//1
cout<<(d1 != d2)<<endl;//1
d3 = d1 = d2;
d1.Print();
d3.Print();
//调用默认(全缺省)构造函数并使用函数中的缺省值
//Date d1;
//调用默认(全缺省)构造函数并传参
//Date d2(2024, 3, 27);
//调用拷贝构造
//Date d3(d2);
return 0;
}
注意:
1.为什么要带返回值?因为对于赋值运算符来说,例如i=j=k这个表达式,j=k这个表达式返回左操作数,返回值 再作为右操作数再赋值给给i。
所以我们要支持连续赋值,例如d3=d1=d2,d1作为返回值对应*this,所以返回d1,那为什么要用*this呢?因为*this出了赋值重载的作用域还在(因为d1是在主函数定义的,生命周 期在主函数里)所以可以直接返回*this。
2.为什么参数要用引用?
因为可以减少拷贝。当然这里不加&也不会死循环,因为传值传参调用拷贝构造,我们已经实现过拷贝构造了,调用完拷贝构造就进赋值重载函数了,只有自己对自己传值传参的拷贝构造才会死循环。
3.为什么函数体要这样写?
函数中要考虑自己对自己赋值,判断一下防止自己对自己赋值。这里调用d1=d2也就是调用d1.operator=(d2)。
b.赋值重载与拷贝构造
注意区分赋值重载与拷贝构造,赋值重载是对于两个实例化的对象而言的。
c.赋值重载对类型的处理
理解为与拷贝构造一样,也要考虑双重释放的问题。
d.再来看流插入
有了前面的知识储备,我们再来理解一下流插入与流提:
查阅官方文档,我们可以看到流插入也是经过了运算符的重载,而这个类就是我们库中的ostream这个类,运算符重载被封装在这个类里面了;而对于的我们使用cout,cin(cin是istream的对象)就是根据这个类创建的对象,然后去调用对应的运算符重载函数。
再进一步理解为什么我们使用c++中的流插入与流提取可以自动识别类型了,因为是在运算符重载中帮你实现好了:
上面的运算符重载都构成函数重载。假设我们现在有一个int类型的i与double类型的d,去使用流插入就是:
cout<<i;//cout.operator<<(i) --->int
cout<<d;//cout.operator<<(d) --->double
e.日期类打印自定义类型
结合前面的知识,我们知道了cout其实就是ostream的对象,现在我们来完成日期类的打印:
上面的可以完成吗?答案是不可以的,因为格式不符合我们的习惯:
调用的时候,由于第一个参数我们需要传递this指针,所以调用的时候写法只能如上图写。
那怎么办呢?我们不定义在类中不就好了:
但是又发现,类中的保护起来的成员不让访问啊?那我们再让成员放开,有些不值当啊,所以我们可以有两种解决方案:
提供公有的成员函数来获取例如GetYear()等;或者提供一个友元函数(友元函数在类和对象的总结篇会细说):
注意位置放在类中公有和私有域的外面,这样我们的友元函数就能访问类中的成员了,此时调用就变为了:
现在完了吗?还没有!我们还需要考虑连续的调用(流插入从左往右连续调用),所以我们需要返回值,out是cout的别名,返回的其实就是cout:
完整代码:
class Date
{
friend ostream& operator << (ostream& out, const Date& d);
public:
/*Date()
{
_year = 1;
_month = 1;
_day = 1;
}
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
*/
Date(int year = 12, 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;
}
//d1==d2
bool operator==(const Date& d)//这里使用引用是因为函数传参也需要拷贝,引用来减少拷贝
{
return _year == d._year//this->_year==d._year
&& _month == d._month
&& _day == d._day;
}
//d1<d2
bool operator<(const Date& d)
{
return (_year < d._year)
||((_year == d._year )&&( _month < d._month))
|| ((_year == d._year) && (_month == d._month) &&( _day < d._day));
}
//d1<=d2
bool operator<=(const Date& d)
{
return (*this < d) || (*this == d);
}
//d1>d2
bool operator>(const Date& d)
{
return !(*this <= d);
}
//d1>=d2
bool operator>=(const Date& d)
{
return !(*this < d);
}
//d1!=d2
bool operator!=(const Date& d)
{
return !(*this == d);
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
ostream& operator << (ostream & out, const Date & d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
int main()
{
Date d1(2023, 3, 28);
Date d2(2024, 3, 28);
Date d3(2025, 3, 28);
//调用时相当于d1.operator==(d2)
//调用也可以写为operator==(d1,d2),但一般不会这样写
cout<<(d1 == d2)<<endl;//0
cout<<(d1 < d2)<<endl;//1
cout<<(d1 > d2)<<endl;//0
cout<<(d1 >= d2)<<endl;//0
cout<<(d1 <= d2)<<endl;//1
cout<<(d1 != d2)<<endl;//1
d3 = d1 = d2;
d1.Print();
d3.Print();
cout << d3;
//调用默认(全缺省)构造函数并使用函数中的缺省值
//Date d1;
//调用默认(全缺省)构造函数并传参
//Date d2(2024, 3, 27);
//调用拷贝构造
//Date d3(d2);
return 0;
}
f.补充内联函数
如果我们在头文件中定义一个函数:
如果没有定义只有声明,那么就需要链接找到定义,call的时候没有地址,就需要到符号表里面去找函数的地址;如果有定义,再编译阶段就能拿到函数的地址,直接call地址就能找到这个函数。
而内联函数不会进符号表:
所以我们在将内联函数定义到头文件时,要确保头文件中有定义和声明,这样在编译的阶段就能拿到函数的地址。不然没有定义需要去链接call内联函数的地址,就要去符号表找函数的地址,但是是找不到的。
g.总结
六、const修饰的非静态成员函数
调用Print这里传递的还有&aa给this指针,而&aa的类型是const A*,传递给Print()后,变成了A* this,权限被放大了,但*this又不能改变,所以如图const写后面表示const修饰*this,this类型变成了const A*,不这样写编译器会报错。类内部不改变成员变量的成员函数,最好加上const(声明定义分离的都要加),const对象和普通对象都能调用。
通常间接的调用需要注意要加上const防止权限放大。Print中的this指针是个const A*类型的,用法Func的x去调用要确保x是一个const A类型的,防止权限放大。
七、const修饰的取地址操作符重载
一般不需要重载,使用编译器默认生成的即可。
也可写:
需要注意的是,不能加任何参数,不然就是被看做按位与&的重载了。
可以用作于不想让别人取地址,直接返回一个空。