C++ 提高性能手段 —— 临时对象的产生与避免

2020-07-09  本文已影响0人  从不中二的忧伤
一、临时对象的概念
二、临时对象的产生与避免

一、临时对象的概念

临时对象是 在源码中不可见的,是上的、没有名字的对象。与函数内定义的临时对象有根本差别。

int func()
{
    int tmp = 1;    // 这里的 tmp 并不是临时对象,其生命周期在退出函数后结束 
    return tmp; 
}

产生临时对象的情况:

int main()
{
    int i = 1;
    int j = i++;    // 这里的 i++ 会产生临时对象,这里的临时对象是在系统中产生,代码中看不见的
                    // 首先将 i 的值赋给临时对象,再把临时对象的值作为返回结果赋给 j,再对 i 进行自增操作。 
    
    return 0;   
} 

二、临时对象的产生与避免

(以下程序在 C++ 11 环境下编译,并且关闭了编译器的构造优化 -fno-elide-constructors,以分析临时对象的产生)

C++标准允许一种(编译器)实现省略创建一个只是为了初始化另一个同类型对象的临时对象。指定这个参数(-fno-elide-constructors)将关闭这种优化,强制G++在所有情况下调用拷贝构造函数。

并且从C++ 11 起,C++ 14 和 C++ 17 都有不同程度上的复制优化,具体可参考:
https://zh.cppreference.com/w/cpp/language/copy_elision

1、类型转换生成临时对象

例一:

class A
{
public:
    int val;

public:
    A(int v = 0) : val(v)
    {
        cout << "A()\t" << val << endl;
    }
    
    ~A()
    {
        cout << "~A()\t" << val << endl;
    }
    
    A(const A& t) : val(t.val)
    {
        cout << "A(const A& t)" << val << endl;
    }
    
    A& operator=(const A& t)
    {
        val = t.val;
        cout << "A& operator=(const A& t)" << endl;
    } 
    
}; 

int main()
{   
    A a;
    a = 10; // 1. 将 10为参数调用了 A 的构造函数创建了一个 A 类型的临时对象
            // 2. 通过拷贝赋值运算符,将临时对象的成员值赋给了 a
            // 3. 临时对象被销毁,调用 A 的析构函数 
    
    return 0;
}

执行结果:

A()     0   // A a 调用构造函数 
A()     10  // 临时对象调用构造函数,val = 10 
A& operator=(const A& t)    // 调用 拷贝赋值运算符,将临时对象的 tmp.val 赋值给 a.val 
~A()    10  // 销毁临时对象 
~A()    10  // 销毁 a 



优化方法:将 A 的定义与初始化在一行代码中完成:

int main()
{
    A a = 10;
    return 0;
}

C++ 11 环境下执行结果:

A()     10      // 临时对象调用构造函数,val = 10 
A(const A& t)10 // 调用拷贝构造函数,构造对象 a 
~A()    10      // 销毁临时对象 
~A()    10      // 销毁对象 a 

在 C++ 11 的环境下,可以看到,系统通过将对象 a 通过临时对象调用拷贝构造函数,后置构造,省略了一步拷贝赋值的过程。



C++ 17 环境下执行结果:

A()     10      // 系统为 a 预留了空间,直接在预留空间中用 10 构造了对象 a 
~A()    10      // 销毁对象 a 



例二:
隐式类型转换时,产生临时对象

void PrintStr(const string& src)
{
    cout << src << endl;
}

int main()
{
    char mystr[100] = "hello world";
    
    PrintStr(mystr);    // char[] 类型隐式转换成 string 类型,产生临时对象 
                        // src 绑定到了临时对象上 
    
    return 0;
}

将 char[] 类型强制转换成 string 类型,产生 string 类型的临时对象,并通过 mystr 进行赋值。此时调用 PrintStr,传入的实际上是这个 临时对象。也就是说,src 绑定到了临时对象上。

临时对象是右值,可以通过 const 左值引用绑定,但是不允许修改。

如果将 PrintStr 修改成:

void PrintStr(string& src)
{
    cout << src << endl;
}

那么将会编译报错,因为左值引用是不能够绑定右值的。

如果将 PrintStr 修改成:

void PrintStr(string&& src)
{
    cout << src << endl;
}

