cpp_2_5_智能指针_0_设计思想

  1. Overview
  2. 什么是内存泄露?
  3. 智能指针的设计思想
  4. Move Sematics 移动语义
  5. std::auto_ptr
  6. 智能指针
  7. Reference

Overview

1.什么是内存泄露?
2.智能指针的设计思想
3.Move Sematics 移动语义

什么是内存泄露?

1.内存泄露是 cpp 中最经典的常见 Bug 之一, 理解内存泄露问题, 本质上是在理解 cpp 内存管理机制
2.内存是一种有限的”资源”, 对于资源我们要做好相应的管理, 不能因为我们管理不善导致用尽资源, 造成系统的崩溃; 换句话说, 我们应该时刻关注我们资源使用的状况, 用完后该回收的要回收
3.两种内存的分配方式

(i). 栈内存
函数调用时, 局部变量的内存分配发生在栈内存上面; 函数调用结束后局部变量自动释放, 这个过程由编译器自动控制

void func() {
  int a = 100; // a的内存是由编译器自动分配的
  int* p = &a; // 指针变量p存储了a的地址
}

(ii). 堆内存
1.堆内存用于动态分配内存, 需要程序员手动请求这块内存, 使用完必须手动释放; 不然这块内存就会一直存在, 造成内存泄露, 别人无法访问这块内存; 没人访问的内存就会成为”荒芜之地”, 久而久之”荒芜之地”多了, (内存) 资源会被完全消耗完毕
2.现代意义下, 堆内存的”堆”, 和数据结构中的”堆”是没有关系的, 只是一大块可以用来请求的内存的意思
3.可以用 new 关键字来动态请求一些内存, 用 new 请求的内存要用 delete 手动删除, 不然内存不会自动释放

void func() {
  int a = 100;
  int* p = new int; // 用 new 来动态请求一个 int 大小的内存
  delete p;         // 用 new 请求的内存必须用 delete 手动删除, 否则不会自动释放内存
                    // delete p是释放了 p 指向的内存, 不是删除了 p 本身
  p = &;            // 删除 p 指向的内存之后, p 还可以指向别的地方
}

new 关键字不光可以请求单个元素的内存, 还可以请求多个元素组成的数组

void func() {
  int* p = new int[100];
  delete[] p;   // delete 的时候[]不需要带元素的个数, 因为编译器会记下来创建了多少个元素
}

再看另一个例子

int foo(int x) {
  int y = x * 2;
  int* p = new int[5];
  p[0] = y;
  // 函数执行结束时, p所指向的内存就成了荒芜之地
  // 发生内存泄露
}

上述 foo 函数执行完毕之后, x 和 y 都会被释放, 但是p指向的数组不会被释放; 如果我们想删除这段内存, 就得加上一个delete[] p

int foo(int x) {
  int y = x * 2;
  int *p = new int[5];
  p[0] = y;
  delete[] p; // 回收p指向的栈内存
}

4.在使用delete回收内存的时候, 还有一些注意事项:

  • 对于同一个指针, 不能删 2 次, 该操作属于一个 undefined behavior;

    int *p = new int;
    delete p;
    delete p;  // wrong code
    
  • delete 之后的指针, 如果还想后续使用指针变量, 可以先暂时设为 nullptr

    int* p = new int;
    delete p;
    p = nullptr;
    // ...
    if (p != nullptr) {
    // some code here...
    }
    

5.上述 case 中, 动态开辟和回收很简单很容易理解, 因为我们在实际使用完内存中立马就删除了; 但在实际代码中可能会非常复杂, 由于指针可以穿来穿去, 在foo()函数里面动态申请了一块内存, 甚至是多个指针的大量多个内存, 可能会传到一个非常遥远的 bar()函数当中, bar() 函数用完可能会传递到 bar2()函数里面; 在这个过程中, 也可能出现多个指针指向同一块内存的情况; 那么在哪里释放内存就成了一个复杂的问题, 在foo()释放不合适, 因为在bar()中还在使用. 一旦数据的使用传递变得复杂, 程序员可能就会忘记释放内存从而产生Bug; 这类错误往往不是因为”疏忽”造成的, 本质上是问题的复杂度迫使人类犯错; 再体会一个动态分配内存忘记释放的例子:

void someFunction() {
  Resource* ptr = new Resource(); // Resource 是一个class或者struct
  // 用ptr做一些事情
  delete ptr; // 释放ptr指向的内存
}

