跳转到内容

C++ 编程/内存管理

来自维基教科书,开放的书本,开放的世界

内存管理是一个庞大的主题,C++ 提供了多种选择来管理内存(以及其他资源,但我们最初将重点放在内存上)。

好消息是,现代 C++ 使得大多数情况下内存管理变得简单明了,同时为那些需要偏离常规路径的人提供全面的工具。我们将涵盖高级方法(通常更可取),并详细介绍低级方面,例如使用 new/delete/new[]/delete[],这些方面通常最好隐藏在实现高级模式的类内部。

垃圾回收和 RAII

[编辑 | 编辑源代码]

垃圾回收(GC)处理动态内存的管理,具有不同程度的自动化,其中称为收集器的构造试图回收垃圾(由应用程序对象使用但永远不会被访问或修改的内存)。这通常被认为是近些年语言的一个重要特性,特别是如果它们禁止手动内存管理,因为手动内存管理很容易出错,因此需要程序员具有很高的经验。由于内存管理而导致的错误主要会导致不稳定和崩溃,这些错误只会在运行时才被发现,这使得它们难以检测和纠正。

C++ 可选择支持垃圾回收,并且某些实现包括垃圾回收(通常基于所谓的 Boehm 收集器)。C++ 标准定义了该语言的实现及其底层平台,但允许包含扩展。例如,Sun 的 C++ 编译器产品确实包含libgc库(一个保守的垃圾收集器)。

与许多高级语言不同,C++ 并不强制使用垃圾回收,并且主流 C++ 内存管理习惯用法并不假定使用传统的自动垃圾回收。C++ 中最常见的垃圾回收方法是使用奇怪命名的习语“RAII”,它代表“资源获取即初始化”,此习语在本书的 RAII 部分 中介绍。RAII 的核心思想是,无论是在初始化时获取还是在其他时间获取,资源都由一个对象拥有,并且该对象的析构函数将在适当的时间自动释放该资源。这使得 C++ 通过 RAII 支持对资源的确定性清理,因为用于释放内存的相同方法也可以用于释放其他资源(文件句柄、互斥锁、数据库连接、事务以及更多)。

在没有默认垃圾回收的情况下,RAII 是一种可靠的方法,可以确保即使在可能导致异常抛出的代码中也不发生资源泄漏。它可以说比 Java 和类似语言中的finally 结构更好;当一个类拥有一个资源时,Java 要求该类的每个用户都将它的使用封装在 try/finally 块中。在 C++ 中,类提供了一个析构函数,该类的用户无需执行任何操作,只需确保在他们完成使用对象后销毁该对象(这通常不需要任何工作,例如,在对象是局部变量或另一个对象的成员变量的情况下)。

对于常见应用程序,适当的类已经编写完成:std::string 和 std::vector(以及其他标准容器,例如 std::map 和 std::list)涵盖了内存管理的许多简单情况。

与 C 的内存管理比较

[编辑 | 编辑源代码]

许多从 C 转向 C++ 的程序员习惯于进行手动内存管理,特别是对于字符串操作。

以下是对具有类似功能的 C 程序和 C++ 程序的简单比较。这两个示例都省略了错误处理,这些处理将出现在实际代码中。

首先,C 代码(使用 C99,但可以轻松更改为与 C90 兼容)

#include <stdio.h>  // for puts, getchar, stdin
#include <stdlib.h> // for malloc and free

char *getstr(int minlen, int inc) // minlen - Minimum length, inc - Increment of length
{
  int index;
  int ch;
  char *str = malloc(minlen);

  for (index = 0; (ch = getchar()) != EOF && ch != '\n'; index++)
  {
    if (index >= minlen - 1)
    {
      minlen += inc;
      str = realloc(str, minlen);
    }
    str[index] = (char)ch;
  }
  str[index] = 0;  // mark end of string
  return str;
}

int main()
{
  char* name;
  puts("Please enter your full name: ");
  name = getstr(10, 10);  // 10, 10 are arbitrary
  printf("Hello %s\n", name);
  free(name);
  return 0;
}

为了比较,C++ 代码

