C++中的对象实例化是否存在显著的固有成本

Is there a significant inherent cost of object instantiation in C++?

本文关键字:存在 对象 实例化 是否 C++      更新时间:2023-10-16

我最近在一次代码审查中(由一位更老、更聪明的C++开发人员)被告知重写我编写的一个类,将其变成一组静态方法。他为这一点辩护说,尽管我的对象确实包含了非常少量的内部状态,但它无论如何都可以在运行时派生,如果我改为静态方法,我可以避免在所有地方对对象进行实例化的成本。

我现在已经做了这个改变,但它让我思考,在C++中实例化的成本是多少?我知道,在托管语言中,垃圾收集对象的所有成本都很高。然而,我的C++对象只是在堆栈上,它不包含任何虚拟方法,因此不会有运行时函数查找成本。我使用了新的C++11删除机制来删除默认的复制/赋值运算符,因此不涉及复制。它只是一个简单的对象,有一个只做少量工作的构造函数(静态方法无论如何都需要)和一个什么都不做的析构函数。无论如何,你能告诉我这些安装常数是什么吗?(评论者有点吓人,我不想问他看起来很愚蠢!);-)

简短回答-固有的对象分配很便宜,但在某些情况下可能会变得昂贵。

长答案

在C++中,实例化对象的成本与在C中实例化结构的成本相同。所有对象都是一块足够大的内存,可以存储v表(如果有)和所有数据属性。方法在实例化v表之后不再消耗内存。

非虚拟方法是以隐式this作为其第一个参数的简单函数。调用虚拟函数有点复杂,因为它必须进行v表查找才能知道要调用哪个类的哪个函数。

这意味着实例化堆栈上的对象涉及堆栈指针的简单递减(对于完全递减的堆栈)。

当一个对象在堆上实例化时,成本可能会大幅上升。但这是任何与堆相关的分配所固有的。在堆上分配内存时,堆需要找到一个足够大的空闲块来容纳对象。找到这样的块是一个非恒定的时间操作,并且可能是昂贵的。

C++的构造函数可以为某些指针数据属性分配更多内存。这些通常是堆分配的。如果所述数据成员自己执行堆分配,则会进一步加剧这种情况。这可能会导致涉及大量指令的事情。

所以最重要的是,这取决于你如何以及你要讨好的对象是什么。

如果您的对象类型在其生命周期内必须调用一个非平凡的构造函数和析构函数,那么成本将是创建任何具有非平凡构造函数和析构函数的C++对象的最小成本。将其余方法设为static不会降低成本。空间的"价格"将至少为1字节,因为您的类不是派生类的基类,并且static类方法调用中唯一节省的成本将是省略作为调用的隐藏第一个参数传递的隐式this指针,这是非静态类方法所需的。

如果评审员要求您重新指定为static的方法永远不会接触类类型的非静态数据成员,那么传递隐式this指针是浪费资源,评审员说得很好。否则,您将不得不向静态方法添加一个参数,该参数将类类型作为引用或指针,从而抵消由于省略隐式this指针而获得的性能。

可能不多,如果它是任何类型的瓶颈,我都会感到惊讶。但如果没有别的,这就是事情的原理。

然而,你应该问那个家伙;永远不要害怕这样做,而且这里并不完全清楚,丢失存储状态并每次派生它(如果你是这样做的话)不会让事情变得更糟。如果不是,你会认为名称空间会比静态方法更好。

一个测试用例/例子会让这个问题更容易明确地回答,而不是"你应该问他"。

这取决于应用程序的功能。它是内存有限的设备上的实时系统吗?如果没有,大多数时候对象实例化都不会成为问题,除非你正在实例化数百万个对象并将它们保留下来,或者类似的奇怪设计。大多数系统将有更多的瓶颈,例如:

  • 用户输入
  • 网络呼叫
  • 数据库访问
  • 计算密集型算法
  • 线程切换成本
  • 系统调用

我认为在大多数情况下,封装到类中进行设计胜过实例化的小成本。当然,可能有1%的情况不成立,但你是其中之一吗?

一般来说,如果一个函数可以是静态的,那么它可能应该是静态的。它更便宜。便宜多少?这取决于对象在构造函数中的作用,但构造C++对象的基本成本并没有那么高(动态内存分配当然更昂贵)。

重点是不要为那些你不需要的东西买单。如果一个函数可以是静态的,为什么要将其作为成员函数?在这种情况下,作为成员函数是没有意义的。创建对象的惩罚会影响应用程序的性能吗?也许不会,但再说一遍,为什么要为你不需要的东西买单?

