12. 动态内存

youncyb 发布于 20 天前 168 次阅读 C++


本章节讲解了 C++ 中的动态内存管理,包括动态内存的分配与释放、智能指针的使用以及常见的内存管理陷阱。动态内存允许程序在运行时根据需求灵活地申请和释放内存,从而优化资源的使用和程序的性能。通过学习本章节,您将了解如何正确应用动态内存管理技术,以避免内存泄漏和非法访问等问题,确保程序运行的高效性与安全性。

1. 智能指针

1.1 shared_ptr

shared_ptr 允许多个指针指向同一个对象,通过引用计数,判断对象是否需要销毁。当最后一个 shared_ptr 不再指向该对象时,系统则会自动销毁该对象。shared_ptr 允许多个对象共享相同的底层数据,保证数据一致性。

智能指针也是模版,其定义如下,没有直接初始化或者通过函数赋值的 shared_ptr 默认等于 nullptr。由于 shared_ptr 的构造函数有 explicit 关键字,所以我们只能使用直接初始化的方式。

shared_ptr<int> p1; // p1可以指向 int,但其现在还是nullptr
shared_ptr<vector<string>> p2; // p2可以指向vector<string>

shared_ptr 支持以下操作:

p.get(); // 返回一个普通指针,如果智能指针p销毁了对象,那么该普通指针就变成了野指针

swap(p, q); // 交换p与q的指针
p.swap(q);  //

make_shared<T>(args); // 返回shared_ptr,指向类型为T,且使用args进行初始化的对象
shared_ptr<T>p(q); // p是q的拷贝,增加q的引用计数器
p = q; // p与q都是shared_ptr,此举减少p所指对象的引用计数,递增q所指对象的引用计数,如果p所指对象引用计数为0,则销毁p所指对象
p.unique(); // 返回一个bool值,当p所指对象引用计数为1,则返回true,否则返回false
p.use_count(); // 返回与p共享对象的智能指针数量,仅用于调试(影响性能)

当使用 shared_ptr 自动销毁对象,是通过 “析构函数” 的方式。但自动销毁对象也不是百分百有效,当 shared_ptr 存放的是一个容器,而 我们对容器进行了算法操作后,不再需要某些元素了,这时需要手动 erase 这些元素

1.2 unique_ptr

unique_ptr 独占对象,同一时间只能有一个 unique_ptr 指向该对象,当 unique_ptr 被销毁后,其指向的对象也会自动销毁。由于是独占模式,所以 unique_ptr 不支持拷贝或者赋值操作。

unique_ptr<int> p1(new int(1024));

unique_ptr<int> p2(p1); // 错误,不支持拷贝

unique_ptr<int> p2;
p2 = p1; // 错误,不支持赋值

unique_ptr 支持以下操作:

unique_ptr<T> u; // 定义空指针u,可以指向类型为T的对象
unique_ptr<T, D> u; // 调用D销毁对象
unique_ptr<T, D> u(d);

u = nullptr; // 销毁u指向的对象
u.release(); // 释放u指向对象的控制权,并将u设置为nullptr,返回一个T*普通指针
u.reset(); // 销毁u指向的对象,并将u设置为nullptr
u.reset(p); // u指向指针p所指的对象
u.reset(nullptr); // 销毁u指向的对象

虽然我们不能通过拷贝或赋值 unique_ptr,但可以通过 resetrelease,转移对象的控制权:

unique_ptr<string> p1(new string("Hello"));
unique_ptr<string> p2(p1.release()); // p2接管p1指向的对象

unique_ptr<string> p3(new string("World"));
p2.reset(p3.releases()); // p2接管p3指向的对象

虽然我们不能通过拷贝或赋值 unique_ptr,但在函数中返回 unique_ptr 是一种例外,编译器会进行特殊处理,以保证返回的 unique_ptr 可用:

std::string& createString() {
    std::string localStr = "Hello, World!";
    return localStr;  // 危险!返回了局部对象的引用
}

int main() {
    std::string& result = createString();  // 未定义行为
    std::cout << result << std::endl;      // 可能崩溃
    return 0;
}

// 使用智能指针

#include <iostream>
#include <memory>

std::unique_ptr<std::string> createString() {
    auto localStr = std::make_unique<std::string>("Hello, World!");
    return localStr;  // 返回智能指针,局部对象的所有权被转移
}

int main() {
    std::unique_ptr<std::string> result = createString();
    std::cout << *result << std::endl;  // 输出: Hello, World!
    return 0;
}

1.3 weak_ptr

