使用智能指针的C++链表

C++ Linked list using smart pointers

本文关键字:C++ 链表 指针 智能      更新时间:2023-10-16

我只使用了带模板的链表的原始指针。例如,成员数据为Node<T>* head;,当我插入节点时,其中一行将为head = new Node<T>(data);

然而,现在我需要使用智能指针,我不确定如何将其更改为使用智能指针。是否将成员数据更改为shared_ptr<Node<T>> head;,另一行更改为
head = shared_ptr<Node<T>>( new <Node<T>>(data) );

您不需要为链表使用智能指针,因为该语句没有意义。对于低级数据结构,您不使用智能指针。您将智能指针用于高级程序逻辑。

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

如果您真的需要自己的高度专业化/优化的自定义容器类,因为整个C++标准库不适合您的要求,并且您需要替换std::liststd::vectorstd::unordered_map和其他经过优化、测试、记录和安全的容器–我非常怀疑–,那么无论如何,您都必须手动管理内存,因为这样一个专门类的重点几乎肯定是需要内存池、写时复制甚至垃圾收集等技术,所有这些都与典型的智能指针相当简单的删除逻辑相冲突。

用赫伯·萨特的话说:

永远不要使用拥有的原始指针和删除,除非在极少数情况下实现自己的低级数据结构(即使这样很好地封装在类边界内)。

Herb Sutter和Bjarne Stroustrup的C++核心指南:中也表达了类似的内容

这个问题不能(大规模)通过转变所有所有权来解决指向unique_ptrs和shared_ptrs的指针,部分原因是我们需要/使用在实现中拥有"原始指针"和简单指针的基本资源句柄。例如,公共矢量实现有一个拥有指针和两个非拥有指针。

用C++编写一个带有原始指针的链表类可能是一个有用的学术练习。用C++编写一个带有智能指针的链表类是一项毫无意义的学术练习。在生产代码中使用这两种自制的东西中的任何一种几乎都是自动错误的。


[*] 或者只是std::vector,因为由于缓存位置的原因,这几乎总是更好的选择

设置智能指针增强列表基本上有两种选择:

  1. 使用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
    

    那将是我的第一选择。唯一指针_next注意没有内存泄漏,而_prev是一个观察指针。然而,复制构造函数和诸如此类的东西——如果您需要的话——需要手工定义和实现。

  2. 使用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
    

    这是一种可供选择的方案,可通过设计进行复制,并由于weak_ptr而增加了进一步的安全性,请参阅下文。当涉及到列表的结构更改(如插入和删除)时,它的性能不如unique_ptr,例如由于shared_ptr的控制块中的线程安全性。

    然而,遍历列表,即取消引用指针,应该和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是否仍然有效。

我会看看std::list的接口,它是链表的C++实现。看来您对链表类的模板化处理是错误的。理想情况下,您的链表不应该关心所有权语义(即,它是用原始ptr、智能指针还是堆栈分配的变量实例化的)。下面是一个使用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.

理解所有权、内存分配/释放和共享指针的一个很好的练习是做一个教程,在那里你可以实现自己的智能指针。然后,你将确切地了解如何使用智能指针,你将有一个xen时刻,你会意识到C++中的几乎所有东西都回到了RAII(资源所有权)。

那么回到你问题的关键。如果要坚持使用T类型的节点,请不要将节点包装在智能指针中。节点析构函数必须删除基础的原始指针。原始指针可能指向一个指定为T的智能指针本身。当调用"LinkedList"的类析构函数时,它会遍历所有具有Node::next的节点,并在获得指向下一个节点的指针后调用delete node;

你可以创建一个列表,其中节点是智能指针。。。但这是一个非常专业的链表,可能被称为SharedLinkedList或UniqueLinkedList,在对象创建、弹出等方面具有非常不同的语义。举个例子,当向调用方弹出值时,UniqueLinked list会移动返回值中的节点。为了解决这个问题进行元编程,需要对传递的不同类型的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,它将导致对该共享节点的析构函数的调用,该析构函数将调用下一个共享节点析构函数,依此类推(递归可能导致堆栈溢出)。所以这就是为什么我不太喜欢这种方法。

此处应使用unique_ptr,因为只有当前节点才应拥有下一个节点。引入shared pointers不仅会损害性能,而且如果程序中存在错误,还会带来内存泄漏的机会(这是更大的问题)。这是一个示例实现,作为额外的奖励,我将向您展示如何反转链表以及如何迭代由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并赋予它所有权,然后我们返回新头的所有权。在其他情况下,我们可以像迭代打印结果一样使用原始指针。

结构看起来像

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 });

不要在类似图形的数据结构中使用智能指针,因为它可能会导致堆栈溢出由于递归调用析构函数或inc而导致的许多性能问题,如果引用计数由于dfs和bfs算法如何工作而非最优,则