上面的例子看起来很简单, 但有可能出现一种函数提前退出的情况或者抛出异常, 导致ptr不被删除, 分配给ptr的内存被泄露, 而且每次调用的时候都会再次泄露

void someFunction() {
  Resource* ptr = new Resource();
  int x;
  std::cout << "Enter an integer: ";
  std::cin >> x;

  if (x == 0) {
    return; // 函数会提前退出, ptr不会被删除
  }
  if (x == -1) {
    throw 0;  // 函数提前退出, ptr不会被删除
  }

  // 用ptr做一些事情

  delete ptr;
}

6.很多高级语言比如Java都有垃圾回收机制, 但是 cpp 作为追求性能极致的语言, 没有在语言层面上的垃圾回收功能, 需要程序员自行管理内存的释放
7.虽然 cpp 没有自动垃圾回收机制, cpp 支持用其他的机制去实现辅助的内存管理, 比如智能指针, 智能指针可以帮助我们去管理内存, 降低一些负担; 在正式介绍之智能指针之前, 我们先体会一下智能指针的设计思想

智能指针的设计思想

1.cpp 中的 class 最有用机制之一就是包含析构函数, class 的对象超出 scope 之后就会自动执行析构函数. 因此如果我们能够将管理内存的机制设计在里面, 其实就设计了一种自动管理内存的机制: 在构造函数里面分配 (或者获取) 内存, 然后再析构函数中释放内存, 并保证当类对象销毁是内存被释放 (无论是否超出scope, 是否被显示删除), 就能解决内存泄露的问题.
2.基于上述思想, 我们设计一个特殊的指针管理类, 这个类专门管理和清理我们的指针: 这个类唯一的工作就是”持有”传递给它的指针, 然后在类对象超出 scope 之后释放该指针. 只要该类的对象仅创建为某个局部变量, 我们就可以保证该类将正确超出范围 (无论我们的函数何时或者如何终止) , 并且持有的指针将被销毁.

As long as objects of that class were only created as local variables, we could guarantee that the class would properly go out of scope, regardless of when or how our functions terminate) and the owned pointer would get destroyed.

因此所谓的智能指针, 就是一个自动管理指针的 (模板) 类, 我们先看下第一个版本智能指针设计 (要设计N个版本才能达到理想的目的, 但最本质的是第一步设计)

#include <iostream>

template <typename T>
class Auto_ptr1 {
  T* m_ptr;
 public:
  // 构造函数: 传入指针并持有指针
  Auto_ptr1(T* ptr=nullptr) : m_ptr(ptr) {}
  // 析构函数: 确保指针被释放
  ~Auto_ptr1() {
    delete m_ptr;
  }
  // 重载dereference和->运算符, 这样我们就可以像m_ptr一样使用Auto_ptr1
  T& operator*() const { return *m_ptr; }
  T* operator->() const { return m_ptr; }
};

class Resource {
 public:
  Resource() { 
    std::cout << "Resource acquired\n";
  }
  ~Resource() { 
    std::cout << "Resource destroyed\n";
  }
};

int main() {
  Auto_ptr1<Resource> res(new Resource()); // 分配内存

  // 有了Auto_ptr1, 我们不需要显示的删除ptr了
  // 注意这里我们使用的是<Resource>, 而不是<Resource*>
  // 是因为我们在Auto_ptr1里面定义了指针类型 T* (而不是T)

  return 0;
} // res超出了scope, 销毁了已分配的资源

上面的 Auto_ptr1 的设计存在一个比较大的问题, 没有提供拷贝构造函数或者赋值运算符, 遇到对象赋值或者以对象初始化对象时会存在问题 (在这种情况下, cpp默认提供给我们了一个浅拷贝), 造成可能的指针悬停问题

// resource和auto_ptr定义相同
int main() {
  Auto_ptr1<Resource> res1(new Resource());
  Auto_ptr1<Resource> res2(res1); 
  Auto_ptr1<Resource> res3 = res1; 
  return 0;
}

分析: 用res1去初始化res2的时候, 两个auto_ptr1都指向同一个资源; 当res2超出scope时, 它会删除资源, 留下res1一个悬空指针 (a dangling pointer). 当res1准备删除资源的时候, 其实资源已经被删除了, 将导致未定义的行为, 可能导致程序崩溃.

还有另外一个常见的同理的例子

void passByValue(Auto_ptr1<Resource> res) {
  // do something
}

