前言

本文的内容将专门对付内存管理,培养起有借有还的好习惯,方可消除资源管理的问题。

正文

所谓的资源就是,一旦用了它,将来必须还给系统。如果不是这样,糟糕的事情就会发生。

C++ 程序内常见的资源:

  • 动态分配内存
  • 文件描述符
  • 互斥锁
  • 图形页面中的字型和笔刷
  • 数据库连接
  • 网络 sockets

无论哪一种资源,重要的是,当你不再使用它时,必须将它还给系统,有借有还是个好习惯。

细节 01 : 以对象管理资源

把资源放在析构函数,交给析构函数释放资源

假设某个 class 含有个工厂函数,该函数获取了对象的指针:

A* createA();    // 返回指针,指向的是动态分配对象。
// 调用者有责任删除它。

如上述注释所言,createA 的调用端使用了函数返回的对象后,有责任删除它。现在考虑有个f函数履行了这个责任:

void f()
{
A *pa = createA(); // 调用工厂函数
... // 其他代码
delete pa; // 释放资源
}

这看起来稳妥,但存在若干情况f函数可能无法执行到delete pa语句,也就会造成资源泄漏,例如如下情况:

  • 或许因为「…」区域内的一个过早的 return 语句;
  • 或许因为「…」区域内的一个循环语句过早的continue 或 goto 语句退出;
  • 或许因为「…」区域内的语句抛出异常,无法执行到 delete。

当然可以通过谨慎地编写程序可以防止这一类错误,但你必须想想,代码可能会在时间渐渐过去后被修改,如果是一个新手没有注意这一类情况,那必然又会再次有内存泄漏的可能性。

为确保 A 返回的资源都是被回收,我们需要将资源放进对象内,当对象离开作用域时,该对象的析构函数会自动释放资源。

「智能指针」是个好帮手,交给它去管理指针对象。

对于是由动态分配(new)于堆内存的对象,指针对象离开了作用域并不会自动调用析构函数(需手动delete),为了让指针对象能像普通对象一样,离开作用域自动调用析构函数回收资源,我们需要借助「智能指针」的特性。

常用的「智能指针」有如下三个:

  • std::auto_ptr( C++ 98 提供、C++ 11 建议摒弃不用 )
  • std::unique_ptr( C++ 11 提供 )
  • std::shared_ptr( C++ 11 提供 )
std::auto_ptr

下面示范如何使用 std::auto_ptr 以避免 f 函数潜在的资源泄漏可能性:

void f()
{
std::auto_ptr<A> pa (createA()); // 调用工厂函数
... // 一如既往的使用pa
} // 离开作用域后,经由 auto_ptr 的析构函数自动删除pa;

这个简单的例子示范「以对象管理资源」的两个关键想法:

  • 获得资源后立刻放进管理对象内。以上代码中 createA 返回的资源被当做其管理者 auto_ptr 的初值,也就立刻被放进了管理对象中。
  • 管理对象运用析构函数确保资源释放。不论控制流如何离开区块,一旦对象被销毁(例如当对象离开作用域)其析构函数自然会被自动调用,于是资源被释放。

为什么在 C++11 建议弃用 auto_ptr 吗?当然是 auto_ptr 存在缺陷,所以后续不被建议使用。

auto_ptr 有一个不寻常的特质:若通过「复制构造函数或赋值操作符函数」 copy 它们,它们会变成 null ,而复制所得的指针将获取资源的唯一拥有权!
见如下例子说明:

std::auto_ptr<A> pa1(createA()); // pa1 指向 createA 返回物

std::auto_ptr<A> pa2(pa1); // 现在 pa2 指向对象,pa1将被设置为 null

pa1 = pa2; // 现在 pa1 指向对象,pa2 将被设置为 null

这一诡异的复制行为,如果再次使用指向为 null 的指针,那必然会导致程序奔溃
意味着 auto_ptr 并非管理动态分配资源的神兵利器。

std::unique_ptr

unique_ptr 也采用所有权模型,但是在使用时,是直接禁止通过复制构造函数或赋值操作符函数 copy 指针对象,如下例子在编译时,会出错:

