0. 引入6个默认成员函数

对于默认成员函数,如果我们不主动实现,编译器会自己生成一份。
比如我们在上一篇里举过的一个 Stack 的例子,如果需要初始化和清理,"构造函数" 和 "析构函数" 就可以帮助我们完成。构造函数就类似于 Init,而析构函数就类似于 Destroy。
1. 构造函数(默认成员函数)
#include <iostream>
using namespace std;
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
printf("%d-%d-%d\n", _year, _month, _day);
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Init(2023, 4, 23);
d1.Print();
Date d2;
d2.Init(2022, 5, 2);
d2.Print();
return 0;
}
1.1 构造函数的概念
构造函数的意义:能够保证对象被初始化。
构造函数是特殊的成员函数,主要任务是初始化对象,而不是开空间。
(虽然构造函数的名字叫构造)
1.2 构造函数的特性和用法
构造函数是特殊的成员函数,主要特征如下:
① 构造函数的函数名和类名是相同的
② 构造函数无返回值(也不用写void)
③ 构造函数可以重载
④ 会在对象实例化时自动调用对象定义出来。
构造函数的用法:
#include <iostream>
using namespace std;
class Date
{
public:
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
printf("%d-%d-%d\n", _year, _month, _day);
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1; // 对象实例化,此时触发构造,调用无参构造函数
d1.Print();
Date d2(2023, 5, 2); // 对象实例化,此时触发构造,调用带参构造函数
// 这里如果调用带参构造函数,我们需要传递三个参数(这里我们没设缺省) 。
//如果想传几个就传几个可以自己设置重载
d2.Print();
// 注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明
// 以下代码的函数:声明了d3函数,该函数无参,返回一个日期类型的对象
Date d3();
// warning C4930: “Date d3(void)”: 未调用原型函数(是否是有意用变量定义的?)
//构造函数是特殊的,不是常规的成员函数,不能直接调d1.Data();
return 0;
}
1.3 默认构造函数
class Date
{
public:
//无参构造函数 是 默认构造函数
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
//全缺省构造函数 也是 默认构造函数 (一般写全缺省,不写上面那个)
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
无参构造函数、全缺省构造函数都被称为默认构造函数。并且默认构造函数只能有一个。
注意事项:
① 无参构造函数、全缺省构造函数、我们没写编译器默认生成的无参构造函数,这三个都可以认为是默认构造函数。
② 语法上无参和全缺省可以同时存在,但如果同时存在会引发二义性:无参的构造函数和全缺省的构造函数都成为默认构造函数,并且默认构造参数只能有一个,语法上他们两个可以同时存在,但是如果有对象定义去调用就会报错。
C++ 规定:我们不写编译器默认生成构造函数,对于内置类型的成员变量,不做初始化处理。
但是对于自定义类型的成员变量会去调用它的默认构造函数(不用参数就可以调的)初始化。
如果没有默认构造函数(不用参数就可以调用的构造函数)就会报错。
#include <iostream>
using namespace std;
class Time
{
public:
Time()
{
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
void Print()
{
printf("%d %d %d\n", _year, _month, _day);
}
private:
int _year;// 基本类型(内置类型)
int _month;
int _day;
Time _t;// 自定义类型
};
int main()
{
Date d;
d.Print();
return 0;
}
很多人吐槽不写构造函数编译器会默认生成的这个特性设计得不好,因为没有对内置类型和自定义类型统一处理,不处理内置类型成员变量,只处理自定义类型成员变量。
但是覆水难收,所以C++11 中针对内置类型成员不初始化的缺陷,又打了补丁:内置类型成员变量在类中声明时可以给默认值:
#include <iostream>
using namespace std;
class Time
{
public:
Time()
{
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
void Print()
{
printf("%d %d %d\n", _year, _month, _day);
}
private:
int _year = 1;// 基本类型(内置类型)
int _month = 1;
int _day = 1;
//注意这里不是初始化,是给默认构造函数缺省值
Time _t;// 自定义类型
};
int main()
{
Date d;
d.Print();
return 0;
}
需要注意的是,上面代码中如果自定义类型Time没有写构造函数,编译器也什么都不会处理。
2. 析构函数(默认成员函数)
2.1 析构函数概念
以前我们写数据结构的时候经常忘记调用 destroy 函数,但是现在我们有析构函数了。
2.2 析构函数特性
构造函数是特殊的成员函数,主要特征如下:
① 析构函数名是在类名前面加上字符
② 析构函数既没有参数也没有返回值(因为没有参数,所以也不会构成重载问题)
③ 一个类的析构函数有且仅有一个(如果不写系统会默认生成一个析构函数)
④ 析构函数在对象生命周期结束后,会自动调用。(和析构函数是对应的构造函数是在对象实例化时自动调用)
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
printf("%d-%d-%d\n", _year, _month, _day);
}
~Date()
{
cout << "~Date()" << endl;// 日期类没有资源需要清理,所以只打印下知道调用了
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2(2023, 5, 2);
return 0;
}
d1 和 d2 都会调用析构函数:
拿 Stack 来举个例子,体会下构造函数和析构函数的用处,我们知道,栈是需要 destroy 清理开辟的内存空间的。
#include<iostream>
#include<stdlib.h>
using namespace std;
typedef int StackDataType;
class Stack
{
public:
Stack(int capacity = 4) // 这里只需要一个capacity就够了,默认给4(利用缺省参数)
{
_array = (StackDataType*)malloc(sizeof(StackDataType) * capacity);
if (_array == NULL)
{
cout << "Malloc Failed!" << endl;
exit(-1);
}
_top = 0;
_capacity = capacity;
}
~Stack() // 这里就用的上析构函数了,我们需要清理开辟的内存空间(防止内存泄漏)
{
free(_array);
_array = nullptr;//下面这两行可以不写,这个野指针已经没人能访问到了
_top = _capacity = 0;//但写了也是个好习惯
}
private:
int* _array;
size_t _top;
size_t _capacity;
};
int main(void)
{
Stack s1;
Stack s2(20); //初始capacity给20
return 0;
}
代码解读:我们在设置栈的构造函数时,定义容量 capacity 时利用缺省参数默认给个4的容量,这样用的时候默认就是4,如果不想要4可以自己传。如此一来,就可以保证了栈被定义出来就一定被初始化,用完后会自动销毁。以后就不会有忘记调用 destroy 而导致内存泄露的惨案了,这里的析构函数就可以充当销毁的作用。
有没有想过,这里是先析构 s1 还是先析构 s2?既然都这样问了,应该是先析构 s2 了 ,没错没错,栈帧和栈里面的对象都符合栈的性质,析构的顺序在局部的栈中是相反的,栈帧销毁清理资源时 s2 先清理,然后再清理 s1 。(可以在析构函数打印参数看看)(贴两个图)
这张图3也是全局的:
如果我们不自己写析构函数,让编译器自动生成,那么这个默认析构函数和默认构造函数类似: ① 对于 "内置类型" 的成员变量:不作处理,② 对于 "自定义类型" 的成员变量:会调用它对应的析构函数。
可能有人要说帮我都销毁掉不就好了?举个最简单的例子,迭代器,析构的时候是不释放的,因为不需要析构函数来管,所以默认不对内置类型处理是正常的,这么一来默认生成的析构函数不就没有用了吗?
有用,他对内置类型的成员类型不作处理,会在一些情况下非常的有用。比如说:以前我们写过的:两个栈实现一个队列用C++非常方便:数据结构与算法⑨(第三章_下)队列的概念和实现(力扣:225+232+622)_GR C的博客-优快云博客还可以去试着用C++写一下:力扣232. 用栈实现队列。
3. 拷贝构造函数(默认成员函数)
我们在创建对象的时候,能不能创建一个与已存在对象一模一样的新对象呢?
Date d1(2023, 5, 3);
d1.Print();
Date d2(d1);//把d1拷贝给d2
d2.Print();
Date d3 = d1;//把d1拷贝给d3 (这也是拷贝构造,后面学的赋值是两个已存在的对象)
d3.Print();
当然可以,这时我们就可以用拷贝构造函数。
3.1 拷贝构造函数概念
拷贝构造函数:只有一个形参,该形参是对本类类型对象的引用(一般常用 const 修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。实现一个上面日期类的拷贝构造:
Date(const Date& d) // 这里要用引用,否则就会无穷递归下去
{
_year = d._year;
_month = d._month;
_day = d._day;
}
3.2 拷贝构造函数特性和用法
拷贝构造函数也是一个特殊的构造函数,所以他符合构造函数的一些特性:
① 拷贝构造函数是构造函数的一个重载形式。函数名和类名相同,没有返回值。
② 拷贝构造函数的参数只有一个,并且必须要使用引用传参, 使用传值方式编译器直接报错,因为会引发无穷递归调用。
拷贝构造函数的用法:
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)// 这里一定要用引用,否则就会无穷递归下去,加const是为了原来的d1被修改
{
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
printf("%d-%d-%d\n", _year, _month, _day);
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 3);
d1.Print();
Date d2(d1);//把d1拷贝给d2
d2.Print();
Date d3 = d1;//把d1拷贝给d3
d3.Print();
return 0;
}
为什么必须使用引用传参呢?
调用拷贝构造,需要先传参数,传值传参又是一个拷贝构造。
调用拷贝构造,需要先传参数,传值传参又是一个拷贝构造。
调用拷贝构造,需要先传参数,传值传参又是一个拷贝构造。
……
一直在传参这里出不去了,所以这个递归是一个无穷无尽的。
注意:如果参数在函数体内不需要改变,建议把 const 加上。
3.3 默认生成的拷贝构造
默认生成拷贝构造:
① 内置类型的成员,会完成按字节序的拷贝(把每个字节依次拷贝过去)。
② 自定义类型成员,会再调用它的拷贝构造。
拷贝构造我们不写生成的默认拷贝构造函数,对于内置类型和自定义类型都会拷贝处理。但是处理的细节是不一样的,这个跟构造函数和析构函数是不一样的。
(把上面的代码中自己写的拷贝构造屏蔽了,运行结果还是一样:)
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//Date(const Date& d)// 这里一定要用引用,否则就会无穷递归下去,加const是为了原来的d1被修改
//{
// _year = d._year;
// _month = d._month;
// _day = d._day;
//}
void Print()
{
printf("%d-%d-%d\n", _year, _month, _day);
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 3);
d1.Print();
Date d2(d1);//把d1拷贝给d2
d2.Print();
Date d3 = d1;//把d1拷贝给d3
d3.Print();
return 0;
}
所以为什么要写拷贝构造?写它有什么意义?这里没有什么意义。当然,这并不意味着我们都不用写了,有些情况还是不可避免要写的比如实现栈的时候,栈的结构问题,导致这里如果用默认的拷贝构造,会程序崩溃。按字节把所有东西都拷过来会产生问题,如果 Stack st1 拷贝出另一个 Stack st2(st1) ,会导致他们都指向那块开辟的内存空间,导致他们指向的空间被析构两次,导致程序崩溃,然而问题不止这些。
其实这里的字节序拷贝是浅拷贝,下面几章我们会详细讲一下深浅拷贝,这里的深拷贝和浅拷贝先做一个大概的了解。对于常见的类,比如日期类,默认生成的拷贝构造能用。但是对于栈这样的类,默认生成的拷贝构造不能用。
4. 运算符重载
运算符重载简单来说:就是能让自定义类型和内置类型一样使用运算符。
4.1 运算符重载的概念
运算符重载是具有特殊函数名的函数,能让自定义类型和内置类型一样使用运算符。
函数名为 :关键字 operator 后面接需要重载的运算符符号 。 比如:operator+ operator> operator==
函数原型:返回值类型 operator 操作符 ( 参数列表 )返回值类型:看操作符运算后返回的值是什么。参数:操作符有几个操作数,它就有几个参数。
注意事项:
- 不能通过连接其他符号来创建新的操作符,比如operator@,只能对已有的运算符进行重载,也不能对内置类型进行重载。
- 重载操作符必须有一个类类型或枚举类型的操作数。
- 用于内置类型的操作符,其含义不能改变。比如内置的整型 +,不能改变其含义。
- 作为类成员的重载函数时,其形参看起来比操作数数目少 1,成员函数的操作符有一个默认的形参 this,限定为第一个形参。
- 不支持运算符重载的 5 个运算符:(这个经常在笔试选择题中出现)
. (点运算符)
:: (域运算符)
.* (点星运算符)(目前博客没讲过的)
?: (条件运算符)
sizeof
虽然点运算符( . )不能重载,但是箭头运算符( -> )是支持重载的,解引用(*)是可以重载的,不能重载的是点星运算符( .* )
4.2 运算符重载示例
我们重载一个判断日期类相等的运算符:==
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, 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(2023, 5, 2);
Date d2(2023, 5, 3);
cout << (d1 == d2) << endl;//这里的流插入运算符比我们重载的==优先级高,所以要加括号
return 0;
}
这里运算符重载成全局的,不得不将成员变成是公有的,得把 private 注释掉,那么问题来了,封装性如何保证?这里其实可以用 "友元" 来解决,如果现在不知道也没关系,后面会讲。用友元也是不好的,所以一般直接重载成成员函数:
#include <iostream>
using namespace std;
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;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 2);
Date d2(2023, 5, 3);
cout << (d1 == d2) << endl;
//编译器自动转化为:
// cout << (d1.operator==(d2)) << endl;
return 0;
}
既然要当成员函数,就得明白这里的 this 指的是谁。需要注意的是,左操作数是 this 指向的调用函数的对象。(关于运算符重载我们下一篇还会完整的实现一个日期类,重载各种运算符,比如日期减日期)
5. 赋值运算符重载(默认成员函数)
5.1 赋值运算符重载概念
赋值运算符重载主要是把一个对象赋值给另一个对象。
如果你不写,编译器会默认生成。
要分清赋值和拷贝构造的区别:
int main()
{
// 一个已经存在的对象初始化一个马上创建实例化的对象
Date d1(2023, 5, 3);
Date d2(d1); // 拷贝构造
Date d3 = d1; // 拷贝构造
// 两个已经存在的对象,之间进行赋值拷贝
Date d4(2023, 5, 4);
d1 = d4; // 赋值 让 d1 和 d4 一样
return 0;
}
5.2 赋值运算符重载使用
赋值运算符重载主要有以下四点:
- ① 参数类型
- ② 返回值
- ③ 检查是否给自己复制
- ④ 返回 *this
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date& operator=(const Date& d)
{
if (this != &d) // 防止自己跟自己赋值(这里的&d是取地址)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this; // 返回左操作数d1
}
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 3);
Date d2(2023, 5, 4);
d1 = d2;
d1.Print();
d2.Print();
return 0;
}
自己给自己赋值是无意义的,这里加 if 语句来判断就是为了防止极端情况下,自己给自己赋值,加上这条判断后就算遇到自己给自己赋值,就会不做处理,直接跳过。
因为出了作用域 *this 还在,所以我们可以使用引用来减少拷贝。(因为传值返回不会直接返回对象,而是会生成一个拷贝的对象,这里减少了两次拷贝构造的调用)
5.3 默认生成的赋值运算符重载
赋值运算符重载是默认成员函数,所以如果一个类没有显式定义赋值运算符重载,编译器默认生成复制重载,跟拷贝构造做的事情完全类似:
① 内置类型成员,会完成字节序值拷贝 —— 浅拷贝。
② 对于自定义类型成员变量,会调用它的 operator= 赋值。
把我们自己写的赋值运算符重载注释掉:
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//Date& operator=(const Date& d)
//{
// if (this != &d) // 防止自己跟自己赋值(这里的&d是取地址)
// {
// _year = d._year;
// _month = d._month;
// _day = d._day;
// }
// return *this; // 返回左操作数d1
//}
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 3);
Date d2(2023, 5, 4);
d1 = d2;
d1.Print();
d2.Print();
return 0;
}
既然编译器会自己默认生成,已经可以完成字节序的值拷贝了,我们还需要自己实现吗?当然像日期这样的类是没有必要的,但有时候还是需要自己实现的。比如下面的情况:
#include<iostream>
using namespace std;
typedef int DataType;
class Stack
{
public:
Stack(int capacity = 10)
{
_array = (DataType*)malloc(capacity * sizeof(DataType));
if (_array == nullptr)
{
perror("malloc申请空间失败");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(const DataType& data)
{
_array[_size] = data;
_size++;
}
~Stack()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
DataType* _array;
int _size;
int _capacity;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Push(3);
s1.Push(4);
Stack s2;
s2 = s1;
return 0;
}
6. const 成员
6.1 const 成员的作用
上面定义的日期类,普通对象对它调用 Print ,是可以调得动的。
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, 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(2023, 5, 3);
const Date d2(2023, 5, 4);
d1.Print();
d2.Print();
return 0;
}
如果这个对象是 const 的呢?这样编译就报错了:
error C2662: “void Date::Print(void)”: 不能将“this”指针从“const Date”转换为“Date &”
这块报错的原因是什么?这里涉及的问题是 "权限的放大" ,这个知识点在下面这篇博客讲过:
从C语言到C++②(第一章_C++入门_中篇)缺省参数+函数重载+引用_GR C的博客-优快云博客
此时可以使用 const 修饰类的成员函数来解决这种情况。
6.2 const 修饰类的成员函数
将 const 修饰的类成员函数,称之为 const 成员函数。
const 修饰类成员函数,实际修饰的是该成员函数隐含的 this 指针,表明在该成员函数中不能对类的任何成员进行修改。
这里可以在函数后面加 const,保持权限的统一:
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print() const //这里语法就是加在这里的,虽然有点奇怪
{
//void Print(Date* const this)变成了void Print(const Date* const this)
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 3);
const Date d2(2023, 5, 4);
d1.Print();
d2.Print();
return 0;
}
权限的放大会报错,这里d1是权限的缩小,d2没有改变权限。
使用建议:建议能加上 const 都加上,这样普通对象和 const 对象都可以调用了。但是,如果要修改成员变量的成员函数是不能加的,比如日期类中 += ++ 等等实现。它是要修改的,加不了就算了。
7. 取地址及const取地址操作符重载(两个默认成员函数)
这两个运算符一般不需要重载,因为它是默认成员函数,编译器会自己默认生成。一般用编译器自己生成的就够了,所以这里只是简单演示一下使用:
7.1 取地址及const取地址操作符重载(自己实现)
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
Date* operator&()
{
return this;
}
const Date* operator&() const
{
return this;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 3);
cout << &d1 << endl;// 取出d1的地址
const Date d2(2023, 5, 4);
cout << &d2 << endl;// 取出d2的地址
return 0;
}
7.2 取地址及const取地址操作符重载(默认生成)
直接把我们写的注释掉:
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
//Date* operator&()
//{
// return this;
//}
//const Date* operator&() const
//{
// return this;
//}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 3);
cout << &d1 << endl;// 取出d1的地址
const Date d2(2023, 5, 4);
cout << &d2 << endl;// 取出d2的地址
return 0;
}
只有特殊情况才需要重载,比如你不想让别人取到你的地址:
可以放到私有,或者自己实现返回空指针:
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
Date* operator&()
{
return nullptr;
}
const Date* operator&() const
{
return nullptr;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 3);
cout << &d1 << endl;// 取出d1的地址
const Date d2(2023, 5, 4);
cout << &d2 << endl;// 取出d2的地址
return 0;
}
本篇完。
下一篇:从C语言到C++⑥(第二章_类和对象_中篇_续)大练习(日期类)+笔试选择题。
(穿越回来复习顺便放个下篇链接)