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

目录
  • 前言
  • 默认构造函数
  • 数据成员初始化
  • 析构函数
  • 拷贝操作
  • 移动操作
  • 总结

前言

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

默认构造函数

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

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

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

数据成员初始化

编译器生成默认构造函数会初始化所有类类型的数据成员,但是并不会初始化基础类型的数据成员。以下面的代码为例,第六行代码会调用默认构造函数将成员变量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

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

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

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

编译器生成的拷贝操作默认会按成员进行拷贝。对于指针类型的数据成员,如果执行按成员拷贝,那么就只会拷贝成员的值,也就是拷贝指针的值。这样一来,就会有两个对象指向同一块资源。当其中一个对象被析构以后,资源会被释放,另一个对象中的指针就成了悬挂指针(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

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

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

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

与拷贝操作类似,编译器生成的移动操作默认会按成员进行移动。显然,如果数据成员是一个指针类型,那么按成员移动同样将会导致悬挂指针。所以,对于包含指针类型的类,我们需要按照下面的方式实现移动构造函数和移动赋值运算符,其中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).

总结

到此这篇关于C++特殊成员函数以及其生成机制的文章就介绍到这了,更多相关C++特殊成员函数及生成机制内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • C++类静态成员与类静态成员函数详解

    当将类的某个数据成员声明为static时,该静态数据成员只能被定义一次,而且要被同类的所有对象共享.各个对象都拥有类中每一个普通数据成员的副本,但静态数据成员只有一个实例存在,与定义了多少类对象无关.静态方法就是与该类相关的,是类的一种行为,而不是与该类的实例对象相关. 静态数据成员的用途之一是统计有多少个对象实际存在. 静态数据成员不能在类中初始化,实际上类定义只是在描述对象的蓝图,在其中指定初值是不允许的.也不能在类的构造函数中初始化该成员,因为静态数据成员为类的各个对象共享,否则每次创建一

  • C++之普通成员函数、虚函数以及纯虚函数的区别与用法要点

    普通成员函数是静态编译的,没有运行时多态,只会根据指针或引用的"字面值"类对象,调用自己的普通函数:虚函数为了重载和多态的需要,在基类中定义的,即便定义为空:纯虚函数是在基类中声明的虚函数,它可以再基类中有定义,且派生类必须定义自己的实现方法. 假设我们有三个类Person.Teacher.Student它们之间的关系如下: 类的关系图 普通成员函数 [Demo1] 根据这个类图,我们有下面的代码实现 #ifndef __OBJEDT_H__ #define __OBJEDT_H__

  • C++普通函数指针与成员函数指针实例解析

    C++的函数指针(function pointer)是通过指向函数的指针间接调用函数.相信很多人对指向一般函数的函数指针使用的比较多,而对指向类成员函数的函数指针则比较陌生.本文即对C++普通函数指针与成员函数指针进行实例解析. 一.普通函数指针 通常我们所说的函数指针指的是指向一般普通函数的指针.和其他指针一样,函数指针指向某种特定类型,所有被同一指针运用的函数必须具有相同的形参类型和返回类型. int (*pf)(int, int); // 声明函数指针 这里,pf指向的函数类型是int (

  • C++类成员构造函数和析构函数顺序示例详细讲解

    对象并不是突然建立起来的,创建对象必须时必须同时创建父类以及包含于其中的对象.C++遵循如下的创建顺序: (1)如果某个类具体基类,执行基类的默认构造函数. (2)类的非静态数据成员,按照声明的顺序创建. (3)执行该类的构造函数. 即构造类时,会先构造其父类,然后创建类成员,最后调用本身的构造函数. 下面看一个例子吧 复制代码 代码如下: class c{public:    c(){ printf("c\n"); }protected:private:}; class b {pub

  • C++指向类成员函数的指针详细解析

    首先 函数指针是指向一组同类型的函数的指针:而类成员函数我们也可以相似的认为,它是指向同类中同一组类型的成员函数的指针,当然这里的成员函数更准确的讲应该是指非静态的成员函数.前者是直接指向函数地址的,而后者我们从字面上也可以知道 它肯定是跟类和对象有着关系的. 函数指针实例: 复制代码 代码如下: typedef int (*p)(int,int);//定义一个接受两个int型且返回int型变量的函数指针类型int func(int x,int y){ printf("func:x=%d,y=%

  • c++ 成员函数与非成员函数的抉择

    1.尽量用类的非成员函数以及友元函数替换类的成员函数 例如一个类来模拟人People 复制代码 代码如下: 1 class People{ 2 public: 3 ... 4 void Getup( ); 5 void Washing( ); 6 void eating( ); 7 ... 8 } 其实上面三个动作是早上"起床"."洗簌"."吃饭"三个常见的动作,如果现在用一个函数来表示使用成员函数即为 复制代码 代码如下: 1 class Pe

  • C++静态成员变量和静态成员函数的使用方法总结

    一.静态成员变量: 类体中的数据成员的声明前加上static关键字,该数据成员就成为了该类的静态数据成员.和其他数据成员一样,静态数据成员也遵守public/protected/private访问规则.同时,静态数据成员还具有以下特点: 1.静态数据成员的定义. 静态数据成员实际上是类域中的全局变量.所以,静态数据成员的定义(初始化)不应该被放在头文件中. 其定义方式与全局变量相同.举例如下: xxx.h文件 class base{ private: static const int _i;//

  • C++特殊成员函数以及其生成机制详解

    目录 前言 默认构造函数 数据成员初始化 析构函数 拷贝操作 移动操作 总结 前言 在C++中,特殊成员函数指的是那些编译器在需要时会自动生成的成员函数.C++98中有四种特殊的成员函数,分别是默认构造函数.析构函数.拷贝构造函数和拷贝赋值运算符.而在C++11中,随着移动语义的引入,移动构造函数和移动赋值运算符也加入了特殊成员函数的大家庭.本文主要基于Klaus Iglberger在CppCon 2021上发表的主题演讲Back To Basics: The Special Member Fu

  • C++成员函数中const的使用详解

    目录 修饰入参 值传递 址传递 const修饰入参 修饰返回值 修饰函数 总结 const 在C++中是一个很重要的关键字,其不光可以用来修饰变量,还可以放在函数定义中,这里整理了其在函数中的三个用法. 修饰入参 首先我们要明白在C++中调用函数时存在两种方法,即传递值和传递引用. 值传递 值传递时,调用函数时会创建入参的拷贝,函数中的操作不会对原值进行修改,因此这种方式中不需要使用 const 来修饰入参,因为其只是对拷贝的临时对象进行操作. 址传递 传递地址时函数中的操作实际上是直接对原来的

  • C++中成员函数和友元函数的使用及区别详解

    为什么使用成员函数和友元函数 这个问题至关重要,直接影响着后面的理解: 程序数据: 数据是程序的信息,会受到程序函数的影响.封装是面向对象编程中的把数据和操作数据的函数绑定在一起的一个概念,这样能避免受到外界的干扰和误用,从而确保了安全. 数据封装引申出了另一个重要的 OOP 概念,即 数据隐藏 .数据封装 是一种把数据和操作数据的函数捆绑在一起的机制, 数据抽象 是一种仅向用户暴露接口而把具体的实现细节隐藏起来的机制.C++ 通过创建类来支持封装和数据隐藏(public.protected.p

  • C/C++函数参数传递机制详解及实例

    C/C++函数参数传递机制详解及实例 概要: C/C++的基本参数传递机制有两种:值传递和引用传递,我们分别来看一下这两种的区别. (1)值传递过程中,需在堆栈中开辟内存空间以存放由主调函数放进来的实参的值,从而成为了实参的一个副本.值传递的特点是被调函数对形参的任何操作都是作为局部变量进行,不会影响主调函数的实参变量的值. (2)引用传递过程中,被调函数的形参虽然也作为局部变量在堆栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址.被调函数对形参的任何操作都被处理成间接寻址,

  • C++中函数匹配机制详解

    首先,编译器会确定候选函数然后确定可行函数可行函数,再从可行函数中进一步挑选 候选函数:重载函数集中的函数 可行函数:可以调用的函数 最后进行寻找最佳匹配 有以下几种规则 1.该函数的每个实参的匹配都不劣于其他可行函数 2.至少有一个实参的匹配优于其他可行函数的匹配 3.满足上面两种要求的函数有且只有一个 如果上面三个要求都没满足,则出现二义性 一些演示 各有一个精确匹配的实参,编译器报错,不满足条件3 error void func(int a,int b) { cout << "

  • linux epoll机制详解

    在linux 没有实现epoll事件驱动机制之前,我们一般选择用select或者poll等IO多路复用的方法来实现并发服务程序.在linux新的内核中,有了一种替换它的机制,就是epoll. select()和poll() IO多路复用模型 select的缺点: 1.单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差:(在linux内核头文件中,有这样的定义:#define __FD_SE

  • 微信小程序 require机制详解及实例代码

    微信小程序 require机制详解 一, JS模块加载:一次性加载全部JS, 但并不一定立即执行. 先提一提微信小程序架构: 类浏览器 -> HTTP本地服务 -> 云端服务 微信小程序运行的架构,基本上是浏览器 -> HTTP本地服务 -> 云端服务, HTTP本地服务用来读取本地文件或者代理云端的文件资源.读取项目中JS文件, 是由HTTP本地服务取本地存储的脚本文件. 似乎比较简单,一个HTML 引用所有JS文件 既然采用了这种架构,那微信小程序就类似浏览器那样,借助一个HT

  • Java动态代理和反射机制详解

    反射机制 Java语言提供的一种基础功能,通过反射,我们可以操作这个类或对象,比如获取这个类中的方法.属性和构造方法等. 动态代理:分为JDK动态代理.cglib动态代理(spring中的动态代理). 静态代理 预先(编译期间)确定了代理者与被代理者之间的关系,也就是说,若代理类在程序运行前就已经存在了,这种情况就叫静态代理 动态代理 代理类在程序运行时创建的代理方式.也就是说,代理类并不是在Java代码中定义的,而是在运行期间根据我们在Java代码中的"指示"动态生成的. 动态代理比

  • 对Python强大的可变参数传递机制详解

    今天模拟定义map函数.写着写着就发现Python可变长度参数的机制真是灵活而强大. 假设有一个元组t,包含n个成员: t=(arg1,...,argn) 而一个函数f恰好能接受n个参数: f(arg1,...,argn) f(t)这种做法显然是错的,那么如何把t的各成员作为独立的参数传给f,以便达到f(arg1,...,argn)的效果? 我一开始想到的是很原始的解法,先把t的各个成员变为字符串的形式,再用英文逗号把它们串联起来,形成一个"标准参数字符串": str_t=(str(x

  • 关于PyTorch 自动求导机制详解

    自动求导机制 从后向中排除子图 每个变量都有两个标志:requires_grad和volatile.它们都允许从梯度计算中精细地排除子图,并可以提高效率. requires_grad 如果有一个单一的输入操作需要梯度,它的输出也需要梯度.相反,只有所有输入都不需要梯度,输出才不需要.如果其中所有的变量都不需要梯度进行,后向计算不会在子图中执行. >>> x = Variable(torch.randn(5, 5)) >>> y = Variable(torch.rand

随机推荐