std::unique_ptr<A> pa1(createA()); // pa1 指向 createA 返回物

std::unique_ptr<A> pa2(pa1); // 编译出错!

pa1 = pa2; // 编译出错!
std::shared_ptr

shared_ptr 在使用复制构造函数或赋值操作符函数后,引用计会数累加并且两个指针对象指向的都是同一个块内存,这就与 unique_ptr、auto_ptr 不同之处。

void f()
{
std::shared_ptr<A> pa1(createA()); // pa1 指向 createA 返回物 std::shared_ptr<A> pa2(pa1); // 引用计数+1,pa2和pa1指向同一个内存 pa1 = pa2; // 引用计数+1,pa2和pa1指向同一个内存
}

当一个对象离开作用域,shared_ptr 会把引用计数值 -1 ,直到引用计数值为 0 时,才会进行删除对象。

由于 shared_ptr 释放空间时会事先要判断引用计数值的大小,因此不会出现多次删除一个对象的错误。

小结 - 请记住

  • 为防止资源泄漏,请使用 RAII(Resource Acquisition Is Initaliaztion - 资源取得时机便是初始化时机) 对象,它们在构造函数中获取资源,并在析构函数中是释放资源
  • 两个建议使用的 RAII classes 分别是 std::unique_ptr 和 std::shared_ptr。前者不允许 copy 动作,后者允许 copy 动作。但是不建议用 std::auto_ptr,若选 auto_ptr,复制动作会使它(被复制物)指向 null 。

细节 02:在资源管理类中小心 copying 行为

假设,我们使用 C 语音的 API 函数处理类型为 Mutex 的互斥对象,共有 lock 和 unlock 两函数可用:

void locak(Mutex *pm);  // 锁定 pm 所指的互斥器
void unlock(Mutex* pm); // 将互斥器解除锁定

为确保绝不会忘记一个被锁住的 Mutex 解锁,我们可能会希望创立一个 class 来管理锁资源。这样的 class 要遵守 RAII 守则,也就是「资源在构造期间获得,在析构释放期间释放」:

class Lock
{
public:
explicit Lock(Mutex *pm) // 构造函数
: pMutex(pm)
{
lock(pMutex);
} ~Lock() // 析构函数
{
unlock(pMutex);
}
private:
Mutex* pMutex;
};

这样定义的 Lock,用法符合 RAII 方式:

Mutex m;      //定义你需要的互斥锁
...
{ // 建立一个局部区块作用域
Lock m1(&m); // 锁定互斥器
...
} // 在离开区块作用域,自动解除互斥器锁定

这很好,但如果 Lock 对象被复制,会发生什么事情?

Lock m1(&m);  // 锁定m
Lock m2(&m1); // 将 m1 复制到 m2身上,这会发生什么?

这是我们需要思考和面对的:「当一个 RAII 对象被复制,会发生什么事情?」大多数时候你会选择以下两种可能:

  • 禁止复制。如果 RAII 不允许被复制,那我们需要将 class 的复制构造函数和赋值操作符函数声明在 private。
  • 使用引用计数法。有时候我们希望保有资源,直到它直的最后一个对象被消耗。这种情况下复制 RAII 对象时,应该将资源的「被引用数」递增。std::shared_ptr 便是如此。

如果前述的 Lock 打算使用使用引用计数法,它可以使用 std::shared_ptr 来管理 pMutex 指针,然后很不幸 std::shared_ptr 的默认行为是「当引用次数为 0 时删除其所指物」那不是我们想要的行为,因为要对 Mutex 释放动作是解锁而非删除。

幸运的是 std::shared_ptr 允许指定自定义的删除方式,那是一个函数或函数对象。如下:

class Lock
{
public:
explicit Lock(Mutex *pm)
: pMutex(pm, unlock) // 以某个 Mutex 初始化 shared_ptr,
// 并以 unlock 函数为删除器。
{
lock(pMutex.get()); // get 获取指针地址
} private:
std::shared_ptr<Mutex> pMutex; // 使用 shared_ptr
};

