c++复制省略

复制省略问题

问题背景

工作背景

在工作过程中间,由于团队已经使用gcc7编译器并且支持c++17标准的使用,我们在大量代码内使用了tuple结合结构化绑定的代码来替代之前的返回结构体的模式、使用引用传递出参的模式,下面是几个模式的案例:

  • 返回结构体的模式
struct err_t {
    
    
  int ret;
  std::string err_string;
}

err_t DoString() {
    
    
...
}

auto result = DoString();
if (result.ret != 0) {
    
    
...
}

ps: 这里其实也可以用结构化绑定
  • 使用引用传递出参的模式
struct err_t {
    
    
  int ret;
  std::string err_string;
}

void DoString(err_t& result) {
    
    
...
}

err_t result;
DoString(result);
if (result.ret != 0) {
    
    
...
}
  • 使用tuple结合结构化绑定
using err_t = std::tuple<int, std::string>;

err_t DoString() {
    
    
...
return {
    
    0, ""};
}

if (auto [ret, msg] = DoString(); ret != 0) {
    
    
...
}

代码实例

在近些日子里面,我们团队内部对于一段代码有了一个争议,基本的代码案例如下:

using err_t = std::tuple<int, std::string>;

err_t DoString() {
    
    
...
return {
    
    0, ""};
}

// 使用auto推导
if (auto [ret, msg] = DoString(); ret != 0) {
    
    
...
}

// 这里使用通用引用接收右值是否更合理???
if (auto&& [ret, msg] = DoString(); ret != 0) {
    
    
...
}

思考路径

团队内部的疑问是:

  • 使用auto&& 推导是否会少一次拷贝?(这里的拷贝是说 err_t中间的std::string成员部分)

这里疑问的原因是:按照程序猿思维的理解(不关心编译器对代码的暗箱操作),这里使用auto直接推导本质相当于调用了一次 拷贝构造函数使用临时的std::string做入参,构建一个新的std::string对象msg,而使用auto&&万能引用的推导,由于auto&& 遇到右值会直接变成右值引用,从而不会有任何的开销,仅仅只是延长了右值的生命周期。这里auto&& 应该更加性能优越。

结果校验

为了验证这样一个问题,我们构造了一个更好观测的案例,基本代码如下图所示:

#include <tuple>
#include <iostream>
struct Test {
    
    
  Test() {
    
     std::cout << "default construct function" << std::endl;  }
  Test(const Test&) {
    
     std::cout << "copy construct function" << std::endl; }
  Test(Test&& ) {
    
     std::cout << "move construct function" << std::endl; }
};

using ret_struct = std::tuple<int, Test>;

ret_struct GetTestStruct() {
    
    
  return {
    
    0, Test{
    
    }};
}

ret_struct GetTestStructWithName() {
    
    
  Test t;
  return {
    
    0, t};
}

int main() {
    
    
  std::cout << "empty invoke GetTestStruct function begin" << std::endl;
  GetTestStruct();
  std::cout << "empty invoke GetTestStruct function end" << std::endl; 

  std::cout << "auto invoke GetTestStruct function begin" << std::endl; 
  auto [ret1, test1] = GetTestStruct();
  std::cout << "auto invoke GetTestStruct function end" << std::endl; 

  std::cout << "auto&& invoke GetTestStruct function begin" << std::endl; 
  auto&& [ret2, test2] = GetTestStruct();
  std::cout << "auto&& invoke GetTestStruct function end" << std::endl; 

  return 0;
}

运行结果:

empty invoke GetTestStruct function begin
default construct function
move construct function
empty invoke GetTestStruct function end
auto invoke GetTestStruct function begin
default construct function
move construct function
auto invoke GetTestStruct function end
auto&& invoke GetTestStruct function begin
default construct function
move construct function
auto&& invoke GetTestStruct function end

