Loading

C++特殊成员函数及其生成机制

在C++中,特殊成员函数指的是那些编译器在需要时会自动生成的成员函数。C++98中有四种特殊的成员函数,分别是默认构造函数、析构函数、拷贝构造函数和拷贝赋值运算符。而在C++11中,随着移动语义的引入,移动构造函数和移动赋值运算符也加入了特殊成员函数的大家庭。本文主要基于Klaus Iglberger在CppCon 2021上发表的主题演讲Back To Basics: The Special Member Fuctions以及Scott Meyers的著作Effective Modern C++中的条款17,向大家介绍这六种特殊成员函数的特点以及它们的生成机制。

默认构造函数

当且仅当以下条件成立时,编译器会生成一个默认构造函数:

  1. 没有显式声明的构造函数
  2. 所有的数据成员和基类都拥有自己的默认构造函数

如果用户声明了自己的构造函数,那么编译器就不会再去生成一个默认构造函数;如果用户没有声明构造函数,但是类中包含了一个没有默认构造函数的数据成员,那么编译器也不会生成默认构造函数。

数据成员初始化

编译器生成默认构造函数会初始化所有类类型的数据成员,但是并不会初始化基础类型的数据成员。以下面的代码为例,第六行代码会调用默认构造函数将成员变量s初始化为空字符串,但是并不会初始化整型成员变量i以及指针pi

struct Widget {
  int i;
  std::string s;
  int* pi;
};
int main() {
  Widget w1;   // Default initialization
  Widget w2{}; // Vaule initialization
  return 0;
}

如果我们想同时初始化所有的成员变量,可以使用值初始化,只需在声明对象时添加一对大括号即可,见上述代码第8行。如果没有声明默认构造函数,值初始化会zero-initialize整个对象,然后default-initializes所有non-trivial的数据成员。以上面的代码为例,使用值初始化后,i被初始化为0,s仍然被初始化为空字符串,而pi被初始化为nullptr。如果用户声明了默认构造函数,那么值初始化就会按照用户声明来完成初始化操作。

通过默认构造函数,我们可以初始化类中的数据成员。但是需要注意赋值和初始化的区别。在下面的代码中,我们实现了两个默认构造函数(仅仅为了说明赋值和初始化的区别,不代表类中能够实现两个默认构造函数)。在第一个默认构造函数中,所有的成员在函数体内执行赋值操作。对于基础类型来说还好,但是对于类类型或者std::string这种,一次赋值操作带来的开销要比初始化的开销大。而第二个默认构造函数使用了成员初始化列表,每次操作都是初始化,所以它的开销会更低,性能也更好。

struct Widget {
  Widget() {
    i = 42;       // Assignment, not initialization
    s = "CppCon"; // Assignment, not initialization
    pi = nullptr; // Assignment, not initialization
  }

  Widget()
    : i{42}       // Initializing to 42
    , s{"CppCon"} // Initializing to "CppCon"
    , pi{}        // Initializing to nullptr
   {}

  int i;
  std::string s;
  int* pi;
};

对于数据成员的初始化,C++ Core Guideline定义了两条规则。首先,我们要按照数据成员在类中的定义顺序来初始化数据成员;其次,尽量在构造函数中使用初始化而非赋值。

Core Guideline C.47: Define and initialize member variables in the order of member declaration.

Core Guideline C.49: Prefer initialization to assignment in constructors.

析构函数

当用户没有显式声明析构函数时,编译器会生成一个析构函数。编译器生成的析构函数会调用类类型成员变量的析构函数,但是不会对基础类型的成员变量执行任何操作。如果类中含有指针类型的成员变量,那么编译器生成的析构函数就有可能导致资源泄露,因为编译器生成的析构函数并不会释放掉指针所指向的那些资源。

因此,如果类中的数据成员拥有某些外部资源的所有权,我们就需要实现一个析构函数来正确释放掉相关资源。如果确实没有啥资源需要手动释放,那么也不要写一个空的析构函数,最好是让编译器生成或者将析构函数定义成=default

拷贝操作

我们首先来看一下拷贝构造函数和拷贝赋值运算符的函数签名。一般来说,拷贝构造函数的形参是一个常量左值引用,极少数情况下是一个非常量左值引用,但不可能是一个对象的拷贝,因为这会导致递归调用。对于拷贝赋值运算符,它的形参也是一个常量左值引用,极少数情况下是非常量左值引用,也有可能是一个对象的拷贝,因为拷贝赋值运算符可以通过拷贝构造函数实现,所以这种形参是合法的。

