在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. 所有的数据成员都是可以被拷贝或移动

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

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

  1. Item 17: 理解特殊成员函数的生成规则

    本文翻译自modern effective C++,由于水平有限,故无法保证翻译完全正确,欢迎指出错误.谢谢! 博客已经迁移到这里啦 C++的官方说法中,特殊成员函数是C++愿意去主动生成的.C++9 ...

  2. 从成员函数指针生成可调用对象:function<>、mem_fn()和bind()

    我们知道,普通函数指针是一个可调用对象,但是成员函数指针不是可调用对象.因此,如果我们想在一个保存string的vector中找到第一个空string,不能这样写: vector<string& ...

  3. C++中的虚函数(表)实现机制以及用C语言对其进行的模拟实现

    tfref 前言 C++对象的内存布局 只有数据成员的对象 没有虚函数的对象 拥有仅一个虚函数的对象 拥有多个虚函数的对象 单继承且本身不存在虚函数的继承类的内存布局 本身不存在虚函数(不严谨)但存在 ...

  4. 成员函数指针与高性能C++委托

    1 引子 标准C++中没有真正的面向对象的函数指针.这一点对C++来说是不幸的,因为面向对象的指针(也叫做“闭包(closure)”或“委托(delegate)”)在一些语言中已经证明了它宝贵的价值. ...

  5. [转]成员函数指针与高性能的C++委托

    原文(作者:Don Clugston):Member Function Pointers and the Fastest Possible C++ Delegates 译文(作者:周翔): 成员函数指 ...

  6. C++ 空类,默认产生哪些成员函数

    C++ 空类,默认产生哪些成员函数.     默认构造函数.默认拷贝构造函数.默认析构函数.默认赋值运算符 这四个是我们通常大都知道的.但是除了这四个,还有两个,那就是取址运算符和 取址运算符 con ...

  7. Go结构体实现类似成员函数机制

    Go语言结构体成员能否是函数,从而实现类似类的成员函数的机制呢?答案是肯定的. package main import "fmt" type stru struct { testf ...

  8. 如何禁止C++默认生成成员函数

    前言: 前几天在一次笔试过程中被问到c++如何设计禁止调用默认构造函数,当时简单的想法是直接将默认构造函数声明为private即可,这样的话对象的确不能直接调用.之后查阅了<Effective ...

  9. Item 22: 当使用Pimpl机制时,在实现文件中给出特殊成员函数的实现

    本文翻译自<effective modern C++>,由于水平有限,故无法保证翻译完全正确,欢迎指出错误.谢谢! 博客已经迁移到这里啦 如果你曾经同过久的编译时间斗争过,那么你肯定对Pi ...

随机推荐

  1. Java的JDBC

    第一个JDBC程序 创建测试数据库 CREATE DATABASE jdbcStudy CHARACTER SET utf8 COLLATE utf8_general_ci; USE jdbcStud ...

  2. Springboot集成邮箱服务发送邮件

    一.前言 Spring Email 抽象的核心是 MailSender 接口,MailSender 的实现能够把 Email 发送给邮件服务器,由邮件服务器实现邮件发送的功能. Spring 自带了一 ...

  3. Solon 开发,七、自定义注解开发汇总

    Solon 开发 一.注入或手动获取配置 二.注入或手动获取Bean 三.构建一个Bean的三种方式 四.Bean 扫描的三种方式 五.切面与环绕拦截 六.提取Bean的函数进行定制开发 七.自定义注 ...

  4. 更快的Maven构建工具mvnd和Gradle哪个更快?

    Maven 作为经典的项目构建工具相信很多人已经用很久了,但如果体验过 Gradle,那感觉只有两个字"真香". 前段时间测评了更快的 Maven 构建工具 mvnd,感觉性能挺高 ...

  5. Docsify部署IIS

    什么是Docsify? 一个神奇的文档网站生成器.docsify 可以快速帮你生成文档网站.不同于 GitBook.Hexo 的地方是它不会生成静态的 .html 文件,所有转换工作都是在运行时.如果 ...

  6. [开发笔记usbTOcan]软件需求分析和软件架构设计

    前面文章进行了系统分析和系统架构设计,手工焊接了一个板子,集合EK-TMC123GXL开发板(请忽略焊接技术) SWE.1 | 软件需求分析 软件需求分析过程的目的是将系统需求的软件相关部分转化为一组 ...

  7. Java中运算符及其优先级、自动类型提升、类型转化

                   自动类型提升的规则 两个操作数中有一个为double型的数据,计算结果提升为double. 两个操作数中无double型,有一个float,计算结果自动提升为float. ...

  8. unity3d微软语音识别httppost失败。安全验证问题

    using System; using System.Collections; using System.Collections.Generic; using System.IO; using Sys ...

  9. uniapp微信小程序保存base64格式图片的方法

    uniapp保存base64格式图片的方法首先第一要先获取用户的权限 saveAlbum(){//获取权限保存相册 uni.getSetting({//获取用户的当前设置 success:(res)= ...

  10. golang中的tcp编程

    1. tcp server package main import ( "bufio" "fmt" "net" ) func main() ...