c++ copy elision

copy omission problem

problem background

work background

In the middle of the work process, since the team has used the gcc7 compiler and supports the use of the c++17 standard, we have used tuple combined with structured binding code in a large number of codes to replace the previous mode of returning structures and passing by reference The mode of output parameters, the following are several examples of modes:

  • Returns the schema of the structure
struct err_t {
    
    
  int ret;
  std::string err_string;
}

err_t DoString() {
    
    
...
}

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

ps: 这里其实也可以用结构化绑定
  • Mode for passing parameters by reference
struct err_t {
    
    
  int ret;
  std::string err_string;
}

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

err_t result;
DoString(result);
if (result.ret != 0) {
    
    
...
}
  • Using tuples combined with structured binding
using err_t = std::tuple<int, std::string>;

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

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

code example

In recent days, there has been a dispute within our team about a piece of code. The basic code case is as follows:

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) {
    
    
...
}

thought path

Questions within the team are:

  • Does using auto&& deduce one less copy? (The copy here refers to the std::string member part in the middle of err_t)

The reason for the question here is: according to the understanding of the programmer's thinking (don't care about the compiler's black-box operation of the code), using auto to directly deduce the essence is equivalent to calling a temporary std::string as an input parameter to build a 拷贝构造函数new std::string object msg, and the derivation using auto&& universal reference, since auto&& encounters an rvalue, it will directly become an rvalue reference, so there will be no overhead, and it will only prolong the life cycle of the rvalue. Here auto&& should be more performant.

Result verification

In order to verify such a problem, we constructed a better observation case, the basic code is shown in the following figure:

#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;
}

operation result:

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

Obviously, the number of constructor calls in the three function calls is consistent, which is inconsistent with our initial doubts. So what is the reason for this phenomenon?

Next, let's slowly draw the curtain

Knowledge about the problem

In order to better understand the problem, first we need to understand a few basic knowledge (ps: if you are very familiar with these fields, you can skip it directly)

Universal Reference Derivation

Definition of Universal Citation

Using the definition of the C++ standard library, universal reference is a term coined by Scott Meyers in his C++ and Beyond 2012 speech to refer to a type of reference. Such references look like rvalue references in source code ("T&&"), but they can behave like lvalue references (ie, "T&"). Their dual nature allows them to bind rvalues ​​(like rvalue references) and lvalues ​​(like lvalue references). Moreover, they can bind const or non-const objects, volatile and non-volatile objects, and objects that both const and volatile act on at the same time. They can bind virtually anything.

Reference content: Universal References in C++11 – Scott Meyers : Standard C++ (isocpp.org)

Universal citations need to meet two requirements:

  • Satisfies the form of T&&
  • T is derived (the most common scenario is the template parameter T, and auto derivation)

For specific references, please refer to:

Derivation Rules for Universal Citations

lvalue encounters universal reference -> lvalue
rvalue encounters universal reference -> rvalue

The code case has

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已经是一个左值)
}

Among them, if the input parameter is 左值 , the universal reference uses the principle specification of reference folding to meet 引用的引用 the occurrence of this situation

The code case has

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

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

To learn more about Universal References and Reference Collapsing specifically, see Universal References in C++11 – Scott Meyers : Standard C++ (isocpp.org)

structured binding knowledge

Structured binding is the content of the new c++17 standard. The specific links are: Structured binding declaration (since C++17) - cppreference.com

Basic usage

Struct Binding

struct Test {
    
    
    int a;
    int b;
};

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

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

tuple binding

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

auto [first, second] = a;

array binding

int array = {
    
    1, 2, 3};

auto [a, b, c] = array

Structured Binding Principles

Prerequisites

Structured binding is divided into two parts, the first is the most basic grammatical level, for example

auto [a, b] = ...

The second part is qualifiers/modifiers (that is, const/&/&&, etc. that c++ programmers encounter daily)

const auto & = ...;
Principle Explanation

The essence of structured binding is to use specified modifiers to modify, use auto deduction to generate an anonymous object, and then use an alias to bind the anonymous object [ with the name used for binding]

Code example:

结构体案例;

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;

Parsing a case using basic knowledge

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) {
    
    
    ...
}

For this code, after using the knowledge of universal reference + knowledge of structured binding, it can be translated as

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延长了右值的生命周期,没有发生拷贝

