C ++使用原始指针创建循环依赖的对象

问题描述

我正在尝试创建一个连接图并对其执行某些计算。为此,我需要从该图中的每个节点访问其邻居,并从其邻居访问其邻居的邻居,依此类推。不可避免地会创建许多(有用的)循环依赖项。

下面是一个简化的示例,其中包含3个相互连接的节点(例如三角形的3个顶点),我不确定这种方法是否是一个方法,特别是如果清理操作会导致任何内存泄漏的话:

#include <iostream>
#include <vector>

class A {
public:
    int id;
    std::vector<A*> partners;
 
    A(const int &i) : id(i) {
        std::cout << id << " created\n";
    }
    ~A() {
        std::cout << id << " destroyed\n";
    }
};

bool partnerUp(A *a1,A *a2) {
    if (!a1 || !a2)
        return false;

    a1->partners.push_back(a2);
    a2->partners.push_back(a1);

    std::cout << a1->id << " is Now partnered with " << a2->id << "\n";

    return true;
}

int main() {
    std::vector<A*> vecA;
    vecA.push_back(new A(10));
    vecA.push_back(new A(20));
    vecA.push_back(new A(30));
 
    partnerUp(vecA[0],vecA[1]);
    partnerUp(vecA[0],vecA[2]);
    partnerUp(vecA[1],vecA[2]);

    for (auto& a : vecA) {
        delete a;
        a = nullptr;
    }
    vecA.clear();
 
    return 0;
}

我也知道我可以使用shared_ptr + weak_ptr来完成任务,但是智能指针会带来开销,因此我希望尽可能避免这种情况(我也讨厌始终使用.lock()来访问数据,但这并不重要)。我使用智能指针重写了代码,如下所示,我想知道两段代码之间有什么区别(两个代码输出是相同的)。

#include <iostream>
#include <vector>
#include <memory>

using namespace std;

class A {
public:
    int id;
    vector<weak_ptr<A>> partners;
 
    A(const int &i) : id(i) {
        cout << id << " created\n";
    }
    ~A() {
        cout << id << " destroyed\n";
    }
};

bool partnerUp(shared_ptr<A> a1,shared_ptr<A> a2) {
    if (!a1 || !a2)
        return false;

    a1->partners.push_back(a2);
    a2->partners.push_back(a1);

    cout << a1->id << " is Now partnered with " << a2->id << "\n";

    return true;
}

int main() {
    vector<shared_ptr<A>> vecA;
    vecA.push_back(make_shared<A>(10));
    vecA.push_back(make_shared<A>(20));
    vecA.push_back(make_shared<A>(30));
 
    partnerUp(vecA[0],vecA[2]);

    return 0;
}

解决方法

您可以使用所有权原则来防止内存泄漏:在每一点上,都需要有一个负责释放内存的所有者。

在第一个示例中,所有者是main函数:它撤消所有分配。

在第二个示例中,每个图节点都具有共享所有权。 vecA和链接的节点都共享所有权。他们都有责任,因为他们在必要时都会打免费电话。

因此,从这个意义上讲,两个版本的所有权都相对明确。第一个版本甚至使用了更简单的模型。但是:第一个版本存在一些异常安全问题。这些与这个小程序无关,但是一旦将此代码嵌入到较大的应用程序中,它们将变得相关。

问题来自所有权转移:您通过new A执行分配。这并未明确说明所有者是谁。然后,我们将其存储到向量中。但是向量本身不会在其元素上调用delete;它仅调用析构函数(指针无操作)并删除其自身的分配(动态数组/缓冲区)。 main函数是所有者,它仅在循环的最后一点释放分配。如果main函数提前退出(例如由于异常而退出),它将不会作为分配的所有者履行其职责-不会释放内存。

这是智能指针起作用的地方:它们清楚地说明所有者是谁,并使用RAII来防止出现异常情况:

class A {
public:
    int id;
    vector<A*> partners;
 
    // ...
};

bool partnerUp(A* a1,A* a2) {
    // ...
}

int main() {
    vector<unique_ptr<A>> vecA;
    vecA.push_back(make_unique<A>(10));
    vecA.push_back(make_unique<A>(20));
    vecA.push_back(make_unique<A>(30));
 
    partnerUp(vecA[0].get(),vecA[1].get());
    partnerUp(vecA[0].get(),vecA[2].get());
    partnerUp(vecA[1].get(),vecA[2].get());

    return 0;
}

该图仍可以使用原始指针,因为所有权现在仅由unique_ptr负责,并且所有权由vecA拥有,而所有权由main拥有。 Main出口破坏了vecA,这破坏了它的每个元素,而那些破坏了图节点。

但是,这仍然不是理想的,因为我们使用了一种不必要的间接方式。我们需要保持图节点的地址稳定,因为它们是从其他图节点指向的。因此,我们不应该在main中使用vector<A>:如果通过push_back调整其大小,这将更改其元素的地址-图形节点-但我们可能会将这些地址存储为图形关系。也就是说,我们可以使用vector,但前提是我们没有创建任何链接。

即使在创建链接后,我们也可以使用dequedequepush_back期间保持元素的地址稳定。

class A {
public:
    int id;
    vector<A*> partners;

    // ...

    A(A const&) = delete; // never change the address,since it's important!

    // ...
};

bool partnerUp(A* a1,A* a2) {
    // ...
}

int main() {
    std::deque<A> vecA;
    vecA.emplace_back(10);
    vecA.emplace_back(20);
    vecA.emplace_back(30);
 
    partnerUp(&vecA[0],&vecA[1]);
    partnerUp(&vecA[0],&vecA[2]);
    partnerUp(&vecA[1],&vecA[2]);
 
    return 0;
}

图中删除的实际问题是,当您在主目录中没有像vector这样的数据结构时:可以保留指向一个或多个节点的指针,从中可以访问所有其他节点主节点。在这种情况下,您需要使用图遍历算法来删除所有节点。在这里它变得更加复杂,因此更容易出错。

就所有权而言,这里图本身将拥有其节点的所有权,而main仅拥有图的所有权。

int main() {
    A* root = new A(10);
 
    partnerUp(root,new A(20));
    partnerUp(root,new A(30));
    partnerUp(root.partners[0],root.partners[1]);

    // now,how to delete all nodes?
 
    return 0;
}

为什么会推荐第二种方法?

因为它遵循一种广泛的简单模式,可以减少内存泄漏的可能性。如果您始终使用智能指针,那么总会有一个所有者。毫无可能会导致所有权下降的错误。

但是,使用共享的指针,您可以形成多个元素保持活动状态的循环,因为它们在一个循环中相互拥有。例如。 A拥有B,B拥有A。

因此,典型的经验法则建议是:

  • 使用堆栈对象,或者,如果不能使用,则使用unique_ptr,或者,如果不能,请使用shared_ptr
  • 对于多个元素,请依次使用container<T>container<unique_ptr<T>>container<shared_ptr<T>>

这些是经验法则。如果您有时间考虑它,或者有一些诸如性能或内存消耗之类的要求,那么定义自定义所有权模型是很有意义的。但是随后,您还需要花费时间来确保安全并进行测试。因此,值得付出所有确保安全的一切努力,这确实会给您带来巨大的好处。我建议不要假设shared_ptr太慢。这需要在应用程序的上下文中看到并通常进行测量。正确定义自定义所有权概念太棘手。例如,在我上面的示例中,您需要非常小心调整向量的大小。