MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++ std::weak_ptr 解决循环引用问题

2022-02-173.7k 阅读

C++ 中的智能指针概述

在 C++ 编程中,内存管理是一个至关重要的方面。手动管理内存容易引发各种错误,例如内存泄漏、悬空指针等。智能指针(smart pointer)的引入旨在自动管理动态分配的内存,极大地简化了内存管理的过程。C++ 标准库提供了三种主要的智能指针类型:std::unique_ptrstd::shared_ptrstd::weak_ptr

std::unique_ptr

std::unique_ptr 是一种独占式智能指针,它拥有对对象的唯一所有权。当 std::unique_ptr 被销毁时,它所指向的对象也会被自动释放。这意味着同一时刻只能有一个 std::unique_ptr 指向给定的对象。std::unique_ptr 不能被复制,但可以被移动。例如:

#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> uniquePtr(new int(42));
    std::cout << "Value: " << *uniquePtr << std::endl;
    // std::unique_ptr<int> anotherUniquePtr = uniquePtr; // 这将导致编译错误,因为不能复制
    std::unique_ptr<int> movedUniquePtr = std::move(uniquePtr);
    if (!uniquePtr) {
        std::cout << "uniquePtr 现在为空" << std::endl;
    }
    std::cout << "movedUniquePtr 的值: " << *movedUniquePtr << std::endl;
    return 0;
}

在上述代码中,我们首先创建了一个 std::unique_ptr 指向一个动态分配的 int 变量。尝试复制 uniquePtr 会导致编译错误,因为 std::unique_ptr 不支持复制。通过 std::move 我们将 uniquePtr 的所有权转移给 movedUniquePtr,之后 uniquePtr 变为空。

std::shared_ptr

std::shared_ptr 是一种共享式智能指针,允许多个 std::shared_ptr 指向同一个对象。这些指针通过引用计数(reference counting)机制来管理对象的生命周期。当指向对象的最后一个 std::shared_ptr 被销毁时,对象的引用计数减为零,对象被自动释放。例如:

#include <iostream>
#include <memory>

int main() {
    std::shared_ptr<int> sharedPtr1(new int(42));
    std::shared_ptr<int> sharedPtr2 = sharedPtr1;
    std::cout << "引用计数: " << sharedPtr1.use_count() << std::endl;
    std::cout << "Value: " << *sharedPtr2 << std::endl;
    {
        std::shared_ptr<int> sharedPtr3 = sharedPtr1;
        std::cout << "新作用域内引用计数: " << sharedPtr1.use_count() << std::endl;
    }
    std::cout << "离开新作用域后引用计数: " << sharedPtr1.use_count() << std::endl;
    return 0;
}

在这段代码中,sharedPtr1sharedPtr2 都指向同一个 int 对象,它们共享引用计数。当在新的作用域内创建 sharedPtr3 并使其指向相同对象时,引用计数增加。离开该作用域后,sharedPtr3 被销毁,引用计数减少。

循环引用问题

虽然 std::shared_ptr 在大多数情况下很好地解决了内存管理问题,但它会引入一个潜在的严重问题:循环引用(circular reference)。循环引用发生在两个或多个 std::shared_ptr 相互引用,形成一个环,导致对象的引用计数永远不会降为零,从而造成内存泄漏。

循环引用示例

考虑以下简单的类结构,其中两个类 AB 相互引用:

#include <iostream>
#include <memory>

class B;

class A {
public:
    std::shared_ptr<B> bPtr;
    ~A() {
        std::cout << "A 被销毁" << std::endl;
    }
};

class B {
public:
    std::shared_ptr<A> aPtr;
    ~B() {
        std::cout << "B 被销毁" << std::endl;
    }
};

int main() {
    std::shared_ptr<A> a(new A());
    std::shared_ptr<B> b(new B());
    a->bPtr = b;
    b->aPtr = a;
    return 0;
}

在上述代码中,A 类有一个 std::shared_ptr<B> 成员 bPtrB 类有一个 std::shared_ptr<A> 成员 aPtr。在 main 函数中,我们创建了 ab 两个智能指针,并让它们相互引用。当 main 函数结束时,ab 的作用域结束,通常情况下它们所指向的对象应该被销毁。然而,由于循环引用的存在,A 对象的引用计数由于 b->aPtr 而不会降为零,B 对象的引用计数由于 a->bPtr 也不会降为零。这就导致 AB 对象都不会被销毁,造成内存泄漏。