正如其他人建议的那样,请与您的同事交谈,并请他解释他的理由。如果可行,您应该使用一个小型测试程序来研究这两个版本的性能。同时做这两件事将有助于你成长为一名程序员。

总的来说,如果可行的话,我同意将成员函数设置为静态的建议。不是因为性能的原因,而是因为它减少了理解函数行为所需记住的上下文数量。

值得注意的是,有一种情况是,使用成员函数会导致更快的代码。这种情况下编译器可以执行内联。这是一个高级话题,但正是这样的东西让编写关于编程的分类规则变得困难。

#include <algorithm>
#include <iostream>
#include <vector>
#include <stdlib.h>
#include <time.h>
bool int_lt(int a, int b)
{
    return a < b;
}
int
main()
{
    size_t const N = 50000000;
    std::vector<int> c1;
    c1.reserve(N);
    for (size_t i = 0; i < N; ++i) {
        int r = rand();
        c1.push_back(r);
    }
    std::vector<int> c2 = c1;
    std::vector<int> c3 = c1;
    clock_t t1 = clock();
    std::sort(c2.begin(), c2.end(), std::less<int>()); 
    clock_t t2 = clock();
    std::sort(c3.begin(), c3.end(), int_lt);
    clock_t t3 = clock();
    std::cerr << (t2 - t1) / double(CLOCKS_PER_SEC) << 'n';
    std::cerr << (t3 - t2) / double(CLOCKS_PER_SEC) << 'n';
    return 0;
}

在我的i7 Linux上,因为g++不能内联函数int_lt,但可以内联std::less::operator(),所以非成员函数版本慢了大约50%。

> g++-4.5 -O2 p3.cc 
> ./a.out 
3.85
5.88

为了理解为什么会有这么大的差异,你需要考虑编译器为比较器推断出的类型。在int_lt的情况下,它推断出类型bool(*)(int,int),而在std::less的情况下则推断出std::less。有了函数指针,要调用的函数只有在运行时才是已知的。这意味着编译器不可能在编译时内联其定义。与std::less相反,编译器在编译时可以访问类型及其定义,因此可以内联std::less::operator()。在这种情况下,这会对性能产生重大影响。

这种行为只与模板有关吗?不,这与将函数作为对象传递时抽象性的丧失有关。函数指针包含的信息不如编译器要使用的函数对象类型那么多。这里有一个类似的例子,不使用模板(为了方便起见,除了std::vector)。

#include <iostream>
#include <time.h>
#include <vector>
#include <stdlib.h>
typedef long (*fp_t)(long, long);
inline long add(long a, long b)
{
    return a + b;
}
struct add_fn {
    long operator()(long a, long b) const
    {
        return a + b;
    }
};
long f(std::vector<long> const& x, fp_t const add, long init)
{
    for (size_t i = 0, sz = x.size(); i < sz; ++i)
        init = add(init, x[i]);
    return init;        
}
long g(std::vector<long> const& x, add_fn const add, long init)
{
    for (size_t i = 0, sz = x.size(); i < sz; ++i)
        init = add(init, x[i]);
    return init;        
}
int
main()
{
    size_t const N = 5000000;
    size_t const M = 100;
    std::vector<long> c1;
    c1.reserve(N);
    for (size_t i = 0; i < N; ++i) {
        long r = rand();
        c1.push_back(r);
    }
    std::vector<long> c2 = c1;
    std::vector<long> c3 = c1;
    clock_t t1 = clock();
    for (size_t i = 0; i < M; ++i)
        long s2 = f(c2, add, 0);
    clock_t t2 = clock();
    for (size_t i = 0; i < M; ++i)
        long s3 = g(c3, add_fn(), 0);
    clock_t t3 = clock();
    std::cerr << (t2 - t1) / double(CLOCKS_PER_SEC) << 'n';
    std::cerr << (t3 - t2) / double(CLOCKS_PER_SEC) << 'n';
    return 0;
}

游标测试表明自由函数比成员函数慢100%。

> g++ -O2 p5.cc 
> ./a.out 
0.87
0.32

Bjarne Stroustrup最近提供了一个关于C++11的精彩讲座,其中涉及这一点。你可以在下面的链接上观看。

http://channel9.msdn.com/Events/GoingNative/GoingNative-2012/Keynote-Bjarne-Stroustrup-Cpp11-Style