C++智能指针实现链表

12
我之前只使用原始指针与模板实现链表。比如,成员变量 Node<T>* head;,当我插入一个节点时,其中一行代码为head = new Node<T>(data);
现在我需要使用智能指针,但我不确定如何修改以使用智能指针。成员变量是否应更改为 shared_ptr<Node<T>> head;,另一行代码会更改为
head = shared_ptr<Node<T>>( new <Node<T>>(data) );

1
不确定为什么您想要一个共享指针而不是独占式指针,但是没问题。另外,假设您正在使用C++14,则应使用std::make_unique / make_shared。但是,在添加/删除元素时需要非常小心,以免意外删除列表的尾部。 - xaxxon
1
在使用智能指针处理链表时需小心,因为当你删除包含另一个节点的列表中的任何节点时,析构函数将会递归调用。如果删除的部分链表链接了足够多的元素,这可能会导致堆栈溢出。 - Matt Jordan
@xaxxon 这里使用unique_ptr更好吗?如果我改变它,只需要将所有的shared_ptr部分更改为unique_ptr吗? - Mark
@xaxxon 我已经成功将所有内容更改为使用 shared ptr,但是调试器显示了很多内存泄漏。这就是为什么 unique ptr 是更好的选择吗? - Mark
3
@Mark 我们不知道,因为我们不是读心术士,也无法看到你的代码。 当正确使用时,智能指针有助于预防内存泄漏,而不是引入它们。 - WhozCraig
显示剩余2条评论
6个回答

21

关于链表,你不需要使用智能指针,因为这个说法没有意义。低级数据结构不需要使用智能指针,而高级程序逻辑才需要使用智能指针。

就低级数据结构而言,你可以使用C++标准库中的标准容器类,比如std::list[*],它可以解决所有内存管理问题,而且不需要在内部使用任何智能指针。

如果你真的需要自己高度专业化/优化的自定义容器类,因为整个C++标准库都不适合你的要求,你需要一个替代std::liststd::vectorstd::unordered_map等经过优化、测试、文档化和安全的容器——我非常怀疑这种情况存在!——那么你无论如何都必须手动管理内存,因为这种专门的类的目的几乎肯定是需要像内存池、写时复制甚至垃圾回收等技术,所有这些都与典型的智能指针的简单删除逻辑相冲突。

Herb Sutter的话来说:

永远不要使用拥有原始指针和删除(delete),除非在实现自己的低级数据结构时,这是少数情况(即使如此,也要将其封装在类边界内)。Herb Sutter's and Bjarne Stroustrup's C++ Core Guidelines中也表达了类似的观点:无法通过将所有拥有指针转换为unique_ptrs和shared_ptrs来解决这个问题(在规模上),部分原因是我们需要/使用拥有"原始指针"以及简单指针来实现我们的基本资源句柄。例如,常见的vector实现具有一个拥有指针和两个非拥有指针。使用原始指针编写C++链表类可以是有用的学术练习。使用智能指针编写C++链表类则是毫无意义的学术练习。在生产代码中使用这两个自制工具几乎总是错误的。
[*]或者只使用std::vector,因为由于缓存局部性,这通常是更好的选择。

@benzeno:类边界更多地指的是类的用户不应该知道其实现;即在类的公共接口中不应出现T*。是否需要delete取决于容器的实现。我列举的一些高级技术可以在没有显式delete的情况下工作。然而,考虑到std::list,一个简单的链表不需要高级技术或任何自定义类。 - Christian Hackl
@benzeno:除此之外,是的,这样一个简单自定义链表的析构函数会做到这一点。 - Christian Hackl
1
我不同意“不要在低级数据结构中使用智能指针”的说法(...Herb Sutter也没有这样说)。以二叉树为例:当你拥有无尽的资源时,你可以设置一个类似于标准库的实现,使用底层的原始指针以及你提到的所有复杂的东西。但实际上,你并没有这些资源,那么就没有理由不使用unique_ptr--你通常几乎不会失去任何性能,并获得很多安全性。 - davidhigh
5
这不是对问题的回答,因此得分为-1。你可以对问题有各种意见,但在这里首要的是给出一个回答。如果可以的话,我还会再给你-1分,因为不必要地使用了粗体字。 - Fabio A.
4
@ChristianHackl,我远非初学者,但我到这里来只是因为我对与OP相同的话题感到好奇,结果发现你的回答根本不是回答。有合法的情况可以使用OP所问的内容,因此你的说法"你不需要使用智能指针来处理链表,因为那样说没有意义"是不准确的,并且过于强硬了。"强硬"这里指的是态度,而不是文本格式 - Fabio A.
显示剩余3条评论