std::weak_ptr 介绍

std::weak_ptr 是 C++ 标准库中专门用于解决循环引用问题的智能指针。它是一种弱引用(weak reference),不会增加对象的引用计数。std::weak_ptr 指向由 std::shared_ptr 管理的对象,但不影响对象的生命周期。

std::weak_ptr 的基本操作

  1. 创建 std::weak_ptrstd::weak_ptr 可以从 std::shared_ptr 创建,例如:
std::shared_ptr<int> sharedPtr(new int(42));
std::weak_ptr<int> weakPtr(sharedPtr);
  1. 检查 std::weak_ptr 是否有效:可以使用 expired() 方法检查 std::weak_ptr 所指向的对象是否已经被销毁,例如:
if (weakPtr.expired()) {
    std::cout << "对象已被销毁" << std::endl;
} else {
    std::cout << "对象仍然存在" << std::endl;
}
  1. 获取 std::shared_ptr:可以使用 lock() 方法从 std::weak_ptr 获取一个 std::shared_ptr,如果对象已经被销毁,lock() 将返回一个空的 std::shared_ptr。例如:
std::shared_ptr<int> lockedPtr = weakPtr.lock();
if (lockedPtr) {
    std::cout << "获取到共享指针,值为: " << *lockedPtr << std::endl;
} else {
    std::cout << "无法获取共享指针,对象已被销毁" << std::endl;
}

使用 std::weak_ptr 解决循环引用问题

通过将循环引用中的其中一个引用改为 std::weak_ptr,可以打破循环引用,确保对象在不再被需要时能够正确地被销毁。

修改循环引用示例以使用 std::weak_ptr

回到之前的 AB 类的例子,我们将 B 类中的 aPtr 改为 std::weak_ptr

#include <iostream>
#include <memory>

class B;

class A {
public:
    std::shared_ptr<B> bPtr;
    ~A() {
        std::cout << "A 被销毁" << std::endl;
    }
};

class B {
public:
    std::weak_ptr<A> aPtr;
    ~B() {
        std::cout << "B 被销毁" << std::endl;
    }
};

int main() {
    std::shared_ptr<A> a(new A());
    std::shared_ptr<B> b(new B());
    a->bPtr = b;
    b->aPtr = a;
    return 0;
}

在这个修改后的代码中,B 类中的 aPtr 是一个 std::weak_ptr,它不会增加 A 对象的引用计数。当 main 函数结束时,ab 的作用域结束。a 的引用计数由于没有其他 std::shared_ptr 指向它而变为零,A 对象被销毁。A 对象销毁时,其成员 bPtr 也被销毁,导致 B 对象的引用计数变为零,B 对象也被销毁。这样就成功地解决了循环引用问题。

std::weak_ptr 获取 std::shared_ptr 的使用场景

在某些情况下,我们可能需要在 B 类中访问 A 类的成员。由于 aPtrstd::weak_ptr,我们需要先使用 lock() 方法获取一个 std::shared_ptr,然后再访问成员。例如:

#include <iostream>
#include <memory>

class B;

class A {
public:
    int value;
    std::shared_ptr<B> bPtr;
    A(int v) : value(v) {}
    ~A() {
        std::cout << "A 被销毁" << std::endl;
    }
};

class B {
public:
    std::weak_ptr<A> aPtr;
    void printAPtrValue() {
        std::shared_ptr<A> lockedPtr = aPtr.lock();
        if (lockedPtr) {
            std::cout << "A 的值: " << lockedPtr->value << std::endl;
        } else {
            std::cout << "A 对象已被销毁" << std::endl;
        }
    }
    ~B() {
        std::cout << "B 被销毁" << std::endl;
    }
};

int main() {
    std::shared_ptr<A> a(new A(42));
    std::shared_ptr<B> b(new B());
    a->bPtr = b;
    b->aPtr = a;
    b->printAPtrValue();
    return 0;
}

在上述代码中,B 类的 printAPtrValue 方法通过 aPtr.lock() 获取 A 对象的 std::shared_ptr,然后检查对象是否存在并打印其 value 成员。

std::weak_ptr 的其他应用场景

除了解决循环引用问题,std::weak_ptr 在其他场景中也有重要应用。

缓存(Cache)

在缓存系统中,我们可能希望缓存一些对象,但又不想阻止这些对象在其他地方被释放。例如,假设我们有一个缓存类 Cache,它存储一些 std::weak_ptr 指向的对象:

