当前位置: 首页 > article >正文

Modern Effective C++ 条款二十九三十:移动语义和完美转发失败的情况

条款二十九:假定移动操作不存在,成本高,未被使用

移动语义可以说是C++11最主要的特性。"移动容器和拷贝指针一样开销小","拷贝临时对象现在如此高效,“写代码避免这种情况简直就是过早优化"。很多开发者认为通过采用移动语义可以极大地优化代码效率,甚至有时会听到“移动容器与拷贝指针一样开销小”这样的说法,以及建议不必过分担心临时对象的复制问题,因为移动语义已经让这个过程变得非常高效。然而移动语义有失败的情况。

移动语义允许通过移动操作来提高程序性能。然而,并非所有类型都支持移动操作,且即使支持,其带来的性能提升也未必如预期般显著。本文将讨论C++11中移动语义的局限性及其在不同类型中的表现。

1. 容器差异

案例1:std::array

并非所有容器都能从移动语义中获得相同程度的好处。例如std::vector等基于堆分配内存的容器可以通过简单地转移指针实现高效移动。std::array直接存储元素而非指向动态分配内存的指针,因此其移动操作涉及每个元素的单独处理,开销为线性时间复杂度。

考虑一下std::array(C++11中的新容器)>。std::array本质上是具有STL接口的内置数组。这与其他标准容器将内容存储在堆内存不同。存储具体数据在堆内存的容器,本身只保存了指向堆内存中容器内容的指针(实现更复杂,基本逻辑是这样)。这个指针的存在使得在常数时间移动整个容器成为可能,只需要从源容器拷贝保存指向容器内容的指针到目标容器,然后将源指针置为空指针就可以了:

std::vector<Widget> vw1;
//把数据存进vw1.
//把vw1移动到vw2.
//以常数时间运行.只有vw1和vw2中的指针被改变
auto vw2 = std::move(vw1);

std::array没有这种指针实现,数据就保存在std::array对象中:

std::array<Widget, 10000> aw1;
//把数据存进aw1
//把aw1移动到aw2。以线性时间运行
//aw1中所有元素被移动到aw2
auto aw2 = std::move(aw1);

aw1中的元素被移动到了aw2中。假定Widget类的移动操作比复制操作快,移动Widgetstd::array就比复制要快。所以std::array确实支持移动操作。但是使用std::array的移动操作还是复制操作都将花费线性时间的开销,因为每个容器中的元素终归需要拷贝或移动一次,这与“移动一个容器就像操作几个指针一样方便”的含义相去甚远。

案例2:std::string

尽管std::string提供了常数时间复杂度的移动操作与线性时间复杂度的复制操作,但小字符串优化(SSO)使得对于短字符串来说,移动并不比复制更高效。SSO允许短字符串直接存储在std::string对象缓冲区,避免了额外的堆内存分配。在这种情况下,移动这样的字符串不会比复制更快。S大量证据表明,短字符串是大量应用使用的习惯。使用内存缓冲区存储而不分配堆内存空间,是为了更好的效率。

2. 异常安全与移动操作

异常安全性:C++标准库中的某些容器为了提供强大的异常安全保障,要求移动操作必须是noexcept。如果一个类提供的移动操作没有声明为noexcept,即使它实际上更高效,编译器也可能选择使用复制操作以确保代码的异常安全性。这限制了移动语义在这些情况下的应用。

情况分析

(1)没有移动操作:如果要移动的对象不支持移动操作,则移动表达式会退化为复制操作。

(2)移动不比复制快:即使存在移动操作,如果移动的成本不低于复制,那么移动可能并不会带来性能上的提升。

(3)移动不可用:在需要保证移动操作不会抛出异常的情况下,若移动操作未声明为noexcept,则编译器将不得不使用复制操作。

(4)源对象是左值:通常只有右值可以作为移动操作的来源;左值作为来源时,除非特别设计,否则一般采用复制而非移动。

通用编程中的考虑

编写泛型代码或模板时,由于无法预知具体类型是否支持高效的移动操作,应谨慎地依赖于复制操作。这类不稳定的代码经常变更,导致类型特性变化,也应采取保守策略。当对所使用的类型有充分了解,并且该类型确实支持快速移动操作时,可以在合适的上下文中利用这一点来替换复制操作,从而提高性能。

条款三十:熟悉完美转发失败的情况

C++11的完美转发是非常好用,但是只有当你愿意忽略一些误差情况(完美转发失败的情况),这个条款就是使你熟悉这些情形。

完美转发(perfect forwarding)意味着我们不仅转发对象,我们还转发显著的特征:它们的类型,是左值还是右值,是const还是volatile。结合到我们会处理引用形参,意味着将使用通用引用,因为通用引用形参被传入实参时才确定是左值还是右值。

