目录
一、默认成员函数
二、构造函数
1、构造函数概念
2、构造函数编写
3、默认构造函数
4、内置类型成员的补丁
三、析构函数
1、析构函数概念
2、析构函数编写
3、默认析构函数
四、拷贝构造函数
1、拷贝构造函数概念及编写
2、默认拷贝构造函数
3、拷贝构造函数调用场景
五、赋值运算符重载
1、运算符重载概念
2、运算符重载编写
3、赋值运算符重载
3.1、赋值运算符重载格式
3.2、赋值运算符重载位置
4、默认赋值运算符重载
六、const成员
七、取地址及const取地址操作符重载
一、默认成员函数
当我们写了一个没有成员的空类时:
class Date {};
这个空类里面是真的什么都没有吗?其实不是的,任何类在什么都不写时,编译器会自动生成以下 6 个默认成员函数:
- 构造函数
- 析构函数
- 拷贝构造函数
- 赋值运算符重载
- 取地址操作符重载
- const修饰的取地址操作符重载
二、构造函数
1、构造函数概念
在用类实例化出对象之后,我们通常需要对这个对象进行初始化,以我们学过的栈为例:
typedef int DataType;
class Stack
{
public:
void Init(){
_array = (DataType*)malloc(sizeof(DataType) * 3);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = 3;
_size = 0;
}
void Push(DataType data){
CheckCapacity();
_array[_size] = data;
_size++;
}
//....
//....
private:
void CheckCapacity(){
...;
}
private:
DataType* _array;
int _capacity;
int _size;
};
int main()
{
Stack s;
s.Init(); //每次实例化出对象都要进行初始化
s.Push(1);
s.Push(2);
s.Push(3);
return 0;
}
但如果每次创建对象时都调用 s.Init();设置初始化,未免有点麻烦。那能否在对象被创建时,就直接让对象自动初始化呢?
为了实现这个功能,我们引入了一个概念:构造函数。构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。
2、构造函数编写
构造函数是特殊的成员函数。需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任
务并不是开空间创建对象,而是初始化对象。
构造函数的特性:
- 函数名与类名相同
- 无返回值
- 对象实例化时编译器自动调用对应的构造函数
- 构造函数可以重载
根据构造函数特性,我们提炼出核心内容:构造函数的函数名已经被确定好了,就是类名。构造函数没有返回值,而且不需要在函数名前加 void 。我们可以设置多个构造函数,以完成不同需求的初始化方案。构造函数在对象实例化时自动调用。
现在我们使用构造函数来编写栈的初始化:
这里写了两个构造函数,分为 有参构造 Stack(int n);和 无参构造 Stack();。
我们在实例化对象的时候直接写 Stack s;默认调用的构造函数是无参构造函数 Stack();,即不给栈开辟空间。
如果我们想在初始化时给栈开辟指定的空间,可以在实例化对象的时候写 Stack s(n);调用有参构造函数 Stack(int n);,例如:
注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明。例如:
Stack s();
编译器无法区分这段代码是一个函数的声明,还是调用构造函数。
在以后编写构造函数时,我们也可以利用缺省参数来把有参构造函数与无参构造函数合并来写:
3、默认构造函数
如果我们在类中没有写构造函数,那么编译器会自动生成一个无参的默认构造函数。如果我们写了构造函数,那么编译器不会自动生成。
现在把我们自己写的构造函数注释掉:
程序可以正常执行,因为编译器生成了默认的构造函数。
我们自己写一个不可用的构造函数:
程序运行失败,因为我们已经写了一个构造函数了,所以编译器不会自己生成,尽管我们写的构造函数不可用。
那么既然编译器会自动生成默认构造函数,还需要我们自己来编写吗?
关于这个问题,我们上手实践一下就可以了,先把构造函数注释掉,然后运行程序观察结果:
发现程序运行结果全是随机数,没有价值。
那么编译器生成这个默认构造函数是为了什么?好像完全没用。实际上这里出现的问题,是C++祖师爷在设计语言之初时没有解决掉的问题。
在C++语言中,我们把类型分为两种,一种为内置类型,一种为自定义类型。内置类型就是语言提供的数据类型,如 int、char、double 等等。自定义类型则为我们使用 class、struct、union 等关键字自己定义的类型。
编译器生成的默认构造函数对于自定义类型的成员,会调用他的构造函数。而对于内置类型的成员不做处理。
//自定义类型会生成默认构造
class Date
{
//...
//...
private:
//对内置类型不做处理
int _year;
int _month;
int _day;
};
所以以后我们遇到在对象初始化时需要对内置类型成员设置初始值时,就不要再使用默认构造函数了,自己编写才是王道。
默认构造函数在什么样的情况下会有作用呢? 经过上面的学习,同学们心中应该有了答案,那就是在对象初始化时,如果不需要对内置类型成员设置初始值,默认构造函数就发挥了很大的用处。
还是以栈来作为例子进行说明:
因为对象 q1 中的成员类型是自定义类型 Stack 的,所以编译器自动生成的默认构造函数会调用对象 q1 中的两个自定义类型成员的构造函数。
4、内置类型成员的补丁
前面说过,编译器生成的默认构造函数不会对内置类型成员做处理,是当时C++祖师爷在设计语言时没有解决掉的问题。为了弥补这个错误,C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
在内置类型成员的声明位置给上了缺省值。在对象实例化后,如果我们没有写构造函数,那么这些成员就会被初始化为缺省值。如果我们自己写了构造函数,这些成员就会被初始化为我们指定的初始值。
有了这个补丁,C++的默认构造函数才算是完备了,自定义类型与内置类型混合使用起来才更加方便。
补充内容:无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。
注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。即不传参就可以调用的构造函数被称为默认构造函数,一般建议每个类都提供一个默认构造函数。
三、析构函数
1、析构函数概念
还是以栈作为例子,我们上面学习了构造函数,知道可以通过构造函数来自动完成栈的初始化工作。但是与初始化相对应的,我们每创建出一个栈,在程序结束时都得把这个栈销毁掉,这同样是一个麻烦的操作,那我们是不是可以在程序结束时让栈自动销毁呢?
为了实现这个功能,我们又引入了一个概念:析构函数。析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是在对象出了作用域时,由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
2、析构函数编写
析构函数的特性:
- 析构函数名是在类名前加上字符 ~
- 无参数无返回值类型
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载
- 对象生命周期结束时,C++编译系统系统自动调用析构函数
现在我们使用析构函数来编写栈的销毁:
3、默认析构函数
与默认构造函数的性质类似。编译器生成的默认析构函数对于内置类型成员不处理,对于自定义类型成员,会调用他的析构函数。
四、拷贝构造函数
1、拷贝构造函数概念及编写
在我们实例化一个新的对象时,如果想把这个新的对象初始化成某个已存在的对象相同的数据,则可以使用拷贝构造函数。
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰,一方面防止原类类型对象的数据被修改,另一方面可以接收const类型的实参),在用已存在的类类型对象创建新对象时由编译器自动调用。拷贝构造函数是构造函数的一个重载形式。
例如:
需要注意的是,拷贝构造函数的参数只有一个且必须是类类型对象的引用,而不能直接写成类类型对象。这样做的原因是如果写成类类型对象会造成无限递归,这是编译器所不允许的。
为什么直接把拷贝构造函数的参数写成类类型对象会造成无限递归呢?我们假设写成下面的样子:
在 main 函数中实例化类对象后,编译器会自动调用类对象的构造函数。经过函数重载的匹配,所匹配到的构造函数就是拷贝构造函数。而因为拷贝构造函数的参数是类类对象,属于传值传参,所以在调用拷贝构造函数之前需要先传参,即创建一个类类型的形参并把实参拷贝过来。但是这个创建类类型的形参的过程本身又是一个类类型对象的实例化的过程,又需要调用形参的构造函数。如此往复,就会进入无限的递归之中。
而如果拷贝构造函数的参数是类类型对象的引用,就可以避免实参的拷贝,不需要再实例化出来一个自定义类型的形参出来了,自然也就避免了无限递归。
2、默认拷贝构造函数
若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节顺序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
可以看到,当我们未显示定义拷贝构造函数时,编译器自动生成的拷贝构造函数仍然可以完成应有的工作。这是不是说明我们以后都不需要自己写拷贝构造函数了呢?对于日期类这样的类确实是这样的,因为在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的。
我们再来看下像栈这种类:
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 10)
{
_array = (DataType*)malloc(capacity * sizeof(DataType));
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(const DataType& data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
~Stack()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
DataType* _array;
size_t _size;
size_t _capacity;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Push(3);
s1.Push(4);
Stack s2(s1);
return 0;
}
我们不写拷贝构造函数,让编译器自动生成。结果程序直接崩溃了。
这是因为日期类对象内的成员变量都是内置类型成员,直接按字节拷贝不会有任何问题。
但是栈对象不同,栈对象的数据不是单纯的存放在成员变量里面的,而是存放在堆区开辟的一块空间里的,栈对象成员变量里有一个指针指向这个堆区里开辟的空间。如果直接按字节拷贝,把对象A的内容原封不动的拷贝到对象B里去,那么两个栈对象就会指向同一块空间:
这样会导致两个问题,其一是这两个栈对象插入删除数据会互相影响,其二是同一块空间会析构两次,导致程序崩溃。
补充内容:栈对象 s1 与 s2 都是存放在函数栈帧里面的,函数栈帧是进程里的一块空间,具有后进先出的属性,所以后定义的会先析构,所以 s2 是先析构的那一个。
总结:类中如果没有涉及资源申请时,拷贝构造函数是否写都可以。一旦涉及到资源申请时,则拷贝构造函数是一定要写的,因为编译器生成的默认拷贝构造函数的浅拷贝无法满足我们的需求。
为了让对象有自己独自的空间,我们需要自己写拷贝构造函数来实现深拷贝:
Stack(const Stack& st)
{
_array = (DataType*)malloc(sizeof(DataType) * st._capacity);
if (nullptr == _array)
{
perror("malloc fail");
exit(-1);
}
memcpy(_array, st._array, sizeof(DataType) * st._size);
_size = st._size;
_capacity = st._capacity;
}
此时程序就可以成功运行了。
注意:在编译器生成的默认拷贝构造函数中,内置类型是完成浅拷贝--按照字节方式直接拷贝的,而自定义类型是调用他的拷贝构造函数完成拷贝的。
因为对象 q1、q2 中的两个成员变量是自定义类型成员,所以编译器生成的默认构造函数与默认拷贝构造函数会调用自定义类型成员的构造函数与拷贝构造函数,并且因为我们自己写了拷贝构造的深拷贝,所以新生成的四个栈都有独自的空间。
3、拷贝构造函数调用场景
- 使用已存在对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
class Date
{
public:
Date(int year, int minute, int day)
{
cout << "Date(int,int,int):" << 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)
{
Date temp(d);
return temp;
}
int main()
{
Date d1(2022, 1, 13);
Test(d1);
return 0;
}
所以为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用
尽量使用引用。这样可以避免过多的调用拷贝构造函数。
五、赋值运算符重载
1、运算符重载概念
例如日期类对象,在实际的应用过程中,我们时常会遇到需要比较两个日期的先后、中间差了多少天等等场景。在C语言中遇到这种问题时,一般处理方法是专门写一个实现对应功能的函数,并在需要的时候调取该函数。
而在C++中,为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
需要注意的是,我们直接使用类似于 "==" 、"+"、"-" 等运算符时,必须要调用运算符重载。因为不同于 "int"、"double" 等等内置类型编译器知道按照什么样的规则进行运算,我们编写的自定义类型对象的运算规则需要我们自己通过运算符重载来告诉编译器。
与函数重载不同,函数重载是支持函数名相同,参数不同的函数同时使用。而运算符重载是允许自定义类型对象可以使用运算符。
2、运算符重载编写
运算符重载的函数名字为:关键字 operator 后面接需要重载的运算符符号。
函数原型为:[返回值类型] [operator操作符](参数列表)
为了方便讲解,我们先暂时把类类型对象的成员变量设为公有:
需要重载的运算符符号有几个操作数,那么运算符重载就有几个参数,如果有两个参数,第一个参数就是左操作数,第二个参数就是右操作数:
在程序运行时, d1 == d2;这条代码会被编译器转换为去调用 operator==(d1, d2);。
在下面这种情况下,编译器会报错:
这是因为运算符优先级的原因导致的错误,流插入 "<<" 的优先级比运算符 "==" 的优先级高,从而使程序在运行的时候报错。所以在需要打印运算符重载时,要给他加上括号:
讲到这里,相信同学们已经明白运算符重载是什么意思以及如何实现的了,但是我们上面的写法是有问题的,因为我们为了可以在外面访问类的成员变量,把成员变量都设为了公有的。
如果现在想不改变类成员变量的访问限定符,还可以实现运算符重载,该怎么办呢?也很简单,直接把运算符重载当作类成员函数写在类里面就可以了。
不过这里有一点需要注意的地方,那就是当运算符重载在作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的 this 。
此时,在程序运行时, d1 == d2;这条代码会被编译器转换为去调用 d1.operator==(d2);。
如果还需要比较日期的大于、小于等于、大于等于、不等于,就可以直接复用我们写过的等于和小于:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1) {
_year = year;
_month = month;
_day = day;
}
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
bool operator<(const Date& d)
{
if (_year > d._year)
{
return false;
}
else if (_month > d._month)
{
return false;
}
else if (_day >= d._day)
{
return false;
}
return true;
}
bool operator<=(const Date& d)
{
return *this < d || *this == d;
}
bool operator>(const Date& d)
{
return !(*this <= d);
}
bool operator>=(const Date& d)
{
return !(*this < d);
}
bool operator!=(const Date& d)
{
return !(*this == d);
}
//其他成员函数
//....
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
总结:
- 不能通过连接其他符号来创建新的操作符:比如operator@
- 重载操作符必须有至少一个自定义类型参数
- 用于内置类型的运算符,其含义不能改变,例如:内置的整型 +,不能改变其含义
- 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
- .* :: sizeof ?: . 注意以上5个运算符不能重载。这个经常在笔试选择题中出现。
进阶内容,小白通篇学习之后可以再回来研究:
现在我们再来写一个运算符重载的应用:
class Array
{
public:
int& operator[](int i)
{
assert(i < 10);
return _a[i];
}
const int& operator[](int i) const
{
assert(i < 10);
return _a[i];
}
private:
int _a[10];
int _size;
};
void Func(const Array& aa)
{
for (int i = 0; i < 10; ++i)
{
cout << aa[i] << " ";
}
cout << endl;
}
int main()
{
Array a;
for (int i = 0; i < 10; ++i)
{
a[i] = i;
}
for (int i = 0; i < 10; ++i)
{
cout << a[i] << " ";
}
cout << endl;
Func(a);
return 0;
}
我们把运算符 "[]" 进行运算符重载,就可以通过调用函数把一个类当成一个数组来使用。
其中 int& operator[](int i) 函数与 const int& operator[](int i) const 构成函数重载。调用哪一个取决于我们想要怎么使用。所以一个成员函数可以既有普通成员函数,又有const成员函数。
3、赋值运算符重载
3.1、赋值运算符重载格式
想要给已存在的类对象进行赋值,可以调用赋值运算符重载:
void operator= (const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
在运行时,编译器遇到代码 d2 = d1;会自动调用赋值运算符重载 d2.operator=(d1);从而完成赋值。
这种写法虽然可行,但是不好。因为我们在进行赋值操作的时候,有时需要连续赋值。形如:
x = i = j = k;
这种连续赋值的底层是从右向左赋值的。赋值表达式会先把右边的数赋值给左边的数,并把左边的数作为返回值返回,依次向左赋值。同理,为了保证赋值运算符可以连续赋值,形如:
d3 = d2 = d1;
我们同样需要有返回值:
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
先把 d1 的值赋值给 d2 ,并把 d2 作为返回值返回。因为出了函数作用域之后 d2 依然存在,所以可以使用传引用返回,如此依次递推,可以从右向左全部完成赋值。
其中返回值就是为了支持连续赋值,保持运算符的特性而加进去的。
为了防止出现类似于 "d1 = d1" 这种自己给自己赋值浪费资源的情况,函数中加了一个 if 判断语句。
注意点总结:
- 参数类型:const T&,传递引用可以提高传参效率
- 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
- if语句检测是否自己给自己赋值
- 返回*this :要符合连续赋值的含义
补充内容:
Date d2 = d1;//拷贝构造
Date d3(d1); //拷贝构造
上面的两行代码都属于拷贝构造,而不是赋值运算符重载。赋值运算符重载是用于两个已经实例化完成的对象的,而此时 d2 还没有实例化完成,这里使用 d1 来初始化 d2 ,属于拷贝构造。
3.2、赋值运算符重载位置
赋值运算符只能重载成类的成员函数而不能重载成全局函数 :
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
int _year;
int _month;
int _day;
};
// 赋值运算符重载成全局函数,注意重载成全局函数时没有this指针了,需要给两个参数
Date& operator=(Date& left, const Date& right)
{
if (&left != &right)
{
left._year = right._year;
left._month = right._month;
left._day = right._day;
}
return left;
}
// 编译失败:
// error C2801: “operator =”必须是非静态成员
原因是赋值运算符重载如果不显式实现,编译器就会生成一个默认的赋值运算符重载。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
以下是《C++ primer》中对于赋值运算符重载的说法:
4、默认赋值运算符重载
用户没有在类中显式实现赋值运算符重载时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。
注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符
重载完成赋值。
如上如所示,我们把自己写的赋值运算符重载注释掉后,依靠编译器自动生成的依然可以完成赋值。既然编译器生成的默认赋值运算符重载函数已经可以完成字节序的值拷贝了,还需要自己实
现吗?
像日期类这样的类是没必要的,因为类中未涉及到资源管理,赋值运算符是否实现都可以。但是像栈这样涉及到资源管理的类则必须要自己实现赋值运算符重载以完成深拷贝。关于深拷贝的内容我后面会再写一篇博客专门讲解。
六、const成员
将 const 修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的 this 指针,表明在该成员函数中不能对类的任何成员进行修改。
当我们写出如下代码时,编译器会报错:
class Test
{
public:
void Print()
{
cout << _a << endl;
}
private:
int _a = 10;
};
int main()
{
const Test t;
t.Print();
}
提示不能将 “this” 指针从 “const Test” 转换为 “Test &” 。
这是因为我们实例化的对象 t 是 const 类型的,而调用成员函数时所传递的 this 指针不是 const 类型的,这属于权限的放大,不被编译器所允许。
所以我们要把 this 指针也修改成 const 类型。而 this 指针是一个隐藏起来的指针,我们无法直接修改,只能通过一个间接的方式来设置。设置方式如下:
void Print() const
{
cout << _a << endl;
}
const 修饰 *this , this 的类型变成 const Test* 。
此时,程序成功运行。
所以对于内部不改变成员变量的成员函数,最好加上 const ,这样 const对象 与 普通对象 都可以调用该函数。
七、取地址及const取地址操作符重载
这两种成员函数一般不用我们自己去定义,编译器默认会生成。
我们没有定义取地址及const取地址操作符重载,但是编译器默认生成的函数已经完全可以满足我们的使用需求了。
当然如果非要自己写也可以自己来定义编写,不过没有什么价值:
Test* operator&() //取地址操作符重载
{
return this;
}
const Test* operator&() const //const取地址操作符重载
{
return this;
}
如果大家闲得无聊的话也可以硬是给他们创造价值,比如不希望别人使用操作符 "&" 来取地址的时候,我们就可以返回一个假的地址给操作者:
Test* operator&() //取地址操作符重载
{
return nullptr;
}
const Test* operator&() const //const取地址操作符重载
{
return nullptr;
}
关于类和对象第二部分的内容就讲到这里,希望同学们多多支持,如果有不对的地方欢迎大佬指正,谢谢!