C++之动态内存和智能指针
为什么要使用动态内存?
开始本次内容之前,我们先要学习一个概念:内存泄漏。
在C++等编程语言中,堆内存和栈内存时两种主要的内存使用方式。它们有各自不同的用途和管理方式。
栈内存
栈内存是由编译器自动管理,局部变量在其声明的作用域结束后就会被销毁。栈内存的分配和回收都是由系统自动进行的,无需程序员来进行控制。此外需要注意的是,栈内存的大小是有限的,因此它不适合大量的数据存储。栈内存的对象随着函数的调用结束而销毁,所以其生命周期受限于作用域。栈内存的访问速度一般比堆内存快。栈内存的分配遵循后进先出的顺序。
需要注意的是,我们所讲的内存泄漏,是对于堆内存来说的。
堆内存
堆内存是应用程序在运行时用于动态内存分配的一块内存区域,这块区域程序员可以显式的申请和释放内存,在C++中对应的操作符就是new和delete。(或者是malloc和free)。堆内存的大小只受限于计算机的物理内存大小和操作系统的限制,也就是说,使用堆内存,你可以轻而易举的全部占满内存;而栈内存的大小是固定的,你无法使用栈内存占满内存空间。
对于一个简单的小程序来说还好,但对于一个大型程序,我们可能需要频繁的向堆内存申请空间。由于空间是有限的,因此做好堆内存的分配就尤为重要。然而这很困难,如果我们忘记释放内存,那么这块空间中分配的对象将会一直被占用。长此以往,我们的内存空间就会被挤满,最终导致内存泄漏。内存被耗尽直接导致应用运行失败,系统性能下降甚至卡死。善于分配内存也是C++程序员的必修课。
直接管理内存
C++语言定义了两个运算符来分配和释放动态内存。运算符new来分配内存,delete释放new分配的内存。然而使用这两个运算符来管理内存十分容易出错。正式学习智能指针之前,最好是先学习一下new和delete。
使用new来动态分配和初始化对象
new可以在堆内存中为对象开辟空间,new返回的是指向该对象的指针。默认情况下,new分配的对象是默认初始化的,也就是说,被分配的内置类型(int等)的值是未定义的,而类类型对象会使用默认构造函数进行初始化。
int *pi = new int; // pi指向未初始化的int
int *pii = new int(98); // pi指向int,int为98,但是这个int没有名字。
还可以使用auto关键字来推断我们想要分配对象的类型:
auto p = new auto(obj); // p指向与obj类型相同的对象,该对象用obj进行初始化。
new还可以动态分配const对象,但是该对象必须进行初始化。
const int *pci = new const int(1024); // 必须进行初始化。
内存耗尽
当内存不足时,new就无法分配空间。此时new就会返回一个类型为bad_alloc的异常,我们可以不让new抛出异常,而是返回一个空指针。
int *p1 = new int; //分配失败,抛出bad_alloc异常
int *p2 = new (std::nothrow) int // 分配失败,返回空指针,这种new称为定位new
使用delete来释放动态内存
为了防止内存耗尽,在动态内存使用完毕后,必须将其归还给系统。delete可以将指针指向的对象释放掉。
delete p // p为指向动态分配的对象或是一个空指针
需要注意的是,delete只能删除动态分配的内存,也就是new分配的内存。如果释放非动态分配的内存,或是将相同的指针值释放多次,其行为将是未定义的。
动态对象的生存期不受限于作用域!
如果在函数中通过new定义了动态对象,函数调用结束后,指针会被释放掉,但是指针指向的对象并没有被释放掉!这种情况很容易造成内存泄漏。
void func()
{
int* p = new int(20);
// ...
// 忘记释放p,p指向的int类型的值并没有被释放。
}
动态内存的管理非常容易出错,以下是常见的三个问题:
- 忘记delete内存,应用程序运行到直到耗尽内存时才能检测出这种错误;
- 使用已经释放掉的对象。
- 同一块内存释放两次,自由空间可能遭到破坏。
空悬指针
当我们delete一个指针指向的对象时,该指针对应的地址值并没有被消除。此时该指针成为了空悬指针。也就是说,该指针指向一个地址,但是该地址没有任何内容(或是其他内容)。如果我们继续访问该指针指向的对象,可能会导致程序崩溃。如果我们向其中写入数据,则会导致数据损坏。对于安全性来说,攻击者会利用空悬指针进行攻击,称为UAF漏洞。
如何防止空悬指针带来的危害,方法是在释放对象后,将指针定义为nullptr,此时指针指向的必然是确定的无效地址,该指针就没有用了。
#include <iostream>
int main() {
int* ptr = new int(42); // 动态分配内存
std::cout << *ptr << std::endl; // 输出指针指向的值
delete ptr; // 释放内存
// 此时ptr变成了空悬指针,因为它指向的内存已经被释放
// 但是ptr本身并没有被清空,它仍然保留着之前的地址
// 下面的操作是危险的,因为它试图访问已经释放的内存
// std::cout << *ptr << std::endl; // 未定义行为,可能导致程序崩溃
ptr = nullptr; // 推荐做法:释放内存后立即将指针设为nullptr,避免空悬指针
return 0;
}
手写智能指针
使用智能指针动态分配和释放内存
基于上述问题的存在,智能指针应运而生。C++已经的库中已经写好了智能指针类,我们仅需要调用即可。智能指针能够自动管理内存,减少内存泄漏的风险。在学习现成的智能指针之前,我们来尝试自己手写一下智能指针类,类的结构大概是这样的:
#include <iostream>
template <class T>
class SimpleSmartPointer {
private:
T* ptr; // 原始指针
public:
// 构造函数
explicit SimpleSmartPointer(T* p = nullptr) : ptr(p) {}
// 析构函数
~SimpleSmartPointer() {
delete ptr;
ptr = nullptr;
}
};
该类使用模板类,是因为这个指针应该能指向各种类型,为了实现泛型化,这里使用模板。由于是智能指针,构造函数和析构函数就尤为重要。在构造函数中,添加了explicit关键字,防止发生隐式转换也就是说,只有当直接调用构造函数时才会创建对象,而不会在需要该类型对象的地方自动调用构造函数来创建对象。
//SimpleSmartPointer<int> p = new int(23); // 不能隐式调用构造函数
SimpleSmartPointer<int> p(new int(32)); // 只能显式调用构造函数
我们需要保持这个指针的独立性,也就是说,这个指针不能复制(拷贝)。这里不妨想一想,为什么要每个指针都是独一无二的呢?原因是为了防止多个智能指针对象拥有对同一资源的所有权,这样可以避免当一个智能指针对象被销毁时释放资源,而其他智能指针对象还在尝试使用该资源的问题。这种情况下会导致未定义行为,通常是访问已经被释放的内存,这是非常危险的。
= delete可以阻止编译器自动生成拷贝构造函数和赋值操作,在public中进行声明:
// 禁止拷贝构造和赋值操作
SimpleSmartPointer(const SimpleSmartPointer&) = delete;
SimpleSmartPointer& operator=(const SimpleSmartPointer&) = delete;
构建移动构造函数:
// 移动构造函数
SimpleSmartPointer(SimpleSmartPointer&& other) noexcept : ptr(other.ptr) {
other.ptr = nullptr;
}
移动构造函数是C++11标准中引入的一个特性。移动构造函数对于管理动态分配资源的对象尤其有用,如智能指针、容器等。移动构造函数,它接受一个右值引用到另一个 SimpleSmartPointer 对象 other。右值引用是C++11新增的,它允许你引用一个临时对象,通常是一个将要被销毁的对象。noexcept 是 C++11引入的一个关键字,用于指定函数是否抛出异常。noexcept 表明这个构造函数不会抛出任何异常,这是移动操作的常见属性,因为移动通常应该是一个简单的指针操作,不涉及任何可能抛出异常的操作。
// 移动赋值运算符
SimpleSmartPointer& operator=(SimpleSmartPointer&& other) noexcept {
if (this != &other) {
delete ptr;
ptr = other.ptr;
other.ptr = nullptr;
}
return *this;
}
这段函数的作用和移动构造函数的作用几乎一样,只是将原来的显式声明改为了用=进行。在C++中,使用 std::move 可以将一个左值转换为对应的右值引用,从而允许对其进行移动语义操作。因此使用移动构造函数的方法是:SimpleSmartPointer<MyObject> new_ptr = std::move(ptr);
在重载运算符一文中有预告过如何重载成员访问运算符、函数调用运算符和重载类型转换运算符。这里我们就用这个智能指针类来作为示例:
重载*运算符
无需传入任何参数,返回类型为模板类型的引用。
T& operator*() const {
return *ptr;
}
重载->运算符
和重载*运算符类似,不过返回类型应该是一个指针:
T* operator->() const {
return ptr;
}
重载类型转换运算符
重载bool方法,当指针不为空指针时,返回true:
operator bool() const {
return ptr != nullptr;
}
避免过度使用类型转换函数,如果类类型和转换类型不存在明显的映射关系,那就最好不用重载。比如说本次的智能指针类型,就没有必要重载int()类型的函数。
还有一个重载函数调用运算符,这部分内容会在之后讲到lambda函数再详细讲解。
以上,一个简单的智能指针类就完成了,加一点测试代码,看一下这个类能否正常运行。
// 用于测试的类
class TestClass {
public:
void doSomething() {
std::cout << "Doing something" << std::endl;
}
};
int main() {
SimpleSmartPointer<TestClass> ptr(new TestClass());
ptr->doSomething(); // 使用->访问TestClass的成员函数
(*ptr).doSomething(); // 使用*运算符解引用
if (ptr) { // 类型转换运算符
std::cout << "Pointer is not null" << std::endl;
}
return 0;
}
如果你哪里运行报错了,可以查看一下这里的源代码。
到这里,我们来思考一下为什么智能指针能够动态的分配内存?在我们定义智能指针时,实际上分别定义了智能指针和智能指针指向的两个对象。指向的对象在前面已经说过,它是分配在堆内存之中的。而定义的智能指针,其实是分配在栈内存之中的,因为智能指针实际上是一个局部变量。当智能指针的作用域结束后,智能指针被释放调用析构函数,析构函数会把智能指针所指向的对象也释放掉,达成了动态分配内存的目的。
上述我们所手写的智能指针有一个很大的问题,没有考虑多线程环境下的线程安全问题。多线程编程会在未来的文章讲述。
既然如此,推荐还是使用C++自带的智能指针。下面我们就详细讲述。
智能指针
unique_ptr智能指针
基本使用
上面的手写代码实际上就实现了unique_ptr指针的部分实现。std::unique_ptr 是 C++11 引入的智能指针类型,它是一种资源管理类,用于确保动态分配的对象会在适当的时候被自动释放。std::unique_ptr 维护对对象的唯一所有权,意味着两个 std::unique_ptr 实例不能指向同一个对象。这种唯一性确保了当 std::unique_ptr 被销毁时,它所指向的对象也会被自动删除。std::unique_ptr 不能被复制,但可以被移动。这意味着你不能使用拷贝构造函数或拷贝赋值操作符来复制 std::unique_ptr,但你可以使用移动构造函数和移动赋值操作符来转移其所有权。std::unique_ptr 重载了 * 和 -> 操作符,所以你可以像使用普通指针一样使用它来访问其所指向的对象。
#include <memory>
...
std::unique_ptr<TestClass> ptr(new TestClass);
// 重载了解引用运算符和箭头运算符
(*ptr).doSomething();
ptr->doSomething();
std::unique_ptr<TestClass> ptrn;
// ptrn = ptr; // 无法进行赋值操作
看,这些特性是不是和我们之前写的类十分地相似,但是std::unique_ptr支持的特性不止如此。
动态数组
std::unique_ptr 可以管理动态分配的数组。如果你需要一个指向数组的 std::unique_ptr,你必须在对象类型后面使用方括号 []。
#include <iostream>
#include <memory>
int main()
{
std::unique_ptr<int[]> arr(new int[5]);
for (int i = 0; i < 5; ++i)
{
arr[i] = i;
}
for (int i = 0; i < 5; ++i)
{
std::cout << "arr[" << i << "] = " << arr[i] << std::endl; // 可以直接通过arr[i]的方式调用
}
return 0;
}
std::unique_ptr 提供了 release 方法来释放对对象的所有权,返回原始指针,并且不会删除对象。reset 方法可以用来替换 std::unique_ptr 所管理的对象,或者将其设置为空。std::unique_ptr可以在不复制对象的情况下将所有权转移给另一个 unique_ptr,这在实现资源管理的同时提供了高效的性能。
#include <iostream>
#include <memory>
class MyObject
{
public:
MyObject()
{
std::cout << "MyObject Constructor" << std::endl;
}
~MyObject()
{
std::cout << "MyObject Destructor" << std::endl;
}
};
int main()
{
// 创建一个 unique_ptr 指向 MyObject 对象
std::unique_ptr<MyObject> ptr1(new MyObject());
// 使用移动语义将 ptr1 的所有权转移到 ptr2
std::unique_ptr<MyObject> ptr2 = std::move(ptr1);
// 现在 ptr1 不再指向对象,因为它的所有权已经被转移
if (ptr1 == nullptr)
{
std::cout << "ptr1 is nullptr" << std::endl;
}
// ptr2 现在拥有 MyObject 对象的所有权
if (ptr2 != nullptr)
{
std::cout << "ptr2 is not nullptr" << std::endl;
}
return 0;
}
自定义删除器
你可以为 std::unique_ptr 提供一个自定义删除器,这在需要特殊方式释放资源时非常有用,例如,当你使用的资源不是通过 delete 释放的时候。例如下面的文件管理:
#include <iostream>
#include <memory>
void FileDeleter(FILE *file)
{
std::cout << "Closing file" << std::endl;
std::fclose(file);
}
int main()
{
// 使用 unique_ptr 和自定义删除器来管理文件资源
std::unique_ptr<FILE, decltype(FileDeleter) *> filePtr(std::fopen("example.txt", "w"), FileDeleter);
// std::unique_ptr<FILE, decltype(&FileDeleter)> filePtr(std::fopen("example.txt", "w"), FileDeleter); 也可以
if (filePtr)
{
std::cout << "File opened successfully" << std::endl;
std::fprintf(filePtr.get(), "Hello, custom deleter!");
}
// filePtr 超出作用域时,自定义删除器将会关闭文件
return 0;
}
这里unique_ptr接收的第二个可选参数就是删除器,删除器可以是一个函数指针、函数对象或者 lambda 表达式,用于自定义资源的释放操作。
decltype 是 C++11 引入的一个关键字,用于获取表达式的类型或值。它可以用于声明变量、模板参数、函数返回类型等,以便在编译时获取表达式的类型。具体内容将会放在未来的C++11专门篇章中进行讲解。
不妨想一想,这里为什么要用自定义删除器来管理文件类型?
空指针检查
unique_ptr 支持空指针检查,可以通过 get() 方法获取原始指针,并且支持 operator bool 用于检查是否包含有效指针。
#include <iostream>
#include <memory>
class MyObject
{
private:
public:
MyObject() { std::cout << "Object Generated." << std::endl; }
~MyObject() { std::cout << "Object Deleted." << std::endl; }
void MemberFuction()
{
std::cout << "MemberFuction called." << std::endl;
}
};
int main()
{
std::unique_ptr<MyObject> ptr(new MyObject);
if (ptr) // 重载了bool,隐式调用
{
std::cout << "ptr is not empty." << std::endl;
ptr.get()->MemberFuction(); // 通过调用原始指针访问成员函数
}
std::unique_ptr<MyObject> new_ptr = std::move(ptr);
if (!ptr) // 重载了bool,隐式调用
{
std::cout << "ptr is empty." << std::endl;
}
return 0;
}
shared_ptr智能指针
引用计数
shared_ptr也是智能指针的一种,与unique_ptr不同的是,该智能指针能够有多个智能指针指向同一个智能对象。其实现方法是添加了一个引用计数的功能。我们能够调用use_count方法来查看该对象有几个智能指针。
std::shared_ptr<int> p1(new int(32));
std::shared_ptr<int> p2(p1);
std::shared_ptr<int> p3 = p2;
std::cout << *p3 << std::endl; // 32
std::cout << p1.use_count() << std::endl; // 3
p2.reset(); // 删除一个指针
std::cout << p1.use_count() << std::endl; // 2
思考一下,如何用类实现引用计数的功能?想好了可以查看一下源代码。
当智能指针的引用计数降到0时,会自动的销毁该对象。在C++自带的shared_ptr中,保证了引用计数是线程安全的。
动态数组
和unique_ptr一样shared_ptr也可以用于动态数组:
std::shared_ptr<int[]> p(new int[5]);
for (int i = 0; i < 5; i++)
{
p[i] = i;
}
auto q = p;
std::cout << q[3] << std::endl; // 3
std::cout << p.use_count() << std::endl; // 2
但是shared_ptr不支持管理动态数组,它不会自动摧毁数组,需要定义一个删除器。
自定义删除器
你也可以为shared_ptr自定义删除器。
std::shared_ptr<FILE> filePtr(std::fopen("example.txt", "rb"), FileDeleter);
if (filePtr)
{
std::cout << "File opened successfully" << std::endl;
}S
为上面的动态数组设计一个删除器:
#include <iostream>
#include <memory>
void deleter(int *p)
{
delete[] p; // 释放数组使用delete[]
}
int main()
{
std::shared_ptr<int[]> p(new int[5], deleter);
for (int i = 0; i < 5; i++)
{
p[i] = i;
}
auto q = p;
std::cout << q[3] << std::endl; // 3
std::cout << p.use_count() << std::endl; // 2
return 0;
}
unique_ptr和shared_ptr绑定删除器之间的差别
看到上面的示例代码,你会发现两种不同类型的智能指针在绑定删除器有一些差别。这两个智能指针的差异体现在1. 共享指针和独占指针;2. 删除器绑定的差别。
两者在删除器绑定的差别在性能上也有重要影响。
shared_ptr是在运行时绑定删除器的,我们可以在运行时改变删除器的类型,在调用删除器的时候,shared_ptr需要跳转到删除器函数的地址,然后再执行函数。
unique_ptr是在编译时绑定删除器的,因此我们需要在绑定删除器的时候说明删除器的类型。在执行析构函数的时候,unique_ptr可以直接调用删除器,无需跳转,减少了运行的开销。
循环引用问题
看下面一段代码:
#include <memory>
#include <iostream>
class B; // 前向声明
class A {
public:
std::shared_ptr<B> b_ptr;
~A() {
std::cout << "A destructor called" << std::endl;
}
};
class B {
public:
std::shared_ptr<A> a_ptr;
~B() {
std::cout << "B destructor called" << std::endl;
}
};
int main() {
std::shared_ptr<A> a = std::make_shared<A>();
std::shared_ptr<B> b = std::make_shared<B>();
a->b_ptr = b; // A持有B的shared_ptr
b->a_ptr = a; // B持有A的shared_ptr
std::cout << a.use_count() << std::endl; // 2
// 现在,a 和 b 相互引用,形成循环引用
// 当main函数结束时,a 和 b 的shared_ptr将被销毁
// 但是它们的引用计数不会降到零,因为它们相互持有对方的引用
// 因此,它们所管理的对象的析构函数不会被调用,导致内存泄漏
return 0;
}
如果两个或多个 shared_ptr 实例相互引用,形成循环引用,那么它们所指向的对象可能永远不会被释放。解决方法是使用 std::weak_ptr。
weak_ptr智能指针
std::weak_ptr 是 C++ 标准库中的一个智能指针,它设计用来解决 std::shared_ptr 可能导致的循环引用问题。其解决方法是std::weak_ptr不会增加对象的引用计数。
std::shared_ptr<int> p1 = std::make_shared<int>(8);
std::shared_ptr<int> p2 = p1;
std::weak_ptr<int> p3 = p2; // weak_ptr不会增加引用计数
std::cout << *p1 << std::endl; // 8
std::cout << p1.use_count() << std::endl; // 2
观察监控
weak_ptr不能直接访问指向的对象,但是能够起到一个监控对象存在的作用。通过expired()的方法可以判断该指针指向的对象是否存在,不存在则返回true。如果想要访问对象,需要使用lock()方法将其转换成shared_ptr。
std::shared_ptr<int> p1 = std::make_shared<int>(8);
std::shared_ptr<int> p2 = p1;
std::weak_ptr<int> p3 = p2; // weak_ptr不会增加引用计数
std::cout << (p3.expired() ? "true" : "false") << std::endl; // false
std::cout << *(p3.lock()) << std::endl; // 8
std::cout << (p3.lock()).use_count() << std::endl; // 3
p1.reset();
p2.reset();
std::cout << (p3.expired() ? "true" : "false") << std::endl; // true
使用allocator分别实现内存分配和对象构建
基础使用
new有一些灵活性上的局限,其中一方面就是表现在它把内存分配和对象构造组合在了一起。delete也有一些局限,其中一方面就是把对象析构和内存释放组合在了一起。有的时候,我们只是想分配一大块内存,接着在程序里慢慢使用(对象构建),对象使用完毕了就进行析构,但是不释放内存空间。
allocator能够实现上述的构想,标准库allocator定义在头文件memory中。它能够将内存分配和对象构造分离开来。allocator是一个模板,接下来看使用这个类的典型例子。
std::allocator<string> alloc; // alloc 运行为string类型开辟内存空间
auto const p = alloc.allocate(10); // 开辟的空间能构建10个string类型
auto q = p;
alloc.construct(q++, "Hello, world!"); // 第一个参数为指针,其余参数为string的构造参数
alloc.construct(q++, "This is a string.");
std::cout << p[0] << std::endl; // 输出:Hello, world!
std::cout << p[1] << std::endl; // 输出:This is a string.
// std::cout << q[0] << std::endl; // 灾难!不能访问虽然编译可以通过,但不能访问未构建的对象
在这段程序中,我们使用了两个指针q和p来作为已构造对象的指针,p始终指向索引为0的对象,q始终指向最后一个对象的下一个即将开辟空间位置。
成员函数construct(p, args)中p为将要指向构建对象的指针,args用于传入构造对象的构造函数。
释放内存的方法如下:
alloc.destroy(--q); // p[1]被删除
alloc.deallocate(p, 10); // 释放分配过的10个string大小的内存空间
成员函数destroy(p),会删除指针p所指向的对象,而deallocate(p ,size)会删除从p开始的size个内存空间。相当于allocate的反操作。
拷贝可填充未初始化的内存
allocator类还伴随着两个函数,可以在未初始化的内存中重建对象。就如下面这个例子。
std::vector<int> arr({1, 2, 3, 4, 5, 6, 7, 8});
std::allocator<int> alloc;
auto const p = alloc.allocate(2 * arr.size()); // 分配两倍的arr大小
auto q = std::uninitialized_copy(arr.begin(), arr.end(), p); // 先将arr整体复制
std::uninitialized_fill_n(q, arr.size(), 42); // 将其余的值初始化为42
for (auto i = 0; i < 2 * arr.size(); i++) // 1 2 3 4 5 6 7 8 42 42 42 42 42 42 42 42
std::cout << p[i] << " ";
std::cout << std::endl;
| 函数名 | 参数说明 | 函数说明 |
|---|---|---|
std::uninitialized_copy (InputIt, InputIt, ForwardIt) | - InputIt first: 起始迭代器。- InputIt last: 结束迭代器。- ForwardIt d_first: 目标内存起始迭代器。 | 将 [first, last) 范围内的元素复制到从 d_first 开始的未初始化内存区域。 |
std::uninitialized_fill (ForwardIt, ForwardIt, const T&) | - ForwardIt first: 起始迭代器。- ForwardIt last: 结束迭代器。- const T& value: 要填充的值。 | 在 [first, last) 区域内的未初始化内存填充 value。 |
std::uninitialized_copy_n (InputIt, Size, ForwardIt) | - InputIt first: 起始迭代器。- Size count: 元素数量。- ForwardIt d_first: 目标内存起始迭代器。 | 从 first 开始复制 count 个元素到从 d_first 开始的未初始化内存区域。 |
std::uninitialized_fill_n (ForwardIt, Size, const T&) | - ForwardIt first: 起始迭代器。- Size count: 元素数量。- const T& value: 要填充的值。 | 在从 first 开始的未初始化内存区域填充 count 个 value。 |
评论区