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

c++ creating cyclically dependent objects with raw pointers

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

下面是一个简化的例子,有 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[1]);
    partnerUp(vecA[0], vecA[2]);
    partnerUp(vecA[1], vecA[2]);

    return 0;
}

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

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

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

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

问题来自所有权转让:您通过 new A 执行分配。这并没有明确说明所有者是谁。然后我们将其存储到向量中。但是 vector 本身不会对其元素调用 delete;它只是调用析构函数(no-op 用于指针)并删除它自己的分配(动态 array/buffer)。 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 中的所有其他节点。在这种情况下,您需要图形遍历算法来删除所有节点。这是它变得更复杂因此更容易出错的地方。

就所有权而言,这里图本身将拥有其节点的所有权,而 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。

因此,典型的 rule-of-thumb 建议是:

  • 使用堆栈对象,或者如果不可能,使用 unique_ptr 或者如果不可能,使用 shared_ptr.
  • 对于多个元素,请按顺序使用 container<T>container<unique_ptr<T>>container<shared_ptr<T>>

这些是经验法则。如果你有时间考虑一下,或者有一些要求,比如性能或内存消耗,那么定义一个自定义所有权模型是有意义的。但是你还需要投入时间来确保它的安全并对其进行测试。所以它应该真的会给你带来很大的好处,值得为确保它安全而付出的所有努力。我建议不要假设 shared_ptr 太慢。这需要在应用程序的上下文中看到并且通常被测量。获得正确的自定义所有权概念太棘手了。例如,在我上面的示例之一中,您需要非常小心地调整矢量的大小。