智能指针怎么就智能了?
在C++中,内存泄漏是一个不太容易发现但又有一定影响的问题,而且在引入了异常的机制之后,代码的走向就更加不可控,更容易发生内存泄露。【补充:内存泄露(Memory Leak)指的是在程序运行期间,动态分配的内存没有被释放或无法被回收,从而导致这些内存块一直被占用而无法再被使用的情况。】
比如这段代码:
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void func()
{
int* ptr = new int;
cout << div() << endl;
delete ptr;
}
int main()
{
try
{
func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
当div
函数抛出异常(例如,b == 0
时),func
函数中的ptr
指针(通过new
分配的内存)将不会被释放,因为delete ptr;
这一行在异常抛出后根本不会被执行。因此,ptr
所指向的内存块将被遗留在内存中,导致内存泄露。
为了避免这种情况,一个常见的做法是使用 RAII(Resource Acquisition Is Initialization)原则来管理资源,这就引入了智能指针(如std::unique_ptr
)来自动管理内存,或者确保在异常发生时总是能够释放已分配的内存。RAII是一种利用对象生命周期来控制程序资源(如内存、文件句柄、互斥量等等)的简单技术,即在对象构造时获取资源,在对象析构的时候释放资源。
智能指针的原理分析:通过对象来管理获取的资源,保证在对象结束生命周期时,可以自动调用析构函数避免获取的资源没有被释放。
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
cout << "delete: " << _ptr << endl;
delete _ptr;
}
private:
T* _ptr;
};
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void func()
{
SmartPtr<int> sp(new int);
cout << div() << endl;
}
int main()
{
try
{
func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
1. auto_ptr
[不推荐使用]
std::auto_ptr
有许多设计缺陷,并且已经在C++11中被弃用,在C++17中彻底移除。
问题
-
不安全的所有权转移:
std::auto_ptr
在赋值或拷贝时会转移所有权,即把内存的所有权从一个auto_ptr
对象转移到另一个auto_ptr
对象。这种行为很容易引起程序错误,特别是在函数参数传递和容器使用时。例如:
std::auto_ptr<int> p1(new int(5)); std::auto_ptr<int> p2 = p1; // p1的所有权被转移给p2 // p1变为空悬指针,可能导致未定义行为
这种所有权的自动转移可能在编程中引入难以发现的bug,尤其是在复杂代码中。
-
不支持标准容器:
因为std::auto_ptr
的所有权转移行为,不能将其放入STL容器中,如std::vector
、std::list
等。标准容器要求其元素能够被复制,而std::auto_ptr
的复制语义是移动语义,这违反了标准容器的要求。 -
不符合现代C++的智能指针语义:
std::auto_ptr
的语义与现代C++的资源管理思想(RAII和明确的所有权管理)不匹配。std::auto_ptr
的行为不够直观,容易造成混淆和错误。
2. unique_ptr
std::unique_ptr
被引入来解决std::auto_ptr
的问题,并成为现代C++的首选智能指针。
优势
-
明确的唯一所有权:
std::unique_ptr
明确表示它拥有的对象具有唯一的所有权,因此不会有意外的所有权转移问题。在任何需要转移所有权的情况下,都必须显式地使用std::move
,这样更清晰、直观,避免了不必要的错误。
例如:
std::unique_ptr<int> p1(new int(5)); // std::unique_ptr<int> p2(p1); // error // p1所有权被转移给p2,p1被显式地std::move std::unique_ptr<int> p2 = std::move(p1);
-
支持标准容器:
std::unique_ptr
的设计符合标准容器的要求,可以安全地用于容器中,提供更好的内存管理和性能。 -
更好的性能:
std::unique_ptr
不需要在复制时进行所有权的转移检查,因此在性能上更优。 -
现代C++标准支持:
std::unique_ptr
是C++11标准引入的现代智能指针类型,是当前及未来C++代码的首选。它的设计符合现代C++语言的最佳实践。
这里是使用std::unique_ptr
对原先的代码进行改进:
#include <iostream>
#include <stdexcept>
#include <memory>
using namespace std;
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void func()
{
// 使用智能指针来管理内存
unique_ptr<int> ptr = make_unique<int>();
cout << div() << endl;
// 不需要手动delete,智能指针会在超出作用域时自动释放内存
}
int main()
{
try
{
func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
这样,即使在div
函数中抛出异常,也不会发生内存泄露,因为std::unique_ptr
会在func
函数结束时自动释放内存。
总而言之
由于std::auto_ptr
的所有权管理方式不直观且容易出错,在现代C++开发中,std::unique_ptr
完全替代了std::auto_ptr
。std::unique_ptr
提供了更好的内存管理、更安全的所有权转移语义和更高效的性能,是管理动态资源时的最佳选择。
3. shared_ptr
std::shared_ptr
是 C++11 引入的另一种智能指针,与 std::unique_ptr
不同,它允许多个指针共享同一个对象的所有权。这使得 std::shared_ptr
特别适用于需要在多个地方使用相同对象并且对象的生命周期需要由多个使用者共同管理的场景。
主要特点和用途
-
共享所有权:
std::shared_ptr
可以被多个指针共享。每个shared_ptr
都维护一个指向相同对象的指针,并使用一个引用计数(reference count)来跟踪当前有多少个指针指向同一对象。当一个shared_ptr
被拷贝或赋值时,引用计数增加;当一个shared_ptr
被销毁或重置时,引用计数减少。当引用计数降为零时,表示没有指针再指向这个对象,这时对象才会被释放。例如:
std::shared_ptr<int> p1 = std::make_shared<int>(10); // 创建一个共享指针 std::shared_ptr<int> p2 = p1; // p1 和 p2 共享同一个对象
-
自动管理对象生命周期:
std::shared_ptr
可以有效管理动态分配的对象生命周期,无需程序员手动释放内存。当最后一个shared_ptr
指针超出作用域或被重置时,所管理的对象会自动释放。 -
循环引用检测:
尽管std::shared_ptr
能管理对象的生命周期,但它无法处理循环引用的问题(两个对象相互引用对方)。如果两个std::shared_ptr
对象形成了循环引用(即 A 持有 B,B 持有 A),则它们的引用计数永远不会为零,从而导致内存泄露。为了解决这个问题,可以搭配
std::weak_ptr
使用(part4)。
使用场景
-
在多个地方共享对象:
当你希望多个对象或函数共享同一个对象,并且需要自动管理该对象的生命周期时,std::shared_ptr
非常有用。例如,一个对象在多处被使用,而且这些使用者希望共享这个对象的所有权。 -
工厂函数或资源管理:
当一个函数创建一个对象并将其返回给多个调用者时,std::shared_ptr
可以保证对象在不再被任何使用者使用时自动释放。例如,在工厂函数中返回一个对象的指针,多个调用者可以共享它:std::shared_ptr<MyClass> createObject() { return std::make_shared<MyClass>(); }
-
多线程环境下的资源共享:
std::shared_ptr
可以安全地在多线程环境中共享对象,因为其引用计数是线程安全的。在多线程环境中,如果多个线程需要访问共享资源,可以使用std::shared_ptr
管理该资源。
与 unique_ptr
的对比
-
std::unique_ptr
:用于表示唯一所有权,即对象只能由一个指针拥有,当指针超出作用域时,内存会自动释放。它通常用于不需要共享所有权的情况,并且具有更好的性能(因为它没有引用计数的开销)。 -
std::shared_ptr
:用于表示共享所有权,即多个指针可以共同拥有一个对象。适用于需要多个对象或函数共享同一资源的场景,但它有引用计数的开销,因此在性能上稍逊于std::unique_ptr
。
示例:
#include <iostream>
#include <memory>
class Example {
public:
Example() { std::cout << "Example Constructor\n"; }
~Example() { std::cout << "Example Destructor\n"; }
void sayHello() const { std::cout << "Hello, Shared Pointer!\n"; }
};
int main() {
std::shared_ptr<Example> ptr1 = std::make_shared<Example>(); // 引用计数为 1
{
std::shared_ptr<Example> ptr2 = ptr1; // 引用计数为 2
ptr2->sayHello();
} // ptr2 超出作用域,引用计数减为 1
ptr1->sayHello();
// 当 main 结束时,ptr1 超出作用域,引用计数为 0,Example 对象被销毁
return 0;
}
在上面的代码中,Example
对象在 ptr1
和 ptr2
之间共享。当 ptr2
超出其作用域时,ptr1
仍然指向 Example
对象。当 ptr1
也超出作用域后,Example
对象才被销毁。
std::shared_ptr
提供了共享对象所有权的机制,适用于需要多个实体共享同一对象并自动管理对象生命周期的场景。尽管它有一定的性能开销(由于引用计数),但在需要共享资源的复杂系统中,它是非常有用且必不可少的工具。
引用计数问题
std::shared_ptr
将引用计数(reference count)放在堆上,而不是使用静态成员变量,这是为了正确管理对象的生命周期并确保其线程安全性和多样性使用场景。以下是详细的原因:
a. 支持多个实例指向不同对象
如果引用计数是静态成员变量,那么所有的 std::shared_ptr
对象都将共享同一个引用计数。这意味着无论多少个 shared_ptr
指向多少个不同的对象,它们都会共享相同的计数,这显然是不合理的。
例如:
std::shared_ptr<int> p1 = std::make_shared<int>(10);
std::shared_ptr<int> p2 = std::make_shared<int>(20);
如果引用计数是静态的,那么p1
和p2
将共享同一个引用计数,这样无法区分两个不同的对象(10
和20
)的生命周期。每个shared_ptr
应该独立维护其指向的对象的引用计数。
b. 确保线程安全
std::shared_ptr
是线程安全的,其引用计数是原子操作。将引用计数放在堆上(一个独立的内存块中)可以保证多个shared_ptr
实例在不同线程中操作同一个对象时能够安全地增加或减少计数。
如果引用计数是静态成员变量,那么对引用计数的所有操作都会涉及同一个变量,会导致多个线程同时访问和修改同一个计数器,带来竞争条件(race condition)问题,需要额外的同步机制,这样会导致效率下降,并增加复杂性。
c. 避免内存泄漏和未定义行为
使用堆内存而不是静态成员变量有助于防止内存泄漏和未定义行为。当引用计数归零时,控制块及其管理的对象将被正确销毁。反之,如果引用计数是静态成员变量,那么当程序结束时,它可能仍然有残留的非零计数,导致资源未被释放。
d. 符合对象多态性和继承设计
堆上的控制块可以与对象一起动态分配并关联对象的多态行为(如虚析构函数调用等)。这种设计方式非常适合管理复杂对象的生命周期,例如基于继承的对象结构。使用静态成员变量的设计将无法灵活应对多态性和继承的场景,因为静态变量不依赖于具体对象实例。
总结
将引用计数放在堆上,使 std::shared_ptr
能够灵活管理不同对象的生命周期,支持多种使用场景,包括多线程环境下的安全性,动态对象管理,防止内存泄露等。这些特点使得 std::shared_ptr
成为一个强大且安全的智能指针类型,广泛应用于现代C++编程。
3.1 shared_ptr的定制删除器
std::shared_ptr
的定制删除器(Custom Deleter)允许开发者在智能指针销毁其所管理的对象时,自定义如何释放资源。这对于需要自定义资源管理的情况非常有用,例如管理动态分配的内存、文件句柄、网络连接、数据库连接等。
为什么使用定制删除器
默认情况下,std::shared_ptr
使用 delete
运算符来销毁它管理的对象。这对于大多数简单的动态分配对象(如通过 new
分配的对象)是足够的。但是,有时候我们需要更复杂的资源管理,比如:
- 与C库的兼容性:某些资源(如用
malloc
分配的内存)需要使用free
而不是delete
来释放。 - 管理非内存资源:如文件句柄、套接字、数据库连接等,需要特定的函数来释放资源。
- 调试或日志记录:在删除对象时记录日志或执行其他调试操作。
- 池化或缓存管理:将对象返回到对象池而不是直接删除。
如何使用 shared_ptr 的定制删除器
std::shared_ptr
的构造函数可以接受一个删除器(通常是一个函数指针、函数对象或 lambda
表达式)。当 std::shared_ptr
不再需要管理的对象时,它会调用这个删除器来释放资源。
示例:使用 lambda 表达式作为删除器
以下是一个使用 lambda
表达式作为定制删除器的示例:
#include <iostream>
#include <memory>
int main() {
// 使用 malloc 分配内存
int* p = (int*)std::malloc(sizeof(int));
if (p == nullptr) {
throw std::bad_alloc();
}
// 使用自定义删除器创建 shared_ptr
std::shared_ptr<int> ptr(p, [](int* p) {
std::cout << "Using custom deleter to free memory.\n";
std::free(p); // 使用 free 释放内存
});
*ptr = 42; // 使用 shared_ptr 访问数据
std::cout << "Value: " << *ptr << std::endl;
// 当 ptr 超出作用域时,删除器将被调用,释放内存
return 0;
}
在这个例子中,std::shared_ptr
使用 malloc
分配的内存,并提供了一个定制删除器(lambda
表达式),在内存释放时调用 free
。这样确保了资源被正确释放。
示例:管理文件句柄
你可以使用 std::shared_ptr
来管理一个打开的文件句柄,并提供一个自定义删除器来关闭文件:
#include <iostream>
#include <memory>
#include <cstdio> // 使用 C 标准库的文件操作函数
int main() {
// 打开一个文件
std::shared_ptr<FILE> file(fopen("example.txt", "w"), [](FILE* fp) {
if (fp) {
std::cout << "Closing file.\n";
fclose(fp); // 关闭文件
}
});
if (!file) {
std::cerr << "Failed to open file.\n";
return 1;
}
// 使用文件句柄
fprintf(file.get(), "Hello, world!\n");
// 当 file 超出作用域时,文件将被自动关闭
return 0;
}
在这个示例中,std::shared_ptr
管理一个 FILE*
指针(文件句柄),并在文件指针超出作用域时自动调用 fclose
关闭文件。
定制删除器的使用细节
-
定制删除器的存储:
std::shared_ptr
内部会存储删除器,因此这个删除器本身也会占用一定的内存空间。通常,删除器是一个小的函数对象(如lambda
表达式),其开销可以忽略不计,但对于复杂的删除器对象,可能会增加一些内存使用。 -
删除器的类型:删除器的类型是
std::shared_ptr
类型的一部分。因此,不同的删除器会导致不同类型的std::shared_ptr
。例如:std::shared_ptr<int> p1(new int(10), [](int* p) { delete p; }); std::shared_ptr<int> p2(new int(20), std::default_delete<int>());
p1
和p2
的类型虽然都是std::shared_ptr<int>
,但它们的删除器类型不同,因此无法相互赋值。 -
使用
std::function
存储删除器:在需要将具有不同删除器的std::shared_ptr
存储在一起时,可以使用std::function
:std::vector<std::shared_ptr<void>> resources; resources.push_back(std::shared_ptr<void>(new int(10), [](void* p) { delete static_cast<int*>(p); })); resources.push_back(std::shared_ptr<void>(fopen("example.txt", "w"), [](void* fp) { fclose(static_cast<FILE*>(fp)); }));
- 定制删除器允许在
std::shared_ptr
管理的对象销毁时执行自定义的资源释放逻辑,这对非内存资源的管理非常有用。 - 可以使用函数指针、函数对象或
lambda
表达式作为定制删除器。 - 定制删除器的使用确保了资源的正确释放,避免了内存泄漏和资源泄漏。
- 定制删除器的灵活性使
std::shared_ptr
成为管理各种资源的理想选择。
4. weak_ptr
std::weak_ptr
是 C++11 引入的另一种智能指针,用来解决 std::shared_ptr
引用计数可能导致的循环引用问题。它提供了一种弱引用的方式来引用一个对象,而不影响该对象的引用计数。
主要作用
-
解决循环引用问题:
当两个或多个对象相互引用对方的std::shared_ptr
时,会形成循环引用。这种情况下,即使这些对象之间没有其他引用,它们的引用计数也永远不会变为零,导致内存泄漏。std::weak_ptr
可以打破这种循环引用的情况,因为它不会增加引用计数。 -
提供一种安全的方式检查对象是否存在:
std::weak_ptr
可以检查一个对象是否已经被销毁而不尝试访问该对象。使用std::weak_ptr
时,我们可以用它来创建一个std::shared_ptr
,只有在对象仍然存在的情况下,这样就可以安全地使用这个对象。
工作原理
std::weak_ptr
是一个指向由std::shared_ptr
管理的对象的弱引用。它不会增加共享对象的引用计数(use_count
),因此不会影响对象的生命周期。- 当你需要访问由
std::weak_ptr
引用的对象时,你需要将它转换为一个std::shared_ptr
。如果对象仍然存在(use_count
> 0),这个操作将成功;否则,转换将产生一个空的std::shared_ptr
。
使用场景
-
避免循环引用:
在对象之间存在相互依赖的情况下,
std::weak_ptr
可以打破循环引用。例如,在实现一个树状或图状数据结构时,父节点和子节点可以互相引用:#include <iostream> #include <memory> class Node { public: std::shared_ptr<Node> parent; std::vector<std::shared_ptr<Node>> children; Node() { std::cout << "Node created\n"; } ~Node() { std::cout << "Node destroyed\n"; } }; int main() { std::shared_ptr<Node> parent = std::make_shared<Node>(); std::shared_ptr<Node> child = std::make_shared<Node>(); parent->children.push_back(child); child->parent = parent; // 循环引用 // `parent`和`child`的引用计数相互增加,导致内存泄漏 return 0; }
在上面的例子中,
parent
和child
相互引用,形成一个循环引用。当程序结束时,parent
和child
的引用计数都不会变为零,因此它们都不会被销毁,导致内存泄漏。为了解决这个问题,可以使用
std::weak_ptr
将父节点的引用变为弱引用:#include <iostream> #include <memory> #include <vector> class Node { public: std::weak_ptr<Node> parent; // 父节点使用弱引用 std::vector<std::shared_ptr<Node>> children; Node() { std::cout << "Node created\n"; } ~Node() { std::cout << "Node destroyed\n"; } }; int main() { std::shared_ptr<Node> parent = std::make_shared<Node>(); std::shared_ptr<Node> child = std::make_shared<Node>(); parent->children.push_back(child); child->parent = parent; // 弱引用,不增加引用计数 // 正常情况下,`parent`和`child`的引用计数将达到零,内存会被释放 return 0; }
使用
std::weak_ptr
后,父节点的引用不再增加子节点的引用计数,这样当没有其他std::shared_ptr
指向子节点时,子节点将会被销毁,从而避免了循环引用导致的内存泄漏。 -
临时访问共享对象:
在某些情况下,你只需要短时间访问一个共享对象,而不希望延长它的生命周期。使用
std::weak_ptr
可以实现这种临时访问,避免不必要的引用计数增加。
如何使用
以下是 std::weak_ptr
的基本用法示例,大家可以自行跑一下:
#include <iostream>
#include <memory>
int main() {
// 创建一个 std::shared_ptr
std::shared_ptr<int> sp = std::make_shared<int>(42);
// 创建一个 std::weak_ptr,指向同一个对象
std::weak_ptr<int> wp = sp;
// 检查对象是否仍然存在
if (auto locked = wp.lock()) { // 使用 lock() 获得 std::shared_ptr
std::cout << "对象仍然存在,值为: " << *locked << "\n";
} else {
std::cout << "对象已销毁\n";
}
sp.reset(); // 手动释放 shared_ptr,引用计数变为0,对象被销毁
// 再次检查对象是否存在
if (auto locked = wp.lock()) {
std::cout << "对象仍然存在,值为: " << *locked << "\n";
} else {
std::cout << "对象已销毁\n";
}
return 0;
}
std::weak_ptr
通过提供一种不影响引用计数的弱引用方式,解决了 std::shared_ptr
循环引用导致的内存泄漏问题,并且允许安全地检查对象是否仍然存在。它在缓存、观察者模式和避免延长对象生命周期的场景中非常有用。
如果你能看到这里,给你点个赞,如果对你有帮助的话不妨点赞支持一下~