weak_ptr 是弱化版的 shared_ptr,不会控制对象的生命周期,当其指向一个 shared_ptr 管理的对象时,不会增加 ptr 的引用计数。其应用场景包括:

  • 避免 shared_ptr 的循环引用
  • 缓存设计,延迟加载对象
  • 观察者模式,避免被观察对象生命周期延长

weak_ptr 支持以下操作:

weak_ptr<T> wp; // 定义一个可以指向类型为T的对象的空指针
weak_ptr<T> wp(sp); // wp绑定到sp所指对象,sp是shared_ptr
wp = sp; // 隐式转换,wp绑定到sp所指对象
w.reset(); // 将w设置为空指针
w.use_count(); // 返回shared_ptr指针的引用计数
w.expired(); // 当use_count==0,返回true;否则,返回false
w.lock(); // 当expired==true,返回空指针;否则返回shared_ptr

由于 weak_ptr 不控制对象声明周期,在使用 weak_ptr 之前需要确认对象是否存在:

if(auto sp = w.lock())
{
    // do something ...
}

2. 直接管理内存

2.1 new 与 delete

C++定义了 new 用于直接分配内存,delete 用于销毁内存。new 使用如下方式进行内存分配,由于 new 分配的内存是无名的,所以需要使用指针指向它,并且 new 会自动调用对象的默认初始化方法。而内置类型 int,则没有默认初始化方法,所以 new int 是一块未初始化的内存。

new 申请的内存需要手动调用 delete 进行释放。

方式一:默认初始化

int *p = new int; // 分配了一块未初始化的int
string *p = new string; // 分配了一块空字符串

方式二:直接初始化

int *p = new int(1024); // p指向对象的值为1024
string *p = new string(2, "H"); // p指向对象的值为HH

方式三:值初始化

int *p = new int(); // p指向对象值为0
string *p = new string(); // p指向对象的值为空字符串

对动态内存对象进行初始化通常是一个好的注意。

如果对象的类型比较复杂,我们也可以使用 auto 自动推到:

// 动态分配一个 vector<string> 对象
auto p = new auto(std::vector<std::string>{"Hello", "world", "from", "C++"});

// 使用智能指针管理动态分配的内存(可选,防止内存泄漏)
// auto p = std::make_unique<std::vector<std::string>>(std::initializer_list<std::string>{"Hello", "world", "from", "C++"});

// 输出 vector<string> 中的每个元素
for (const auto& str : *p) {
    std::cout << str << " ";
}
std::cout << std::endl;

// 手动释放内存
delete p;

如果不想改变对象的值,也可以分配一个 const 对象:

const int *p = new const int(1024); // 需要注意的是const对象必须初始化

当内存不足时,使用 new 进行分配内存,会抛出 bad_alloc 错误,可以使用名为:定位new 的定义方式避免其抛出异常:

#include <new> // bad_alloc和nothrow都定义在头文件new中
int *p = new (nothrow) int(1024);

对同一个指针进行多次释放,会导致错误的结果,而内置指针(非智能指针)管理的动态内存对象在手动释放前都一直会存在于内存。

delete p;
delete p; // 未定义的行为

obj* factory(args)
{
    return new obj(args); // 动态分配
}

void do_something(args)
{
    obj *p = factory(args);
    // do something....
} // 没有手动销毁p,p就会一直存在,造成内存泄露

直接使用 new 和 delete,经常会遇到 3 种错误:

  • 忘记 delete
  • 使用 delete 后的指针(空悬指针)
  • 双重 delete

所以当我们 delete 一个指针后,最好立即加上 p = nullptr;。但即使加上了诸多保护措施,直接使用 delete 仍然可能导致内存泄露,例如:程序块中,delete p 之前发生了异常,那么 p 指向的内存就不会被释放,进而变成 “孤儿内存”。

void func()
{
    int *p = new int(1024);
    // throw an exception...
    delete p; // 不会被执行,发生内存泄露
}

2.2 shared_ptr 结合 new

为了避免直接使用 new 带来的潜在隐患,我们可以将 shared_ptr 与 new 结合起来使用。

shared_ptr<int> p(new int(1024)); // 正确的初始化
shared_ptr<int>p = new int(1024); // 错误的初始化,shared_ptr构造函数是explicit

shared_ptr<int> clone(int num)
{
    return new int(num); // 错误,explicit无法转换为shared_ptr<int>
}

shared_ptr 结合 new 支持的操作:

shared_ptr<T> p(q); // 使用p管理内存指针q指向的对象,该对象必须使用new进行分配
shared_ptr<T> p(u); // u放弃对象的管理权,变成nullptr,将管理权交给p
shared_ptr<T> p(q, d); // p接管内置指针q的对象,但在销毁时使用d进行销毁而不是delete
shared_ptr<T> p(p2, d); // p2也是shared_ptr,p是p2的拷贝,引用计数加1,销毁对象时,p将会调用d进行销毁