很明显,在三个函数调用调用的构造函数次数都是一致的,与我们一开始的疑惑是不一致的。那么出现这样的现象的原因是什么呢?

接下来我们慢慢来拉开帷幕

问题相关知识

为了更好的理解问题,首先我们需要了解几个基础知识(ps:如果您对这几个领域非常熟悉,可以直接跳过)

通用引用推导

通用引用的定义

使用c++ 标准库的定义,通用引用(universal reference)是Scott Meyers在C++ and Beyond 2012演讲中自创的一个词,用来特指一种引用的类型。这种引用在源代码中(“T&&”)看起来像右值引用,但是它们可以表现左值引用(即“T&”)的行为。它们的双重性质允许它们绑定右值(就像右值引用那样)和左值(就像左值引用那样)。而且,它们可以绑定const或者非const对象,可以绑定volatile和非volatile对象,还可以绑定const和volatile同时作用的对象。它们实际上可以绑定任何东西。

可参考内容: Universal References in C++11 – Scott Meyers : Standard C++ (isocpp.org)

通用引用需要满足两个要求:

  • 满足T&&的形式
  • T是被推导出来的(最常见的场景是模版参数T,以及auto推导)

具体的参考可以参考:

通用引用的推导规则

左值遇到通用引用 -> 左值
右值遇到通用引用 -> 右值

代码案例有

template<typename T>
void Test(T&& params) {
    
    
    ...
}

int main() {
    
    
    Test(1); // T -> int
    
    int a = 10;
    Test(a); // T -> int&
    
    auto&& b = 10;
    auto&& c = b;
    Test(c); // T -> int& (左右值与类型无关,这里c已经是一个左值)
}

其中,若入参为左值 ,通用引用运用了引用折叠的原则规范来满足引用的引用 这种情况的发生

代码案例有

template<typename T>
void Test(T&& params) {
    
    
    ...
}

int main() {
    
    
    int a = 10;
    Test(a); // 此时在模板函数```Test ```中, params的类型为 int& && -> int& 这里运营了模板折叠的知识
}

若要具体进一步了解通用引用和引用折叠,请参考Universal References in C++11 – Scott Meyers : Standard C++ (isocpp.org)

结构化绑定知识

结构化绑定是c++17新标准的内容,具体可参考的链接有:Structured binding declaration (since C++17) - cppreference.com

基本使用方法

结构体绑定

struct Test {
    
    
    int a;
    int b;
};

auto [a, b] = Test{
    
    10, 20};

auto&& [a, b] = Test{
    
    10, 20}; // 可附带修饰符

tuple绑定

using TestType = std::tuple<int, int>;
TestType a {
    
    1, 2};

auto [first, second] = a;

数组绑定

int array = {
    
    1, 2, 3};

auto [a, b, c] = array

结构化绑定原理

前提说明

结构化绑定总共分为两个部分,首先是最基本的语法层面,例如

auto [a, b] = ...

第二部分是限定符/修饰符(也就是c++程序猿日常遇到的const/&/&&等等)

const auto & = ...;
原理阐述

结构化绑定的本质是使用指定的修饰符修饰,使用auto推导,生成一个匿名的对象,随后通过别名的方式将[ ] 的用来绑定的名字绑定的匿名对象上

代码案例:

结构体案例;

struct Test {
    
    
    int a;
    int b;
};

(无修饰符) 
Test test {
    
    1, 1};
auto [name_a, name_b] = test;

/ 本质内容
auto e = test;
alias name_a = e.a;
alias name_b = e.b;

(有修饰符)
Test test {
    
    1, 1};
const auto & [name_a, name_b] = test;

/ 本质内容
const auto& e = test;
alias name_a = e.a;
alias name_b = e.b;

使用基本知识解析案例

using err_t = std::tuple<int, std::string>;

err_t DoString() {
    
    
...
return {
    
    0, ""};
}

// 使用auto推导
if (auto [ret, msg] = DoString(); ret != 0) {
    
    
    ...
}