请注意,本例的 Lock class 不再声明析构函数。因为编译器会自动创立默认的析构函数,来自动调用其 non-static 成员变量(本例为 pMutex )的析构函数。

而 pMutex 的析构函数会在互斥器的引用次数为 0 时,自动调用 std::shared_ptr 的删除器(本例为 unlock )

小结 - 请记住

  • 复制 RAII 对象必须一并复制它的所管理的资源(深拷贝),所以资源的 copying 行为决定 RAII 对象的 copying 行为。
  • 普通而常见的 RAII class copying 行为是:禁止 copying、施行引用计数法。

细节 03 :在资源类中提供对原始资源的访问

智能指针「显式」转换,也就是通过 get 成员函数的方式转换为原始指针对象。

上面提到的「智能指针」分别是:std::auto_ptr、std::unique_ptr、std::shared_ptr。它们都有访问原始资源的办法,都提供了一个 get 成员函数,用来执行显式转换,也就是它会返回智能指针内部的原始指针(的复件)

举个例子,使用智能指针如 std::shared_ptr 保存 createA() 返回的指针对象 :

std::shared_ptr<A> pA(createA());

假设你希望以某个函数处理 A 对象,像这样:

int getInfo(const A* pA);

你想这么调用它:

std::shared_ptr<A> pA(createA());
getInfo(pA); // 错误!!

会编译错误,因为 getInfo 需要的是 A 指针对象,而不是类型为 std::shared_ptr<A> 的对象。

这时候就需要用 std::shared_ptr 智能指针提供的 get 成员函数访问原始的资源:

std::shared_ptr<A> pA(createA());
getInfo(pA.get()); // 很好,将 pA 内的原始指针传递给 getInfo

智能指针「隐式」转换的方式,是通过指针取值操作符。

智能指针都重载了指针取值操作符(operator->和operator*),它们允许隐式转换至底部原始指针:

class A
{
public:
bool isExist() const;
...
}; A* createA(); // 工厂函数,创建指针对象 std::shared_ptr<A> pA(createA()); // 令 shared_ptr 管理对象资源 bool exist = pA->isExist(); // 经由 operator-> 访问资源
bool exist2 = (*pA).isExist(); // 经由 operator* 访问资源

多数设计良好的 classes 一样,它隐藏了程序员不需要看到的部分,但是有程序员需要的所有东西。

所以对于自身设计 RAII classes 我们也要提供一个「取得其所管理的资源」的办法。

小结 - 请记住

  • APIs 往往要求访问原始资源,所以每一个 RAII class 应该提供一个「取得其所管理的资源」的办法。
  • 对原始资源的访问可能经由显式转换或隐式转换。一般而言显式转换比较安全,但隐式转换比较方便。

细节 04:成对使用 new 和 delete

以下动作有什么错?

std::string* strArray = new std::string[];
...
delete strArray;

每件事情看起来都井然有序。使用了 new,也搭配了对应的 delete。但还是有某样东西完全错误。strArray 所含的 100 个 string 对象中的 99 个不太可能被适当删除,因为它们的析构函数很可能没有被调用。

当使用 new ,有两件事发生:

  • 内存被分配出来(通过名为 operator new 的函数)
  • 针对此内存会有一个或多个构造函数被调用

当使用 delete,也会有两件事情:

  • 针对此内存会有一个或多个析构函数被调用
  • 然后内存才被释放(通过名为 operator delete 的函数)

delete 的最大问题在于:即将被删除的内存之内究竟有多少对象?这个答案决定了需要执行多少个析构函数。

对象数组所用的内存通常还包括「数组大小」的记录,以便 delete 知道需要调用多少次析构函数。单一对象的内存则没有这笔记录。你可以把两者不同的内存布局想象如下,其中 n 是数组大小:

当你对着一个指针使用 delete,唯一能够让 delete 知道内存中是否存在一个「数组大小记录」的办法就是:由你告诉它。如果你使用 delete 时加上中括号[],delete 便认定指针指向一个数组,否则它便认定指针指向一个单一对象:

std::string* strArray = new std::string[];
std::string* strPtr = new std::strin;
...
delete [] strArray; // 删除一个对象
delete strPtr; // 删除一个由对象组成的数组

