上一篇文章提到,如果一个类中什么成员都没有,简称为空类。
空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自生成以下6个默认成员函数。
默认成员函数
:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。
由于本篇文章知识较为繁杂,所以画了一个思维导图便于理解,同时,标红的地方是重点需要理解的内容!!
上篇中,类里面会设置初始化成员函数,但是如果每次创建对象之后,都要调用这个函数来初始化,未免过于麻烦,构造函数就是在创建这个对象的同时,就初始化。
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
构造函数
是特殊的成员函数,构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特征如下:
- 函数名与类名相同。
- 无返回值。
- 对象实例化时编译器自动调用对应的构造函数。
- 构造函数可以重载。
比如下方代码,两个构造函数造成重载,一个是无参数的,另一个有三个参数。那么我们在创建对象的时候,就可以有两种方法来初始化,如 main 函数中的 d1 和 d2,分别对应两个构造函数。
class Date
{
public:
Date()
{
_year = 1;
_month = 2;
_day = 3;
}
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(2023, 2, 3);
Date d2;
d1.Print();
d2.Print();
return 0;
}
但是,写多个构造函数过于繁琐,可以利用缺省参数
,如下,就可以有多种方式初始化对象。
Date(int year=1,int month=2,int day=3)
{
_year = year;
_month = month;
_day = day;
}
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义,编译器将不再生成。
如下代码,将 Date 类中的 成员变量 test 屏蔽,然后创建两个对象,由于 Date 类没有定义构造函数,所以由编译器自动生成。通过输出结果可以知道,两个对象里面的数据都是随机值。
这样的话,编译器自动生成的构造函数岂不是毫无意义,反正对象里的数据也是随机值。实际上编译器自动创建的构造函数有一些规则:
- 对于
内置类型
的成员变量,构造函数不会进行任何操作。 内置类型——int、double、char等等。- 对于
自定义类型
的成员变量,会调用该自定义类型的构造函数。
明白这个规则之后,将 Date 类里面的成员变量 test 放开。使用 Stack 类 的 test 来测试一下,Stack 是自定义类型。发现确实打印了两行 " Test Stack" 字符串,证明 Stack 类里面的构造函数被调用了两次,并且也可以监视变量,d1、d2 里的值确实符合上面的规则。
class Stack
{
public:
Stack()
{
cout << "Test Stack" << endl;
_a = nullptr;
_size = 0;
_capacity = 0;
}
private:
int* _a;
int _size;
int _capacity;
};
class Date
{
public:
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
//Stack test;
};
int main()
{
Date d1;
Date d2;
d1.Print();
d2.Print();
// 对上面规则的验证——在日期类中,定义一个Stack类的成员变量
//通过监视窗口或者输出结果可以看到,确实调用了自定义类型的构造函数,但是为什么内置类型也初始化了?
// 这是因为有的编译器会自动初始化,有的不会,具体要看编译器
// 默认生成析构函数,对自定义类型成员,会调用他的析构函数
Date d1;
return 0;
}
当然了,对于内置类型不初始化,这其实是 C++ 本身设计的一个缺陷,但是由于软件设计“向前兼容”的特性,新的版本要兼容旧版本,可以“打补丁”,但不可以直接删掉旧版本的缺陷部分。所以在C++11 中针对这一缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
如下,在声明三个内置类型成员变量的时候,都给了默认值。但是这并不是初始化,因为这三个变量并没有开辟空间,只是在类里面的定义了而已。
也可以理解为缺省值——自定义的构造函数中,没有初始化的变量,其值就是缺省值。下面代码有构造函数,但是只可以初始化 _year ,所以 Date d1(1900); 对象 d1 就代表 1900 年 2 月 4 日 。
class Date
{
public:
void Date(int year)
{
_year=year;
}
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year = 2023;
int _month = 2;
int _day = 4;
Stack test;
};
无参的构造函数和全缺省的构造函数都称为默认构造函数
,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。
创建一个对象的时候可以有构造函数这样便捷的方式,清理资源的时候当然也有类似的方法,那就是析构函数
。
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
- 析构函数名是在类名前加上字符 ~。
- 无参数、无返回值类型。
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载。
- 对象生命周期结束时,C++编译系统系统自动调用析构函数。
如下代码,我们并没有对析构函数进行调用,只是实例化了一个对象,但是依然输出了析构函数中的"~Stack()" ,可以证明是编译器自动调用了析构函数。
但是上面几点特性不是说到,如果不定义析构函数,系统会自己生成吗?那为什么还要自己写呢?下文将会做出解答。
class Stack
{
public:
Stack(int size)
{
_a=(int*)malloc(sizeof(int)*size);
// ……
}
~Stack()
{
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_size = _capacity = 0;
}
private:
// 成员变量
int* _a;
int _size;
int _capacity;
};
int main()
{
Stack s1;
return 0;
}
当我们不写析构函数的时候,编译器会自动生成。
类比于构造函数,编译器自动生成的析构函数也有相似的规则:
- 对于
内置类型
的成员变量,不会进行任何操作。- 对于
自定义类型
的成员变量,会调用该自定义类型的析构函数。
既然如此,那么试想一下,如果是Date 类的对象,由于没有申请资源,所以写不写析构函数无所谓。
但是上面的Stack 类实例化对象 s1,申请了资源(malloc),如果不写析构函数,当 s1 出了自己的作用域A,A的函数栈帧(在栈区)被销毁,但是 s1 中的 _a 指向堆区开辟的资源并未被释放,就会造成资源浪费。
如下,运行后会打印 “Test Stack” 和 “Test ~Stack” ,但是我们只实例化了 Date 类的一个对象d1。这是因为, d1 中有一个 Stack 类的变量 test,创建 d1 的时候,会调用它的构造函数,销毁 d1 的时候,会调用它的 析构函数。
class Stack
{
public:
Stack()
{
cout << "Test Stack" << endl;
_a = nullptr;
_size = 0;
_capacity = 0;
}
~Stack()
{
cout<<"Test ~Stack" << endl;
}
private:
int* _a;
int _size;
int _capacity;
};
class Date
{
public:
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
Stack test;
};
int main()
{
Date d1;
return 0;
}
有了构造函数和析构函数的预备知识,在某些方面我们就可以很方便地进行代码编写了。如下,对比 C 和 C++ 的代码,C++ 一方面初始化的时候方便了,另一方面,销毁的时候不需要自己去调用函数, 编译器会自动调用析构函数。
在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎。
那在创建对象时,可否创建一个与已存在对象一模一样的新对象呢?
拷贝构造函数
:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
拷贝构造函数也是特殊的成员函数,其特征如下:
- 拷贝构造函数是构造函数的一个重载形式。
- 拷贝构造函数的参数只有一个且必须是类类型对象的引用,参数使用传值方式编译器直接报错,因为会引发无穷递归调用。
如下,是拷贝构造的一个正确形式
class Date
{
public:
Date(int year=2023, int month=2, int day=3)
{
_year = year;
_month = month;
_day = day;
}
// 拷贝构造
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2 = d1; // 拷贝构造
Date d3(d1); // 这样写也可以
d1.Print();
d2.Print();
d3.Print();
return 0;
}
那么,为什么拷贝构造的参数必须要传引用,而不可以传值拷贝呢?
在这里我们对 C++ 中的函数传参做一个区分,如下图。对于传值传参而言,如果是自定义类型,C++ 规定必须要调用它的拷贝构造函数。
可以看这下面三个函数感受一下。
// 传值传参
void Func1(Date d)
{
// 对于这种参数,要拷贝一个Date类型的 d 作为参数,由于Date类型是自定义类型,所以编译器无法直接拷贝,需要调用该类型的拷贝构造函数。
}
// 传引用传参
void Func2(const Date& d)
{
//传引用就不一样了,d是实参的别名,不存在拷贝的问题
// 但是为了避免改变实参,要加上const
}
// 传指针传参
void Func3(Date* d)
{
// 这样子自然也是可以,但是用起来要加&,不如上面方便
}
所以,如果拷贝构造的参数是传值拷贝
,就会造成下图所示的情况:调用拷贝构造,要先拷贝出参数;参数是自定义类型,需要调用拷贝构造,这个拷贝构造也要先拷贝出参数;参数又调用拷贝构造…… 这样会导致死循环。
那为什么 C++ 会有这么 “离谱” 的要求呢?——规定参数如果是 自定义类型,必须调用它的拷贝构造。
假设像C 语言一样,把内存中的数据直接按字节赋值一份(浅拷贝),那假如自定义类型里面有指针呢?拷贝的数据和原来的数据,两个里面的指针岂不是指向一块地方,所以必须要调拷贝构造,不可以编译器自行拷贝。
如下,对于 Date 类,浅拷贝没什么关系, d1、 d2 两个对象互不干涉。
但是对于 Stack 类,如果也是浅拷贝,假设 st2 拷贝 st1(也就是把 st1 中的数据一摸一样复制到 st2 中),就会导致两个对象中的 _a 指针指向同一块空间,两个对象压栈、出栈都会互相影响。并且对于析构函数而言(编译器自动调用),先 free 了 st2 指向的空间(红色箭头),再 free 了 st1 指向的空间,就会崩溃,因为同一块空间无法 free 两次。
正是因为 C 语言只有浅拷贝这种不合理的设计,本贾尼博士才会在 C++ 中引入深拷贝。
当然,传指针实现拷贝构造也可以,但是使用的时候:一方面, Date d3(&d1); 参数要传指针,没有引用方便。 另一方面,只能有 Date d3(&d1); 这一种调用方式,传引用可以有两种调用方法。
若未显式定义拷贝构造,编译器会生成默认的拷贝构造函数。
默认的拷贝构造函数自然也遵循一定的规则:
内置类型
的成员变量,是按照字节方式直接拷贝(浅拷贝)。自定义类型
的成员变量,是调用其拷贝构造函数完成拷贝的(深拷贝)。
编译器生成的默认拷贝构造函数已经可以完成浅拷贝了,还需要自己显式实现吗?当然像 Date 类这样的类是没必要的。那么下面的类呢?
如果 Stack 类也使用默认生成的浅拷贝,那么拷贝构造就会造成上面说的,多个对象中的指针指向同一块空间。所以像这样的类必须实现深拷贝,就要自己写拷贝构造函数。
可是,什么时候才知道要自己实现拷贝构造函数呢?是类里面的成员变量含有指针,就要显示实现吗? 实际上,当自己实现了析构函数释放空间,就需要实现拷贝构造。
class Stack
{
public:
Stack(int size=4)
{
int* tmp = (int*)malloc(sizeof(int)*size);
if (!tmp)
{
perror("malloc fail::");
exit(-1);
}
_a = tmp;
_size = 0;
_capacity = 4;
}
// 拷贝构造
Stack(const Stack& st)
{
int* tmp = (int*)malloc(sizeof(int) * st._capacity);
if (tmp == nullptr)
{
perror("malloc fail::");
exit(-1);
}
_a = tmp;
memcpy(_a, st._a, sizeof(int) * st._size);
_size = st._size;
_capacity = st._capacity;
}
~Stack()
{
if(_a) free(_a);
_a = nullptr;
_size = 0;
_capacity = 0;
}
private:
int* _a;
int _size;
int _capacity;
};
上面两个例子都是:一个类里面没有其他类类型的成员变量。
如下代码,Date类 实例化出了一个 d1 对象,然后 d2对象 用拷贝构造函数复制 d1。Date 类 具有内置类型
和自定义类型(Stack 类)
的成员变量。d2 对于内置类型的数据,是直接浅拷贝 d1(编译器自动生成);d2对于 s 变量,是调用了 Stack 类的拷贝构造函数,实现深拷贝。
这就可以完美地匹配上面所说的,编译器自动生成的拷贝构造遵循的两个规则:
class Stack
{
public:
Stack(int size=4)
{
int* tmp = (int*)malloc(sizeof(int)*size);
if (!tmp)
{
perror("malloc fail::");
exit(-1);
}
_a = tmp;
_size = 0;
_capacity = 4;
}
Stack(const Stack& st)
{
int* tmp = (int*)malloc(sizeof(int) * st._capacity);
if (tmp == nullptr)
{
perror("mallocfail::");
exit(-1);
}
_a = tmp;
memcpy(_a, st._a, sizeof(int) * st._size);
_size = st._size;
_capacity = st._capacity;
}
~Stack()
{
if(_a) free(_a);
_a = nullptr;
_size = 0;
_capacity = 0;
}
private:
int* _a;
int _size;
int _capacity;
};
class Date
{
public:
Date(int year = 2023, int month = 2, int day = 3)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
Stack s; // 自定义类型,拷贝构造的时候需要调用Stack 的拷贝构造
};
int main()
{
Date d1(2002,12,3);
Date d2 = d1;
return 0;
}
如下图可以验证, d2 中的内置类型数据 _year 、_month 、_day 是浅拷贝,而自定义类型 s 中的 _a 指针指向不同的内存空间,s是深拷贝。
在内置类型中,我们可以使用多种运算符,比如 + 、- 、= 、== 、< 、>= 等等,但是对于自定义类型,比如 Date 类,我们就不可以直接使用这些运算符。
C++为了增强代码的可读性引入了运算符重载
,运算符重载是具有特殊函数名的函数,也具有其返回值类型、函数名字、参数列表,其返回值类型与参数列表与普通的函数类似。
函数原型:返回值类型 operator操作符(参数列表)
例如, bool operate==(const Date& d) 就是一个判断Date 类的对象是否相等的运算符重载,其内部的逻辑如下。返回值 bool 类型,不必多说;参数是引用,减少拷贝,同时const修饰,避免修改实参。实际调用的时候 d1== d2 ,编译器会转换成调用 operate==(d1,d2); 有两个参数的原因是,this 指针的存在。
再用小于的重载来深入理解一下,即下方的 bool operator<(const Date& d) 成员函数,调用时可以 d1 < d2 ,编译器实际调用的是 operate<(d1,d2) ;而如果是 d2 < d1 ,那么编译器调用的是 operate<(d2,d1); 要注意顺序。
在下方 main 函数中,输出语句里面的运算符重载调用,是加了括号的,这是因为优先级的问题。<< 的优先级比我们自己定义的运算符重载要高,所以要加上括号。
class Date
{
public:
Date(int year = 2023, int month = 2, int day = 3)
{
_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 true;
else if (_year == d._year && _month < d._month)
return true;
else if (_year == d._year && (_month = d._month) && _day<d._day)
return true;
return false;
}
bool operator<=(const Date& d)
{
return *this < d || *this == d;
}
bool operator>(const Date& d)
{
return !(*this <= d);
}
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 2, 3);
Date d2(2022, 2, 3);
Date d3(2022, 2, 3);
cout<< (d1==d2) << endl;
cout<< (d1<=d3) << endl;
return 0;
}
注意:
理解了运算符重载
,再看赋值重载
就很容易,无非就是 T& operate=(const T&) 的格式嘛,返回值是 T& 的原因是,存在 d1 = d2 = d3 这样调用。
赋值运算符重载格式
- 参数类型:const T&,传递引用可以提高传参效率
- 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
- 检测是否自己给自己赋值
- 返回*this :要复合连续赋值的含义
如下, Date& operator=(const Date& d) 是 Date 类的赋值重载
的例子,要考虑 d1 = d1 的情况,自己给自己赋值,可以加一个 if 语句跳过这种情况,因为如果是类似于深拷贝那样,开辟了一块新空间又赋给自己,那么原来那块空间怎么办?所以可以直接跳过这种情况。
同时,区分拷贝构造
和赋值重载
。赋值重载是在两个已经实例化的对象之间进行的,两个对象都已经开辟了空间。像 Date d1 = d2; 这就是拷贝构造而不是赋值重载,因为此时 d1 还没有开辟空间。
class Date
{
public:
Date(int year = 2023, int month = 2, int day = 3)
{
_year = year;
_month = month;
_day = day;
}
// 这样写可以实现 d1=d2 ,但是如果 d1=d2=d3 呢?所以要设置返回值, 实际上, d2=d3会返回一个值
//void operator=(const Date& d)
//{
// _year = d._year;
// _month = d._month;
// _day = d._day;
//}
// 如果返回值是Date 类型,那么就要拷贝一下,很麻烦,所以引用
Date& operator=(const Date& d)
{
// d1=d1 ,不需要拷贝,而且如果是深拷贝,会出问题,所以过滤这种
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 2, 3);
Date d2;
Date d3 = d1; // 拷贝构造,因为 d3 并没有开辟空间,复制重载是两个有空间的对象之间进行的
d2 = d1; // 赋值重载,要遵循左右顺序,否则反过来了
d1.Print();
d2.Print();
d3.Print();
return 0;
}
赋值运算符只能重载成类的成员函数不能重载成全局函数。原因是赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
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 =”必须是非静态成员
用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。
规则如下:
内置类型
成员变量,直接赋值。自定义类型
成员变量,需要调用对应类的赋值运算符重载完成赋值。
既然编译器生成的默认赋值运算符重载函数已经可以完成字节序的值拷贝了,还需要自己实现吗?当然像日期类这样的类是没必要的。那么下面这样的呢?Stack 类并没有实现赋值重载。
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;
s2 = s1;
return 0;
}
由于 Stack 类需要使用编译器自动生成的赋值重载,而自动生成的赋值重载是浅拷贝,直接复制。所以 ,s2=s1 执行完之后,s2 和 s1 的指针会指向同一块空间,导致错误。
所以,和拷贝构造一样,如果类里面涉及到资源管理,都需要自己写赋值重载函数。
前置++ 并不难,自身数据 +1 即可。
后置++:前置++和后置++都是一元运算符,为了让前置++与后置++形成能正确重载。C++规定:后置++重载时多增加一个int类型的参数,但调用函数时该参数不用传递,编译器自动传递。
注意:后置++是先使用 后+1,因此需要返回+1之前的旧值,故需在实现时需要先将this保存一份,然后给this + 1。而 temp是临时对象,因此只能以值的方式返回,不能返回引用。
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// 前置++:返回+1之后的结果
// 注意:this指向的对象函数结束后不会销毁,故以引用方式返回提高效率
Date& operator++()
{
_day += 1;
return *this;
}
Date operator++(int)
{
Date temp(*this);
_day += 1;
return temp;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d;
Date d1(2022, 1, 13);
d = d1++; // d: 2022,1,13 d1:2022,1,14
d = ++d1; // d: 2022,1,15 d1:2022,1,15
return 0;
}
这两个默认成员函数一般不用重新定义 ,编译器默认会生成。
类似于下面这样,但是这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!
class Date
{
public:
Date* operator&()
{
return this;
}
const Date* operator&()const
{
return this;
}
private:
int _year;
int _month;
int _day;
};