#include <iostream>
#include <memory>
#include <unordered_map>

class Data {
public:
    int value;
    Data(int v) : value(v) {}
    ~Data() {
        std::cout << "Data 被销毁" << std::endl;
    }
};

class Cache {
public:
    std::unordered_map<int, std::weak_ptr<Data>> cache;
    void addToCache(int key, std::shared_ptr<Data> data) {
        cache[key] = data;
    }
    std::shared_ptr<Data> getFromCache(int key) {
        auto it = cache.find(key);
        if (it != cache.end()) {
            std::shared_ptr<Data> data = it->second.lock();
            if (data) {
                return data;
            } else {
                cache.erase(it);
            }
        }
        return nullptr;
    }
};

int main() {
    Cache cache;
    std::shared_ptr<Data> data1(new Data(42));
    cache.addToCache(1, data1);
    std::shared_ptr<Data> retrievedData = cache.getFromCache(1);
    if (retrievedData) {
        std::cout << "从缓存中获取到数据,值为: " << retrievedData->value << std::endl;
    }
    data1.reset();
    retrievedData = cache.getFromCache(1);
    if (!retrievedData) {
        std::cout << "数据已被释放,缓存中不存在" << std::endl;
    }
    return 0;
}

在这个缓存示例中,Cache 类使用 std::unordered_map 存储 std::weak_ptr。当向缓存中添加数据时,我们存储的是 std::weak_ptr,这样即使缓存中有引用,数据对象也可以在其他地方被释放。从缓存中获取数据时,我们使用 lock() 方法尝试获取 std::shared_ptr,如果对象已被释放,我们从缓存中移除该条目。

观察者模式(Observer Pattern)

在观察者模式中,主题(subject)对象可能有多个观察者(observer)对象。主题对象不需要控制观察者对象的生命周期,而观察者对象需要观察主题对象的状态变化。可以使用 std::weak_ptr 来实现这种关系,使得主题对象不增加观察者对象的引用计数。

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

class Subject;

class Observer {
public:
    virtual void update(const Subject& subject) = 0;
    virtual ~Observer() {}
};

class Subject {
public:
    std::vector<std::weak_ptr<Observer>> observers;
    int state;
    Subject(int s) : state(s) {}
    void attach(std::shared_ptr<Observer> observer) {
        observers.push_back(observer);
    }
    void notify() {
        for (auto& weakObserver : observers) {
            std::shared_ptr<Observer> observer = weakObserver.lock();
            if (observer) {
                observer->update(*this);
            }
        }
    }
};

class ConcreteObserver : public Observer {
public:
    void update(const Subject& subject) override {
        std::cout << "观察者接收到更新,主题状态: " << subject.state << std::endl;
    }
};

int main() {
    std::shared_ptr<Subject> subject(new Subject(42));
    std::shared_ptr<ConcreteObserver> observer1(new ConcreteObserver());
    std::shared_ptr<ConcreteObserver> observer2(new ConcreteObserver());
    subject->attach(observer1);
    subject->attach(observer2);
    subject->notify();
    observer1.reset();
    subject->notify();
    return 0;
}

在这个观察者模式的实现中,Subject 类使用 std::vector<std::weak_ptr<Observer>> 存储观察者。当主题通知观察者时,它使用 lock() 方法获取 std::shared_ptr 并调用观察者的 update 方法。如果某个观察者对象已被释放,lock() 将返回空指针,从而避免访问已释放的对象。

std::weak_ptr 的实现原理

std::weak_ptr 的实现依赖于与 std::shared_ptr 相同的引用计数机制。每个由 std::shared_ptr 管理的对象都有一个与之关联的控制块(control block)。控制块不仅存储对象的引用计数,还存储一个弱引用计数(weak reference count)。

当创建一个 std::weak_ptr 指向由 std::shared_ptr 管理的对象时,弱引用计数增加。std::weak_ptr 本身不影响对象的生命周期,只有 std::shared_ptr 的引用计数会影响对象的销毁。当 std::shared_ptr 的引用计数降为零,对象被销毁,但控制块不会立即被销毁,因为可能还有 std::weak_ptr 指向它。只有当弱引用计数也降为零时,控制块才会被销毁。

控制块的结构