#include <string>   // for std::string and std::getline
#include <iostream> // for std::cin and std::cout

int main() {
  std::string name;
  std::cout << "Please enter your full name: ";
  std::getline(std::cin, name);
  std::cout << "Hello " << name << '\n';
  return 0;
}

C++ 版本更短,不包含任何明确的代码来计算要分配的内存量、分配或释放内存,也不需要了解 'getstr()' 的实现细节;标准字符串类负责所有这些工作。C++ 版本还捕获内存分配失败,而上面显示的 C 版本需要在 realloc 的结果上进行额外的检查,以便在内存不足的情况下保持安全。

用于内存管理的智能指针

[编辑 | 编辑源代码]

虽然智能指针在 C++ 中有比简单内存管理更多的用途,但它们通常是用于管理其他动态分配对象的生存期的有效方法。

智能指针类型被定义为任何重载 operator->operator*operator->* 的类类型。需要注意的一点是,“智能指针”从某种意义上来说,实际上并不是指针——但重载这些运算符允许智能指针的行为非常类似于内置指针,并且可以使用许多与“真实”指针和智能指针都可以工作的代码。

std::auto_ptr
[编辑 | 编辑源代码]

2003 年 C++ 标准中包含的唯一智能指针类型是 std::auto_ptr。虽然它有一定的用途,但它并不是最优雅或最强大的智能指针设计。

提供以下功能:

  • 模拟实际动态分配对象的局部变量或成员变量的生存期
  • 提供一种机制,用于将对象的“所有权”从一个所有者转移到另一个所有者。
简单的 auto_ptr 示例
#include <memory> // for std::auto_ptr
#include <iostream>

class Simple {
public:
    std::auto_ptr<int> theInt;

    Simple() : theInt(new int()) {
        *theInt = 3; //get object like normal pointer
    }
    int f() {
        return 42;
    }   
    
    // when this class is destroyed, theInt will
    // automatically be freed
};

int main() {
    std::auto_ptr<Simple> simple(new Simple());
    // note that the following won't work:
    // std::auto_ptr<Simple> simple = new Simple();
    // as auto_ptr can only be constructed with new values

    // access member functions like normal pointers
    std::cout << simple->f(); 

    // the Simple object is freed when simple goes out of scope
    return 0;
}

auto_ptr 中的 = 运算符的工作方式不同于普通方式。它所做的是将所有权从 rhs(右侧)auto_ptr 转移到 lhs(左侧)auto_ptr。rhs 指针将指向 NULL,它指向的对象将被释放。

例如
#include <memory>
#include <iostream>
int main() {
    std::auto_ptr<int> a(new int(3));
    // a.get() returns the raw pointer of a
    std::cout << "a loc: " << a.get() << '\n';
    std::cout << "a val: " << *a << '\n';

    std::auto_ptr<int> b;

    b = a; // now b points to the int, a is null

    std::cout << "b loc: " << b.get() << '\n';
    std::cout << "b val: " << *b << '\n';
    std::cout << "a loc: " << a.get() << '\n'; 

    return 0;
}
输出(示例)
a loc: 0x3d5ef8
a val: 3
b loc: 0x3d5ef8
b val: 3
a loc: 0

有时,一个对象永远不会被释放可能并不明显。考虑以下示例

内存泄漏
#include <memory>
#include <iostream>

class Sample {
public:
    int value;

    Sample(): value(42) {
        std::cout << "The object is allocated.\n";
    }
    ~Sample() {
        std::cout << "The object is going to be deallocated.\n";
    }
};

int main() {
    // the object is allocated on the heap
    // but cannot be deallocated
    // since there's no pointer to it
    std::cout << (new Sample)->value << "\n";
    // destructor ~Sample is never called
}
输出
The sample class is allocated.
42

可以使用 auto_ptr 修复内存泄漏

// the rest of the code stays the same
int main() {
    std::cout << (std::auto_ptr<Sample>(new Sample))->value << "\n";
}

注意,有时可以在堆栈上分配一个对象,避免这样的问题。