// 这里使用通用引用接收右值是否更合理???
if (auto&& [ret, msg] = DoString(); ret != 0) {
    
    
    ...
}

对这段代码来说,使用通用引用的知识 + 结构化绑定的知识后,可翻译为

using err_t = std::tuple<int, std::string>;

err_t DoString() {
    
    
...
return {
    
    0, ""};
}

// 使用auto推导
[拆分结构化绑定]
if (auto e = Dostring(); std::get<0>(e) != 0) {
    
    
    ...
}

[拆分auto推导]
auto e = DoString(); // e为DoString()函数返回的临时值的拷贝 (不考虑编译器优化)

// 使用通用引用接受
[拆分结构化绑定]
if (auto&& e = DoString(); std::get<0>(e) != 0) {
    
    
    ...
}

[拆分auto&& 推导]
auto&& e = DoString(); // DoString()返回的为右值(pvalue)
所以e延长了右值的生命周期,没有发生拷贝

通过上面的分析我们可以知道,使用auto&& 来进行推导的时候,如果单纯从对c++语法概念的理解来说,应该是少一次拷贝的,那为什么在实验中,我们会发现使用auto auto&&接收没有任何区别呢?下面我们将使用c++编译器的能量来解释,也就是编译器优化:Copy elision ``````复制省略

Copy elision(复制省略)

复制省略其他内容可以参考:Copy elision - cppreference.com

复制省略的定义

使用cppreference的话来说,复制省略就是省略 了复制/移动构造函数,能将函数返回的临时变量直接写在接受者的地址上。

复制省略有哪些?

按照场景来分

  • 接收函数返回值
  • 对象初始化(c++17才有)
  • 作为函数参数值传递优化

按照被忽略的对象性质(返回值优化)

  • RVO(返回值优化)
  • NRVO(具名返回值优化)

代码案例

// 返回值优化
struct T {
    
    
    int a;
    std::string b;
    
    T() {
    
    
        std::cout << "default construct!" << std::endl;
    }
    
    T(const T&) {
    
    
        std::cout << "copy construct!" << std::endl;
    }
    
    T(T&&) {
    
    
        std::cout << "move construct!" << std::endl;
    }
};

// --------------------------
T GetOneT() {
    
    
  // RVO(返回值优化)
  // 特征,返回临时变量,且临时变量没有名称是匿名的
	return T {
    
    1, "hello, world"};
}
// --------------------------

// --------------------------
T GetOneT() {
    
    
    T t {
    
    0, "hello, world"};
    t.b.append("love the world !");
    // NRVO(具名返回值优化)
  	// 特征,返回临时变量,临时变量有名称,非匿名
    // 临时变量还存在其他的操作行为
    reutrn t; 
}
// --------------------------

// --------------------------
// 对象初始化优化
T GetT() {
    
    
    return T {
    
    1, "hello, world"};
}
// 对象初始化复制省略(c++17开始)
T a = T(T(GetT()));
// --------------------------

// --------------------------
// 按照值传递作为函数参数
// T为值类型参数
void PassArgByValue(T) {
    
    
}
PassArgByValue(T{
    
    1, "hello, world"}); // 只会有一次默认构造
// --------------------------

// 结合使用
PassArgByValue(GetOneT()); // 只会有一次默认构造

什么样的行为会阻断复制省略

首先需要了解值类型,值类型可参考的链接有Value categories - cppreference.com

对于返回值优化来说,返回值优化针对的对象是pvalue,也就是右值。而对于xvalue(也就是将亡值,一般来说是一系列std::move操作带来的)不会有复制省略。

代码案例

struct T {
    
    
    int a;
    std::string b;
    
    T() {
    
    
        std::cout << "default construct!" << std::endl;
    }
    
    T(const T&) {
    
    
        std::cout << "copy construct!" << std::endl;
    }
    