假定有一些函数f,编写一个转发给它的函数(事实上是一个函数模板)。我们需要的核心看起来像是这样:

template<typename T>
void fwd(T&& param)//接受任何实参{
    f(std::forward<T>(param));  //转发给f
}

转发函数是通用的。例如fwd模板,接受任何类型的实参,并转发得到的任何东西。这种通用性的逻辑扩展是,转发函数不仅是模板,而且是可变模板,因此可以接受任何数量的实参。fwd的可变形式如下:

template<typename... Ts>
void fwd(Ts&&... params)//接受任何实参{
    f(std::forward<Ts>(params)...); //转发给f
}

这种形式你会在容器emplace functions中(item42)和 智能指针的工厂函数std::make_uniquestd::make_shared中(item21)看到,当然还有其他一些地方。

给定我们的目标函数f和转发函数fwd,如果f使用某特定实参会执行某个操作,但是fwd使用相同的实参会执行不同的操作,完美转发就会失败。导致这种失败的实参种类有很多。知道它们是什么以及如何解决它们很重要,因此让我们来看看无法做到完美转发的实参类型。

花括号初始化器

假定f这样声明

void f(conststd::vector<int>& v);

用花括号初始化调用f通过编译

f({ 1, 2, 3 });  //可以,“{1, 2, 3}”隐式转换为std::vector<int>

但是传递相同的列表初始化给fwd不能编译

fwd({ 1, 2, 3 }); //错误!不能编译

这是完美转发失效的一种情况。当通过调用函数模板fwd间接调用f时,编译器不再把调用地传入给fwd的实参和f的声明中形参类型进行比较。而是推导传入给fwd的实参类型,然后比较推导后的实参类型和f的形参声明类型。当下面情况任何一个发生时,完美转发就会失败:

编译器不能推导出fwd的一个或者多个形参类型。 这种情况下代码无法编译。

编译器推导“错”了fwd的一个或者多个形参类型。

"错误"可能意味着fwd的实例将无法使用推导出的类型进行编译,但是也可能意味着使用fwd的推导类型调用f,与用传给fwd的实参直接调用f表现出不一致的行为。

这种不同行为的原因可能是因为f是个重载函数的名字,并且由于是“不正确的”类型推导,在fwd内部调用的f重载和直接调用的f重载不一样。

在上面的fwd({ 1, 2, 3 })例子中,将花括号初始化传递给未声明为std::initializer_list的函数模板形参。着编译器不准在对fwd的调用中推导表达式{ 1, 2, 3 }的类型,因为fwd的形参没有声明为std::initializer_list。对于fwd形参的推导类型被阻止,编译器只能拒绝该调用。

item2说明了使用花括号初始化的auto的变量的类型推导是成功的。这种变量被视为std::initializer_list对象,在转发函数应推导出类型为std::initializer_list的情况,这提供了一种简单的解决方法:使用auto声明一个局部变量,然后将局部变量传进转发函数:

auto il = { 1, 2, 3 };  //il的类型被推导为std::initializer_list<int>
fwd(il);                //可以,完美转发il给f

(这个地方没看懂)

0或者NULL作为空指针

item8说明当试图传递0或者NULL作为空指针给模板时,类型推导会出错,会把传来的实参推导为一个整型类型(典型情况为int)而不是指针类型。

结果就是不管是0还是NULL都不能作为空指针被完美转发。解决方法非常简单,传一个nullptr而不是0或者NULL(参考item8)。

仅有声明的整型static const数据成员

通常无需在类中定义整型static const数据成员;声明就可以了。这是因为编译器会对此类成员实行常量传播(const propagation),因此消除了保留内存的需要。

声明与定义的区别

告诉编译器某个实体(变量,函数,类等)的名称和类型,不为其分配存储空间。不仅告诉编译器实体的名称和类型,还为其分配存储空间,并可能提供初始化值或实现。

static const 整型数据成员的特殊情况。

对于static const整型数据成员,可以在类内部进行初始化,但这实际上是声明的一部分。编译器会将这个常量的值传播到所有使用它的地方,从而不需要为它分配实际的存储空间。例如:

class Widget {
public:
    static const std::size_t MinVals = 28;  // 声明并初始化
};

MinVals被声明为static const整型数据成员,并且在类内部进行了初始化。编译器会在所有使用Widget::MinVals的地方直接替换为28,因此不需要为MinVals分配内存。

为什么这仍然是声明?

编译器优化:编译器会对static const整型数据成员进行常量传播,这意味着它可以直接将值28插入到所有使用MinVals的地方,而不需要实际的存储空间。由于不需要实际的存储空间,MinVals不会在对象文件中生成符号,因此不会产生外部链接。