17

基本上有两种方法来设置智能指针增强列表:

  1. Using std::unique_ptr:

    template<typename T>
    struct Node
    {
         Node* _prev;
         std::unique_ptr<Node> _next;
         T data;
    };
    
    std::unique_ptr<Node<T> > root; //inside list
    

    That would be my first choice. The unique-pointer _next takes care there are no memory leaks, whereas _prev is an observing pointer. However, copy constructor and such things -- in case you need them -- need to be defined and implemented by hand.

  2. Using shared_ptr:

    template<typename T>
    struct Node
    {
         std::weak_ptr<Node> _prev;   //or as well Node*
         std::shared_ptr<Node> _next;
         T data;
    };
    
    std::shared_ptr<Node<T> > root; //inside list
    

    This is alternative is copyable by design and adds further safety because of the weak_ptr, see below. It is less performant than the unique_ptr when it comes to structural changes of the list, such as insertions and removals, e.g. due to thread safety in shared_ptr's control block.

    Yet, traversing the list, i.e. dereferencing the pointers, should be as performant as for the unique_ptr.

在这两种方法中,想法是一个节点拥有完整的剩余列表。现在,当一个节点超出范围时,没有危险剩余列表会成为内存泄漏,因为节点会被迭代地销毁(从最后一个开始)。
在这两个选项中,_prev指针只是一个观察指针:它的任务不是保持前面的节点存活,而是提供一个链接来访问它们。为此,通常使用Node *即可(--注意:观察指针表示您从不对指针执行与内存相关的操作,如newdelete)。
如果您想要更安全的方式,也可以使用std::weak_ptr,这可以防止发生类似的事情。
std::shared_ptr<Node<T> > n;
{
    list<T> li;
    //fill the list
    n = li.root->next->next; //let's say that works for this example
}
n->_prev; //dangling pointer, the previous list does not exists anymore 

使用weak_ptr,您可以使用lock()方法检查_prev是否仍然有效。

1
一些时间已经过去了,自从回答...但我很好奇你的unique_ptr<>版本是如何工作的,因为这将是你的首选。当前节点的unique_ptr<>拥有下一个节点的内存,所以当你通过键删除当前节点时,例如,你也会释放下一个节点的内存...你将遭受与https://dev59.com/RFsW5IYBdhLWcg3wATHe相同的问题。 - celavek
@cevalek:说得好,我从未在如此庞大的树上使用过上述方法。然而,与您链接中列表的问题相反,对于树来说,情况似乎更糟。原因是通常需要迭代地处理树,当您通过迭代调用析构函数而导致堆栈溢出时,您可能也无法递归调用任何其他函数。 - davidhigh
@cevalek:一个可能的解决方法是从叶节点向上遍历——可以在Node析构函数中实现,但这将破坏unique-ptr的优势。因此,最好实现一个自定义的unique-ptr——假设它是一棵树形结构,并从下往上删除。(但这只是一些快速建议)。无论如何,这整个问题都不能成为使用原始指针而不是智能指针的理由...只是提一下。 - davidhigh

