指针对非指针

Pointer against not pointer

本文关键字:指针      更新时间:2023-10-16

我在许多地方读到,包括Effective C++,最好将数据存储在堆栈上,而不是作为数据的指针。

我可以理解用小对象这样做,因为新调用和删除调用的数量也减少了,这减少了内存泄漏的机会。此外,指针可能比对象本身占用更多的空间。

但是对于大对象,复制它们会很昂贵,将它们存储在智能指针中不是更好吗?

因为在对大对象执行许多操作时,很少有对象复制,这是非常昂贵的(我不包括getter和setter)。

让我们纯粹关注效率。不幸的是,没有一刀切的。这取决于您优化的目的。有句话说,总是优化常见情况。但常见的情况是什么?有时,答案在于从内到外理解软件的设计。有时,即使在高级阶段,它也是不可知的,因为你的用户会发现你没有预料到的新的使用方法。有时,您会扩展设计并揭示新的常见案例。因此,优化,尤其是微观优化,在事后看来几乎总是最好的应用,基于这些用户端知识和手头的分析器。

通常情况下,当你的设计是强制而不是响应它时,你会对常见情况有很好的预见性。例如,如果你正在设计一个像std::deque这样的类,那么你会强制使用push_frontspush_backs来写常见情况,而不是插入到中间,所以需求会让你对优化内容有很好地预见性。常见的案例嵌入到设计中,设计不可能有任何不同。对于更高级的设计,你通常就没那么幸运了。即使在你事先知道广泛的常见情况的情况下,即使是专家,在没有探查器的情况下也经常会错误地猜测导致速度减慢的微观指令。因此,任何开发人员在考虑效率时都应该首先感兴趣的是探查器。

但是,如果您确实遇到了带有探查器的热点,这里有一些提示。

内存访问

大多数时候,最大的微观级别热点(如果有的话)都与内存访问有关。因此,如果你有一个大对象,它只是一个连续的块,所有成员都可以在某个紧密的循环中访问,这将有助于提高性能。

例如,如果你有一个4分量数学矢量的数组,你正在一个紧凑的算法中顺序访问,如果它们是连续的,你通常会做得更好,比如

x1,y1,z1,w1,x2,y2,x2,w2...xn,yn,zn,wn

具有这样的单个块结构(全部在一个连续块中):

x
y
z
w

这是因为机器将把这些数据提取到一个缓存行中,当它在内存中像这样紧密封装和连续时,缓存行中会有相邻向量的数据。

如果你在这里使用类似std::vector的东西来表示每个单独的4分量数学向量,你可以很快减慢算法的速度,其中每个单独的向量都将数学分量存储在内存中可能完全不同的地方。现在,每个向量可能都有一个缓存未命中。此外,由于它是一个可变大小的容器,您需要为额外的成员付费。

std::vector是一个"2块"对象,当我们将其用于数学4向量时,它通常看起来是这样的:

size
capacity
ptr --> [x y z w] another block

它还存储了一个分配器,但为了简单起见,我将省略它。

另一方面,如果你有一个大的"1块"对象,在那些严格的、性能关键的循环中,只有它的一些成员可以访问,那么最好把它做成一个"2块"结构。假设你有一些Vertex结构,其中访问次数最多的部分是x/y/z位置,但它也有一个访问次数较少的相邻顶点列表。在这种情况下,最好将其取出,并将邻接数据存储在内存的其他地方,甚至可能完全在Vertex类本身之外(或者仅仅是一个指针),因为您的常见情况是,不访问该数据的性能关键算法将能够访问单个缓存行中附近更连续的顶点,因为这些顶点将更小,并指向内存中其他地方很少访问的数据。

创建/销毁开销

当需要快速创建和销毁对象时,您还可以更好地在连续的内存块中创建每个对象。每个对象的单独内存块越少,它通常运行得就越快(因为无论这些东西是否在堆或堆栈上,要分配/释放的块都会更少)。

免费存储/堆开销

到目前为止,我谈论的更多的是邻接性,而不是堆栈与堆,这是因为堆栈与堆更多地与对象的客户端使用有关,而不是与对象的设计有关。当你设计一个对象的表示时,你不知道它是在堆栈上还是堆上。您所知道的是它是否是完全连续的(1个块)或不连续的(多个块)。

但自然地,如果它不是连续的,那么至少有一部分会进入堆,如果将成本与硬件堆栈联系起来,那么堆分配和释放可能会非常昂贵。但是,您可以通过使用高效的O(1)固定分配器来减轻这种开销。它们比mallocfree有更特殊的用途,但我建议您少关心堆栈与堆的区别,多关心对象内存布局的邻接性。

复制/移动开销

最后但同样重要的是,如果你经常复制/交换/移动对象,那么它们越小,成本就越低。因此,有时你可能想对指向大对象的指针或索引进行排序,而不是对原始对象进行排序,因为即使是sizeof(T)是一个大数字的T类型的移动构造函数,复制/移动也会很昂贵。

因此,移动构建像这里的"2块"std::vector这样不连续的东西(它的动态内容是连续的,但那是一个单独的块),并将其庞大的数据存储在单独的内存块中,实际上要比移动构建像"1块"4x4矩阵这样连续的东西便宜。这是因为如果对象只是一个大内存块,而不是一个指针指向另一个的小内存块,那么就没有廉价的shallow copy。出现的一个有趣的趋势是,复制成本低的物体移动成本高,复制成本高的物体移动价格低。

但是,我不会让复制/移动开销影响您的对象实现选择,因为如果客户需要对复制和移动征税的特定用例,他总是可以在那里添加间接级别。当你设计内存布局类型的微效率时,首先要关注的是连续性。

优化

优化的规则是:如果你没有代码、没有测试或没有评测测量,就不要这样做。正如其他人明智地建议的那样,你最关心的始终是生产力(包括可维护性、安全性、清晰度等)。因此,与其把自己困在假设的假设场景中,首先要做的是编写代码,对其进行两次测量,如果真的必须这样做,就对其进行更改。最好专注于如何适当地设计接口,这样,如果你必须更改任何内容,它只会影响一个本地源文件。

实际情况是这是一个微观优化。您应该编写代码以使其可读、可维护和健壮。如果你担心速度,你可以使用评测工具来测量速度。你会发现事情需要比应该花的时间更多的时间,然后你才会担心速度优化。

一个对象显然应该只存在一次。如果对一个复制成本很高的对象进行多个复制,那就是在浪费时间。同一个对象也有不同的副本,这本身就不是一件好事。

"移动语义"避免了昂贵的复制,在这种情况下,并不真的希望复制任何东西,只是将对象从这里移动到那里。谷歌搜索;理解这是一件非常重要的事情。

你说的基本上是正确的。然而,在许多情况下,移动语义减轻了对对象复制的担忧。