    T(T&&) {
    
    
        std::cout << "move construct!" << std::endl;
    }
};

T GetOneT() {
    
    
    return T {
    
    1, "hello, world!"}; // 匿名对象
}

T GetOneTWithMove() {
    
    
    T t {
    
    1, "hello, world!"};
    return std::move(t); // t变成了xvalue
}

上面代码案例中间,使用了std::move 做右值改动的时候,其实会让对象变成xvalue却导致了编译器无法优化到。

复制省略如何解释案例

我们再次重新看我们之前讨论的代码案例

using err_t = std::tuple<int, std::string>;

err_t DoString() {
    
    
...
return {
    
    0, ""};
}

// 使用auto推导
if (auto [ret, msg] = DoString(); ret != 0) {
    
    
    ...
}

[拆分结构化绑定]
auto e = DoString(); // 满足返回值优化的内容 e会和DoString中间的匿名对象为一块内存

// 这里使用通用引用接收右值是否更合理???
if (auto&& [ret, msg] = DoString(); ret != 0) {
    
    
    ...
}
[拆分结构化绑定 + 通用引用]
auto&& e = DoString(); // DoString满足返回值优化,同时外部使用右值引用接收,实际上还是构造一次,为DoString中间的匿名对象

有了复制省略的知识后,我们便可以解释为什么一开始有的疑惑,auto会造成多一次拷贝的问题不存在。因为被复制省略优化掉了。导致从始至终都只有一次的构造。

不同的c++版本的复制省略的差异

在不同的c++标准中间,对于复制省略的支持是不一样的, 具体的内容大家可以参考[cppreference](Copy elision - cppreference.com)

大概可以总结为下面几个阶段

  • c++17【上升成语言标准】,强制要求复制省略(编译参数已经不起作用了),同时不再要求被复制省略的(类定义,结构体定义)有复制/移动构造函数
  • c++11/14在某些场景下可支持返回值优化/按值传递的函数参数优化,可使用编译参数禁止,同时要求复制省略的(类定义,结构体定义)定义有复制/移动构造函数

这里主要需要关注的点为:(类定义,结构体定义)是否需要有定义复制/移动构造函数。这里本人的一个思路路径是在11/14的标准中间,我们其实并不知道是否能做编译器优化(没有强制)所以导致在某些没有强制优化的情况下需要用到复制/移动构造函数。而17标准后在编译期间就会强制这些行为的执行。

代码案例

#include <iostream>
 
class Test
{
    
    
public:
	Test() {
    
    
    std::cout << "default construct!" << std::endl;
	}
	Test(int value) {
    
    
		std::cout << "single args construct!" << std::endl;
	}
	Test(const Test&) = delete;
	Test(Test&&) = delete;
};
 
Test return_by_value_fun()
{
    
    
	return Test{
    
    };
}

void pass_by_value_fun(Test co)
{
    
    
 
}
int main(void)
{
    
    
  Test x = 42; // 初始化优化(没有拷贝构造了)
  auto y = return_by_value_fun(); // 返回值优化
  pass_by_value_fun(Test(2)); // 值传递入参优化
  return 0;
}

代码在c++11/14中没办法编译,但是在17标准下是可以被编译的。(这里是因为17下面会强制的省略)

换一个角度理解复制省略

从前面的描述来看,相信已经可以解释大部分的被优化的场景,但是我们始终没有解决的一个问题是:编译器是如何做优化的。

下面我们从几种返回值优化的场景来依次说明

按场景区分

RVO/NRVO等返回值优化

对于这类优化而言,可以看下面的例子

#include <iostream>
 
class Test
{
    
    
public:
	Test() {
    
    
    std::cout << "default construct!" << std::endl;
	}
	Test(int value) {
    
    
		std::cout << "single args construct!" << std::endl;
	}
	Test(const Test&) = delete;
	Test(Test&&) = delete;
};
 