总而言之,auto_ptr 的行为在希望只有一个指针始终指向特定对象,但指向它的指针可能会更改时很有用。如果需要不同的行为,使用 Boost 指针之一是一个更好的选择。


Clipboard

待办事项

  • 提供 auto_ptr 同样用途的示例。(已部分完成)
  • 警告不要在类中的成员变量中天真地使用 auto_ptr。包括一个不安全使用的示例,并展示如何使其安全。可能需要参考三法则(在这种情况下,我们需要对复制操作做一些特别的事情,但不需要对析构函数做任何特别的事情)。
  • 注意,auto_ptr 不能与不完整类型一起使用,因此它对于 pimpl 等没有用。
  • 注意,auto_ptr 转移所有权的方式相当奇怪,而且广受诟病,但只要小心使用,它仍然可以有效地使用。


Boost 智能指针
[编辑 | 编辑源代码]

Boost c++ 库包含 5 种不同类型的智能指针,这些指针连同 std::auto_ptr,几乎可以在所有内存管理情况下使用。此外,Boost 中的一些智能指针将在发布的 C++0x 版本中成为标准库的一部分。

Boost 和 std 智能指针
指针 使用情况 性能成本 所有权转移 共享对象 适用于 其他
std::auto_ptr 在给定时间,一个对象只能由一个 auto_ptr 拥有,但该所有者可以更改 nil 单实例 不适用于标准容器(std::vector 等)
boost::scoped_ptr 如果将一个对象分配给scoped_ptr,它就永远无法分配给另一个指针。 单实例 如果用作类的成员,则必须在构造函数中分配。此外,它不适用于标准容器(例如 std::vector)。
boost::shared_ptr 多个 shared_ptr 可以指向同一个对象,当所有 shared_ptr 都超出范围时,该对象将被销毁。 是的,它使用引用计数。 单实例 适用于标准容器。
boost::weak_ptr 与 shared_ptr 一起使用,以打破可能导致内存泄漏的循环。要使用,必须将其转换为 shared_ptr。 与 shared_ptr 相同。 单实例 仅与 shared_ptr 一起使用。
boost::scoped_array 与 scoped_ptr 相同,但适用于数组。 实例数组 参见 scoped_ptr。
boost::shared_array 与 shared_ptr 相同,但适用于数组。 是的,它使用引用计数。 实例数组 参见 shared_ptr。
boost::intrusive_ptr 用于为具有自己引用计数的对象创建自定义智能指针。 取决于实现。 是的 单个实例 在大多数情况下,应使用 shared_ptr 代替它。
创建您自己的智能指针类型
[编辑 | 编辑源代码]

使用智能指针的原因之一是为了避免内存泄漏。为了避免这种情况,我们应该避免手动管理堆基内存。因此,我们必须找到一个容器,当我们不再使用它时,它可以自动将内存返回给操作系统。的析构函数可以满足这一要求。

当然,我们需要在一个基本智能指针中存储的是分配内存的地址。为此,我们可以简单地使用一个指针。假设我们正在设计一个用于存储int内存片的智能指针。

class smt_ptr
{
  private:
    int* ptr;
};

为了确保每个用户在初始化时都将一个地址放入该智能指针中,我们必须指定构造函数来接受一个带有目标地址作为参数的智能指针声明,而不是智能指针本身的“简单声明”。

class smt_ptr
{
  public:
    explicit smt_ptr(int* pointer) : ptr(pointer) { }

  private:
    int* ptr;
};

现在,我们必须指定该类在该智能指针的实例析构时“删除”该指针。

class smt_ptr
{
  public:
    explicit smt_ptr(int* pointer) : ptr(pointer) { }
    ~smt_ptr() { delete ptr; }

  private:
    int* ptr;
};

我们必须允许用户访问存储在该智能指针中的数据,并使其更像“指针”。为此,我们可以添加一个函数来提供对原始指针的访问,并重载一些运算符,例如operator*operator->,使其行为像一个真正的指针。

class smt_ptr
{
  public:
    explicit smt_ptr(int* pointer) : ptr(pointer) { }
    ~smt_ptr() { delete ptr; }
    int* get() const { return ptr; }        // Declares these functions const to indicate that
    int* operator->() const { return ptr; } // there is no modification to the data members.
    int& operator*() const { return *ptr; }

