现代 C++ 性能表现优异的两个主要原因:智能指针和移动语义

代表着自1998年起13年间第一个重要的更新,雄心壮志的C++11标准预示着“现代C++时代”的到来。三年后,C++14的出台标志着委员会在这十三年的孕育期中所追求的的全部功能集合的完成。

读者只需要稍微谷歌一下便能看到现代C++中包含了许多的新特性。在这篇文章中,我仅会关注代表着C++性能演进里程碑式的两个新特性:智能指针和移动语义。

智能指针

C/C++语系中的主要关注点一直在于性能。正如我在教授C++时告诉组内学生的,每当我以“为什么”打头,就C++语言或者库的某一特性提问时,90%的时间我得到的答案都是一个词:性能。

原始指针太过脆弱并且容易出错,不多它们是如此的接近于机器,使得使用它们的代码运行起来就像来自于地狱的蝙蝠。几十年以来,我们没有找到能满足大型程序对性能的需求的更好的方法。内存泄漏,段错误以及折磨人的调试,这些都是你需要的仅原始指针才能提供的性能的代价。

原始指针的问题在于有 太多的方式误用它们了,这包括:忘记初始化,忘记释放动态内存以及多次释放动态内存。大部分这类问题都能通过使用智能指针而缓解甚至完全消除,智能指针是 被设计为封装原始指针并极大地提升原始指针的可靠性的类模板。C++98提供了的auto_ptr模板解决了部分问题,但是依然缺乏足够的语言层面的支持 来解决这些棘手的问题。到了C++1,语言层面的支持有了,而到了C++14,不仅使用原始指针的需求没有了,连使用原始的new和 delete操作符的需求也几乎没有了。代码进行动态内存分配时的可靠性(包括异常安全)在现代C++中得到了提升,却没有任何性能的开销。这也许代表了 切换到现在C++后最引人注目的范式转变:智能指针对原始指针的替代几乎无处不在。下面的代码展示了使用原始指针和unique_ptr来管理动态内存时 的基本差异:

// ----------------------------
// 使用老式的原始指针:
// ----------------------------

Widget *getWidget();

void work()
{
    Widget *wptr = getWidget();

    // 发生异常或者函数返回: Widget未被释放!

    delete wptr; // 需要手动释放
}

//-----------------------------
// 使用现代C++的unique_ptr:
//-----------------------------

unique_ptr<Widget> getWidget();

void work()
{
    unique_ptr<Widget> upw = getWidget();

    // 发生异常或者函数返回: 没有问题!

}   // Widget自动被释放

移动语义

C++11之前,有一个地方依然是性能被限制的:C++基于值的语义会招致对资源密集型对象不必要拷贝的开销。在C++98中,一个如下声明的函数

vector<Widget> makeWidgetVec(creation-parameters);

将恐惧直击循环计数器之心,这是由于返回Widget的vector之值有潜在的开销(我们假设Widget是某种吃资 源的类型)。C++98的规则要求这个vector现在函数内部被构造出来,然后再函数返回时被拷贝,或者至少表现如此。当每个Widget的拷贝开销巨 大时,拷贝一个充满这些Widget的整个vector需要被避免。编译器历史上都应用了返回值优化(Return Value Optimization, RVO),从而避免拷贝这样的vector的开销。但是RVO仅仅是一种优化,而具体环境常常不允许编译器实施这项优化。故而,这类代码的开销并不能被可 靠地预测。

现在C++中移动语义的引入消除了这个不确定性。即使Widget不是可移动的,返回一个Widget的临时容器的值也是十分高效的,因为vector模板本身支持移动语义。另 外,如果Widget是可移动的(其规则直截了当且跨平台,它们不是依赖于具体平台的优化),那么整个vector的开销也将极大地被缓解。我们可以通过 一个例子来展示着一点,在该例中发生了管理内存重分配以及超出预分配容量。下面的代码展示了Widget类的两个版本,“传统的”版本如下所示

#include <cstring>

class Widget
{
    public:
        Widget() : size(10000), ptr(new char[size]) {}
        ~Widget() { delete ptr; }
            // 拷贝构造函数:
        Widget(const Widget &rhs) : size(rhs.size),
            ptr(new char[rhs.size])
            { std::memcpy(ptr, rhs.ptr, size); }
            // 拷贝赋值操作符:
        Widget& operator=(const Widget &rhs) {
            char *p = new char[rhs.size];
            delete [] ptr;
            ptr = p;
            size = rhs.size;
            std::memcpy(ptr, rhs.ptr, size);
            return *this;
        }

    private:
        size_t size;
        char *ptr;
};

// 测试程序输出
//
// vw大小: 500000
// 对满了的vector执行push_back操作的时间: 57.861

以及一个增强的支持移动语义的版本:

#include <cstring>

class Widget
{
    public:
        Widget() : size(10000), ptr(new char[size]) {}
        ~Widget() { delete ptr; }
            // 拷贝构造函数:
        Widget(const Widget &rhs) : size(rhs.size),
            ptr(new char[rhs.size])
            { std::memcpy(ptr, rhs.ptr, size); }
            // 移动构造函数
        Widget(Widget &&rhs) noexcept : size(rhs.size), ptr(rhs.ptr)
                { rhs.size = 0; rhs.ptr = nullptr; }
            // 拷贝赋值操作符:
        Widget& operator=(const Widget &rhs) {
            size = rhs.size;
            char *p = new char[rhs.size];
            delete []ptr;
            ptr = p;
            std::memcpy(ptr, rhs.ptr, size);
            return *this;
        }
            // 移动赋值操作符
        Widget &operator=(Widget &&rhs) noexcept {
            delete [] ptr;
            size = rhs.size;
            ptr = rhs.ptr;
            rhs.size = 0;
            rhs.ptr = nullptr;
            return *this;
        }

    private:
        size_t size;
        char *ptr;
};

// 输出:
//
// vw大小: 500000
// 对满了的vector执行push_back操作的时间: 0.031

通过一个简单的 timer类,测试程序用五十万个Widget实例填满vector,确保vector的容量足够,汇报每个将Widgetpush_back到 vector操作的时间。在C++98重,push_back操作最长要花差不多一分钟时间(在我的老电脑Dell Latitude E6500上)。使用现代C++以及移动语义版本的Widget,同样的push_back操作花了0.031秒。以下是这个简单的timer类:

#include <ctime>

class Timer {
    public:
        Timer(): start(std::clock()) {}

        operator double() const 
                { return (std::clock() - start) / static_cast<double>(CLOCKS_PER_SEC); }

        void reset() { start = std::clock(); }

    private:
        std::clock_t start;
};

以下是测试在一个大的、满的Widget(一个非常占内存的类) vector上调用push_back所需时间的程序:

#include <iostream>
#include <exception>
#include <vector>
#include "Widget2.h"
#include "Timer.h"   // for Timer class

int main()
{
    using namespace std;

    vector<Widget> vw(500000);
    while (vw.size() < vw.capacity())
        vw.push_back(Widget());

    cout << "Size of vw: " << vw.size() << endl;

    Timer t;    // initialize timer to current clock time
    vw.push_back(Widget());
    cout << "Time for one push_back on full vector: " << t << endl;
}

现代C++移动语义的效果通过整体的性能提升而大放异彩,但是从用户源代码层面上讲,它们并不总是那么明显。所以,我把移动语义当做一个“隐形”的特性。相反的,智能指针在源代码层面则非常地明显,因为开发人员需要作出有意思的决策方能使用它们。如果不深入到实现细节里去,我们很难评价这些新的特性。

本文文字及图片出自 coyee.com

你也许感兴趣的:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注