控制块通常包含以下信息:

  1. 对象的引用计数:记录当前有多少个 std::shared_ptr 指向该对象。
  2. 弱引用计数:记录当前有多少个 std::weak_ptr 指向该对象。
  3. 指向对象的指针:实际指向动态分配的对象。

示例说明

考虑以下代码:

std::shared_ptr<int> sharedPtr(new int(42));
std::weak_ptr<int> weakPtr(sharedPtr);

在这个例子中,sharedPtr 创建了一个控制块,对象的引用计数为 1,弱引用计数为 1(因为 weakPtr 指向该对象)。当 sharedPtr 被销毁时,对象的引用计数减为零,对象被释放,但控制块仍然存在,因为弱引用计数为 1。只有当 weakPtr 也被销毁,弱引用计数减为零,控制块才会被释放。

std::weak_ptr 的性能考虑

虽然 std::weak_ptr 为解决循环引用等问题提供了强大的功能,但在使用时也需要考虑其性能影响。

内存开销

std::weak_ptr 本身的内存开销相对较小,通常与 std::shared_ptr 相当。然而,由于它依赖于控制块,每个由 std::shared_ptr 管理的对象都需要额外的控制块内存,这可能会增加整体的内存占用,尤其是在大量使用 std::shared_ptrstd::weak_ptr 的情况下。

操作性能

  1. 创建和销毁:创建和销毁 std::weak_ptr 的操作相对高效,因为它们主要涉及对弱引用计数的增减,不涉及对象的实际创建或销毁。
  2. lock() 操作lock() 操作需要检查弱引用计数并获取对象的 std::shared_ptr,这涉及到对控制块的访问。在多线程环境下,由于需要保证引用计数操作的原子性,lock() 操作可能会有一定的性能开销。

多线程环境下的 std::weak_ptr

在多线程环境中使用 std::weak_ptr 需要特别小心,因为引用计数的操作需要保证原子性。

原子操作

C++ 标准库确保 std::shared_ptrstd::weak_ptr 的引用计数操作是原子的。这意味着在多线程环境下,多个线程可以安全地同时操作 std::shared_ptrstd::weak_ptr,而不会导致数据竞争。

示例代码

以下是一个简单的多线程示例,展示了在多线程环境下使用 std::weak_ptr

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

class Data {
public:
    int value;
    Data(int v) : value(v) {}
    ~Data() {
        std::cout << "Data 被销毁" << std::endl;
    }
};

void threadFunction(std::weak_ptr<Data> weakPtr) {
    std::shared_ptr<Data> lockedPtr = weakPtr.lock();
    if (lockedPtr) {
        std::cout << "线程获取到数据,值为: " << lockedPtr->value << std::endl;
    } else {
        std::cout << "线程无法获取数据,对象已被销毁" << std::endl;
    }
}

int main() {
    std::shared_ptr<Data> sharedPtr(new Data(42));
    std::weak_ptr<Data> weakPtr(sharedPtr);
    std::vector<std::thread> threads;
    for (int i = 0; i < 5; ++i) {
        threads.emplace_back(threadFunction, weakPtr);
    }
    for (auto& thread : threads) {
        thread.join();
    }
    sharedPtr.reset();
    return 0;
}

在这个示例中,我们创建了一个 std::shared_ptr 和一个 std::weak_ptr 指向 Data 对象。然后启动多个线程,每个线程尝试通过 weakPtr.lock() 获取 std::shared_ptr。由于引用计数操作是原子的,这个过程在多线程环境下是安全的。

总结 std::weak_ptr 的要点

  1. std::weak_ptr 是解决 std::shared_ptr 循环引用问题的有效工具,通过不增加对象引用计数来打破循环。
  2. 它在缓存、观察者模式等场景中有重要应用,能够实现不影响对象生命周期的弱引用。
  3. std::weak_ptr 的实现依赖于与 std::shared_ptr 相同的控制块和引用计数机制,其操作在多线程环境下是安全的,但在性能方面需要权衡内存开销和操作性能。
  4. 在使用 std::weak_ptr 时,需要注意通过 lock() 方法获取 std::shared_ptr 时检查对象是否已被销毁,以避免空指针解引用等错误。

通过深入理解 std::weak_ptr 的特性、应用场景和实现原理,开发者可以更好地利用它来编写高效、健壮的 C++ 代码,避免内存管理相关的问题。无论是在小型项目还是大型复杂系统中,合理使用 std::weak_ptr 都能显著提升代码的质量和可维护性。