  private:
    int* ptr;
};

实际上,我们已经完成了基本部分,它已经可以使用了,但是,为了使这个“自制”的智能指针与其他数据类型和类一起使用,我们必须将其转换为类模板。

template<typename T>
class smt_ptr
{
  public:
    explicit smt_ptr(T* pointer) : ptr(pointer) { }
    ~smt_ptr() { delete ptr; }
    T* get() const { return ptr; }
    T* operator->() const { return ptr; }
    T& operator*() const { return *ptr; }

  private:
    T* ptr;
};

这个实现非常基本,只提供基本功能,并且存在很多严重的问题,例如复制这个智能指针会导致双重删除,但我们在这里不讨论这些问题。


Clipboard

待办事项
举一个简单的例子,也许是一个 scoped_ptr 的基本版本。


其他智能指针
[编辑 | 编辑源代码]

除了 auto_ptr 之外,还有许多其他智能指针,可以用于完成从包装 COM 对象到提供多线程访问的自动同步或提供数据库接口的事务管理等任务。

Boost 库是许多这些智能指针的一个很好的存储库;Boost 中的一些智能指针包含在 C++ 委员会的“TR1”中,这是一组与标准 C++ 集成良好的库组件。


Clipboard

待办事项

  • 提及其他智能指针来源。
  • 添加 c++11 指针信息,例如 unique_ptr、shared_ptr 等。


使用 new、delete 等进行手动内存管理

[编辑 | 编辑源代码]

现代 C++ 代码往往很少使用new,也很少使用delete。从内存的角度来看,它的缺点是“new”从堆中分配内存,而局部对象从栈中分配内存。堆分配时间比栈分配时间慢得多。但是,仍然有一些情况下需要这样做,并且深入了解这些低级功能的工作原理有助于理解通常在“幕后”发生的事情。甚至在某些情况下,newdelete 的级别太高,我们需要降级到mallocfree——但这些情况的确是罕见的例外。

newdelete 的基本思想很简单:new 创建一个给定类型的对象,并返回指向它的指针,而 delete 销毁由 new 创建的对象,并返回指向它的指针。newdelete 在语言中存在的原因是,代码在编译时通常不知道它将在运行时需要创建哪些对象,或者需要创建多少个对象。因此,newdelete 表达式允许对对象进行“动态”分配。

示例
int main() {
  int * p = new int(3);
  int * q = p;
  delete q; // the same as delete p
  return 0;
}

不幸的是,很难用几行代码编写一个现实的例子;只有在更简单的方法不起作用时,才需要动态分配,例如因为一个对象需要超出函数的范围,或者因为它使用的内存太多,以至于我们只想按需创建它。


Clipboard

待办事项
将下面的段落(比较 new/delete 和 malloc/free)放在某种侧边栏中或以其他方式与主文本隔开会很好,因为它主要针对具有 C 背景的人。我不知道使用什么好的标记来实现这一点。


对于熟悉 C 编程语言的读者来说,newmalloc的一种“类型感知”版本:表达式“new int”的类型是“int*”。因此,在 C++ 中,需要进行强制转换才能编写int * p = reinterpret_cast<int *>(malloc(sizeof *p));,而在使用new时不需要进行强制转换。因为 new 是类型感知的,所以它还可以初始化新创建的对象,如果需要的话,调用构造函数。上面的示例使用了这种能力来初始化创建的int,使其值为 3。与mallocfree相比,newdelete的另一个改进是,C++ 标准提供了一种标准方法来更改newdelete分配内存的方式;在 C 中,这通常是通过一种称为“插桩”的非标准技术来实现的。

注意
在指向它的指针(智能指针除外)超出范围之前,必须释放所有动态分配的内存。因此,如果内存是在函数中的一个变量中动态分配的,则应该在函数中释放该内存,除非将指向该内存的指针返回或由该函数存储。


Clipboard