如果需要通过引用或指针传递MinVals,或者需要获取其地址,那么必须提供一个定义。

// 在Widget的.cpp文件中
const std::size_t Widget::MinVals;  // 定义

引用和指针需要实际的内存地址来指向,而不仅仅是值。如果没有定义,编译器将无法找到实际的内存地址,导致链接错误。

class Widget{
 public:
    static const std::size_t MinVals = 28; //声明并初始化
};
//使用
void printValue(std::size_t val) {
    std::cout << "Value: " << val << std::endl;
}

template<typename T>
void fwd(T&& arg) {
    printValue(std::forward<T>(arg));
}

int main() {
   std::vector<int> widgetData;
   widgetData.reserve(Widget::MinVals);//使用MinVals
   printValue(Widget::MinVals);//可以,视为“printValue(28)”
   //下面的调用会导致链接错误,因为fwd需要实际的内存地址
   //fwd(Widget::MinVals);  // 错误!不应该链接
    return 0;
}

为了修复链接错误,需要在.cpp文件中提供定义:

// 在Widget的.cpp文件中
const std::size_t Widget::MinVals;  // 定义

尽管代码中没有使用MinVals的地址,但是fwd的形参是通用引用,引用在编译器生成的代码中,通常被视作指针。在程序的二进制底层代码中指针和引用是一样的。在这个水平上,引用只是可以自动解引用的指针。通过引用传递MinVals实际上与通过指针传递MinVals是一样的,因此,必须有内存使得指针可以指向。通过引用传递的整型static const数据成员,通常需要定义它们,这个要求可能会造成在不使用完美转发的代码成功的地方,使用等效的完美转发失败。

根据标准,通过引用传递MinVals要求有定义。但不是所有的实现都强制要求这一点。所以,取决于你的编译器和链接器,你可能发现你可以在未定义的情况使用完美转发,恭喜你,但是这不是那样做的理由。为了具有可移植性,只要给整型static const提供一个定义:

const std::size_t Widget::MinVals;  //在Widget的.cpp文件

注意定义中不要重复初始化(这个例子中就是赋值28)。但是不要忽略这个细节。如果你忘了,并且在两个地方都提供了初始化,编译器就会报错,提醒你只能初始化一次。

重载函数与模板名称在完美转发中的问题

假设有一个函数f,接受一个函数指针作为参数,并且这个函数指针的类型是int (*)(int)。可以通过传递一个符合条件的函数来定制f的行为。

void f(int (*pf)(int));  // pf = "process function"
// 或者使用更简单的非指针语法
void f(int pf(int));

重载函数的问题

如果有一个重载函数processVal

int processVal(int value);
int processVal(int value, int priority = 1);

可以直接将processVal传递给f,因为编译器可以根据f的参数类型推断出需要哪个版本的processVal:

f(processVal);  //可以,编译器选择正确的processVal版本

当使用一个完美转发函数fwd时,问题出现了。fwd是一个模板函数,没有具体的类型信息,因此编译器无法确定应该传递哪个版本的processVal。

template<typename T>
void fwd(T&& arg) {
    f(std::forward<T>(arg));
}
fwd(processVal);  // 错误!哪个processVal?

函数模板的问题

同样的问题也出现在函数模板上。假设有一个函数模板workOnVal:

template<typename T>
T workOnVal(T param) {
    // 处理值的模板
}

尝试将workOnVal传递给fwd也会失败,因为编译器不知道应该实例化哪个版本的workOnVal:

fwd(workOnVal);  // 错误!哪个workOnVal实例?

解决方法

为了使完美转发能够处理重载函数或函数模板,需要显式地指定要传递的具体函数或函数模板实例。这可以通过创建一个具体类型的函数指针来实现。(这个没看懂)

定义类型别名:

定义一个类型别名,表示所需的函数指针类型。

using ProcessFuncType = int (*)(int);  // 定义类型别名

创建函数指针:

使用该类型别名创建一个函数指针,并将其初始化为所需的重载函数或函数模板实例。

ProcessFuncType processValPtr = processVal;  // 指定所需的processVal签名

传递函数指针:

将创建的函数指针传递给fwd。

fwd(processValPtr);  // 可以

处理函数模板:

对于函数模板,可以使用static_cast来显式地实例化并传递。

fwd(static_cast<ProcessFuncType>(workOnVal));  // 也可以

