class A {};
上面声明了一个空类,但空类真的是什么也没有吗。其实空类中,编译器会自动生成六个默认的成员函数。
1. 构造函数
构造函数是一个特殊的成员函数,名字与类名相同,不写返回值类型。创建一个对象的时候必定调用某一个构造函数。构造函数可以用于给成员变量赋一个初值,或者做些其他的初始化工作。构造函数在对象的生命周期里只调用一次。
如果类中没有显式的定义一个构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
构造函数当然也可以重载,创建对象的时候编译器根据给定的初始化参数来选择调用哪个重载的构造函数。
根据函数重载的性质,无参的构造函数和全缺省的构造函数可以构成重载,不过调用的时候可能会出现调用不明确的问题。
编译器自动生成的默认构造函数,我们自己写的无参构造函数,以及全缺省构造函数,都被称为默认构造函数,即可以不带任何参数就能调用的构造函数。默认构造函数的调用不需要加函数调用的括号,否则会被编译器误认为是一个函数的声明。
一个全缺省构造函数的例子:
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;
};
1.1. 默认构造函数
这里,我们讨论编译器自动生成的无参默认构造函数有什么功能。事实上如果将这个默认构造函数写出来,它可能具有一个空的函数体,但其实这并不表示这个空的默认构造函数什么也不做。这里就要再引入一个概念:成员初始化列表。
成员初始化列表
首先讨论,在上面代码中的Date类,构造函数中将每个成员变量赋了一个值,严格地说这个动作并不叫作初始化,初始化是在声明变量的同时给该变量一个初始值,而之后对该变量的每一次赋值就都不能称作初始化了,也就是说变量初始化只能进行一次。而我们完全可以在构造函数中对成员变量多次赋值,所以构造函数中的赋值一定不是初始化。
其实类成员变量的初始化的工作是由成员初始化列表完成。
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;
};
成员初始化列表只存在于构造函数中,作用是对成员变量进行初始化。成员初始化列表必须位于构造函数的小括号之后,大括号之前。最前面是一个冒号,后面跟上初始化列表,列表项用逗号隔开。每一个初始化列表项由成员变量名和紧随其后的括号及其内部的初始值组成。
C++ Primer 将构造函数的执行过程分为两个阶段:隐式或显式初始化阶段,以及计算阶段。计算阶段由构造函数内的所有语句构成。在计算阶段中,成员变量的设置称为赋值而不是初始化。而隐式或显式初始化阶段即执行成员初始化列表的阶段。所谓隐式初始化即执行编译器自动生成的一些初始化列表项,而显式初始化即执行显式写出的初始化列表。
为什么编译器会自动生成一些初始化列表项呢?因为根据C++语法,初始化类成员的时候,必须调用该类的所有基类的构造函数,同样也必须调用所有成员类对象的构造函数来初始化该类对象。所以编译器会检测显式写出的初始化列表是否调用了上述的构造函数,如果没调用则自动调用所有基类的默认构造函数,以及所有成员类对象的默认构造函数。
成员初始化列表具有一些特殊的性质:
- 每个成员变量在初始化列表中只能出现一次(因为初始化只能进行一次)。
类中若包含以下成员,则必须将这些成员放在初始化列表初始化:
- 引用成员变量(引用性质决定必须初始化)
- const成员变量(const性质决定必须初始化)
- 类类型成员且该类没有默认构造函数(如上所说,若不显式提供该类对象的初始化列表项,则编译器会自动调用该类的默认构造函数,则会调用失败)
- 成员变量在类中的声明次序就是其初始化列表项被执行的次序,与其在初始化列表中的位置无关。
关于第3条,初始化列表分为隐式初始化和显式初始化,其初始化顺序同样满足上述规律,思考如下代码:
#include <iostream>
using namespace std;
class A {
public:
A() {
cout << "A::A() called" << endl;
}
};
class B {
public:
B() {
cout << "B::B() called" << endl;
}
};
class C {
public:
C() {
cout << "C::C() called" << endl;
}
};
class D {
public:
explicit D()
: _b() {}
private:
A _a;
B _b;
C _c;
};
int main() {
D d;
system("pause");
return 0;
}
输出:
A::A() called
B::B() called
C::C() called
1.2. 默认构造函数真的什么都不做吗?
所以回到最初的问题,编译器生成的默认构造函数真的什么都不做吗?现在来看答案当然是否定的,如果类中有其他类的对象,则该默认构造函数会调用成员类对象的默认构造函数。
偷懒的编译器
不过假如这个类没有其他类的对象,而且也不存在基类,那么这个自动生成的无参默认构造函数确实什么都不需要干,此时在VS下,编译器会不生成这个默认构造函数以优化性能,转到反汇编会发现这一句甚至没有对应的汇编语句:
2. 析构函数
析构函数同样是一个特殊的成员函数,对象在销毁时编译器会自动调用析构函数,完成一些类的资源清理工作,例如释放申请的动态空间等。
析构函数名是在类名前加~
,无参数无返回值。
对象生命周期结束时,编译器自动调用析构函数。
析构函数可以手动多次调用。注意对象释放的时候自动调用析构函数,但并不说明析构函数就代表对象的生命的终结,对象的生命周期还是由编译器控制。
class Date {
public:
Date(int year = 1970, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day) {}
~Date() {
cout << "Destructor is called" << endl;
}
private:
int _year;
int _month;
int _day;
};
2.1. 默认析构函数
一个类只有一个析构函数,若未显示定义,则系统会生成默认的析构函数。
编译器自动生成的析构函数,会对类对象成员调用它的析构函数。
3. 拷贝构造函数
拷贝构造函数也是一种构造函数,与构造函数的性质完全相同,只不过拷贝构造函数的参数必须是当前类对象的引用。这说明拷贝构造函数的作用是,通过当前类的一个已有对象来构建这个新对象。
class Date {
public:
Date(int year, int month, int day)
: _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(1997, 1, 1);
Date d2(d1);
system("pause");
return 0;
}
3.1. 为什么参数是类对象的引用而不是对象自身?
C++中,假设传参时形参是一个类对象,则实参类对象是通过构造函数给形参赋值的。
void func(A _a) {}
int main() {
A _b;
func(_b);
// 此时 func 函数内的局部变量 _a 的声明类似于下面这样
// A _a(_b)
}
假设类A有一个构造函数,参数直接是类A的对象:
class A {
public:
A(A _a) {}
}
则可以想到调用这个构造函数时会出现调用构造函数、传参、调用构造函数、传参的无限递归,所以C++规定,类的构造函数的参数不能是该类的类型。所以拷贝构造函数也只能用引用。
3.2. 默认拷贝构造函数
如果未显式给出拷贝构造函数,则编译器会给出一个默认的拷贝构造函数。该拷贝构造函数通过内存复制的方式实现对象的拷贝。
这在类成员变量指向一个动态空间的时候会出现问题,即两个对象的成员变量最终会指向同一个动态空间。假设析构函数中要释放这块空间,则最终会free()两次,导致程序崩溃。
故将这种默认的拷贝方式称为浅拷贝。
4. 赋值运算符重载
4.1. 运算符重载
C++为了增强代码的可读性引入了对运算符的重载,即最终会允许运算符作用在对象上。
运算符重载函数要以下面的方式定义:
// 返回值类型 operator操作符(参数列表);
bool operator==(Date& d);
类的运算符重载函数的参数个数比这个运算符所需的操作数的个数少一个,即一个隐藏的 this 指针。对于上面的 ==
的例子来说,==
操作符需要两个操作数,但 ==
的重载函数定义在类内部,则此时重载函数的第一个参数其实是一个隐藏的 this
指针,其对应的是 ==
的左操作数,而写在参数列表里的那个参数才是 ==
的右操作数。注意这对所有二元操作符的重载都适用,即 this
指针永远代表左操作数。这也意味着对类的某个二元操作符重载后,要想调用则对象一定要在这个操作符的左边,如果在右边就无法触发该运算符重载函数。
下面这是一个重载加号的例子:
class Date {
public:
int operator+(Date& d) {
return this->_day + d._day;
}
}
int main() {
cout << d1 + d2 << endl;
}
.*
、::
、sizeof
、?:
、.
这五个运算符不能重载!!!
一个比较特殊的例子是自增运算符与自减运算符分别有两种(前置、后置),为了区分这两种情况,C++规定重载后置自增自减的时候在声明时的参数列表中要加上一个int型的参数,但在调用的时候并不用给出这个参数(也给不出),编译器会自动压一个0进栈(VS)。
void operator++() {
cout << "++i" << endl;
}
void operator++(int) {
cout << "i++" << endl;
}
最后,运算符重载可以不定义在类内部,而是像普通函数一样定义,例如这样:
#include <iostream>
using namespace std;
class cls1 {
public:
cls1(int _a = 1, int _b = 2) {
a = _a;
b = _b;
}
int a;
int b;
};
int operator+(cls1 &_obj1, int num) {
return _obj1.a + _obj1.b + num;
}
int operator-(int num, cls1 &_obj1) {
return num - _obj1.a - _obj1.b;
}
int main() {
cls1 obj1;
cout << operator+(obj1, 3) << endl;
cout << operator-(3, obj1) << endl;
cout << obj1 + 3 << endl;
cout << 3 - obj1 << endl;
return 0;
}
在这种情况下,这就是一个全局的运算符重载函数,此时这个函数不是任何一个类的成员函数,所以也没有隐含的 this
指针,所以该运算符有几个操作数就要写几个参数,且此时必须有一个参数是类类型。而且因为没有 this
指针,就不能访问对象的私有属性,所以你看我上面把 a
和 b
都写在 public
里。最后,想要调用这种全局重载函数,可以用上面代码里的两种方法,都可以。
4.2. 赋值运算符重载
Date& operator=(const Date& d) {
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}
int main() {
d1 = d2;
}
4.3. 默认的赋值运算符重载
如果不显式给出赋值运算符重载,编译器同样会自动生成一个默认的赋值运算符重载,但同样有默认拷贝构造函数的问题,即浅拷贝。
5. (const)取地址操作符重载
5.1. const 修饰成员函数
void Display() const {
cout << "Year: " << _year << endl
<< "Month: " << _month << endl
<< "Day: " << _day << endl;
}
这样的成员函数称为const成员函数,其实这里的const修饰的是隐含的this指针,即这个函数内部不能够修改成员变量的值。
- const 对象不能调用非const成员函数
- 非 const 对象可以调用 const 成员函数
- const 成员函数内部不能调用其他非 const 成员函数
- 非 const 成员函数内部不能调用其它 const 成员函数
5.2. 取地址操作符和const取地址操作符
class A {
public:
A* operator&() {
return this;
}
const A* operator&() const {
return this;
}
}
这两个函数一般不需要自己重载,编译器会自动生成。