特殊成员函数是指在某些情况下隐式定义为类成员的成员函数。有六个:
成员函数 | C类的典型形式: |
默认构造函数 | C::C(); |
默认析构函数 | C::~C(); |
拷贝构造函数 | C::C (const C&); |
拷贝赋值函数 | C& operator= (const C&); |
移动构造函数 | C::C (C&&); |
移动赋值函数 | C& operator= (C&&); |
默认构造函数是在声明类对象但未使用任何实参初始化时调用的构造函数。
如果类定义没有构造函数,则编译器假定类有隐式定义的默认构造函数。因此,在像这样声明一个类之后:
class Example { public: int total; void accumulate (int x) { total += x; } }; |
编译器假定Example有一个默认构造函数。因此,这个类的对象可以通过不带任何参数的简单声明来构造:
Example ex; |
但是,一旦一个类的构造函数显式声明了任意数量的形参,编译器就不再提供隐式的默认构造函数, 也不再允许声明该类的新对象而不带实参。例如,以下类:
class Example2 { public: int total; Example2 (int initial_value) : total(initial_value) { }; void accumulate (int x) { total += x; }; }; |
这里,我们声明了具有int类型形参的构造函数。因此,下面的对象声明是正确的:
Example2 ex (100); // ok: calls constructor |
Example2 ex; // not valid: no default constructor |
将无效,因为该类已使用接受一个参数的显式构造函数声明,并替换了接受无参数的隐式默认构造函数。
因此,如果需要在不带参数的情况下构造该类的对象,则也应该在类中声明正确的默认构造函数。例如:
// classes and default constructors #include <iostream> #include <string> using namespace std; class Example3 { string data; public: Example3 (const string& str) : data(str) {} Example3() {} const string& content() const {return data;} }; int main () { Example3 foo; Example3 bar ("Example"); cout << "bar's content: " << bar.content() << '\n'; return 0; } |
bar's content: Example |
在这里,Example3有一个空的默认构造函数(即,一个没有形参的构造函数):
Example3() {} |
这允许在不带参数的情况下构造Example3类的对象(就像在本例中声明foo一样)。 通常,对于没有其他构造函数的所有类都隐式定义了这样的默认构造函数, 因此不需要显式定义。但在本例中,Example3有另一个构造函数:
Example3 (const string& str); |
析构函数实现了与构造函数相反的功能:它们负责类生命周期结束时所需的必要清理。 我们在前几章中定义的类没有分配任何资源,因此实际上不需要任何清理。
但是现在,让我们假设上一个例子中的类分配了动态内存来存储作为数据成员的字符串; 在这种情况下,让一个在对象生命周期结束时自动调用的函数负责释放该内存是非常有用的。 为此,我们使用析构函数。析构函数是一种非常类似于默认构造函数的成员函数:它不接受参数, 不返回任何东西,甚至不返回void。它也使用类名作为自己的名字,但是前面有一个波浪号(~):
// destructors #include <iostream> #include <string> using namespace std; class Example4 { string* ptr; public: // constructors: Example4() : ptr(new string) {} Example4 (const string& str) : ptr(new string(str)) {} // destructor: ~Example4 () {delete ptr;} // access content: const string& content() const {return *ptr;} }; int main () { Example4 foo; Example4 bar ("Example"); cout << "bar's content: " << bar.content() << '\n'; return 0; } |
bar's content: Example |
在构造时,Example4为字符串分配存储空间。稍后由析构函数释放此存储空间。
对象的析构函数在其生命周期结束时被调用;在foo和bar的情况下,这发生在函数main的末尾。
当一个对象的参数是和它相同类型的对象,它将被调用拷贝构造函数创建。
拷贝构造函数是这样一种构造函数,其第一个形参是类本身类型的引用(可能是限定为const的), 可以用一个此类型的实参来调用。例如,对于MyClass类,拷贝构造函数语法是:
MyClass::MyClass (const MyClass&); |
如果类没有自定义拷贝或移动构造函数(或赋值),则提供隐式拷贝构造函数。 这个拷贝构造函数只是执行它自己成员的副本(浅拷贝)。例如,对于以下类:
class MyClass { public: int a, b; string c; }; |
自动定义隐式拷贝构造函数。假设这个函数的定义执行一个浅拷贝,大致相当于:
MyClass::MyClass(const MyClass& x) : a(x.a), b(x.b), c(x.c) {} |
这个默认的拷贝构造函数可以满足许多类的需要。但是浅拷贝只复制类本身的成员, 这可能不是我们对上面定义的Example4之类的类所期望的,因为它包含存储数据的指针。
对于这个类,执行浅拷贝意味着只复制指针值,而不是数据本身;
这意味着两个对象(复件的对象及原对象)将共享同一个字符串对象(他们都是指向同一个对象), 并且销毁字符串对象都将删除同一块内存,这可能会导致在运行时程序崩溃。
这可以通过以下自定义拷贝构造函数来解决:
// copy constructor: deep copy #include <iostream> #include <string> using namespace std; class Example5 { string* ptr; public: Example5 (const string& str) : ptr(new string(str)) {} ~Example5 () {delete ptr;} // copy constructor: Example5 (const Example5& x) : ptr(new string(x.content())) {} // access content: const string& content() const {return *ptr;} }; int main () { Example5 foo ("Example"); Example5 bar = foo; cout << "bar's content: " << bar.content() << '\n'; return 0; } |
bar's content: Example |
这个拷贝构造函数执行的深拷贝为字符串分配新的存储空间, 该字符串被初始化为原字符串对象的一个副本。 通过这种方式,两个对象都有存储在不同位置的内容的副本。
对象不仅可以在构造时被复制,也可以在初始化时被复制: 它们还可以在任何赋值操作中被复制。看区别:
MyClass foo; MyClass bar (foo); // object initialization: copy constructor called MyClass baz = foo; // object initialization: copy constructor called foo = bar; // object already initialized: copy assignment called |
注意,baz在构造时使用等号进行初始化,但这不是赋值操作!对象的声明不是赋值操作, 它只是调用单参数构造函数的另一种语法。
foo上的赋值(foo=bar)是赋值操作。这里没有声明任何对象,但正在对现有对象foo执行(赋值)操作;
拷贝赋值操作符(=)是operator=的重载,该操作符接受类本身的值或引用作为形参。 返回值通常是对*this的引用(尽管这不是必需的)。例如,对于MyClass类,复制赋值可以有以下形式:
MyClass& operator= (const MyClass&); |
拷贝赋值操作符也是一个特殊函数,如果类没有自定义拷贝或move赋值(或move构造函数),则也会隐式定义拷贝赋值操作符。
但同样,隐式版本执行的是浅拷贝,这适用于许多类,但不适用于使用指针处理其存储的对象的类, 如Example5中的情况。在这种情况下,不仅会导致删除对象两次的风险, 而且赋值操作在赋值之前没有删除对象所指向的对象,从而导致内存泄漏。 这些问题可以通过拷贝赋值来解决,即删除先前的对象并执行深度拷贝:
Example5& operator= (const Example5& x) { delete ptr; // delete currently pointed string ptr = new string (x.content()); // allocate space for new string, and copy return *this; } |
或者更好的是,因为它的string成员不是常量,它可以重用同一个string对象:
Example5& operator= (const Example5& x) { *ptr = x.content(); return *this; } |
与拷贝类似,移动也使用一个对象的值来设置另一个对象。但是,与复制不同的是, 内容实际上是从一个对象(源)传输到另一个对象(目标):源丢失了该内容,该内容由目标接管。 只有当值的来源是一个未命名的对象时,才会发生这种移动。
未命名的对象是指在本质上暂时存在的对象,甚至还没有命名。未命名对象的典型例子是函数的返回值或类型转换。
使用临时对象的值来初始化另一个对象或给它赋值,实际上并不需要复制: (对象永远不会被用于任何其他用途),因此,它的值可以移动到目标对象中。 这些情况会触发move构造函数和move赋值:
当对象在构造时使用未命名的临时函数初始化时,调用移动构造函数。 同样,移动赋值也会在对象被赋值一个未命名的临时变量时被调用:
MyClass fn(); // function returning a MyClass object MyClass foo; // default constructor MyClass bar = foo; // copy constructor MyClass baz = fn(); // move constructor foo = bar; // copy assignment baz = MyClass(); // move assignment |
fn返回的值和MyClass构造的值都是未命名的临时值。在这些情况下,不需要创建副本, 因为未命名对象的寿命很短,其他对象可以获取该对象,这是一种更有效的操作。
移动构造函数和移动赋值函数是接受类本身的右值引用类型作形参的成员:
MyClass (MyClass&&); // move-constructor MyClass& operator= (MyClass&&); // move-assignment |
右值引用是通过在类型后面加上两个&& 来指定的。作为形参, 右值引用匹配这种类型的临时变量的实参。
移动的概念对管理它们使用的存储的对象最有用,比如用new和delete分配存储的对象。
在这些对象中,复制和移动是完全不同的操作:
-从A复制到B意味着给B分配了新的内存,然后把A的全部内容复制到这个分配给B的新内存中。
—从A转移到B意味着将已经分配给A的内存转移到B,而不分配新的存储空间。它只涉及复制指针。
// move constructor/assignment #include <iostream> #include <string> using namespace std; class Example6 { string* ptr; public: Example6 (const string& str) : ptr(new string(str)) {} ~Example6 () {delete ptr;} // move constructor Example6 (Example6&& x) : ptr(x.ptr) {x.ptr=nullptr;} // move assignment Example6& operator= (Example6&& x) { delete ptr; ptr = x.ptr; x.ptr=nullptr; return *this; } // access content: const string& content() const {return *ptr;} // addition: Example6 operator+(const Example6& rhs) { return Example6(content()+rhs.content()); } }; int main () { Example6 foo ("Exam"); Example6 bar = Example6("ple"); // move-construction foo = foo + bar; // move-assignment cout << "foo's content: " << foo.content() << '\n'; return 0; } |
foo's content: Example |
编译器已经优化了许多需要移动赋值调用的情况,这就是所谓的返回值优化。 最值得注意的是,当函数返回的值用于初始化对象时。 在这些情况下,移动构造函数实际上可能永远不会被调用。
请注意,尽管右值引用可以用于任何函数形参的类型,但除了移动构造函数之外, 它很少有用。右值引用很麻烦,不必要的使用可能是难以跟踪的错误来源。
成员函数 | 隐含的条件 | 默认定义 |
默认构造函数 | 如果没有其他构造函数 | 什么也不做 |
默认析构函数 | 如果没有析构函数 | 什么也不做 |
拷贝构造函数 | 如果没有move构造函数和move赋值 | 复制所有成员 |
拷贝赋值函数 | 如果没有move构造函数和move赋值 | 复制所有成员 |
移动构造函数 | 如果没有析构函数,没有拷贝构造函数,也没有拷贝或移动赋值 | 移动所有成员 |
移动赋值函数 | 如果没有析构函数,没有拷贝构造函数,也没有拷贝或移动赋值 | 移动所有成员 |
注意,并非所有特殊成员函数都在相同的情况下隐式定义。 这主要是由于与C结构和早期c++版本的向后兼容性,事实上有些包含了不允许的情况。 但是,每个类都可以分别使用关键字default和delete显式地选择这些成员中哪些与默认定义一起存在, 哪些被删除。语法可以是:
function_declaration = default; function_declaration = delete; |
// default and delete implicit members #include <iostream> using namespace std; class Rectangle { int width, height; public: Rectangle (int x, int y) : width(x), height(y) {} Rectangle() = default; Rectangle (const Rectangle& other) = delete; int area() {return width*height;} }; int main () { Rectangle foo; Rectangle bar (10,20); cout << "bar's area: " << bar.area() << '\n'; return 0; } |
foo's content: Example |
在这里,Rectangle 可以用两个int参数或默认构造(不带参数)来构造。 然而,它不能从另一个Rectangle 对象复制构造,因为它的拷贝构造函数已经被删除。 因此,假设下面的语句是这个例子的对象,将是无效的:
Rectangle baz (foo); |
Rectangle::Rectangle (const Rectangle& other) = default; |
Rectangle::Rectangle (const Rectangle& other) : width(other.width), height(other.height) {} |
请注意,关键字default并不定义等于默认构造函数的成员函数 (即,默认构造函数指的是没有形参的构造函数),而是等于如果不删除将隐式定义的构造函数。
一般来说,为了将来的兼容性,显式定义了一个copy/move构造函数或一个copy/move赋值(但没有同时定义两种)的类, 鼓励它们在未显式定义的其他特殊成员函数上指定delete或default。