Test return_by_value_fun()
{
    
    
	return Test{
    
    };
}

void pass_by_value_fun(Test co)
{
    
    
 
}
int main(void)
{
    
    
  auto y = return_by_value_fun(); // 返回值优化
  return 0;
}

对于这里的return_by_value_fun 函数而言,我们可以使用下面的图例来说明编译器眼里的函数

如果编译器没有做复制省略优化时候,编译器的行为是:

Test return_by_value_fun()
{
    
    
	return Test{
    
    };
  
  // ... return_by_value_fun函数
  Test tmp = Test{
    
    }; // step1: 创建临时变量
}

int main(void) {
    
    
  auto y = return_by_value_fun();
  
  // ... Main函数
  // 拆分
  auto y; // step1: 创建一个Test类型的变量y
  tmp = return_by_value_fun(); // step2: 返回值被存储到临时空间
 	y = tmp // step3: 拷贝临时空间到y中
}

但是在做了复制省略优化后,编译器的行为是

Test return_by_value_fun(Test* t)
{
    
    
  new(t) Test{
    
    };
}

int main(void)
{
    
    
  Test y = *(Test *)operator new(sizeof(Test));
  
  return_by_value_fun(y);
}

这里将会省略掉

  • 局部临时变量拷贝到临时空间的拷贝
  • 临时空间的拷贝到外部变量y的拷贝
NRVO

对于这类拷贝来说,可以看下面的例子

// 返回值优化
struct T {
    
    
    int a;
    std::string b;
    
    T() {
    
    
        std::cout << "default construct!" << std::endl;
    }
    
    T(const T&) {
    
    
        std::cout << "copy construct!" << std::endl;
    }
    
    T(T&&) {
    
    
        std::cout << "move construct!" << std::endl;
    }
};

T GetOneT() {
    
    
    T t {
    
    0, "hello, world"};
    t.b.append("love the world !");
    
    reutrn t; // NRVO(具名返回值优化)
}

int main(void)
{
    
    
  auto = GetOneT();
}

NRVO同样是使用此类方法来进行优化的,即将外部接受者的地址传入,方便在被调用函数内部直接做初始化/赋值。

按值参数传递

对于参数传递而言,可以使用下面例子来说明

void f(std::string a)
{
    
    
  int b{
    
    23};
  
  // ... 
 	return;
}

int main() 
{
    
    
  f(std::string{
    
    "A"});
  std::vector<int>y;
}

对于这个例子来说,我们使用两个函数栈帧空间内存来分析,可以使用下面来解释

// ps: local标识为局部变量
// 	 : temporaries为临时变量
//   : parameters为参数

// ... f函数栈
local     : b : 23
parameters: a : "A"
  
// .. main函数栈
local			 : y
temporaries: [匿名]:"A"

由于这里的main函数 栈的[匿名]:"A"f函数栈中的pa rameters a:"A"中间的本质是一个内容,所以这里可以通过去掉main 函数栈的[匿名]:"A"临时对象,而直接将"A"参数传递到f函数栈的参数区即可

// ps: local标识为局部变量
// 	 : temporaries为临时变量
//   : parameters为参数

// ... f函数栈
local     : b : 23
parameters: a : "A"
  
// .. main函数栈
local			 : y
// temporaries: [匿名]:"A"  删除,“A”直接写入f.parameters.a

这就是按值传递的复制省略优化。

总结

本文到这里就结束了,本文主要从一个工作中的案例进行研究,随后着重说明了复制省略的知识点。总结下来可以是一下几点:

  • 复制省略主要包括两类:1. 返回值优化, 2.按值传递参数优化
  • 复制省略在c++17中强制执行,属于语言标准
  • 复制省略中返回值优化场景下,只对纯右值有效,不要使用move等方式来处理,否则会变成xvalue将亡值反而达不到效果。

猜你喜欢

转载自blog.csdn.net/gaoyanwangxin/article/details/127709101