// copy constructor
Widget(const Weidget&); // The default
Widget(Widget&);        // Possible, but very likely not reasonable
Widget(Widget);         // Not possible, recursive call

// copy assignment operator
Widget& operator=(const Widget&); // The default
Widget& operator=(Widget&);       // Possible, but very likely not reasonable
Widget& operator=(Widget);        // Reasonable, builds on the copy constructor

当且仅当以下条件成立时,编译器会生成拷贝操作:

  1. 不存在显式声明的拷贝操作
  2. 不存在显式声明的移动操作
  3. 所有的成员变量都能够被拷贝构造或拷贝赋值

拷贝构造函数和拷贝赋值运算符的生成是独立的:声明了其中一个,并不会阻止编译器生成另一个。如果用户声明了拷贝构造函数,但是没有声明拷贝赋值运算符,同时又编写了要求拷贝赋值的代码,那么编译器就会自动生成拷贝赋值运算符,反之亦然。

编译器生成的拷贝操作默认会按成员进行拷贝。对于指针类型的数据成员,如果执行按成员拷贝,那么就只会拷贝成员的值,也就是拷贝指针的值。这样一来,就会有两个对象指向同一块资源。当其中一个对象被析构以后,资源会被释放,另一个对象中的指针就成了悬挂指针(Dangling Pointer)。当这个对象被析构时,它所指向的资源就会被析构两次,内存的重复释放会导致严重的错误。为了解决此问题,我们需要在拷贝构造函数和拷贝赋值运算符中执行深拷贝操作,也就是要拷贝指针指向的那一块资源。

struct Widget {
  Widget(Wiget& other) noexcept
    : Base{other}
    , i{other.i}
    , s{other.s}
    , pr{other.pr ? new Resource(*ohter.pr) : nullptr}
  {}

  Widget& operator=(Widget&& other) {
    deleter pr; // cleanup current resource
    Base::operator=(std::move(other));
    i = other.i;
    s = other.s;
    pr = other.pr ? new Resource{*other.pr} : nullptr;
    return *this;
  }
  int i;
  std::string s;
  Resource* pr{};
};

注意在上述代码的拷贝赋值运算符中,我们首先删除了当前对象所指向的资源,然后再执行相关的拷贝操作。然而,这会导致程序不能正确处理self-assignment的情况。形如Widget w{}; w = w;这样的代码就会释放掉对象w指向的资源,从而导致程序发生错误。幸运的是,我们可以用copy-and-swap的思想,通过一个临时对象和swap函数来解决此问题。临时对象在退出作用域是会自动调用析构函数,所以我们就不用担心资源泄漏的问题。

Widget& operator=(const Widget& other) {
  Widget tmp(other);
  swap(tmp);
  return *this;
}
void swap(Widget& other) {
  std::swap(id, other.id);
  std::swap(name, other.name);
  std::swap(pr, other.pr);
}

这种做法的好处就是安全,代码能正确处理self-assignment的情况,但它的缺点就是性能比较一般。

移动操作

我们首先来看一下移动构造函数和移动赋值运算符的函数签名。一般来说,移动构造函数和移动赋值运算符的形参都是一个右值引用,带有const的形参是合法的,但是非常少见,一般也不会遇到。

// move constructor
Widget(Widget&&) noexcept;      // The default
Widget(const Widget&&) noexcept // Possible, but uncommon

// move assignment operator
Widget& operator=(Widget&&) noexcept;      // The default
Widget& operator=(const Widget&&) noexcept // Possible, but uncommon

当且仅当以下条件成立时,编译器会生成移动操作:

  1. 不存在显式声明的移动操作
  2. 不存在显式声明的析构函数和拷贝操作
  3. 所有的数据成员都是可以被拷贝或移动

移动构造函数和移动赋值运算符的生成并不独立:声明了其中一个,编译器就不会生成另一个。这样做的原因是,如果用户声明了一个移动构造函数,那么这就表明移动操作的行为将会与编译器所生成的移动构造函数不一致。而若是按成员进行的移动操作有不合理之处,那么按成员移动的赋值运算符极有可能同样有不合理之处。因此,声明移动构造函数会阻止编译器生成移动赋值运算符,反之亦然。