p.reset(); // 引用计数减1,当引用计数等于0,销毁对象
p.reset(q); // 释放p之前的资源,接管内置指针q的对象
p.reset(q, d); // 同上,但调用d销毁对象

不要将智能指针与内置指针混合使用,及其容易造成空悬指针、双重释放等问题。例如:

void test(shared_ptr<int> p_num)
{
    // do something...
}
int *p1 = new int(1024);
test(shared_ptr<int>p2(p1)); // p2是局部智能指针,引用计数等于1,离开test函数后,则会销毁对象,
int num = *p1; // bug: use-after-free,p1现在是一个空悬指针,

所以当使用智能指针管理对象后,不要再使用内置指针访问对象。同理,也不要使用 p.get()初始化另一个智能指针,其带来的问题与上述例子相同。

3. 动态数组

3.1 动态分配数组

new 和 delete 一次分配/删除一个对象,像 string、vector 等容器都是一次性分配多个内存,所以 C++也设计了一次性分配多个内存的方式:

int *p = new int[5]; // 返回元素类型int []的指针
delete [] p; // 删除多个对象

动态数组初始化有以下方式:

  • 方式一:值初始化

    int *p = new int[6](); // 6个值为0的数组
    
  • 方式二:列表初始化

    int *p = new int[6]{1, 2, 3, 4, 5, 6}; // 列表初始化
    

当内存不足时,会抛出:bad_array_new_length 错误。由于动态数组不支持直接初始化(在小括号内,给出初始化内容),所以动态数组不能通过 auto 进行推导。

当调用 delete [] p 销毁对象时,按照逆序的方式销毁。C++也提供了一种自动管理动态数组的智能指针:unique_ptr,其与动态数组结合后支持以下操作,使用 unique_ptr 管理动态数组,用户不必关心销毁对象。

unique_ptr<T[]> u;
unique_ptr<T[]> u(p);
u[i]; // 访问数组对象中的某个元素

而使用 shared_ptr 管理动态数组,则需要手动指定删除器,

shared_ptr<int> p(new int[1024], [](int *p){ delete [] p };);
p.reset(); // 调用delete [] p 销毁对象

由于 shared_ptr 不支持直接管理动态数组,其也不支持通过下标访问数组(sp[i] 操作),也不支持指针运算符(--+++ 等操作):

for (int i = 0; i < 1024; ++i) {
    p.get()[i] = i; // 先获取普通指针
}

3.2 allocator

new 与 delete 将分配与初始化合为了一个整体,当分配一块大内存时,我们可能只需要部分区域对象初始化,但 new[] 会在内存块上进行全部对象初始化,造成浪费。

所以,allocator 因此诞生,allocator 支持以下操作:

allocator<T> alloc; // 定义一个alloc对象,可以为T类型的对象分配内存

alloc.allocate(n); // 分配n个未初始化的T对象到内存上,返回一个T*指针
alloc.construct(p, args); // 使用args构造对象,并将地址赋值给指针p
alloc.destroy(p); // 销毁指针p所指的对象
alloc.deallocate(p, n); // 释放p所指地址的内存,但务须保证该内存的对象已调用destroy,且n等于allocate时的n
size_t n = 5;  // 分配 5 个 int 对象的内存
int* array = alloc.allocate(n);  // 分配未初始化的内存

// 在分配的内存中构造对象
for (size_t i = 0; i < n; ++i) {
    alloc.construct(&array[i], i + 1);  // 构造对象,值为 1, 2, 3, 4, 5
}

// 使用分配的对象
std::cout << "Allocated and constructed array: ";
for (size_t i = 0; i < n; ++i) {
    std::cout << array[i] << " ";
}
std::cout << std::endl;

// 显式销毁数组中的对象
for (size_t i = 0; i < n; ++i) {
    alloc.destroy(&array[i]);  // 销毁对象
}

// 释放数组内存
alloc.deallocate(array, n);  // 释放内存

C++标准库也提供了算法用于快速填充 allocator 申请的内存:

uninitialized_copy(b, e, p); // 从容器拷贝数据到p指向的内存,返回最后一个元素之后的位置
uninitialized_copy_n(b, n, p); // 拷贝n个数据到p指向的内存,返回最后一个元素之后的位置
uninitialized_fill(b, e, t); // 在迭代器b和e的范围内,填充t
uninitialized_fill_n(b, n, t); // 填充n个t
std::allocator<int> alloc;
int* destination = alloc.allocate(n);  // 分配内存,但未构造对象

// 使用 std::uninitialized_fill 填充内存
std::uninitialized_fill(destination, destination + n, 42);