int main() {
  Auto_ptr1<Resource> res1(new Resource());
  passByValue(res1);
  return 0;
}

分析: res1拷贝一份到函数形参res, res1.m_ptr 和 res.m_ptr 指向同一内存, 当 res 在执行完passByValue被销毁之后, res1.m_ptr 指针悬空 (dangling), 然后再删除 res1.m_ptr 时, 将导致未定义的行为

Move Sematics 移动语义

但是采用拷贝构造函数和赋值运运算符的方法拷贝指针 (或者叫copy sematics) 不可行, 我们采用转移/移动指针的所有权的方式, 将源对象移动到目标对象. 移动语义 (Move sematics) 意味着类选择转移对象的所有权, 而不是制作一个副本

#include <iostream>

template <typename T>
class Auto_ptr2 {
  T* m_ptr;
 public:
  Auto_ptr2(T* ptr=nullptr) :m_ptr(ptr) {}

  ~Auto_ptr2() {
    delete m_ptr;
  }

  // 实现移动语义的构造函数
  Auto_ptr2(Auto_ptr2& a) { // 这里不能声明成const
    m_ptr = a.m_ptr;        // 将dump指针从源对象转移到局部变量
    a.m_ptr = nullptr;      // 确保源对象不在拥有指针
  }

  // 实现移动语义的赋值运算符
  Auto_ptr2& operator=(Auto_ptr2& a) { // 这里不能声明成const
    if (&a == this)
      return *this;

    delete m_ptr;       // 确保我们首先释放目标已经持有的任何指针
    m_ptr = a.m_ptr;    // 将dump指针从源对象转移到局部变量
    a.m_ptr = nullptr;  // 确保源对象不在拥有指针
    return *this;
  }

  T& operator*() const { return *m_ptr; }
  T* operator->() const { return m_ptr; }
  bool isNull() const { return m_ptr == nullptr; }
};

class Resource {
 public:
  Resource() { std::cout << "Resource acquired\n"; }
  ~Resource() { std::cout << "Resource destroyed\n"; }
};

int main() {
  Auto_ptr2<Resource> res1(new Resource());
  Auto_ptr2<Resource> res2; // res2一开始是个空指针

  std::cout << "res1 is " << (res1.isNull() ? "null\n" : "not null\n");
  std::cout << "res2 is " << (res2.isNull() ? "null\n" : "not null\n");

  res2 = res1; // res2 取得指针所有权, res1 设置为nullptr

  std::cout << "Ownership transferred\n";

  std::cout << "res1 is " << (res1.isNull() ? "null\n" : "not null\n");
  std::cout << "res2 is " << (res2.isNull() ? "null\n" : "not null\n");

  return 0;
}

输出结果如下, 重载运算符=将m_ptr的所有权从res1转移到了res2, 我们不会得到指针重复的复制, 所有内容会被清理干净

Resource acquired
res1 is not null
res2 is null
Ownership transferred
res1 is null
res2 is not null
Resource destroyed

std::auto_ptr

1.auto_ptr 设计的核心问题在于, 在 c++11 之前 cpp 没有区分 copy sematics 和 move sematics 的机制, 覆盖复制语义来实现移动语义会导致奇怪的边缘 case 和无意识 bug, 例如可以写 res1 = res2, 但不知道 res2 是否会改变.
2.在 c++11 中, 正式定义了 “move” 的概念, 在语言中添加了 move senamtics 来正确区分 copy 和 move. auto_ptr一些其他类型的”move-aware”的指针指针替代: shared_ptr, unique_ptr, weak_ptr

智能指针

1.虽然有 new 和 delete 可以动态请求内存和删除内存, 但是手动管理内存终究很容易出问题造成内存泄露, cpp 中采用智能指针可以很大程度的降低这种问题
2.常见智能指针分为几类: shared_ptr, unique_ptr 和 weak_ptr, 使用智能指针需要导入 memory 头文件, 且智能指针命名空间都在 std::, 我们以 shared_ptr 为例先进行来感受下定义

#include <memory>
std::shared_ptr<int> p = make_shared<int>(100);

Reference

1.C++11 shared_ptr智能指针(超级详细)http://c.biancheng.net/view/7898.html
2.https://www.learncpp.com/cpp-tutorial/introduction-to-smart-pointers-move-semantics/11


转载请注明来源, from goldandrabbit.github.io

💰

×

Help us with donation