1
我会查看std::list的接口,它是一个C++实现的链表。似乎你在错误地处理Linked list类的模板化。理想情况下,你的链表不应关心所有权语义(即无论是使用原始指针、智能指针还是栈分配变量进行实例化)。以下是STL容器所有权语义的示例。但是,还有更权威的来源提供了更好的STL和所有权示例。
#include <iostream>
#include <list>
#include <memory>

using namespace std;

int main()
{

    // Unique ownership.
    unique_ptr<int> int_ptr = make_unique<int>(5);

    {
        // list of uniquely owned integers.
        list<unique_ptr<int>> list_unique_integers;

        // Transfer of ownership from my parent stack frame to the
        // unique_ptr list.
        list_unique_integers.push_back(move(int_ptr));

    } // list is destroyed and the integers it owns.

    // Accessing the integer here is not a good idea.
    // cout << *int_ptr << endl;
    // You can make a new one though.
    int_ptr.reset(new int(6));

    // Shared ownership.
    // Create a pointer we intend to share.
    shared_ptr<int> a_shared_int = make_shared<int>(5);

    {
        // A list that shares ownership of integers with anyone that has
        // copied the shared pointer.
        list<shared_ptr<int>> list_shared_integers;

        list_shared_integers.push_back(a_shared_int);

        // Editing and reading obviously works.
        const shared_ptr<int> a_ref_to_int = list_shared_integers.back();
        (*a_ref_to_int)++;
        cout << *a_ref_to_int << endl;

    } // list_shared_integers goes out of scope, but the integer is not as a
    // "reference" to it still exists.

    // a_shared_int is still accessible.
    (*a_shared_int)++;
    cout << (*a_shared_int) << endl;

} // now the integer is deallocated because the shared_ptr goes 
// out of scope.

一个很好的练习是实现自己的智能指针来理解所有权、内存分配/释放和共享指针。然后你就会明确如何使用智能指针,并且会有一种领悟:几乎所有C++中的东西都与RAII(资源所有权)有关。
如果你想坚持使用类型为T的节点,则不要将节点包装在智能指针中。节点析构函数必须删除底层的原始指针。原始指针可能指向作为T指定的智能指针本身。当调用"LinkedList"类的析构函数时,它通过Node::next迭代所有节点,并在获取下一个节点的指针后调用delete node;
你可以创建一个节点为智能指针的链表...但这是一个非常专业化的链表,可能称为SharedLinkedList或UniqueLinkedList,具有非常不同的对象创建、弹出等语义。只是作为一个例子,UniqueLinkedList在向调用者弹出值时会将节点移动到返回值中。对于这个问题进行元编程需要使用部分特化来传递不同类型的T。例如,像这样的东西:
template<class T>
struct LinkedList
{
    Node<T> *head;
};

// The very start of a LinkedList with shared ownership. In all your access
// methods, etc... you will be returning copies of the appropriate pointer, 
// therefore creating another reference to the underlying data.
template<class T>
struct LinkedList<std::shared_ptr<T>>
{
    shared_ptr<Node<T>> head;
};

现在你开始实现自己的STL!正如在评论中提到的那样,您已经看到了这种方法可能存在问题。如果节点具有shared_ptr next,则会导致调用该共享节点的析构函数,该析构函数将调用下一个共享节点的析构函数,依此类推(由于递归而导致堆栈溢出是可能的)。因此,这就是为什么我不太喜欢这种方法。


1
你应该在这里使用一个unique_ptr,因为只有当前节点应该拥有下一个节点。引入shared_ptr不仅会损害性能,还会引入内存泄漏机会(这是更大的问题),如果程序中存在错误。这里是一个示例实现,并且作为额外的奖励,我会向您展示如何反转链接列表以及如何迭代由unique_ptrs组成的链接列表:
struct Node{
    int val;
    unique_ptr<Node> next;
    Node(int v, unique_ptr<Node> n) : val(v), next(std::move(n)) {}
};