Through the above analysis, we can know that auto&& when using to deduce, if we simply understand the concept of c++ grammar, there should be one less copy, so why in the experiment, we will find that there is no difference between using and receiving auto ? auto&&? Below we will use the energy of the c++ compiler to explain, that is, compiler optimization:Copy elision ``````复制省略

Copy elision (copy omitted)

For copying and omitting other content, please refer to: Copy elision - cppreference.com

copy omitted definition

In the words of cppreference, copy elision 省略 is the copy/move constructor, which can directly write the temporary variable returned by the function to the address of the receiver.

What are copy elisions?

According to the scene

  • Receive function return value
  • Object initialization (c++17 only)
  • Passing optimizations as function parameter values

By ignored object properties (return value optimization)

  • RVO (Return Value Optimization)
  • NRVO (Named Return Value Optimization)

code example

// 返回值优化
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()); // 只会有一次默认构造

What behavior blocks copy elision

First of all, you need to understand the value type. The link for the value type is Value categories - cppreference.com

For return value optimization, the object of return value optimization is pvaluervalue. And for xvalue(that is, the x value, generally brought about by a series of std::move operations) there will be no copy elision.

code example

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
}

In the middle of the above code example, std::move when using rvalue changes, it will actually make the object into a value, xvaluebut the compiler cannot optimize it.

How copy elision explains the case

Let's revisit the code case we discussed earlier

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中间的匿名对象

With the knowledge of copying and omitting, we can explain why there were doubts at the beginning, the problem that auto will cause one more copy does not exist. Because it is optimized away by copy elision. Resulting in a construct that is exactly once from start to finish.

Differences in copy elision for different c++ versions

Among different c++ standards, the support for copy elision is different. For details, please refer to [cppreference]( Copy elision - cppreference.com )

It can be roughly summarized into the following stages

  • C++17 [upgraded to a language standard], mandatory copy elision (compilation parameters no longer work), and no longer require copy elision (class definition, structure definition) to have a copy/move constructor
  • C++11/14 can support return value optimization/function parameter optimization passed by value in some scenarios, which can be prohibited by using compilation parameters, and require copy-omitted (class definition, structure definition) definitions to have copy/move construction function

The main point of concern here is: (class definition, structure definition) whether there is a need to define a copy/move constructor. One of my thinking paths here is in the middle of the 11/14 standard. In fact, we don’t know whether compiler optimization can be done (not mandatory), so the copy/move constructor needs to be used in some cases where there is no mandatory optimization. After the 17th standard, these behaviors will be enforced during compilation.

code example

#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;
}

The code cannot be compiled in c++11/14, but it can be compiled under the 17 standard. (This is because it will be omitted under 17 forcibly)

Understanding copy elision from another angle

Judging from the previous description, I believe that most of the optimized scenarios can be explained, but one problem we have not solved is: how the compiler optimizes.

Let's explain in turn from several return value optimization scenarios

by scene

RVO/NRVO and other return value optimization

For this type of optimization, see the following example

#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;
}

For the function here return_by_value_fun , we can use the following legend to illustrate the function in the eyes of the compiler

If the compiler does not do copy elision optimization, the behavior of the compiler is:

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中
}

But after doing the copy elision optimization, the behavior of the compiler is

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);
}

will be omitted here

  • Copy of local temporary variable to temporary space
  • Copy of temporary space to copy of external variable y
NO

For this type of copy, see the following example

// 返回值优化
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 also uses this method for optimization, that is, the address of the external receiver is passed in, so that it can be directly initialized/assigned inside the called function.

pass by value parameter

For parameter passing, you can use the following example to illustrate

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

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

For this example, we use two function stack frame space memory to analyze, which can be explained using the following

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

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

Since the essence here main函数 栈的[匿名]:"A"and the middle are the same content, you can directly pass the "A" parameter to the parameter area of ​​the f function stack f函数栈中的pa rameters a:"A"by removing the [anonymous]: "A" temporary object of the function stack.main

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

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

This is the pass-by-value copy-elision optimization.

Summarize

This article ends here. This article mainly studies a case at work, and then focuses on the knowledge points of copying and omitting. To sum up, there are a few points:

  • Copy elision mainly includes two categories: 1. Return value optimization, 2. Pass parameter by value optimization
  • Copy elision is enforced in C++17 and is part of the language standard
  • In the return value optimization scenario of copy elision, it is only valid for prvalues, do not use move and other methods to deal with it, otherwise it will become xvalue and xvalue will not achieve the effect.

Guess you like

Origin blog.csdn.net/gaoyanwangxin/article/details/127709101