游戏规则很简单:

  • 如果你在 new 表达式中使用[],必须在相应的 delete 表达式也使用[]
  • 如果你在 new 表达式中不使用[],一定不要在相应的 delete 表达式使用[]

小结 - 请记住

  • 如果你在 new 表达式中使用[],必须在相应的 delete 表达式也使用[]。如果你在 new 表达式中不使用[],一定不要在相应的 delete 表达式使用[]。

细节 05:以独立语句将 newed (已被 new 的)对象置入智能指针

假设我们有个以下示范的函数:

int getNum();
void fun(std::shared_ptr<A> pA, int num);

现在考虑调用 fun:

fun(new A(), getNum());

它不能通过编译,因为 std::shared_ptr 构造函数需要一个原始指针,而且该构造函数是个 explicit 构造函数,无法进行隐式转换。如果写成这样就可以编译通过:

fun(std::shared_ptr<A>(new A), getNum());

令人想不到吧,上述调用却可能泄露资源。接下来我们来一步一步的分析为什么存在内存泄漏的可能性。

在进入 fun 函数之前,肯定会先执行各个实参。上述第二个实参只是单纯的对 getNum 函数的调用,但第一个实参 std::shared_ptr<A>(new A) 由两部分组成:

  • 执行 new A 表达式
  • 调用 std::shared_ptr 构造函数

于是在调用 fun 函数之前,先必须做以下三件事:

  • 调用 getNum 函数
  • 执行 new A 表达式
  • 调用 std::shared_ptr 构造函数

那么他们的执行次序是一定如上述那样的吗?可以确定的是 new A 一定比 std::shared_ptr 构造函数先被执行。但对 getNum 调用可以排在第一或第二或第三执行。

如果编译器选择以第二顺位执行它:

  1. 执行 new A 表达式
  2. 调用 getNum 函数
  3. 调用 std::shared_ptr 构造函数
    万一在调用 getNum 函数发生了异常,会发生什么事情?在此情况下 new A 返回的指针将不会置入 std::shared_ptr 智能指针里,就存在内存泄漏的现象

避免这类问题的办法很简单:使用分离语句

分别写出:

  1. 创建 A
  2. 将它置入一个智能指针内
  3. 然后再把智能指针传递给 fun 函数。
std::shared_ptr<A> pA(new A); // 先构造智能指针对象
fun(pA, getNum()); // 这个调用动作绝不至于造成泄漏。

以上的方式,就能避免原本由于次序导致内存泄漏发生。

小结 - 请记住

  • 以独立语句将 newed (已 new 过) 对象存储于智能指针内。如果不这样做,一旦异常被抛出,有可能导致难以察觉的资源泄漏。

最后

本文部分内容参考了《Effective C++ (第3版本)》第三章节内容,前两章节的内容可看旧文
学过 C++ 的你,不得不知的这 10 条细节!


关注公众号,后台回复「我要学习」,即可免费获取精心整理「服务器 Linux C/C++ 」成长路程(书籍资料 + 思维导图)

