前言
本文介绍C++中类的基础知识,介绍所有的构造函数,和什么时候应该该写哪些构造函数,并介绍经典的三五法则。
在C++中,只是声明一个空类,不做任何事情的话,编译器会自动为你生成如下八个默认函数:
- 默认构造函数;
- 默认析构函数;
- 默认拷贝构造函数;
- 默认拷贝赋值运算符函数;
- 默认移动构造函数(C++11);
- 默认移动赋值操作符函数(C++11)。
- 默认取址运算符函数;
- 默认取址运算符const函数;
-
只是声明一个空类,不做任何事情的话,编译器会自动为你生成一个默认构造函数、一个默认拷贝构造函数、一个默认重载赋值操作符函数和一个默认析构函数。这些函数只有在第一次被调用时,才会被编译器创建,当然这几个生成的默认函数的实现就是什么都不做。所有这些函数都是inline和public的。
-
C++11新增标识符default和delete,控制这些默认函数是否使用。
- default:被标识的默认函数将使用类的默认行为,如:A() = default;
- delete:被标识的默认函数将禁用,如:A() = delete;
- override:被标识的函数需要强制重写基类虚函数;
- final:被标识的函数禁止重写基类虚函数;
类
- c++新标准规定,可以为数据成员提供一个类内初始值(in-class initialize)。
创建对象时,类内初始值将用于初始化类内成员对象。 - 类通常被定义在头文件中,且类所在的头文件文件名和类名保持一致。
- 头文件通常包含只能定义一次的实体,如类、const和constexpr变量。
- 一个空类时,编译器会默认生成默认构造函数、拷贝构造函数、析构函数。
- 使用class和struct定义类的唯一区别就是默认的访问权限;
当定义的类所有成员是public时,使用struct;反之,则使用class。
class Sales_data {
private:unsigned_sold = 0;
}
构造函数
构造函数的任务是初始化类对象的数据成员,只要类的对象被创建,就会执行构造函数。
- 构造函数不能被声明成const的。
当创建类的一个const对象时,知道构造函数完成其初始化过程,对象才能真正取得其常量属性。因此,构造函数在const对象的构造过程中可以向其写值。
默认构造函数
如果我们的类没有显示的定义构造函数,则编译器会隐式的定义一个默认构造函数。
-
默认构造函数将按照如下规则初始化类的数据成员:
- 如果存在类内的初始值,则用它来初始化成员
- 否则,默认初始化该成员
-
只有当类没有声明任何构造函数时,编译器才会自动的生成默认构造函数。
-
在c++11新标准中,如果我们需要默认的行为,可以通过添加
= default
来要求编译器生成默认构造函数。
class Person {
private:int member = 0;public:Person() = default; // 使用 = default来声明这是一个默认构造函数Person(int a) : member(a) {}Person(int a, int b) : member(a + b) {}
};Person person(10); // 创建person对象
Person person2(); // Empty parentheses interpreted as a function declaration,定义一个person2()方法
Person person1 = 10; // 隐式类型转换
构造函数初始值列表
如下构造函数一个是进行赋值、一个是进行初始化,这种区别取决于数据成员的类型;事关底层效率问题,前者是先初始化数据成员后再赋值,后者是直接初始化数据成员。
class ConstRef {
public:// 是对成员变量进行赋值操作
// ConstRef(int a) {
// this->a = a;
// ca = a; // Constructor for 'ConstRef' must explicitly initialize the const member 'ca'
// ra = a; // Constructor for 'ConstRef' must explicitly initialize the reference member 'ra'
// }// 这种构造函数就是构造函数初始值列表,是对成员进行初始化操作ConstRef(int number) : a(number), ca(number), ra(number) {};private:int a;const int ca;int &ra;
};
隐式类型转换
如果构造函数只接受一个实参,则他实际上定义了此类类型的隐式转换机制;可以通过关键字explicit禁止掉隐式构造。
-
关键字explicit只对一个实参的构造函数有效,需要多个实参的构造函数不能用于执行隐式转换。
-
explicit构造函数只能用于直接初始化
class Person {
private:int member = 0;public:Person() = default;explicit Person(int a) : member(a) {} // 使用explicit禁止隐式类型转换,提高代码可读性Person(int a, int b) : member(a + b) {}
};void testPerson(Person person) {
}Person getPerson2() {return 10; // 错误,已禁止隐式类型转换
}Person person(10); // 正确,直接初始化
Person person1 = 10; // 错误,explicit构造函数只能用于直接初始化,不能用于拷贝形式的初始化
testPerson(10); // 错误,已禁止隐式类型转换
getPerson2();
类的静态成员
因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的,即不是由的类的构造函数初始化的。
而且我们不能在类的内部初始化静态成员(除非是const或constexpr的),必须在类的外部定义和初始化静态成员。
静态成员和全局变量一样,存在于整个整个程序的生命周期。
// .h
struct Person {
private:const static int count0 = 10;constexpr static int count1 = 10;// static int count2 = 10; // Non-const static data member must be initialized out of linestatic int count3;
}// .cpp,在类外初始化
int Person::count3 = 10;
拷贝控制
拷贝构造函数(copy constructor)、拷贝赋值运算符(copy-assignment constructor)、移动构造函数(move constructor)、移动赋值运算符(move-assignment constructor)、析构函数(destructor),来显示的或隐式的指定在此类型的对象拷贝、移动、赋值和销毁。
这些操作统称为拷贝控制操作。
编译期会自动为类添加这些操作,如果类本身没有进行定义的话。
拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么;
拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么;
析构函数定义了当此类型对象销毁时做什么;
拷贝构造函数copy constructor
- 如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
class Foo {
public:Foo() = default; // 默认无参构造函数Foo(const Foo&); // 拷贝构造函数
}
-
拷贝构造函数在几种情况下都会被隐式地使用。因此,拷贝构造函数通常不应该是explicit的
-
Copy constructor must pass its first argument by reference.
拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。
如果其参数不是引用类型,则调用永远也不会成功;为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。 -
拷贝初始化发生在以下情况:
- 使用 = 进行赋值或者定义变量的时候
- 将一个对象是作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象(RVO、NRVO优化)
- 用花括号列表初始化一个数组中的元素或一个聚合类中的成员
- 使用标准库容器的不同api也会有不同的效果
string dots(10, ','); // 直接初始化string s(dots); // 直接初始化string s2 = dots; // 直接初始化string null_book = "123"; // 拷贝初始化string null_book2 = string(10, ','); // 拷贝初始化
拷贝赋值运算符copy-assignment constructor
- 为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。
class Foo {
public:Foo() = default; // 默认无参构造函数Foo(const Foo&); // 拷贝构造函数Foo& operator=(const Foo&); // 拷贝赋值运算符
}
析构函数
-
构造函数初始化对象的非static数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非static数据成员。
-
析构函数不接受参数,因此它不能被重载。对一个给定类,只会有唯一一个析构函数。
-
在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
-
无论何时一个对象被销毁,就会自动调用其析构函数:
- 变量在离开其作用域时被销毁。
- 当一个对象被销毁时,其成员被销毁。
- 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
- 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁(参见12.1.2节,第409页)。
- 对于临时对象,当创建它的完整表达式结束时被销毁。
class Foo {
public:Foo() = default; // 默认无参构造函数Foo(const Foo&); // 拷贝构造函数Foo& operator=(const Foo&); // 拷贝赋值运算符~Foo(); // 析构函数
}
移动构造函数move constructor
移动构造函数的第一个参数是该类类型的一个右值引用,且不是const类型的。
-
除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态——销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。
-
如果我们的移动移动构造函数不抛出异常,则必须在类头文件的声明中和定义中(如果定义在类外的话)都指定noexcept。
由于移动操作“窃取”资源,它通常不分配任何资源。因此,移动操作通常不会抛出任何异常;
必须显式声明出该对象在移动时不会抛出异常,会有助于容器类选择移动构造而非拷贝构造。
class Foo {
public:Foo() = default; // 默认无参构造函数Foo(const Foo&); // 拷贝构造函数Foo& operator=(const Foo&); // 拷贝赋值运算符~Foo(); // 析构函数Foo(Foo&&) noexcept; // 移动构造
}
移动赋值move-assignment constructor
-
与移动构造函数一样,如果我们的移动赋值运算符不抛出任何异常,我们就应该将它标记为noexcept。
-
移动赋值运算符需要检查自赋值情况
如果相同,右侧和左侧运算对象指向相同的对象,我们不需要做任何事情。
我们进行检查的原因是此右值可能是move调用的返回结果。
class Foo {
public:Foo() = default; // 默认无参构造函数Foo(const Foo&); // 拷贝构造函数Foo& operator=(const Foo&); // 拷贝赋值运算符~Foo(); // 析构函数Foo(Foo&&) noexcept; // 移动构造Foo& operator=(Foo&&) noexcept; // 移动赋值
}
移后源对象必须可析构
-
从一个对象移动数据并不会销毁此对象,但有时在移动操作完成后,源对象会被销毁。
因此,当我们编写一个移动操作时,必须确保移后源对象进入一个可析构的状态。 -
移动操作还必须保证对象仍然是有效的。
对象有效就是指可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值。 -
移动操作对移后源对象中留下的值没有任何要求。因此,我们的程序不应该依赖于移后源对象中的数据。
-
在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。
三五法则
有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。
在新标准下,一个类还可以定义一个移动构造函数和一个移动赋值运算符。
-
需要析构函数的类也需要拷贝和赋值操作
当我们决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本原则是首先确定这个类是否需要一个析构函数。
如果这个类需要一个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。 -
需要拷贝操作的类也需要赋值操作,反之亦然
因为某些类所要完成的工作,只需要拷贝或赋值操作,不需要析构函数。所以有该法则。 -
如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。
定义了拷贝操作的类类通常拥有一个资源,而拷贝成员必须拷贝此资源。但是拷贝一个资源会导致一些额外开销。在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。
MyString类
举个例子,如如下String类。
class MyString {
private:char *mData;friend ostream &operator<<(ostream &out, MyString &myStr);public:MyString() {cout << "默认无参构造函数" << endl;mData = new char[1];*mData = '\0';}MyString(const char *data) {cout << "单参构造函数-隐式类型转换" << endl;mData = new char[strlen(data)];strcpy(mData, data);}~MyString() {if (mData) {cout << mData << "~析构函数" << endl;delete[] mData;mData = nullptr;} else {cout << "~析构函数" << endl;}}MyString(const MyString &other) {cout << "拷贝构造函数" << endl;mData = new char[strlen(other.mData) + 1];strcpy(mData, other.mData);}MyString(MyString &&other) noexcept: mData(other.mData) {cout << "移动构造函数" << endl;other.mData = nullptr;}MyString &operator=(const MyString &other) {cout << "拷贝赋值函数" << endl;if (this == &other) {return *this;}delete[] mData;mData = new char[strlen(other.mData) + 1];strcpy(mData, other.mData);return *this;}MyString &operator=(MyString &&other) noexcept {cout << "移动赋值函数" << endl;if (this == &other) {return *this;}delete[] mData;mData = other.mData;other.mData = nullptr;return *this;}bool empty() const {return mData == nullptr || std::strlen(mData) == 0;}
};ostream &operator<<(ostream &out, MyString &myStr) {return out << myStr.mData;
}
void test() {MyString s; // 默认初始化,栈对象MyString s1 = "s1"; // 隐式类型转换,栈对象MyString s2("s2"); // 值初始化,栈对象
// s1 = s2; // 左值拷贝,拷贝赋值函数s1 = std::move(s2); // 右值移动,移动赋值函数cout << s1 << endl;
// cout << s2 << endl; // 移动源对象不可再使用
}// 默认无参构造函数
// 单参构造函数-隐式类型转换
// 单参构造函数-隐式类型转换
// 移动赋值函数
// s2
// ~析构函数
// s2~析构函数
// ~析构函数