#include <iostream>
// 目标函数
void f(int (*pf)(int)) {
    std::cout << "f called with: " << pf(42) << std::endl;
}
// 重载函数
int processVal(int value) {
    return value * 2;
}
int processVal(int value, int priority) {
    return value + priority;
}
// 函数模板
template<typename T>
T workOnVal(T param) {
    return param * 3;
}
// 完美转发函数
template<typename T>
void fwd(T&& arg) {
    f(std::forward<T>(arg));
}
int main() {
    // 定义类型别名
    using ProcessFuncType = int (*)(int);
    // 创建函数指针
    ProcessFuncType processValPtr = processVal;
    // 传递函数指针
    fwd(processValPtr);  // 正确
    // 传递函数模板实例
    fwd(static_cast<ProcessFuncType>(workOnVal<int>));  // 正确
    return 0;
}

位域与完美转发的问题

位域(bit-fields)是一种特殊的成员变量,用于节省内存空间。它们通常用来表示结构体或类中的小整数。然而,当涉及到完美转发时,位域会带来一些特殊的问题。位域可能只占用一个机器字的部分位,例如32位整型中的某些位。这些位无法直接寻址,因此不能通过指针或引用直接访问。C++标准明确禁止非const引用绑定到位域上,因为位域可能不是对齐的,且不支持直接寻址。

假设有一个IPv4头部结构体定义如下:

struct IPv4Header {
  std::uint32_t version:4,IHL:4,DSCP:6,ECN:2,totalLength:16;
//其他字段...
};

如果有一个函数f接收一个std::size_t类型的参数,并且希望使用IPv4Header对象的totalLength字段调用它,那么直接调用是可行的:

void f(std::size_t sz);  //要调用的函数
IPv4Header h;
// 填充h.totalLength
f(h.totalLength);  //可以正常工作

但是,如果你希望通过一个转发函数fwd来调用f,并且fwd的参数是引用类型,那么就会出现问题。

template<typename T>
void fwd(T&& arg) {
    f(std::forward<T>(arg));
}
IPv4Header h;
// 填充h.totalLength
fwd(h.totalLength);  // 错误!non-const引用不能绑定到位域

解决方法

为了使完美转发能够处理位域,可以采取以下几种方法:

(1)按值传递

将位域的值复制到一个新的变量中,然后将这个新变量传递给目标函数。

auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length);  // 正确,传递的是副本

(2)传const引用

使用const引用传递位域的值。根据C++标准,const引用实际上绑定到一个包含位域值的临时整型对象。

void f(const std::uint16_t& sz); //修改f的参数为const引用
template<typename T>
void fwd(T&& arg){
    f(std::forward<T>(arg));
}
IPv4Header h;
//填充h.totalLength
fwd(h.totalLength);  //正确,const引用绑定到临时对象

(3)显式创建临时对象

在调用转发函数之前,显式地创建一个临时对象,然后传递这个临时对象。

  auto length = static_cast<std::uint16_t>(h.totalLength);
  fwd(length);  // 正确,传递的是副本

请记住:

  • 当模板类型推导失败或者推导出错误类型,完美转发会失败。
  • 导致 完美转发失败的实参种类有 花括号初始化,作为 空指针的0 或者 NULL,仅有声明的整型static const数据成员,模板和重载函数的名字,位域。

http://www.kler.cn/a/422121.html

相关文章:

  • 显卡(Graphics Processing Unit,GPU)比特币挖矿
  • Linux 网卡收包流程如下
  • 单片机学习笔记 12. 定时/计数器_定时
  • CSS函数
  • 计算机网络——不同版本的 HTTP 协议
  • 腾讯云平台 - Stable Diffusion WebUI 下载模型
  • 基于Matlab实现三维点坐标生成点云(源码+数据)
  • IDEA中Maven相关使用
  • IDEA中更改了项目模块名,IDEA丢失该模块的问题
  • 基于Java Springboot武汉市公交路线查询APP且微信小程序
  • elasticsearch 8.x常用命令
  • 容器运行应用及Docker命令
  • SpringBoot 基于 MVC 高校办公室行政事务管理系统:设计构思与实现范例展示
  • XRP 深度解析:从技术到 Meme 币交易指南
  • TCP/IP 9 网络安全
  • C# CancellationToken 终止操作
  • Java入门:22.集合的特点,List,Set和Map集合的使用
  • 性能之巅:Go语言优化深度探索
  • 机器学习实战:泰坦尼克号乘客生存率预测(数据处理+特征工程+建模预测)
  • hhdb数据库介绍(10-22)
  • 【Python】一、最新版Python基础知识总结、综合案例实战
  • 【软考网工笔记】网络基础理论——传输层
  • Subprocess 和 Multiprocessing 的区别与使用要点及进程关闭方法
  • ElasticSearch7.x入门教程之全文搜索聚合分析(十)
  • MongoDB复制(副本)集实战及原理分析
  • 1.Git安装与常用命令