2

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

下面是一个带有 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;
}
4

1 回答 1

3

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

在第一个示例中,所有者是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 退出,destroys vecA,这会破坏它的每个元素,而这些元素会破坏图形节点。

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

我们deque甚至可以在创建链接之后使用。Adeque在 a 期间保持元素的地址稳定push_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;
}

在图中删除的实际问题是当您没有像vectorin main 这样的数据结构时:可以只保留指向一个或多个节点的指针,您可以从这些节点到达 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。

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

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

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

于 2020-09-04T16:55:50.057 回答