待办事项

  • 使用一个简单的非泛型侵入式链表作为 new/delete 使用的更现实的示例。
  • 谈谈删除后是否将指针设置为 NULL 的背后考虑因素;讨论为什么 delete 不能这样做。请注意,指针的值在传递给 delete 之后不能再使用。


基本的newdelete运算符旨在一次只分配一个对象;它们由new[]delete[]补充,用于动态分配整个数组。new[]delete[]的使用比基本的 new 和 delete 还要少见;通常,std::vector 是管理动态分配数组的更方便的方法。

请注意,当您动态分配一个对象数组时,您必须在释放时编写delete[],而不是简单的delete。编译器通常无法在您出错时给出错误;最有可能的是您的代码在运行时会崩溃。

注意
请记住,不要尝试使用 C free 函数释放使用new分配的内存,也不要使用delete释放使用 C malloc 函数分配的内存。这可能会导致数据结构损坏和意外结果。

当调用delete[]时,它首先检索由new[]存储的信息,这些信息描述了动态分配的数组中存在多少个元素,然后在释放内存之前调用每个元素的析构函数。分配的内存块的实际地址可能与由new[]返回的值不同,以便留出空间来存储元素数量;这是意外混淆new[]的数组形式与delete的单元素形式可能导致崩溃的原因之一。

注意
仅出于历史原因:最初,在使用delete[]时,需要在[]中指定数组中的元素数量,例如delete [number_of_elements] pointer_to_first_element;,但经验表明这太容易出错,因此new[]发生了改变,以便记录分配的元素数量,以便以后可以由调用delete[]自动检索。现在,C++ 语法不允许显式指定元素数量。

特别敏锐的读者可能会想知道是否有可能消除记住使用哪一个new/new[]delete/delete[]的需要,而是让编译器来弄清楚。答案是可以的,但这样做会给每个单对象分配增加开销(因为 delete 需要能够确定分配是用于单个对象还是数组),而 C++ 的一个设计原则一直是“不使用的东西就不必为它付费”,因此所做的权衡是使单对象分配保持高效,但用户在使用这些低级功能时必须小心。


Clipboard

待办事项
记录operator newoperator delete,并尝试解释术语混淆。谈论“new 表达式”很清楚,但“new 运算符”这个短语可能模棱两可,并且经常被误解为“operator new”。


常见错误

[编辑 | 编辑源代码]
typedef 的使用
[编辑 | 编辑源代码]

阅读以下(有错误的)代码。

...
typedef char CStr[100];
...
void foo()
{
  ...
  char* a_string = new CStr;
  ...
  delete a_string;
  return;
}

上面的代码会导致资源泄漏(或者在某些情况下导致崩溃)。常见的错误是使用 **delete** 释放一块内存数组,而不是使用 "array delete"(即 **delete[]**)。在这种情况下,**typedef** 会让人误以为 "a_string" 是一个指向一块内存的指针,这块内存足够容纳一个 "**char**" 变量,但不足以容纳一个内存数组。错误地执行 **delete**,而不是 **delete[]**,只会释放分配给数组第一个元素的内存,而会让另外 99 个 "**char**" 元素的内存泄漏。在这种情况下,只会泄漏 99 字节,但是当数组用来存放包含很多非静态数据成员的复杂类时,就会泄漏数兆字节的内存。此外,当包含此错误的同一程序再次运行时,将会泄漏另一块内存。

因此,上面的代码

delete a_string;

应该更正为

delete[] a_string;

或者,更理想的是,使用像 std::string 这样的字符串类,而不是隐藏在 **typedef** 后面的普通数组。


Clipboard

待办事项

  • 记录常见的错误以及症状。双重释放,内存损坏。还要注意,有时调用 new 或 delete 时发生的崩溃是由代码其他地方的内存损坏造成的,并提及可以使用工具来帮助解决这些问题。举例说明此类工具,包括开源工具和商业工具。
  • 可以考虑提及 new[] 在元素具有平凡析构函数的情况下不需要记住元素数量的可能优化,这也解释了为什么不能从用户代码中调用函数来查找分配的元素数量;它可能没有存储在任何地方。或者,这对于本书来说可能过于详细。您的意见如何?
华夏公益教科书