unique_ptr<Node> reverseList(unique_ptr<Node> head) {
    unique_ptr<Node> prev{nullptr};
    unique_ptr<Node> current = std::move(head);
    while (current) {
        unique_ptr<Node> temp = std::move(current->next);
        current->next = std::move(prev);
        prev = std::move(current);
        current = std::move(temp);
    }
    return prev;
}

void testReverseList() {
    auto n1 = make_unique<Node>(1, nullptr);
    auto n2 = make_unique<Node>(2, std::move(n1)); 
    auto n3 = make_unique<Node>(3, std::move(n2)); 
    auto n4 = make_unique<Node>(4, std::move(n3)); 
    auto n5 = make_unique<Node>(5, std::move(n4)); 

    Node* nodePtr = n5.get();
    cout << "before: " << endl;
    while(nodePtr) {
        cout << nodePtr->val << endl;
        nodePtr = nodePtr->next.get();
    }

    auto result = reverseList(std::move(n5));

    cout << "after: " << endl;
    nodePtr = result.get();
    while(nodePtr) {
        cout << nodePtr->val << endl;
        nodePtr = nodePtr->next.get();
    }
}

这里需要注意的一件事是,当我们想要修改指针的所有权(例如在reverseList函数中)时,我们需要传入unique_ptr并将其拥有,然后返回新头的所有权。在其他情况下,我们可以像迭代打印结果一样使用原始指针。

你的回答如何提供那些早已存在且被接受的众多旧答案中所没有提供的信息呢?而且你的事实都是错误的,智能指针比愚蠢指针提供更好的安全性,这一点也适用于共享指针。对于列表来说,使用共享指针是一个糟糕的选择,实际上任何智能指针都不适合列表实现。 - UpAndAdam
@UpAndAdam 你有没有看过我的回答,还是只是对我写的一切都点踩?这里的其他答案都说要使用shared_ptr或unique_ptr,而我提出的正确答案是使用unique_ptr,因为在这里不需要shared...另外,我从来没有提到智能指针和愚蠢指针之间的任何事实,所以我不知道你在说什么...最后,问题是关于如何在链表中使用智能指针,而不是愚蠢指针,所以不要告诉我为什么我们不应该在这里使用智能指针,因为这不是OP所问的。 - jmbauerful
1
啊,我现在明白你指出的独特与共享之间微妙的区别了。抱歉。我并不是针对你,昨天我正在审查第一个回答队列。你回答了一个旧问题,答案几乎与提供的所有答案相同,所以我问了这个问题。你对此有一个很好的回答...我现在已经点赞了。尽管你对共享指针内存泄漏的解释值得商榷,因为共享指针正是为了防止智能指针带来的内存泄漏。那么如何使用共享指针造成内存泄漏呢? - UpAndAdam
实际上,其他答案中确实使用了独特指针... - UpAndAdam
谢谢 @UpAndAdam。是的,我相信被接受的答案提到了共享和独特,我只是想指出为什么我们应该在这里更喜欢独特。共享指针可以帮助解决内存泄漏问题,但你仍然可以创建循环引用,从而导致内存泄漏,所以在这方面它们并不像独特指针那样安全。 - jmbauerful

0
结构将会是这样的
template<typename T> struct Node
{
    T data;
    shared_ptr<Node<T>> next;
};

创建节点的样子将会是这样的
shared_ptr<Node<int>> head(new Node<int>);

或者

auto head = make_shared<Node>(Node{ 1,nullptr });

在这种情况下,极力推荐使用make_shared而不是调用new,因为它提供了new调用所不具备的异常安全性。 - UpAndAdam

-2
不要在类似于图形数据结构中使用智能指针,因为这可能会导致堆栈溢出和许多性能问题,由于析构函数的递归调用或增加、减少引用计数,这是非最优的,因为深度优先搜索和广度优先搜索算法的工作方式。

你的回答可以通过添加额外的支持信息来改进。请[编辑]以添加进一步的细节,例如引用或文档,以便他人可以确认你的回答是否正确。你可以在帮助中心找到有关如何编写良好答案的更多信息。 - Community

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接