这里需要注意的是,移动操作的生成规则显然要比拷贝操作的生成严格,这算是一个历史遗留问题。从理论上来说,如果用户声明了一个拷贝构造函数,往往意味着该类需要进行某种资源管理,进而可以推断出①该类的另一个拷贝操作(拷贝赋值运算符)也需要进行资源管理;②该类的析构函数需要正确地释放相关资源。此时,拷贝/析构操作的行为会跟编译器生成的拷贝/析构操作不一致。因此,如果我们声明了析构函数/拷贝构造函数/拷贝赋值运算符其中的任意一个,那么就要同时声明另外的两个,这就是著名的The Rule of Three。换句话说,如果我们声明了析构函数,那么拷贝操作就不应该被生成,因为它们的行为可能不正确。不过在C++98标准被接受的时代,这样的论证没有得到充分的重视,所以在C++98中,用户声明的析构函数即使存在,也不会影响拷贝操作的生成。这种情况在C++11中仍然得到了保持,但原因仅仅在于,如果要对拷贝操作的生成条件施加更严格的限制,就会破坏太多遗留代码了。不过C++11标准同时也规定,在已经存在拷贝操作或析构函数的条件下,自动生成拷贝操作已经成为了被废弃的行为。或许在未来的某个C++标准中,拷贝操作的生成也会变得跟移动操作一样严格。

与拷贝操作类似,编译器生成的移动操作默认会按成员进行移动。显然,如果数据成员是一个指针类型,那么按成员移动同样将会导致悬挂指针。所以,对于包含指针类型的类,我们需要按照下面的方式实现移动构造函数和移动赋值运算符,其中std::exchange(a, b)的作用是用b的值去替换a的值并返回a的旧值。

struct Widget {
  Widget(Wiget&& other) noexcept
    : Base{std::move(other)}
    , i{std::move(other.i)}
    , s{std::move(other.s)}
    , pr{std::exchange(other.pr, {})}
  {}

  Widget& operator=(Widget&& other) {
    deleter pr;
    Base::operator=(std::move(other));
    i = std::move(other.i);
    s = std::move(other.s);
    pr = std::exchange(other.pr, {});
  }
  int i;
  std::string s;
  Resource* pr{};
};

然而,上面这种实现方式同样无法处理self-assignment的问题。虽然移动一个对象到它本身是一件非常奇怪的事情,一般也不会有人去写这种代码,但是作为类的提供者,我们必须要尽量考虑到所有可能出现的情况。对于self-assignment这个问题,我们可以借助copy-and-swap思想,利用一个临时对象来解决,代码如下。

Widget& operator=(Widget&& other) noexcept {
  Widget tmp(std::move(other));
  swap(tmp);
  return *this;
}
~Widget() { delete pr; }

使用原生指针来管理资源会让我们的代码写起来比较困难和繁琐。如果我们用智能指针替换掉原生指针,那么代码写起来将会容易很多。如果我们使用unique_ptr替换掉上例中的原生指针,因为unique_ptr只能被移动不能被拷贝,所以我们只需要实现拷贝构造函数和拷贝赋值运算符(如果我们真的需要拷贝操作的话),并将默认构造函数、析构函数和移动操作声明为=default即可。如果我们使用shared_ptr,那么连拷贝操作也不用写了,六个特殊成员函数群都定义成=default就完事了,不过shared_ptr会改变整个类的语义,因为所有的指针都会指向同一个资源,所以在用它的时候要多加小心。C++ Core Guideline就指出,尽量用unique_ptr而非shared_ptr,除非你是真的想共享资源的所有权。

Core Guideline R.21: Prefer unique_ptr over shared_ptr unless you need to share ownership.

最后,我们再来看下C++ Core Guideline中的The Rule of Zero以及The Rule of Five。这两条规则的意思非常简单,就是说我们在定义一个类的时候,如果能避免定义所有的默认操作,那就尽量不定义;如果定义或删除了某个默认操作,那么就定义或删除所有的默认操作。

Core Guideline C.20: If you can avoid defining default operation, do (aka The Rule of Zero).

Core Guideline C.21: If you define or =delete any default operation, define or =delete them all (aka The Rule of Five).

posted @ 2022-02-20 22:07  shuo-ouyang  阅读(506)  评论(0编辑  收藏  举报