那么对于本 case,是可以编译通过的,因为可以通过右值引用绑定右值。但是如果此时未经过隐式类型转换,直接传入 string 类型左值,就会编译报错,因为不能通过右值引用绑定左值。

这里要避免产生临时对象,实际上将传入的实参和函数的形参类型保持一致即可:

void PrintStr(const string& src)
{
    cout << src << endl;
}

int main()
{
    string mystr("hello world") ;
    PrintStr(mystr);
    
    return 0;
}
2、类型转换生成临时对象

例一:

A Double(A& src)
{
    A tmp(src.val * 2);
    return tmp;
}

int main()
{
    A a1(10);
    
    A a2 = Double(a1);
    
    return 0;
}

执行结果:

A()     10      // 产生对象 a1,调用构造函数 
A()     20      // 产生对象 tmp,调用构造函数 
A(const A& t)20 // 产生临时对象,调用拷贝构造函数,拷贝对象 tmp 
~A()    20      // 对象 tmp 被销毁 
A(const A& t)20 // 产生对象 a2,调用 拷贝构造函数,拷贝临时对象 
~A()    20      // 临时对象被销毁 
~A()    20      // 对象 a2 被销毁 
~A()    10      // 对象 a1 被销毁 

通过执行结果,可以看出,函数返回对象,会导致临时对象的产生,多了一次拷贝构造函数的产生和一次析构函数的产生。
同时,在此编译环境下,会产生 “从临时对象到对象 a2 的拷贝”,我看了其他人的操作,都没有多出来的这一步,可能是C++版本不同导致(本环境为 Windows C++ 11),待更多尝试验证。

例二:
通过右值引用绑定函数返回的临时对象:

A Double(A& src)
{
    A tmp(src.val * 2);
    return tmp;
}

int main()
{
    A a1(10);
    
    A&& a2 = Double(a1);
    
    return 0;
}

执行结果:

A()     10 
A()     20
A(const A& t)20
~A()    20
~A()    20
~A()    10

通过右值引用绑定 函数返回对象 产生的 临时对象,不会产生从 临时对象到 a2 的拷贝。因为此时是通过 引用 去绑定,不会产生新的对象。

例三:
函数返回对象的引用:

A& Double(A& src)
{
    A tmp(src.val * 2);
    A& tmplr = tmp; 
    return tmplr;
}

int main()
{
    A a1(10);
    
    A& a2 = Double(a1);
    
    cout << "a2.val\t" << a2.val << endl;
    
    A& a3 = Double(a1);
    
    cout << "a3.val\t" << a3.val << endl;
    cout << "a3.val\t" << a3.val << endl;
    cout << "a2.val\t" << a2.val << endl;
    
    return 0;
}

执行结果:

A()     10
A()     20
~A()    20
a2.val  20
A()     20
~A()    20
&a2     0x71fdd0
&a3     0x71fdd0
a3.val  632089152
a3.val  632089152
a2.val  632089152
~A()    10

在这种情况下,函数返回对象的引用,在 Double 函数中,tmplr 所绑定的 tmp 退出函数时已被销毁,但是引用仍然保持可访问的状态。这种引用被称为 悬垂引用
从执行结果可以看出, 引用 a2 和 a3 的地址相同,都是 Double(a1) 返回的引用。但是当访问这个引用的数据时,会发现返回的数据是 不稳定 的,因为实际上此时它们所绑定的对象已经被销毁。
这种 悬垂引用 在代码的书写中,应该尽量避免。

优化方法:
不建议使用 【引用】来减少 构造和析构的开销。建议使用 返回值优化 (RVO)

A Double(A& src)
{
    return A(src.val * 2);
}

int main()
{
    A a1(10);
    
    A a2 = Double(a1);
    
    return 0;
}

理想执行结果:

A()     10
A()     20
~A()    20
~A()    10

但是。。 可能因为编译环境的问题…… 本人实操时的执行结果却是:

A()     10
A()     20
A(const A& t)20
~A()    20
A(const A& t)20
~A()    20
~A()    20
~A()    10

待验证……待验证……

不过幸运的是,现在编译器已经对 消除复制 有了很好的优化,当编译时去掉参数 -fno-elide-constructors,就能够得到最优的优化效果:

A()     10
A()     20
~A()    20
~A()    10
上一篇 下一篇

猜你喜欢

热点阅读