1、智能指针
1.1 概念
C++ 的智能指针是 C++ 标准库提供的一种资源管理工具,用于自动管理动态分配的内存,并在不再需要时自动释放内存,从而避免内存泄漏和悬空指针等问题。智能指针通过在内部使用 RAII(资源获取即初始化)技术来实现自动化的内存管理。
C++ 标准库提供了三种主要类型的智能指针:
1)std::unique_ptr:独占所有权的智能指针。每个 std::unique_ptr
拥有对其所管理的对象的唯一所有权,当 std::unique_ptr
被销毁时,它所管理的对象会被自动释放。因此,std::unique_ptr
不能拷贝,但可以移动。通常用于实现对象的独占所有权和资源的独占管理。
2)std::shared_ptr:共享所有权的智能指针。多个 std::shared_ptr
可以共享对同一个对象的所有权,当所有 std::shared_ptr
都被销毁时,它们所管理的对象会被自动释放。std::shared_ptr
使用引用计数来跟踪有多少个 std::shared_ptr
共享对对象的所有权。通常用于实现多个对象共享同一资源的情况,如多个指针指向同一个动态分配的对象。
3)std::weak_ptr:弱引用智能指针。std::weak_ptr
是一种不增加引用计数的智能指针,它可以从 std::shared_ptr
转换而来。std::weak_ptr
用于解决 std::shared_ptr
的循环引用问题,因为它不会增加对象的引用计数,所以可以防止循环引用导致的内存泄漏。
使用智能指针可以简化代码,提高程序的安全性和可靠性,减少内存泄漏和悬空指针等问题的发生。但是需要注意,虽然智能指针可以自动管理内存,但它们并不能完全取代对内存管理的手动控制,仍然需要根据具体情况来选择合适的智能指针类型以及合理地使用它们。
1.2 RAII
RAII(Resource Acquisition Is Initialization)是 C++ 中的一种编程技术,用于管理资源的生命周期。它的核心思想是:资源的获取应该在对象的初始化阶段完成,而资源的释放应该在对象的析构阶段完成。RAII 技术通过利用对象的生命周期来自动管理资源,从而避免了资源泄漏和资源泄漏的问题。
智能指针是一种常见的实现 RAII 技术的方式之一。智能指针在其构造函数中获取资源(如动态分配的内存),并在其析构函数中释放资源,从而保证资源在对象生命周期内得到正确的管理。因此,使用智能指针可以很容易地实现 RAII,避免手动管理资源带来的麻烦和错误。
举例来说,当我们使用 std::unique_ptr
时,可以将动态分配的内存绑定到 std::unique_ptr
对象上。当这个 std::unique_ptr
对象超出作用域时,它的析构函数会被自动调用,从而释放动态分配的内存。这样就实现了 RAII 的效果,无需手动管理资源的分配和释放。
1.3 设计模式
智能指针主要使用了两种设计模式:
1)工厂模式(Factory Pattern):智能指针的创建通常通过静态工厂函数(如 std::make_shared
、std::make_unique
等)来完成。这些工厂函数会根据传入的参数动态地创建适当类型的智能指针对象,隐藏了对象的创建细节,提供了更简洁的接口。这符合工厂模式的思想,即通过工厂方法来创建对象,而不是直接调用构造函数。
2)代理模式(Proxy Pattern):智能指针可以被看作是指向对象的代理,它封装了对实际对象的访问,并在访问时提供了额外的功能,比如引用计数、自动内存管理等。通过智能指针,我们可以在不改变原始对象的情况下,为对象的访问添加了额外的功能。这符合代理模式的思想,即通过代理对象控制对原始对象的访问。
智能指针主要使用了工厂模式来创建对象,并且可以被看作是一种代理,因此它既具有工厂模式的特点,又符合代理模式的设计思想。
2、基础用法
2.1 std::unique_ptr
下面是一个简单的示例,演示了如何使用 std::unique_ptr
来管理动态分配的内存:
#include <iostream>
#include <memory>
// 示例类:用于动态分配内存并在析构函数中释放内存
class Demo {
public:
Demo(int data) : data(data) {
std::cout << "Demo " << data << " 构造\n";
}
~Demo() {
std::cout << "Demo " << data << " 析构\n";
}
void display() const {
std::cout << "Data: " << data << std::endl;
}
private:
int data;
};
int main() {
// 创建一个 std::unique_ptr 智能指针,用于管理 Demo 对象的动态分配内存
std::unique_ptr<Demo> ptr(new Demo(42));
// 使用智能指针调用对象的成员函数
ptr->display();
// 使用智能指针释放对象的动态分配内存,无需手动调用 delete
// 当 ptr 超出作用域时,Demo 对象会被自动释放
return 0;
} // 在此处,ptr 超出作用域,Demo 对象会被自动释放
在这个示例中,我们首先创建了一个 std::unique_ptr
智能指针 ptr
,并用 new
运算符分配了一个 Demo
对象的动态内存,并将其指针传递给 std::unique_ptr
。然后,我们使用 ptr->display()
调用 Demo
对象的 display()
成员函数。在 main()
函数结束时,ptr
超出作用域,std::unique_ptr
的析构函数会自动释放动态分配的内存。
2.2 std::shared_ptr
下面是一个简单的示例,演示了如何使用 std::shared_ptr
来管理动态分配的内存:
#include <iostream>
#include <memory>
// 示例类:用于动态分配内存并在析构函数中释放内存
class Demo {
public:
Demo(int data) : data(data) {
std::cout << "Demo " << data << " \n";
}
~Demo() {
std::cout << "Demo ~" << data << " \n";
}
void display() const {
std::cout << "Data: display" << data << std::endl;
}
private:
int data;
};
/*
Demo 42
Data: display42
Data: display42
ptr1 :2
ptr2 :2
Demo 99
Data: display99
ptr3 :1
Demo ~99
ptr1 :2
ptr2 :2
Demo ~42
*/
int main() {
// 创建一个 std::shared_ptr 智能指针,用于管理 Demo 对象的动态分配内存
std::shared_ptr<Demo> ptr1(new Demo(42));
// 使用智能指针调用对象的成员函数
ptr1->display();
// 创建另一个 std::shared_ptr 智能指针,指向同一个动态分配内存的对象
// 这样就形成了两个智能指针共享对同一个对象的所有权
std::shared_ptr<Demo> ptr2 = ptr1;
// 使用智能指针调用对象的成员函数
ptr2->display();
// 输出两个智能指针的引用计数
std::cout << "ptr1 :" << ptr1.use_count() << std::endl;
std::cout << "ptr2 :" << ptr2.use_count() << std::endl;
// 在作用域内部创建一个新的智能指针,指向另一个动态分配内存的对象
// 当 ptr3 超出作用域时,Demo 对象会被自动释放
{
std::shared_ptr<Demo> ptr3(new Demo(99));
ptr3->display();
std::cout << "ptr3 :" << ptr3.use_count() << std::endl;
}
std::cout << "ptr1 :" << ptr1.use_count() << std::endl;
std::cout << "ptr2 :" << ptr2.use_count() << std::endl;
return 0;
} // 在此处,ptr1 和 ptr2 超出作用域,Demo 对象会被自动释放
在这个示例中,我们首先创建了一个 std::shared_ptr
智能指针 ptr1
,并用 new
运算符分配了一个 Demo
对象的动态内存,并将其指针传递给 std::shared_ptr
。然后,我们创建了另一个 std::shared_ptr
智能指针 ptr2
,指向同一个动态分配内存的对象,形成了两个智能指针共享对同一个对象的所有权。在作用域内部,我们创建了另一个智能指针 ptr3
,指向另一个动态分配内存的对象,但在 ptr3
超出作用域时,由于 ptr1
和 ptr2
仍然指向之前的对象,所以对象不会被释放。在 main()
函数结束时,ptr1
和 ptr2
超出作用域,std::shared_ptr
的析构函数会自动释放动态分配的内存。
2.3 std::weak_ptr
下面是一个简单的示例,演示了如何使用 std::weak_ptr
来解决 std::shared_ptr
的循环引用问题:
#include <iostream>
#include <memory>
class B; // 前向声明 B 类
class A {
public:
A() {
std::cout << "A 构造\n";
}
~A() {
std::cout << "A 析构\n";
}
void setB(std::shared_ptr<B> bPtr) {
bPtr_ = bPtr;
}
private:
std::shared_ptr<B> bPtr_;
};
class B {
public:
B() {
std::cout << "B 构造\n";
}
~B() {
std::cout << "B 析构\n";
}
void setA(std::weak_ptr<A> aPtr) {
aPtr_ = aPtr;
}
private:
std::weak_ptr<A> aPtr_;
};
int main() {
std::shared_ptr<A> aPtr = std::make_shared<A>();
std::shared_ptr<B> bPtr = std::make_shared<B>();
// 将 aPtr 和 bPtr 分别传递给对方,形成循环引用
aPtr->setB(bPtr);
bPtr->setA(aPtr);
// 输出两个智能指针的引用计数
std::cout << "aPtr 引用计数:" << aPtr.use_count() << std::endl;
std::cout << "bPtr 引用计数:" << bPtr.use_count() << std::endl;
return 0;
} // 在此处,aPtr 和 bPtr 超出作用域,A 和 B 对象会被自动释放
在这个示例中,我们创建了两个类 A
和 B
,并且 A
类中包含一个 std::shared_ptr
智能指针成员变量 bPtr_
,B
类中包含一个 std::weak_ptr
弱指针成员变量 aPtr_
。在 main()
函数中,我们分别创建了 A
类和 B
类的对象,并将它们互相传递给对方,形成了循环引用。在 main()
函数结束时,std::shared_ptr
的析构函数会自动释放动态分配的内存,但由于循环引用的存在,对象的引用计数永远不会归零,导致对象无法被正确释放,从而产生内存泄漏。
为了解决这个问题,我们使用了 std::weak_ptr
来打破循环引用。std::weak_ptr
是一种不增加引用计数的智能指针,它允许我们在不破坏对象间关系的情况下,对其中一个对象进行访问。在这个示例中,当 A
类需要引用 B
类的对象时,使用 std::weak_ptr
来引用,这样就不会增加 B
对象的引用计数,避免了循环引用问题。
2.4 基本用法
智能指针的成员函数和成员变量允许对智能指针进行操作,包括获取指向的对象、重置指针、检查引用计数、判断是否为空等。
1)成员函数:
operator*
:解引用操作符,用于获取智能指针管理的对象。
operator->
:箭头操作符,用于通过智能指针访问对象的成员。
get
:返回指向所管理对象的原始指针。
reset
:重置智能指针,释放原来的资源并指向新的资源。
use_count
:返回当前共享指针的引用计数。
unique
:检查智能指针是否是唯一拥有其资源的指针。
operator bool
:用于检查智能指针是否为空。
swap
:交换两个智能指针的指向。
2)非成员函数:
std::make_shared
:创建一个 shared_ptr,可以减少动态内存分配次数。
std::make_unique
:创建一个 unique_ptr,可以减少动态内存分配次数。
3)成员变量:
nullptr
:表示空指针的常量。
简单的示例演示智能指针的各种用法:
#include <iostream>
#include <memory>
class MyClass {
public:
MyClass(int value) : data(value) {}
void print() {
std::cout << "Data: " << data << std::endl;
}
private:
int data;
};
int main() {
// 创建智能指针并初始化
std::shared_ptr<MyClass> sharedPtr = std::make_shared<MyClass>(42);
std::unique_ptr<MyClass> uniquePtr = std::make_unique<MyClass>(24);
// 使用operator*和operator->解引用和访问对象成员
(*sharedPtr).print();
uniquePtr->print();
// 使用get获取原始指针
MyClass* rawPtr1 = sharedPtr.get();
MyClass* rawPtr2 = uniquePtr.get();
rawPtr1->print();
rawPtr2->print();
// 使用reset重置智能指针
sharedPtr.reset(new MyClass(100));
uniquePtr.reset(new MyClass(200));
// 使用use_count获取引用计数
std::cout << "Shared pointer use count: " << sharedPtr.use_count() << std::endl;
// 使用unique检查是否是唯一拥有资源的指针
if (sharedPtr.unique()) {
std::cout << "Shared pointer is unique\n";
}
// 使用operator bool检查智能指针是否为空
std::shared_ptr<MyClass> emptySharedPtr;
if (!emptySharedPtr) {
std::cout << "Empty shared pointer\n";
}
// 使用swap交换两个智能指针
std::shared_ptr<MyClass> sharedPtr2 = std::make_shared<MyClass>(300);
std::cout << "Before swap: ";
sharedPtr->print();
std::cout << "Before swap: ";
sharedPtr2->print();
sharedPtr.swap(sharedPtr2);
std::cout << "After swap: ";
sharedPtr->print();
std::cout << "After swap: ";
sharedPtr2->print();
return 0;
}
3、管理特定的资源
3.1 自定义删除器
自定义删除器的主要目的是在智能指针释放资源时执行特定的清理操作,这些操作可以是任意类型的,不一定是释放动态分配的内存。例如,自定义删除器可以是一个函数对象、Lambda 函数或者函数指针,它可以执行以下任何操作:
1)调用 delete
来释放动态分配的内存。
2)调用 free
函数释放动态分配的内存(用于 C 风格的内存管理)。
3)调用关闭文件句柄的函数,如 fclose
。
4)执行其他资源的清理操作,如释放线程、关闭数据库连接等。
std::shared_ptr
提供了一个构造函数,允许用户指定一个删除器(Deleter),该删除器是一个可调用对象(函数、函数对象、Lambda函数等),它负责在智能指针释放资源时执行特定的清理操作。
在C++中,智能指针std::shared_ptr
通过使用自定义删除器(Custom Deleter)来实现类型删除。自定义删除器的灵活性使得我们可以针对不同的资源,实现不同的释放操作,例如释放动态分配的内存、关闭文件句柄、释放线程等。
3.2 简单示例
自定义删除器的类型必须符合 std::shared_ptr
的要求,即接受一个指向所管理资源类型的指针,并且不返回任何值。其函数签名通常是 void(*)(T*)
,其中 T
是所管理资源的类型。
以下是使用自定义删除器的示例:
#include <iostream>
#include <memory>
class MyClass {
public:
MyClass() {
std::cout << "MyClass constructed\n";
}
~MyClass() {
std::cout << "MyClass destructed\n";
}
};
// 自定义删除器函数
void customDeleter(MyClass* ptr) {
std::cout << "Custom deleter called\n";
delete ptr;
}
int main() {
// 使用自定义删除器创建 shared_ptr
std::shared_ptr<MyClass> ptr(new MyClass(), customDeleter);
return 0;
} // 在此处,ptr 超出作用域,MyClass 对象会被自动释放,同时调用自定义删除器customDeleter
在这个示例中,我们通过std::shared_ptr
的构造函数,将自定义删除器customDeleter
传递给了智能指针ptr
。当ptr
超出作用域时,它会自动调用自定义删除器,从而释放资源并执行特定的清理操作。
3.3 管理文件句柄
有些资源不是用new和delete运算符在堆上申请和释放的,比如文件系统中打开文件、动态库链接模块(比如Windows操作系统上的动态库DLL),以及图形界面的特殊平台(如窗口对象、按钮对象、文本框输入对象等)。
通常,这些资源是通过文件句柄管理的,而文件句柄的申请(open)与释放(close)与new和delete有类似的对称性,也可以使用RAII来管理,可以对此使用智能指针。
即使特有资源不是对象(不需要调用 delete
),我们仍然可以使用自定义删除器来执行特定的清理操作,比如关闭文件句柄。
下面是一个示例,演示了如何使用自定义删除器来管理文件句柄:
#include <iostream>
#include <memory>
#include <cstdio> // 包含文件操作相关的头文件
// 自定义删除器:用于关闭文件句柄
struct FileCloser {
void operator()(FILE* file) const {
std::cout << "Closing file...\n";
fclose(file); // 关闭文件句柄
}
};
int main() {
// 使用 std::shared_ptr 创建一个文件句柄智能指针,并指定自定义删除器
std::shared_ptr<FILE> filePtr(fopen("example.txt", "w"), FileCloser());
// 检查文件是否成功打开
if (filePtr) {
std::cout << "File opened successfully!\n";
// 写入数据到文件
fprintf(filePtr.get(), "Hello, file!\n");
} else {
std::cerr << "Failed to open file!\n";
}
// 在此处,filePtr 超出作用域,自定义删除器会自动关闭文件句柄
return 0;
} // 在此处,filePtr 超出作用域,自定义删除器会自动关闭文件句柄
在这个示例中,我们定义了一个自定义删除器 FileCloser
,它的 operator()
成员函数负责关闭文件句柄。然后,我们使用 std::shared_ptr
创建了一个文件句柄智能指针 filePtr
,并将文件句柄和自定义删除器传递给构造函数。当 filePtr
超出作用域时,自定义删除器会被自动调用,从而关闭文件句柄。
4、编程习惯建议
4.1 尽量使用栈
尽量使用栈而不是动态内存分配(堆)的原因主要有以下几点:
1)性能:栈上的内存分配和释放通常比堆上的内存分配和释放更快。栈上的内存分配是通过简单的栈指针移动来实现的,而堆上的内存分配需要动态内存管理器来进行分配和释放,涉及到内存分配算法和数据结构,因此效率较低。
2)内存管理:使用栈分配内存可以避免内存泄漏和内存碎片问题,因为栈上的内存分配是自动管理的,当变量超出作用域时会自动释放内存,不需要手动释放。而堆上的内存分配需要手动管理内存的生命周期,容易出现内存泄漏和内存碎片问题。
3)线程安全:栈是线程安全的,因为每个线程都有自己的栈空间,不会发生竞争条件。而堆上的内存分配是共享的,可能会导致多线程竞争和数据竞争问题,需要额外的同步机制来保证线程安全。
4)局部性原理:栈上分配的内存具有良好的局部性原理,即连续分配的内存地址在物理上也是连续的,有利于缓存命中和数据访问的效率。而堆上的内存分配是随机的,可能会导致缓存失效和性能下降。
尽管栈上的内存分配有以上优点,但也有一些限制。栈空间是有限的,通常比堆空间小得多,且栈上分配的内存大小在编译时就需要确定。因此,对于需要动态大小或长时间存储的数据,使用堆空间更为合适。但在可能的情况下,尽量使用栈上的内存分配,可以提高程序的性能和可靠性。
4.2 尽量避免直接使用 new
和 delete
原因主要是为了避免手动管理动态分配的内存所带来的一系列问题,其中包括:
1)内存泄漏:手动管理内存时容易忘记释放内存或释放位置错误,导致内存泄漏。
2)悬空指针和野指针:手动释放内存后,指针可能仍然指向已经释放的内存,造成悬空指针;或者指针被释放后,未置空,造成野指针。
3)内存越界和多次释放:手动管理内存时,容易出现内存越界的问题,即访问已经释放的内存;或者多次释放同一块内存的问题。
4)异常安全性:在异常抛出的情况下,手动释放内存的过程容易出错,从而导致资源泄漏或者程序崩溃。
因此,为了避免这些问题,现代 C++ 推荐使用智能指针来管理动态分配的内存。智能指针可以自动管理内存的生命周期,当指针超出作用域时,会自动释放动态分配的内存,从而避免了手动管理内存所带来的风险。
4.3 尽量使用容器
尽量使用容器可以提高代码的效率、简化代码、增强类型安全性、提供抽象数据结构和标准化接口等方面的优势。原因主要包括以下几点:
1)高效性:标准库提供的容器实现通常是经过优化的,能够提供高效的数据访问和操作。例如,向向量(vector)添加元素的平均时间复杂度为常数时间(O(1)),而插入到列表(list)的平均时间复杂度为线性时间(O(n))。因此,使用容器可以获得高效的数据管理和操作。
2)简化代码:容器提供了丰富的成员函数和算法,可以方便地对数据进行查找、插入、删除等操作。使用容器可以减少手动编写这些操作的工作量,简化代码结构,提高代码可读性和可维护性。
3)类型安全:标准库提供的容器是类型安全的,可以在编译时检查数据类型的一致性,避免了类型错误和运行时错误。
4)抽象数据结构:容器提供了抽象的数据结构,可以将数据和操作进行封装,使得代码更具有模块化和可重用性。使用容器可以将数据结构和算法分离,使得代码更易于理解和维护。
5)标准化:标准库提供了统一的接口和规范,使得不同的容器之间可以互相替换和组合,减少了代码的耦合度和依赖性。使用标准容器可以使代码更加标准化和规范化。
因此,在设计和编写程序时,尽量使用标准库提供的容器是一个良好的实践。