观前提醒
本篇文章共6736词,读完大约需要20分钟。
文章目录
写在前面
本篇文章关于C++类和对象的讲解中的第二篇。到了本篇文章就真正开始到了类和对象真正难啃的地方了。本篇文章聚焦于类的6个默认成员函数的讲解,希望对你有所帮助。
类的6个默认成员函数
在上一篇文章中,我提到过空类,也就是什么成员都没有的类。
但事实上,空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下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(2023, 1, 1);//初始化赋值
d1.Print();
date d2;//定义对象
d2.Init(2023, 1, 7);//初始化赋值
d2.Print();
return 0;
}
每当我们定义一个date类对象,我们都需要使用Init
成员函数来初识化对象才能开始使用。使用起来有点麻烦。而实际上,无论是自定义类型还是内置类型,当我们定义一个对象并使用时,我们总是需要将该对象进行初识化,赋值成我们需要的值开始使用。
对于自定义类型,我们只需要的定义的时候直接使用赋值符号赋值即可完成初识化。即便没有手动初始化,这个对象也会有随机的初始值。
而对于自定义类型,我们也有构造函数来简化初始化的步骤,不必像上面的代码一样总是需要手动调用Init
函数。
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个成员对象都有一个合适的初始值,并且在对象的整个生命周期内只(由编译器)调用一次。
特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称带有构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。以下是构造函数的特性:
-
函数名与类名相同。
-
无返回值。
-
对象实例化时编译器自动调用对应的构造函数。
-
构造函数可以重载。
class date { public: //无参构造函数 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(2022,12,31); //调用带参构造函数 d1.Print(); date d2; //调用无参构造函数 d2.Print(); return 0; }
运行截图:
注意事项:
int main() { //注意:使用无参构造函数定义变量时,对象不用跟括号,否则就变成函数声明 //像以下代码,变成了一个名字为d的函数声明,返回值为date类,无参 date d(); //warning C4930: “date d(void)”: 未调用原型函数(是否是有意用变量定义的?) return 0; }
-
如果类中没有显式定义构造函数,则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() { //没有不带参的构造函数,必须传入参数初始化,此时编译不通过 //error C2512: “date”: 没有合适的默认构造函数可用 date d1; return 0; }
-
不显式写,编译器自动生成
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; d1.Print(); return 0; }
运行截图:
-
-
通过以上代码的演示,我们会发现:不实现构造函数的情况下,编译器会生成默认的构造函数,但是看起来默认构造函数好像又没什么用。生成的默认构造函数并没有对成员对象进行处理,数据依然是个随机值。也就是说在这里编译器生成的默认构造函数并没有什么用???
其实并不是。C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:char、int、double……,自定义类型就是我们使用class/struct/union等自己定义的类型。通过以下代码,我们会发现编译器生成默认的构造函数会对自定义类型成员对象调用它对应的默认成员函数。
class Time { public: //无参默认构造函数 Time() { cout << "Time()" << endl; _hour = 0; _min = 0; _sec = 0; } private: int _hour; int _min; int _sec; }; class date { private: //内置类型 int _year; int _month; int _day; //自定义类型 Time _t; }; int main() { date d1; //没有传入参数,调用类内部编译器生成无参的默认构造函数 //对于自定义类型成员对象,生成的默认构造函数会调用该类的无参默认构造函数 return 0; }
运行截图:
也就是说,编译器生成的默认构造函数,对内置类型成员对象不做初始化处理,对自定义类型成员对象调用它的默认构造函数。
注意:C++11中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
-
无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。
注意:无参构造函数、全缺省构造函数、自己没实现而编译器生成的构造函数,都可以认为是默认构造函数。
class date { public: date() { _year = _month = _day = 0; } date(int year = 1970, 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 d1; //编译不通过 //error C2668: “date::date”: 对重载函数的调用不明确 return 0; }
-
在C++中,函数参数的缺省参数可以使用
malloc
、new
或其他函数赋值。在这一点上,构造函数和其他普通函数是一样的。而给内置类型成员对象设置的默认值同样也可以使用。但是,这样的方式好不好呢?像
malloc
这样的函数需要对返回值进行检查的,以这样的方式,如何对返回值检查?建议尽量少使用这样的方式,类其实有更好的初始化方案,我会在后面讲解。
析构函数
概念引入
一个类对象有构造函数,使他能够像基本类型(内置类型)一样有着较为标准的初始化行为,但是这个对象又如何做到像基本类型一样有着较为标准的销毁行为呢?所以,便有了析构函数。
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
特性
析构函数是特殊的成员函数,其特征如下:
-
析构函数名是在类名前加上字符~。
-
无参数无返回值类型。
-
一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载
-
对象生命周期结束时,C++编译系统自动调用析构函数。
class date { public: //析构函数 ~date() { cout << "~date()" << endl; } private: int _year; int _month; int _day; }; void test() { date d1; //当对象生命周期结束,编译器自动调用析构函数 } int main() { test(); return 0; }
运行截图:
-
关于编译器自动生成的析构函数,是否会完成一些事情呢?下面的程序我们会看到,编译器生成的默认析构函数,对自定义类型成员调用它的析构函数。
class Time { public: ~Time() { cout << "~Time()" << endl; } private: int _h; int _m; int _s; }; class date { private: //内置类型 int _year; int _month; int _day; //自定义类型 Time _t; }; int main() { date d1; return 0; }
运行截图:
可以看到,程序运行结束后输出了
~Time()
,而在main
中并没直接创建Time
类对象,为什么最后会调用Time
类的析构函数?因为
main
函数中创建了date
对象d
,而d
中包含4个成员变量,其中有三个基本类型(内置类型),对于内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可。而
_t
成员对象则是Time
类对象,所以在对象d
销毁时,要将其内部包含的Time
类的_t
对象销毁,所以要调用Time
类的析构函数。需要注意的是,这里并不是直接调用的
Time
类的析构函数,而是调用date
的析构函数,而date
类并没有显式提供析构函数。所以编译器会给date
类生成一个默认的析构函数,并在其中调用Time
类的析构函数。即当
Date
类对象销毁时,要保证其内部每个自定义类型成员对象都可以正确销毁。而默认生成的析构函数对内置类型不进行处理,对自定义类型会调用它的析构函数。注意:创建哪个类的对象则调用该类的构造函数,销毁哪个类就调用该类的析构函数。
-
如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如
date
类,有资源申请时,一定要写,否则会造成资源泄露,比如stack
类等等。
拷贝构造函数
概念引入
在计算机中,Ctrl+C(复制)和Ctrl+V(粘贴)这对快捷键组合几乎是我们最常用的,毕竟,复制粘贴功能确实是比较节省操作步骤以及提高效率。而在C/C++中,对于内置类型,我们也常常使用一个已存在的对象去初始化另一个对象,以得到一个该对象的复制品。
而C++中,当我们创建一个自定义类型对象时,我们当然希望能够像内置类型一样,能够轻易使用一个已存在的该类型对象以初始化该对象。于是,就有了拷贝构造函数。
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const
修饰),再用已存在的该类类型对象创建新对象时由编译器自动调用。
特征
特殊的成员函数,特征如下:
-
拷贝构造函数是构造函数的一个重载形式。
-
拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
class date { public: date(int year = 1970, 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; int _month; int _day; }; int main() { date d1; date d2(d1); //vs下的报错 //error C2652 : “date”: 非法的复制构造函数: 第一个参数不应是“date” //g++下的报错 //error: invalid constructor; you probably meant ‘date (const date&)’ return 0; }
死递归示意图:
正确写法:
注意:可以看到我这里使用了
const
修饰这个引用参数,因为我们并不希望在拷贝的过程中,用于拷贝的对象被改变,所以这里使用const
修饰。 -
若未显式定义,编译器会生成默认的拷贝构造函数。默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
class Time { public: Time() { _h = _m = _s = 0; } Time(const Time& t) { _h = t._h; _m = t._m; _s = t._s; cout << "Time::Time(const Time& t)" << endl; } void Print() { cout << _h << ":" << _m << ":" << _s << endl; } //private: int _h; int _m; int _s; }; class date { public: date(int year = 1970, int month = 1, int day = 1) { _year = year; _month = month; _day = day; _t._h = _t._m = _t._s = 1; } void Print() { cout << _year << "/" << _month << "/" << _day << endl; _t.Print(); } private: //内置类型 int _year; int _month; int _day; //自定义类型 Time _t; }; int main() { date d1(2023, 1, 1); date d2(d1); d2.Print(); return 0; }
运行截图:
注意:在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的。
-
编译器生成的默认拷贝构造函数已经可以完成字节序的拷贝了,还需要自己显式实现吗?当然像日期类这样的类是没有必要的。但是,来看看下面这个简易的
Stack
类,这个类要不要自己实现拷贝构造呢?typedef int Datatype; class Stack { public: //构造函数,申请空间存放栈中的数据 Stack() { _array = new Datatype[16]; _size = 0; _capacity = 16; } //析构函数,释放类对象占用资源 ~Stack() { delete[] _array; _size = _capacity = 0; } //将数据压入栈中 void push(const Datatype& x) { _array[_size++] = x; } private: Datatype* _array; size_t _size; size_t _capacity; }; int main() { Stack st1; st1.push(1); st1.push(2); st1.push(3); st1.push(4); Stack st2(st1); return 0; }
运行截图:
原因分析:
注意:类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。(也可以参考析构函数是否要自己实现,要,那么拷贝构造函数也要)
-
拷贝构造函数典型调用场景:
- 使用已存在对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
class date { public: date(int year = 1970, int month = 1, int day = 1) { cout << "date(int year, int month, int day)" << 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) { return d; } int main() { date d1; test(d1); return 0; }
运行截图:
原理分析:
为了提高程序效率,一般对象传参时,尽量使用引用(指针)类型,返回时根据实际场景,能用引用(指针)尽量使用引用(指针)。
赋值运算符重载
操作符重载
在讲重载赋值操作符前先简单了解一下重载操作符。(当然如果你对函数重载的概念不太清晰或者想了解其原理的话可以看看我的这篇文章)
C++为了增强代码的可读性引入了操作符符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通函数类似。
函数名字:关键字(保留字)operator
后面接需要重载的运算符符号
函数原型:返回值类型 operator
操作符(参数列表)
注意:
- 不能通过连接其他符号来创建新的操作符:比如
operator@ - 重载操作符必须有一个类类型(非内置类型)参数
- 不能重新定义用于内置类型对象的操作符的含义,如内置的整型+,不能改变其含义,也不能使内置的整型数组增加+的操作符功能。
- 形参表必须具有与该操作符数目相同的形参(如果操作符被重载为一个类成员函数,则包括隐式 this 形参)。从左至右的形参位置对应着从左至右操作位。
.* :: sizeof ?: .
注意以上5个运算符不能重载。(这个经常在笔试选择题中出现)
演示:
class date
{
public:
date(int year = 1970, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//private:
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;
}
int main()
{
date d1(20231, 1, 1);
date d2(20231, 1, 1);
date d3;
cout << (d1 == d2) << endl;
//d1 == d2 编译器会解析成 operator==(d1,d2) 这样的函数调用
cout << (d1 == d3) << endl;
//同理 d1 == d3 -> operator==(d1,d3)
return 0;
}
运行截图:
class date
{
public:
date(int year = 1970, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//二
//重载为类的成员函数
//注意此时的函数参数表应该是 (date* const this, const date& d)
//左操作数为this指向调用该函数的对象 也是操作符左侧的对象
bool operator==(const date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
date d1(20231, 1, 1);
date d2(20231, 1, 1);
date d3;
cout << (d1 == d2) << endl;
//d1 == d2 编译器会解析成 d1.operator==(d2) 变成了对象d1的成员函数调用
cout << (d1 == d3) << endl;
//同理 d1 == d3 -> d1.operator==(d3)
return 0;
}
前置++与后置++重载
C++对++
和--
这样的特殊(有前置和后置)的单目操作符又有点特别的处理。
class date
{
public:
date(int year = 1970, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//
// 前置++(--)和后置++(--)都是一元运算符,为了让前置++(--)和后值++(--)形成正确重载
// C++规定:后置++重载时多增加一个int类型的参数,但调用函数时该参数不用传递,编译器自动传递
//
// 简易演示
//
//前置
//返回“+1”之后的对象 这里*this是对象,函数结束后并不会销毁,故可使用引用返回提高程序效率
date& operator++()
{
_day += 1;
return *this;
}
//后置
//返回“+1”之前的对象 这里tmp是函数内局部变量,只能使用传值返回
date operator++(int)//这里只需要写入类型就能时参数表中加入int
{
date tmp(*this);
_day += 1;
return *this;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
date d1(20231, 1, 1);
date d2 = ++d1;
date d3 = d1++;
d1.Print();
d2.Print();
d3.Print();
return 0;
}
运行截图:
赋值运算符重载
-
赋值运算符重载格式
- 参数类型:
const T&
,传递引用可以提高传参效率。 - 返回值类型:
T&
,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值。 - 检测是否自己给自己赋值。
- 返回*this:用于连续赋值。
class date { public: date(int year = 1970, 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; };
- 参数类型:
-
赋值运算符只能重载成类的成员函数不能重载成全局函数。
class date { public: date(int year = 1970, int month = 1, int day = 1) { _year = year; _month = month; _day = day; } //private: int _year; int _month; int _day; }; //此时为非成员函数 没有this指针,所以得传两个参数 date& operator=(const date& d1,const date& d2) { if (&d1 != &d2)//避免自己给自己赋值 { d1._year = d2._year; d1._month = d2._month; d1._day = d2._day; } return d1;//传引用返回对象本身 } //编译失败 //error C2801: “operator =”必须是非静态成员
原因:赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,所以赋值运算符重载只能是类的成员函数。
-
用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。
class Time { public: Time& operator=(const Time& t) { if (this != &t) { _h = t._h; _m = t._m; _s = t._s; cout << "Time::operator=(const Time& t)" << endl; } return *this; } private: int _h; int _m; int _s; }; class date { private: //内置类型 int _year; int _month; int _day; //自定义类型 Time _t; }; int main() { date d1; date d2; d2 = d1; return 0; }
运行截图:
与拷贝构造函数类似,编译器生成的默认赋值运算符重载可以完成字节序的值拷贝。而对于一个类需要自己实现赋值运算符重载的情况,与需要自己实现拷贝构造函数是一样的。来看看这段代码:
// 上文中使用过的简易的栈 简单演示一下 typedef int Datatype; class Stack { public: //构造函数,申请空间存放栈中的数据 Stack() { _array = new Datatype[16]; _size = 0; _capacity = 16; } //析构函数,释放类对象占用资源 ~Stack() { delete[] _array; _size = _capacity = 0; } //将数据压入栈中 void push(const Datatype& x) { _array[_size++] = x; } private: Datatype* _array; size_t _size; size_t _capacity; }; int main() { Stack st1; st1.push(1); st1.push(2); st1.push(3); st1.push(4); Stack st2; st2 = st1; return 0; }
运行截图:
原理分析:
注意:如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理就必须要实现。
const成员
将const
修饰的“成员函数”称之为const
成员函数(注意这里不是说const
修饰返回值类型),const
修饰类成员函数,实际修饰该成员函数隐含的this
指针,表明在该成员函数中不能对类的任何成员进行修改。
来看看下面的代码:
class date
{
public:
date(int year = 1970, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << "Print()" << endl;
cout << _year << "/" << _month << "/" << _day << endl;
}
void Print() const
{
cout << "Print() const" << endl;
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
date d1(2023, 1, 1);
const date d2(2023, 1, 1);
d1.Print();
d2.Print();
return 0;
}
思考一下以下问题:
-
const
对象可以调用非const
成员函数吗?注释掉上面代码中的
const
修饰的Print
函数,就会发现编译不通过。//error C2662: “void date::Print(void)”: 不能将“this”指针从“const date”转换为“date &”
也就是说
const
对象不可以调用非const
成员函数。const
修饰的对象不能够进入有权限修改(本质是隐含指针this的指向没有const
修饰,而传参时const
对象也不能传给非const
修饰的指针)成员对象的成员函数中,权限的放大是不被允许的。 -
非
const
对象可以调用const
成员函数吗?注释掉上面代码中的非
const
修饰的Print
函数,编译运行正常。运行截图:
与第一个情况相反,非
const
对象可以调用const
成员函数,非const
对象可以传参给const
修饰的指针,权限的缩小是被允许的。 -
const
成员函数内可以调用其它非const
成员函数吗?实验代码截图:
//error C2662: “void date::test(void)”: 不能将“this”指针从“const date”转换为“date &”
和第一个问题类似,
const
不能传给非const
,权限不能放大。 -
非
const
成员函数内可以调用其它的const
成员函数吗?编译运行通过,运行截图:
与第二个问题类似,非
const
可以传给const
,权限可以缩小。
取地址及const取地址操作符重载
这两个默认成员函数一般不用重新定义,编译器默认会生成。
class date
{
public:
date* operator&()
{
return this;
}
const date* operator&() const
{
return this;
}
private:
int _year;
int _month;
int _day;
};
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!
结语
以上就是关于类的第二篇讲解了,恭喜你能够看到这里,希望我的文章能帮助你啃下这难啃的骨头。如果你觉得做的还不错的话请点赞收藏加分享,当然如果发现我写的有误或者有建议给我的话欢迎在评论区或者私信告诉我。