为什么stl::list会复制添加到列表中的元素



列表的标准模板库文档显示:

void push_back(const T&x);

在末尾添加元素在列表末尾添加新元素,就在其当前最后一个元素之后。这个新元素的内容初始化为x的副本。

这些语义与Java语义有很大不同,让我感到困惑。STL中是否缺少设计原则?"一直复制数据"?这让我很害怕。如果我添加了对对象的引用,为什么会复制该对象?为什么不只是物体穿过?

这里必须有一个语言设计决策,但我在Stack Overflow和其他网站上发现的大多数评论都集中在异常抛出问题上,因为所有这些对象复制都可能抛出异常。如果不复制,只处理引用,那么所有这些异常问题都会消失。非常困惑。

请注意:在我使用的这个遗留代码库中,boost不是一个选项。

STL总是准确地存储您告诉它要存储的内容。list<T>总是T的列表,因此所有内容都将按值存储。如果您想要一个指针列表,请使用list<T*>,这将类似于Java中的语义。

这可能会诱使您尝试list<T&>,但这是不可能的。C++中的引用与Java中的引用具有不同的语义。在C++中,引用必须初始化为指向对象。初始化引用后,它将始终指向此对象。你永远无法使它指向不同的对象。这使得在C++中不可能有一个引用容器。Java引用与C++指针的关系更密切,因此应该使用list<T*>

它被称为"值语义"。C++通常被编码为复制值,不像Java那样,除了基元类型之外,还可以复制引用。它可能会吓到你,但就我个人而言,Java的引用语义更让我害怕。但在C++中,您可以选择,如果您想要引用语义,只需使用指针(最好是智能指针)。然后你会更接近你习惯的Java。但请记住,在C++中没有垃圾回收(这就是为什么你通常应该使用智能指针)。

您不添加对对象的引用。您通过引用传递对象。那就不一样了。如果您没有通过引用传递,则可能在实际插入之前就已经制作了一个额外的副本。

它之所以复制是因为你需要一个副本,否则代码如下:

std::list<Obj> x;
{
Obj o;
x.insert(o);
}

会留下一个无效对象的列表,因为o超出了作用域。如果您想要类似Java的东西,可以考虑使用shared_ptr。这为您提供了Java中常用的优势——自动内存管理和轻量级复制。

Java的工作方式实际上是一样的。请允许我解释一下:

Object obj = new Object();
List<Object> list = new LinkedList<Object>();
list.add(obj);

obj的类型是什么?它是对Object引用。实际的对象在堆上的某个地方浮动——在Java中,你唯一能做的就是传递对它的引用。你将对对象的引用传递给列表的add方法,列表本身存储该引用的副本。以后可以修改命名引用obj,而不会影响存储在列表中的该引用的单独副本。(当然,如果你修改对象本身,你可以通过任何一个引用看到这种变化。)

C++有更多的选择。您可以模仿Java:

class Object {};
// ...
Object* obj = new Object;
std::list<Object*> list;
list.push_back(obj);

obj的类型是什么?它是指向Object的指针。当您将其传递给列表的push_back方法时,列表会在其自身存储该指针的副本。这与Java具有相同的语义。

但如果你从效率的角度来考虑…C++指针/Java引用有多大?4字节或8字节,具体取决于您的体系结构。如果你关心的对象大约是这个大小或更小,为什么要麻烦把它放在堆上,然后到处传递指向它的指针呢?只需传递对象:

class Object {};
// ...
Object obj;
std::list<Object> list;
list.push_back(obj);

现在,obj是一个实际对象。您将其传递给列表的push_back方法,该方法在自身中存储该对象的副本。在某种程度上,这是一个C++习惯用法。它不仅对指针纯粹是开销的小对象有意义,而且在非GC语言中也让事情变得更容易(堆上没有可能意外泄露的东西),如果对象的寿命自然地与列表绑定(即,如果它从列表中删除,那么从语义上讲,它就不应该再存在),那么您不妨将整个对象存储在列表中。它还具有缓存位置优势(无论如何,当在std::vector中使用时)。


你可能会问,"那么,为什么push_back采用引用参数呢?"原因很简单。每个参数都通过值传递(同样,在C++和Java中)。如果您有Object*std::list,那么好吧——您传入指针,然后生成该指针的副本并将其传递到push_back函数中。然后,在该函数中,该指针的另一个副本被制作并存储到容器中。

这对一个指针来说很好。但是在C++中,复制对象可以是任意复杂的。复制构造函数可以执行任何操作。在某些情况下,将对象复制两次(一次复制到函数中,另一次则复制到容器中)可能会造成性能问题。因此,push_back通过const引用获取其参数——它直接从原始对象复制到容器中。

如果没有引用计数,就无法维护共享所有权,因此通过复制来维护单一所有权。

考虑一个常见的情况,您希望将堆栈分配的对象添加到一个比它更长寿的列表中:

void appendHuzzah(list<string> &strs) {
strs.push_back(string("huzzah!"));
}

列表无法保留原始对象,因为当该对象超出范围时,该对象将被销毁。通过复制,列表获得了自己的对象,其寿命完全在自己的控制之下。如果不是这样的话,这种直接的使用会崩溃,毫无用处,我们将总是必须使用指针列表。

Java区分基元类型和引用类型。在C++中,所有类型都是基元的。

最新更新