「C++ 」借来的资源,何如还的潇洒?的更多相关文章

  1. 「NOIP2012」「LuoguP1083」 借教室

    Description 在大学期间,经常需要租借教室.大到院系举办活动,小到学习小组自习讨论,都需要向学校申请借教室.教室的大小功能不同,借教室人的身份不同,借教室的手续也不一样. 面对海量租借教室的 ...

  2. 面试都在问的「微服务」「RPC」「服务治理」「下一代微服务」一文带你彻底搞懂!

    ❝ 文章每周持续更新,各位的「三连」是对我最大的肯定.可以微信搜索公众号「 后端技术学堂 」第一时间阅读(一般比博客早更新一到两篇) ❞ 单体式应用程序 与微服务相对的另一个概念是传统的「单体式应用程 ...

  3. 「C++」理解智能指针

    维基百科上面对于「智能指针」是这样描述的: 智能指针(英语:Smart pointer)是一种抽象的数据类型.在程序设计中,它通常是经由类型模板(class template)来实做,借由模板(tem ...

  4. jvm系列(十):如何优化Java GC「译」

    本文由CrowHawk翻译,是Java GC调优的经典佳作. 本文翻译自Sangmin Lee发表在Cubrid上的"Become a Java GC Expert"系列文章的第三 ...

  5. React + Node 单页应用「二」OAuth 2.0 授权认证 & GitHub 授权实践

    关于项目 项目地址 预览地址 记录最近做的一个 demo,前端使用 React,用 React Router 实现前端路由,Koa 2 搭建 API Server, 最后通过 Nginx 做请求转发. ...

  6. 一个「学渣」从零开始的Web前端自学之路

    从 13 年专科毕业开始,一路跌跌撞撞走了很多弯路,做过餐厅服务员,进过工厂干过流水线,做过客服,干过电话销售可以说经历相当的“丰富”. 最后的机缘巧合下,走上了前端开发之路,作为一个非计算机专业且低 ...

  7. spring cloud 入门,看一个微服务框架的「五脏六腑」

    Spring Cloud 是一个基于 Spring Boot 实现的微服务框架,它包含了实现微服务架构所需的各种组件. 注:Spring Boot 简单理解就是简化 Spring 项目的搭建.配置.组 ...

  8. 从 Spring Cloud 看一个微服务框架的「五脏六腑」

    原文:https://webfe.kujiale.com/spring-could-heart/ Spring Cloud 是一个基于 Spring Boot 实现的微服务框架,它包含了实现微服务架构 ...

  9. 「Python」socket指南

    开始 网络中的 Socket 和 Socket API 是用来跨网络的消息传送的,它提供了 进程间通信(IPC) 的一种形式.网络可以是逻辑的.本地的电脑网络,或者是可以物理连接到外网的网络,并且可以 ...

随机推荐

  1. WebGL简易教程(十五):加载gltf模型

    目录 1. 概述 2. 实例 2.1. 数据 2.2. 程序 2.2.1. 文件读取 2.2.2. glTF格式解析 2.2.3. 初始化顶点缓冲区 2.2.4. 其他 3. 结果 4. 参考 5. ...

  2. Linux网络文件共享服务之NFS

    一.NFS服务简介 NFS全称network file system 网络文件系统,基于内核的文件系统,有sun公司开发,通过使用NFS,用户和程序可以像访问本地文件一样访问远端系统上的文件,它基于r ...

  3. 返回数据中提取数据的方法(JSON数据取其中某一个值的方法)

    返回数据中提取数据的方法 比如下面的案例是,取店铺名称 接口返回数据如下: {"Code":0,"Msg":"ok","Data& ...

  4. kivy file import

    from kivy.app import Appfrom kivy.uix.boxlayout import BoxLayoutfrom kivy.properties import ObjectPr ...

  5. 爬虫之pyspider 安装

    解决方法: 利用wheel安装 S1: pip install wheelS2: 进入www.lfd.uci.edu/~gohlke/pythonlibs/,Ctrl + F查找pycurl S3:这 ...

  6. k8s概述

    k8s概述 概述 Kubernetes 使你在数以千计的电脑节点上运行软件时就像所有这些节点是单个大节点一样.它将底层基础设施抽象,这样做同时简化了应用的开发.部署, 以及对开发和运维团队的管理. K ...

  7. linux--->配置lamp环境(centos7 最小版)

    这篇博客写的很全,按照顺序敲代码即可 参考:https://www.cnblogs.com/me80/p/7218883.html

  8. Docker基础内容之镜像构建

    前言 Docker可以通过读取Dockerfile中的指令来自动构建图像.Dockerfile是一个文本文档,包含用户可以在命令行上调用的所有命令来组装一个图像.使用docker构建用户可以创建一个自 ...

  9. 缓存 ehcache

    只是用于自己记录,防止日后忘记,回看所用 第一步:配置ehcahe 缓存 <?xml version="1.0" encoding="UTF-8"?> ...

  10. Leetcode 题目整理-5 Valid Parentheses & Merge Two Sorted Lists

    20. Valid Parentheses Given a string containing just the characters '(', ')', '{', '}', '[' and ']', ...