Skip to content

Latest commit

 

History

History
923 lines (678 loc) · 49.3 KB

File metadata and controls

923 lines (678 loc) · 49.3 KB

十三、动态对象创建

有时您知道程序中对象的确切数量、类型和生命周期,但并不总是如此。一个空中交通系统需要处理多少架飞机?一个 CAD 系统会使用多少个形状?一个网络中有多少个节点?

要解决一般的编程问题,您必须能够在运行时创建和销毁对象。当然,C 一直提供动态内存分配函数malloc()free() (以及malloc()的变体),它们在运行时从(也称为自由存储 )中分配存储。

然而,这在 C++ 中根本行不通。构造器不允许你给它传递内存的地址来初始化,这是有原因的。如果可以做到这一点,您可以执行以下一项或多项操作:

  1. 忘记吧。那么 C++ 中有保证的对象初始化就不能得到保证。
  2. 在初始化对象之前,意外地对它做了一些事情,期望正确的事情发生(类似于在汽车中错误地移动点火钥匙并被卡住)。
  3. 给它一个错误大小的物体(类似于试图用摩托车的点火钥匙启动汽车)。

当然,即使你做了所有正确的事情,任何修改你的程序的人也容易犯同样的错误。不正确的初始化是造成大部分编程问题的原因,因此保证构造器调用在堆上创建的对象尤为重要。

那么 C++ 如何保证正确的初始化和清理,而also却允许你在堆上动态创建对象呢?

答案是将动态对象创建引入语言的核心。malloc()free()是库函数,因此不受编译器的控制。然而,如果有一个操作符来执行动态存储分配和初始化的组合动作,有和另一个操作符来执行清理和释放存储的组合动作,编译器仍然可以保证为所有对象调用构造器和析构函数。

在本章中,你将学习 C++ 的newdelete如何通过在堆上安全地创建对象来优雅地解决这个问题。

对象创建

创建 C++ 对象时,会发生两个事件。

  1. 为该对象分配存储空间。
  2. 调用构造器来初始化 即存储。

现在你应该明白第二步总是发生。C++ 强制执行它,因为未初始化的对象是程序错误的主要来源。在哪里或者如何创建对象并不重要——总是调用构造器。

然而,步骤 1 可以以几种方式发生,或者在交替的时间发生。

  1. 在程序开始之前,可以在静态存储区中分配存储空间。这种存储存在于程序的整个生命周期中。
  2. 每当到达一个特定的执行点(左大括号),就可以在堆栈上创建存储。该存储在互补执行点(右大括号)自动释放。这些堆栈分配操作内置于处理器的指令集中,非常有效。然而,当你写程序时,你必须准确地知道你需要多少个变量,这样编译器才能生成正确的代码。
  3. 可以从称为堆(也称为自由存储)的内存池中分配存储。这叫做动态内存分配 。为了分配这个内存,在运行时调用一个函数;这意味着你可以在任何时候决定你需要多少内存。您还负责决定何时释放内存,这意味着该内存的生命周期可以根据您的选择而定;它不是由范围决定的。

这三个区域通常被放在一个连续的物理内存中:静态区域、堆栈和堆(按照编译器编写器确定的顺序)。然而,没有规则。堆栈可能在一个特殊的位置,堆可以通过从操作系统调用内存块来实现。作为一名程序员,这些事情通常对你是屏蔽的,所以你需要考虑的是当你调用它时内存就在那里。

c 对堆的处理方法

为了在运行时动态分配内存,C 在其标准库中提供了函数:malloc()及其变体calloc()realloc()从堆中产生内存,以及free()将内存释放回堆中。这些功能很实用,但是很原始,需要程序员的理解和关注。要使用 C 的动态内存函数在堆上创建一个类的实例,你必须做类似于清单 13-1 中的事情。

清单 13-1 。带有类对象的 malloc()

//: C13:MallocClass.cpp
// Malloc with class objects
// What you'd have to do if not for "new"
#include "../require.h"       // To be INCLUDED from *[Chapter 9](09.html)*
#include <cstdlib>            // malloc() & free()
#include <cstring>            // memset()
#include <iostream>
using namespace std;

classObj {
  int i, j, k;
  enum { sz = 100 };
  charbuf[sz];
public:
  void initialize() {         // Can't use constructor
    cout << "initializing Obj" << endl;
    i = j = k = 0;
    memset(buf, 0, sz);
  }
  void destroy() const { // Can't use destructor
    cout << "destroying Obj" << endl;
  }
};

int main() {
  Obj *obj = (Obj*)malloc(sizeof(Obj));
  require(obj != 0);
  obj->initialize();
  // ... sometime later:
  obj->destroy();
  free(obj);
} ///:∼

您可以看到使用malloc()为行中的对象创建存储:

Obj* obj = (Obj*)malloc(sizeof(Obj));

这里,用户必须确定对象的大小(一个错误的位置)。malloc()返回一个void*,因为它只是产生一个内存的补丁,而不是一个对象。C++ 不允许将void*赋给任何其他指针,所以必须进行强制转换。

因为malloc()可能找不到任何内存(在这种情况下,它返回零),所以您必须检查返回的指针以确保它成功。

但最糟糕的问题是这条线:

Obj->initialize();

如果用户正确地做到了这一步,他们必须记住在使用对象之前初始化它。请注意,没有使用构造器,因为无法显式调用该构造器;当一个对象被创建时,编译器会为你调用它。这里的问题是,用户现在可以选择在使用对象之前忘记执行初始化,从而重新引入了一个主要的错误来源。

还发现很多程序员似乎觉得 C 的动态内存函数太混乱太复杂;使用虚拟内存机器的 C 程序员在静态存储区域分配巨大的变量数组,以避免考虑动态内存分配,这种情况并不少见。因为 C++ 试图让普通程序员安全、轻松地使用库,所以 C 的动态内存方法是不可接受的。

操作员新增

C++ 中的解决方案是将创建一个对象所需的所有操作合并到一个名为new的操作符中。当你用new(使用新表达式)创建一个对象时,它在堆上分配足够的存储空间来容纳该对象,并调用该存储空间的构造器。因此,如果你说

MyType *fp = new MyType(1,2);

在运行时,调用相当于malloc(sizeof(MyType))的函数(通常,它实际上是对malloc()的调用),调用MyType的构造器,将结果地址作为this指针,使用(1, 2)作为参数列表。当指针被分配给fp时,它是一个活动的、初始化的对象;在那之前你连手都拿不到。它也是自动正确的MyType类型,所以没有铸造是必要的。

默认的new在将地址传递给构造器之前检查以确保内存分配成功,因此您不必显式地确定调用是否成功。在这一章的后面,你会发现如果没有记忆会发生什么。

您可以使用任何可用于该类的构造器来创建 new-expression。如果构造器没有参数,则编写不带构造器参数列表的 new-expression,如下所示:

MyType *fp = new MyType;

注意在堆上创建对象的过程变得多么简单——一个表达式,内置了所有的大小调整、转换和安全检查。在堆上创建一个对象和在栈上一样容易。

操作员删除

new-expression 的补充是 delete-expression ,它首先调用析构函数,然后释放内存(通常调用free())。正如 new-expression 返回指向对象的指针一样,delete-expression 需要对象的地址,如下所示:

delete fp;

这将析构并释放先前创建的动态分配的MyType对象的存储空间。

只能为由new创建的对象调用delete。如果你malloc()(或calloc()realloc())一个对象,然后delete它,行为是未定义的。因为newdelete的大多数默认实现都使用malloc()free(),所以您可能最终会在不调用析构函数的情况下释放内存。

如果你删除的指针是零,什么都不会发生。出于这个原因,人们经常建议在删除后立即将指针设置为零,以防止删除两次。多次删除一个对象绝对不是一件好事,而且会引发问题。清单 13-2 显示了初始化的发生。

清单 13-2 。说明新建和删除

//: C13:Tree.h
#ifndef TREE_H
#define TREE_H
#include <iostream>

class Tree {
  int height;
public:
  Tree(int treeHeight) : height(treeHeight) {}
  ∼Tree() { std::cout << "*"; }
  Friend std::ostream&
  operator<<(std::ostream &os, const Tree* t) {
    return os << "Tree height is: "
              << t->height << std::endl;
  }
};
#endif            // TREE_H ///:∼

//: C13:NewAndDelete.cpp
// Simple demo of new & delete
#include "Tree.h" // Header FILE to be INCLUDED from above
using namespace std;

int main() {
  Tree *t = new Tree(40);
  cout << t;
  delete t;
} ///:∼

您可以通过打印出Tree的值来证明构造器被调用。在这里,这是通过重载operator<<来使用ostreamTree*来完成的。但是,请注意,即使函数被声明为friend,它也被定义为内联函数!这仅仅是一种便利;将friend函数定义为类的内联函数不会改变friend的状态,也不会改变它是一个全局函数而不是类成员函数的事实。还要注意,返回值是整个输出表达式的结果,这是一个ostream& ( ,它必须满足函数的返回值类型)。

内存管理器开销

当您在堆栈上创建自动对象时,对象的大小和它们的生命周期就内置在生成的代码中,因为编译器知道确切的类型、数量和范围。在堆上创建对象会带来额外的时间和空间开销。下面是一个典型的场景。

image 你可以用calloc()或者realloc()代替malloc()

您调用malloc(),它从池中请求一块内存。(这段代码实际上可能是malloc()的一部分。)

在池中搜索足够大的内存块来满足请求。这是通过检查某种地图或目录来完成的,该地图或目录显示哪些块当前正在使用,哪些块是可用的。这是一个快速的过程,但它可能需要多次尝试,所以它可能不是确定性的——也就是说,你不一定能指望malloc()总是花费完全相同的时间。

在返回指向该块的指针之前,必须记录该块的大小和位置,这样对malloc()的进一步调用将不会使用它,并且当您调用free()时,系统知道要释放多少内存。

所有这一切的实现方式可以千差万别。例如,没有什么可以阻止在处理器中实现内存分配原语。如果你很好奇,你可以写测试程序来猜测你的malloc()是如何实现的。你也可以阅读库源代码,如果你有的话(GNU C 源代码总是可用的)。

重新设计的早期示例

使用newdelete,本书前面介绍的Stash例子可以用本书到目前为止讨论的所有特性重写。研究新代码还会给你一个有用的主题回顾。

在书中的这一点上,StashStack类都不会“拥有”它们所指向的对象;也就是说,当StashStack对象超出范围时,它不会为它所指向的所有对象调用delete。这是不可能的,因为为了更通用,它们持有void指针。如果你delete一个void指针,唯一发生的事情就是内存被释放,因为没有类型信息,编译器也没有办法知道调用什么析构函数。

删除 void*大概是一个 Bug

值得指出的是,如果你为一个void*调用delete,它几乎肯定会成为你程序中的一个 bug,除非那个指针的目的地非常简单;特别是,它不应该有析构函数。清单 13-3 显示了会发生什么。

清单 13-3 。示出了不良空指针删除的情况

//: C13:BadVoidPointerDeletion.cpp
// Deleting void pointers can cause memory leaks
#include <iostream>
using namespace std;

class Object {
  void *data;      // Some storage
  const int size;
  const char id;

public:

  Object(int sz, char c) : size(sz), id(c) {
    data = new char[size];
    cout << "Constructing object " << id
         << ", size = " << size << endl;
  }

  ∼Object() {
    cout << "Destructing object " << id << endl;
    delete []data; // OK, just releases storage,
    // no destructor calls are necessary
  }
};

int main() {
  Object* a = new Object(40, 'a');
  delete a;
  void* b = new Object(40, 'b');
  delete b;
} ///:∼

Object包含一个被初始化为“原始”数据的void*(它不指向有析构函数的对象)。在Object析构函数中,调用delete来调用这个void*没有任何负面影响,因为你唯一需要做的事情就是释放存储空间。

然而,在main()中,你可以看到delete知道它正在处理什么类型的对象是非常必要的。以下是输出结果:

Constructing object a, size = 40
Destructing object a
Constructing object b, size = 40

因为delete a知道a指向一个Object,析构函数被调用,因此分配给data的存储空间被释放。然而,如果你像在delete b的情况下一样通过void*操作一个对象,唯一发生的事情是Object的存储被释放——但是析构函数没有被调用,所以没有释放data指向的内存。当这个程序编译时,你可能看不到任何警告信息;编译器假设你知道你在做什么。所以你得到一个非常安静的内存泄漏。

如果你的程序有内存泄漏,搜索所有的delete语句并检查被删除的指针的类型。如果是一个void*,那么您可能已经找到了内存泄漏的一个来源。

image 注意然而,C++ 为内存泄漏提供了大量的其他机会。

带指针的清理责任

为了使StashStack容器灵活(能够容纳任何类型的对象),它们将容纳void指针。这意味着当一个指针从StashStack对象返回时,你必须在使用它之前将它转换成合适的类型;如前所述,在删除它之前,还必须将它转换为正确的类型,否则会出现内存泄漏。

另一个内存泄漏问题与确保容器中的每个对象指针都被真正调用有关。容器不能“拥有”指针,因为它把它作为一个void*持有,因此不能执行适当的清理。使用者必须负责清理物品。如果将指向在堆栈上创建的对象和在堆上创建的对象的指针添加到同一个容器中,就会产生严重的问题,因为删除表达式对于没有在堆上分配的指针来说是不安全的。()当你从容器中取回一个指针时,你怎么知道它的对象被分配到了哪里?)因此,您必须确保存储在以下版本的StashStack中的对象只能在堆上创建,要么通过精心编程,要么通过创建只能在堆上构建的类。

确保客户端程序员负责清理容器中的所有指针也很重要。在前面的例子中,您已经看到了Stack类如何在其析构函数中检查所有的Link对象是否已经被弹出。然而,对于指针的Stash,需要另一种方法。

存放指针

这个新版本的Stash类叫做PStash,拥有指针指向在堆中独立存在的对象,而之前章节中的旧Stash通过值将对象复制到Stash容器中。使用newdelete,保存指向已经在堆上创建的对象的指针既容易又安全。清单 13-4 包含了“指针Stash的头文件

清单 13-4 。“指针存储”的头文件

//: C13:PStash.h
// Holds pointers instead of objects
#ifndef PSTASH_H
#define PSTASH_H

class PStash {
  int quantity; // Number of storage spaces
  int next;     // Next empty space
   // Pointer storage:
  void** storage;
  void inflate(int increase);
public:
  PStash() : quantity(0), storage(0), next(0) {}
  ∼PStash();
  int add(void* element);
  void* operator[](int index) const; // Fetch
  // Remove the reference from this PStash:
  void* remove(int index);
  // Number of elements in Stash:
  int count() const { return next; }
};
#endif                               // PSTASH_H ///:∼

底层数据元素非常相似,但是现在storage是一个由void指针组成的数组,并且该数组的存储分配是通过new而不是malloc()来执行的。在表达式中

void** st = new void*[quantity + increase];

分配的对象类型是一个void*,所以表达式分配了一个void指针数组。

析构函数删除保存void指针的存储,而不是试图删除它们所指向的内容(如前所述,这将释放它们的存储并且不调用析构函数,因为void指针没有类型信息)。

另一个变化是用operator[ ]替换了fetch()函数,这在语法上更有意义。然而,还是会返回一个void*,所以用户必须记住哪些类型存储在容器中,并在取出它们时转换指针(这个问题将在后面的章节中讨论)。

清单 13-5 显示了成员函数的定义。

清单 13-5 。“指针存储”的实现

//: C13:PStash.cpp {O}
// Pointer Stash definitions
#include "PStash.h"                  // To be INCLUDED from above
#include "../require.h"
#include <iostream>
#include <cstring>                   // 'mem' functions
using namespace std;

int PStash::add(void* element) {
  const int inflateSize = 10;
  if(next >= quantity)
    inflate(inflateSize);
  storage[next++] = element;
  return(next - 1);                  // Index number
}

// No ownership:
PStash::∼PStash() {
  for(int i = 0; i < next; i++)
    require(storage[i] == 0,
      "PStash not cleaned up");
  delete []storage;
}

// Operator overloading replacement for fetch
void* PStash::operator[](int index) const {
  require(index >= 0,
    "PStash::operator[] index negative");
  if(index >= next)
  return 0;          // To indicate the end
  // Produce pointer to desired element:
  return storage[index];
}

void* PStash::remove(int index) {
void* v = operator[](index);
  // "Remove" the pointer:
  if(v != 0) storage[index] = 0;
  return v;
}

void PStash::inflate(int increase) {
  const int psz = sizeof(void*);
  void** st = new void*[quantity + increase];
  memset(st, 0, (quantity + increase) * psz);
  memcpy(st, storage, quantity * psz);
  quantity += increase;
  delete []storage; // Old storage
  storage = st;     // Point to new memory
} ///:∼

除了存储一个指针而不是整个对象的副本之外,add()函数实际上和以前一样。

修改了inflate()代码来处理void*数组的分配,而不是之前的设计,之前的设计只处理原始字节。在这里,标准的 C 库函数memset()首先用于将所有新的内存设置为零,而不是使用之前的通过数组索引进行复制的方法(这并不是绝对必要的,因为 PStash 大概是正确地管理所有的内存——但是多一点额外的关心通常不会有什么坏处)。然后memcpy()将现有数据从旧位置移动到新位置。通常,像memset()memcpy()这样的函数已经随着时间的推移进行了优化,所以它们可能比前面显示的循环更快。但是对于像inflate()这样可能不会经常使用的函数,您可能看不到性能差异。然而,函数调用比循环更简洁的事实可能有助于防止编码错误。

为了将对象清理的责任完全放在客户端程序员的肩上,有两种方法可以访问PStash中的指针:operator[],它简单地返回指针,但将它作为容器的一个成员;第二个成员函数叫做remove(),它返回指针,但也通过将该位置赋为零将它从容器中移除。当调用PStash的析构函数时,它检查以确保所有的对象指针都已被移除;如果没有,系统会通知您,这样您就可以防止内存泄漏(在接下来的章节中将会有更好的解决方案)。

一次测试

清单 13-6 是为PStash改写的旧的Stash测试程序。

清单 13-6 。“指针存储”的测试程序

//: C13:PStashTest.cpp
//{L} PStash
// Test of pointer Stash
#include "PStash.h"
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;

int main() {
  PStash intStash;
  // 'new' works with built-in types, too. Note
  // the "pseudo-constructor" syntax:
  for(int i = 0; i < 25; i++)
    intStash.add(new int(i));
  for(int j = 0; j < intStash.count(); j++)
    cout << "intStash[" << j << "] = "
         << *(int*)intStash[j] << endl;
  // Clean up:
  for(int k = 0; k < intStash.count(); k++)
    delete intStash.remove(k);
  ifstream in ("PStashTest.cpp");
  assure(in, "PStashTest.cpp");
  PStash stringStash;
  string line;
  while(getline(in, line))
    stringStash.add(new string(line));
  // Print out the strings:
  for(int u = 0; stringStash[u]; u++)
    cout << "stringStash[" << u << "] = "
         << *(string*)stringStash[u] << endl;
  // Clean up:
  for(int v = 0; v < stringStash.count(); v++)
    delete (string*)stringStash.remove(v);
} ///:∼

和以前一样,Stash es 被创建并填充了信息,但是这次信息是从new表达式中产生的指针。在第一种情况下,请注意这一行。

intStash.add(new int(i));

表达式new int(i)使用伪构造器的形式,所以在堆上为新的int对象创建存储,并且int被初始化为值i

打印时,PStash::operator[ ]返回的值必须转换成合适的类型;对程序中其余的PStash对象重复这一过程。这是使用void指针作为底层表示的不良效果,将在后面的章节中解决。

第二个测试打开源代码文件,一次一行地将它读入另一个PStash。使用getline()将每一行读入一个string,然后从line创建一个newstring来制作该行的独立副本。如果每次只传入line的地址,就会得到一大串指向line的指针,其中只包含从文件中读取的最后一行。

当获取指针时,您会看到表达式。

*(string*)stringStash[v]

operator[ ]返回的指针必须被转换成string*以赋予它正确的类型。然后string*被解引用,所以表达式计算为一个对象,此时编译器看到一个string对象要发送给cout

在堆上创建的对象必须通过使用remove()语句来销毁,否则您将在运行时得到一条消息,告诉您还没有完全清除PStash中的对象。注意,在使用int指针的情况下,不需要强制转换,因为int没有析构函数,您需要的只是释放内存,如下所示:

delete intStash.remove(k);

然而,对于string指针,如果你忘记进行类型转换,你将会有另一个(安静的)内存泄漏,所以类型转换是必要的。

delete (string*)stringStash.remove(k);

这些问题中的一部分(但不是全部)可以使用模板来解决(你将在第 16 章中了解到)。

对数组 使用 new 和 delete

在 C++ 中,您可以同样轻松地在堆栈或堆上创建对象数组,并且(当然)为数组中的每个对象调用构造器。然而,有一个限制:必须有一个默认的构造器,除了栈上的聚合初始化(参考第 6 章),因为必须为每个对象调用一个不带参数的构造器。

当使用new在堆上创建对象数组时,您必须做一些其他的事情。这种阵列的一个例子是

MyType* fp = new MyType[100];

这在堆上为 100 个MyType对象分配了足够的存储空间,并为每个对象调用构造器。然而,现在你只需要一个MyType*,它和你说的完全一样

MyType* fp2 = new MyType;

创建单个对象。因为您编写了代码,所以您知道fp实际上是一个数组的起始地址,所以使用类似于fp[3]的表达式来选择数组元素是有意义的。但是当你破坏了阵列会发生什么?这些声明

delete fp2; // OK
delete fp;  // Not the desired effect

看起来完全一样,它们的效果也会一样。对于给定地址指向的MyType对象会调用析构函数,然后释放存储。对于fp2来说,这很好,但是对于fp来说,这意味着其他 99 个析构函数调用将不会被调用。然而,适当数量的存储仍将被释放,因为它被分配在一个大块中,并且整个块的大小被分配例程藏在某个地方。

解决方案要求你给编译器信息,这实际上是一个数组的起始地址。这是通过以下语法实现的:

delete []fp;

空括号告诉编译器生成代码,获取数组中的对象数,数组创建时存储在某个地方,并为这些数组对象调用析构函数。这实际上是对早期形式的一种改进语法(在旧代码中仍可能偶尔看到);例如,

delete [100]fp;

强迫程序员在数组中包含对象的数量,并引入了程序员出错的可能性。让编译器处理它的额外开销非常低,而且在一个地方指定对象的数量比在两个地方指定对象的数量更好。

使指针更像数组

顺便说一下,上面定义的fp可以改为指向任何东西,这对数组的起始地址没有意义。将其定义为常量更有意义,因此任何修改指针的尝试都将被标记为错误。要获得这种效果,你可以尝试

int const *q = new int[10];

或者

const int *q = new int[10];

但是在这两种情况下,const将绑定到int——也就是说,所指向的,而不是指针本身的质量。相反,你必须说

int* const q = new int[10];

现在可以修改q中的数组元素了,但是对q(像q++)的任何修改都是非法的,因为这是一个普通的数组标识符。

存储空间不足

operator new()找不到足够大的连续存储块来存放所需对象时会发生什么?调用一个名为 new- 处理程序的特殊函数。或者更确切地说,检查指向函数的指针,如果指针非零,则调用它所指向的函数。

new-handler 的默认行为是抛出一个异常,这个主题在第 17 章中有所涉及。然而,如果您在程序中使用堆分配,明智的做法是至少用一条消息替换 new-handler,这条消息表明您已经用完了内存,然后中止程序。这样,在调试过程中,您将对发生的事情有所了解。对于最后一个程序,您将需要使用更强大的恢复功能。

您通过包含new.h来替换 new-handler,然后用您想要安装的函数的地址来调用set_new_handler();参见清单 13-7

清单 13-7 。处理内存不足的情况

//: C13:NewHandler.cpp
// Changing the new-handler
#include <iostream>
#include <cstdlib>
#include <new>
using namespace std;

int count = 0;

void out_of_memory() {
  cerr << "memory exhausted after " << count
       << " allocations!" << endl;
exit(1);
}

int main() {
  set_new_handler(out_of_memory);
  while(1) {
    count++;
    new int[1000];
       // Exhausts memory
  }
} ///:∼

new-handler 函数必须不带参数,并且有一个void返回值。while循环将继续分配int对象(并丢弃它们的返回地址),直到空闲存储空间耗尽。在下一次调用new时,没有存储空间可以分配,因此将调用 new-handler。

new-handler 的行为与operator new()联系在一起,所以如果你重载了operator new()(将在下一节讨论)new-handler 在默认情况下不会被调用。如果你仍然希望新的处理程序被调用,你将不得不在你的重载operator new()中编写代码。

当然,您可以编写更复杂的新处理程序,甚至可以尝试回收内存(通常称为垃圾收集器 )。这不是程序员新手的工作。

重载新增和删除

当你创建一个新的表达式时,会发生两件事。首先,使用operator new()分配存储,然后调用构造器。在删除表达式中,调用析构函数,然后使用operator delete()释放存储空间。构造器和析构函数的调用永远不受你的控制(,否则你可能会不小心破坏它们,但是你可以改变存储分配函数operator new()operator delete()

newdelete使用的内存分配系统是为通用目的而设计的。然而,在特殊情况下,它不能满足你的需要。改变分配器最常见的原因是效率:您可能会创建和销毁某个特定类的如此多的对象,以至于成为速度瓶颈。C++ 允许你重载newdelete来实现你自己的存储分配方案,所以你可以处理这样的问题。

另一个问题是堆碎片。通过分配不同大小的对象,可以分解堆,从而有效地耗尽存储空间。也就是说,存储可能是可用的,但是由于碎片化,没有足够大的碎片来满足您的需求。通过为特定的类创建自己的分配器,可以确保这种情况永远不会发生。

在嵌入式和实时系统中,一个程序可能需要在有限的资源下运行很长时间。这样的系统可能还要求内存分配总是花费相同的时间,并且不允许堆耗尽或碎片。自定义内存分配器是解决方案;否则,程序员会避免在这种情况下一起使用newdelete,从而错过宝贵的 C++ 资产。

当你重载operator new()operator delete()时,重要的是要记住你只是改变了原始存储的分配方式*。编译器将简单地调用你的new而不是默认版本来分配存储,然后调用那个存储的构造器。所以,尽管编译器分配存储并且在看到new时调用构造器,但是当你重载new时你所能改变的只是存储分配部分。(delete 也有类似的限制。)*

*当你重载operator new()时,你也替换了耗尽内存的行为,所以你必须决定在你的operator new()中做什么:返回零,写一个循环调用新的处理程序并重试分配,或者(通常是)抛出一个bad_alloc异常。

重载newdelete就像重载任何其他操作符一样。然而,您可以选择重载全局分配器或者为特定的类使用不同的分配器。

重载全局新增和删除

newdelete的全局版本对整个系统不满意时,这是极端的方法。如果你重载了全局版本,你会使缺省值完全不可访问——你甚至不能从你的重定义中调用它们。

重载的new必须接受一个参数size_t(标准的 C 标准大小类型)。这个参数由编译器生成并传递给你,它是你负责分配的对象的大小。你必须返回一个指向那个大小(或者更大,如果你有理由这样做的话)的对象的指针,或者如果你找不到内存的话返回一个指向零的指针(在这种情况下,构造器是而不是调用的!).然而,如果你找不到内存,你可能应该做一些比返回 0 更有用的事情,比如调用 new-handler 或者抛出一个异常,来表示有问题。

operator new()的返回值是一个void*而不是指向任何特定类型的指针。你所做的只是产生内存,而不是一个已完成的对象——直到调用构造器时才会发生,这是编译器保证的行为,并且不受你的控制。

operator delete()void*放入由operator new()分配的内存中。这是一个void*,因为operator delete()只有在析构函数被调用后才能获得指针,析构函数从内存中移除了对象 ness 。返回类型为void

关于如何重载全局newdelete的简单例子,参见清单 13-8

清单 13-8 。重载全局 new 和 delete

//: C13:GlobalOperatorNew.cpp
// Overload global new/delete
#include <cstdio>
#include <cstdlib>
using namespace std;

void* operator new(size_t sz) {
  printf("operator new: %d Bytes\n", sz);
  void* m = malloc(sz);
 if(!m) puts("out of memory");
 return m;
}

void operator delete(void* m) {
  puts("operator delete");
  free(m);
}

class S {
  int i[100];
public:
  S() { puts("S::S()"); }
  ∼S() { puts("S::∼S()"); }
};

int main() {
  puts("creating & destroying an int");
  int* p = new int(47);
  delete p;
  puts("creating & destroying an s");
  S *s = new S;
  delete s;
  puts("creating & destroying S[3]");
  S *sa = new S[3];
  delete []sa;
} ///:∼

这里你可以看到重载newdelete的一般形式。这些为分配器(使用了标准的 C 库函数malloc()free(),这可能也是默认的 new delete 所使用的!)。然而,他们也打印关于他们正在做什么的消息。注意使用了printf()puts()而不是iostream。这是因为当一个iostream对象被创建时(像全局cincoutcerr),它调用new来分配内存。有了printf(),你不会陷入死锁,因为它不会调用new来初始化自己。

main()中,创建内置类型的对象来证明重载的newdelete在那种情况下也被调用。然后创建一个类型为S的对象,后面是一个S数组。对于数组,您将从请求的字节数中看到,额外的内存被分配来存储关于它保存的对象数量的信息(数组中的*)。在所有情况下,都使用全局过载版本的newdelete。*

重载新增和删除为一类 为一类

尽管你不必明确地说static,当你重载一个类的newdelete时,你正在创建static成员函数。和以前一样,语法与重载任何其他运算符相同。当编译器看到你使用new创建你的类的对象时,它选择成员operator new()而不是全局版本。然而,newdelete的全局版本用于所有其他类型的对象(除非有自己的newdelete)。

清单 13-9 中,为类Framis创建了一个原始的存储分配系统。程序启动时在静态数据区留出一块内存,该内存用于为类型为Framis的对象分配空间。为了确定哪些块已经被分配,使用一个简单的字节数组,每个块一个字节。

清单 13-9 。重载本地(对于一个类)新建和删除

//: C13:Framis.cpp
// Local overloaded new & delete
#include <cstddef>         // Size_t
#include <fstream>
#include <iostream>
#include <new>
using namespace std;
ofstream out("Framis.out");

class Framis {
  enum { sz = 10 };
  char c[sz];              // To take up space, not used
  static unsigned char pool[];
  static bool alloc_map[];
public:
  enum { psize = 100 };    // framis allowed
  Framis() { out << "Framis()\n"; }
  ∼Framis() { out << "∼Framis() ... "; }
  void* operator new(size_t) throw(bad_alloc);
  void operator delete(void*);
};
unsigned char Framis::pool[psize * sizeof(Framis)];
bool Framis::alloc_map[psize] = {false};

// Size is ignored -- assume a Framis object
void*
Framis::operator new(size_t) throw(bad_alloc) {
  for(int i = 0; i < psize; i++)
    if(!alloc_map[i]) {
      out << "using block " << i << " ... ";
      alloc_map[i] = true; // Mark it used
      return pool + (i * sizeof(Framis));
    }
  out << "out of memory" << endl;
  throw bad_alloc();
}

void Framis::operator delete(void* m) {
  if(!m) return;           // Check for null pointer
  // Assume it was created in the pool
  // Calculate which block number it is:
  unsigned long block = (unsigned long)m
    - (unsigned long)pool;
  block /= sizeof(Framis);
  out << "freeing block " << block << endl;
  // Mark it free:
  alloc_map[block] = false;
}

int main() {
  Framis *f[Framis::psize];
  try {
    for(int i = 0; i < Framis::psize; i++)
      f[i] = new Framis;
    new Framis;  // Out of memory
  } catch(bad_alloc) {
    cerr << "Out of memory!" << endl;
  }
  delete f[10];
  f[10] = 0;
  // Use released memory:
  Framis *x = new Framis;
  delete x;
  for(int j = 0; j < Framis::psize; j++)
    delete f[j]; // Delete f[10] OK
} ///:∼

用于Framis堆的内存池是通过分配足够大的字节数组来保存psize Framis对象而创建的。分配图有psize个元素长,所以每个块都有一个bool。使用设置第一个元素的聚合初始化技巧,将分配映射中的所有值初始化为false,这样编译器会自动将其余所有值初始化为它们正常的默认值(在bool的情况下,该值为false)。

局部operator new()的语法与全局相同。它所做的只是在分配图中搜索一个false值,然后将该位置设置为true以表明它已经被分配,并返回相应内存块的地址。如果找不到任何内存,它会向跟踪文件发出一条消息,并抛出一个bad_alloc异常。

这是你在这本书里看到的第一个例外的例子。由于异常的详细讨论被推迟到第 17 章中,这是它们的一个非常简单的用法。在operator new()中,有两个异常处理的工件。首先,函数参数列表后面是throw(bad_alloc),它告诉编译器和读者这个函数可能抛出一个bad_alloc类型的异常。第二,如果没有更多的内存,函数实际上会在语句throw bad_alloc中抛出异常。当抛出一个异常时,函数停止执行,控制权传递给一个异常处理程序,它被表示为一个catch子句。

main()中,你看到了画面的另一部分,也就是 try-catch 子句。try块用大括号括起来,包含所有可能抛出异常的代码——在本例中,是对涉及Framis对象的new的任何调用。紧跟在try块之后的是一个或多个catch子句,每个子句指定它们捕获的异常的类型。在这种情况下,catch(bad_alloc)表示bad_alloc异常将在这里被捕获。这个特殊的catch子句仅在抛出bad_alloc异常时执行,并且在组中最后一个catch子句结束后继续执行(这里只有一个,但可能有更多的)。

在这个例子中,使用iostream是可以的,因为全局operator new()delete()没有被触动。

operator delete()假设Framis地址是在池中创建的。这是一个合理的假设,因为每当你在堆上创建一个单独的Framis对象时,就会调用局部的operator new()——而不是它们的数组:全局的new用于数组。因此,用户可能不小心调用了operator delete(),而没有使用空括号语法来指示数组销毁。这将导致一个问题。此外,用户可能正在删除指向堆栈上创建的对象的指针。如果您认为这些事情可能会发生,您可能需要添加一行来确保该地址在地址池内并且在正确的边界上。

image 注意您也可能开始看到过载的newdelete对于查找内存泄漏的潜力。

operator delete()计算该指针所代表的池中的块,然后将该块的分配映射标志设置为 false,以指示该块已被释放。

main()中,动态分配足够多的Framis对象耗尽内存;这将检查内存不足行为。然后释放其中一个对象,并创建另一个对象来表明释放的内存被重用。

因为这种分配方案特定于Framis对象,所以它可能比用于默认newdelete的通用内存分配方案要快得多。但是,你应该注意,如果使用了继承,它不会自动工作(继承在第 14 章中有所涉及)。

重载数组 的新建和删除

如果你重载了一个类的操作符new()delete(),那么每当你创建这个类的对象时,这些操作符都会被调用。然而,如果你创建一个这些类对象的数组,全局operator new()被调用来为数组一次分配足够的存储空间,全局operator delete()被调用来释放这些存储空间。您可以通过重载该类的特殊数组版本operator new[ ]operator delete[ ]来控制对象数组的分配。参见清单 13-10 中两个不同版本被调用的例子。

清单 13-10 。对数组使用运算符 new()

//: C13:ArrayOperatorNew.cpp
// Operator new for arrays
#include <new> // Size_t definition
#include <fstream>
using namespace std;
ofstream trace("ArrayOperatorNew.out");

class Widget {
  enum { sz = 10 };
  int i[sz];
public:
  Widget() { trace << "*"; }
  ∼Widget() { trace << ""; }
  void* operator new(size_tsz) {
    trace << "Widget::new: "
          << sz << " bytes" << endl;
    return ::new char[sz];
  }
  void operator delete(void* p) {
    trace << "Widget::delete" << endl;
    ::delete []p;
  }
  void* operator new[](size_tsz) {
    trace << "Widget::new[]: "
          << sz << " bytes" << endl;
    return ::new char[sz];
  }
  void operator delete[](void* p) {
    trace << "Widget::delete[]" << endl;
    ::delete []p;
  }
};

int main() {
  trace << "new Widget" << endl;
  Widget *w = new Widget;
  trace << "\ndelete Widget" << endl;
  delete w;
  trace << "\n new Widget[25]" << endl;
  Widget *wa = new Widget[25];
  trace << "\n delete []Widget" << endl;
  delete []wa;
} ///:∼

这里,newdelete的全局版本被调用,因此除了添加跟踪信息之外,效果与没有newdelete的重载版本相同。当然,你可以在过载的newdelete中使用任何你想要的内存分配方案。

您可以看到数组newdelete的语法与单个对象版本的语法相同,只是增加了括号。在这两种情况下,您都有必须分配的内存大小。传递给数组版本的大小将是整个数组的大小。值得记住的是,重载的operator new()需要做的唯一一件事就是将一个指针交还给一个足够大的内存块。虽然你可以在内存上执行初始化,通常这是构造器的工作,编译器会自动调用你的内存。

构造器和析构函数只是打印出字符,这样你就可以看到它们何时被调用。下面是一个编译器的跟踪文件:

new Widget
Widget::new: 40 bytes
*
delete Widget
∼Widget::delete

new Widget[25]
Widget::new[]: 1004 bytes
*************************
delete []Widget
∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼∼Widget::delete[]

正如您所料,创建一个单独的对象需要 40 个字节。

image 这台电脑用四个字节表示一个int

调用operator new(),然后调用构造器(由*表示)。作为补充,调用delete会导致析构函数被调用,然后是operator delete()

当一个由Widget对象组成的数组被创建时,将使用operator new()的数组版本。但是请注意,请求的大小比预期多了四个字节。这额外的四个字节是系统保存数组信息的地方,特别是数组中对象的数量。这样,当你说

delete []Widget;

括号告诉编译器这是一个对象数组,因此编译器生成代码来查找数组中对象的数量,并多次调用析构函数。您可以看到,尽管数组operator new()operator delete()对于整个数组块只被调用一次,但是默认的构造器和析构函数对于数组中的每个对象都被调用。

构造器调用

考虑到

MyType *f = new MyType;

调用new分配一个MyType大小的存储,然后调用该存储的MyType构造器,如果new中的存储分配失败会发生什么?在这种情况下,不会调用构造器,所以尽管你仍然有一个没有成功创建的对象,至少你没有调用构造器并给它一个零this指针。清单 13-11 证明了这一点。

清单 13-11 。说明在 new 失败的情况下,构造器不会发挥作用

//: C13:NoMemory.cpp
// Constructor isn't called if new fails
#include <iostream>
#include <new>
       // bad_alloc definition
using namespace std;

class NoMemory {

public:
  NoMemory() {
    cout << "NoMemory::NoMemory()" << endl;
  }
void* operator new(size_tsz) throw(bad_alloc){
  cout << "NoMemory::operator new" << endl;
  throw bad_alloc(); // "Out of memory"
  }
};

int main() {
  NoMemory *nm = 0;
  try {
    nm = new NoMemory;
  } catch(bad_alloc) {
    cerr << "Out of memory exception" << endl;
  }
  cout << "nm = " << nm << endl;
} ///:∼

当程序运行时,它不打印构造器消息,只打印来自operator new()的消息和异常处理程序中的消息。因为new永远不会返回,所以构造器永远不会被调用,所以它的消息不会被打印。

nm初始化为零很重要,因为new表达式永远不会完成,并且指针应该为零以确保不会被误用。但是,您实际上应该在异常处理程序中做更多的事情,而不仅仅是打印出一条消息并继续运行,就好像对象已经成功创建一样。理想情况下,您将做一些事情,使程序从问题中恢复,或者至少在记录错误后退出。

在 C++ 的早期版本中,如果存储分配失败,从new返回零是标准的做法。这将阻止建设的发生。然而,如果你试图用符合标准的编译器从new返回零,它会告诉你应该抛出bad_alloc

放置新的

重载operator new()还有另外两种不太常见的用法。

  1. 您可能希望将一个对象放在内存中的特定位置。这对于面向硬件的嵌入式系统尤其重要,在这种系统中,一个对象可能与一个特定的硬件同义。
  2. 当调用new时,您可能希望能够从不同的分配器中进行选择。

这两种情况都用相同的机制解决:重载的operator new()可以接受多个参数。

正如你之前看到的,第一个参数总是对象的大小,这是由编译器秘密计算和传递的。但是其他参数可以是您想要的任何东西:您想要对象放置的地址、对内存分配函数或对象的引用,或者其他任何对您来说方便的东西。

起初,你在通话中向operator new()传递额外参数的方式可能看起来有点奇怪。您将参数列表(不带size_t参数,由编译器处理)放在关键字new之后,您正在创建的对象的类名之前。例如,

X* xp = new(a) X;

将把a作为第二个参数传递给operator new()。当然,这只有在宣布了这样一个operator new()的情况下才能奏效。

清单 13-12 中的例子,展示了如何将一个对象放置在一个特定的位置。

清单 13-12 。举例说明使用运算符 new()放置的情况

//: C13:PlacementOperatorNew.cpp
// Placement with operator new()
#include <cstddef> // Size_t
#include <iostream>
using namespace std;

class X {
  int i;
public:
  X(int ii = 0) : i(ii) {
    cout << "this = " << this << endl;
  }
  ∼X() {
    cout << "X::∼X(): " << this << endl;
  }
  void* operator new(size_t, void* loc) {
    return loc;
  }
};

int main() {
  int l[10];
  cout << "l = " << l << endl;
  X *xp = new(l) X(47); // X at location l
  xp->X::∼X();          // Explicit destructor call
  // ONLY use with placement!
} ///:∼

注意operator new()只返回传递给它的指针。因此,调用者决定对象的位置,构造器作为 new-expression 的一部分被调用。

虽然这个例子只显示了一个额外的参数,但是如果您出于其他目的需要它们,也可以添加更多的参数。

当你想摧毁这个物体时,就会出现两难的局面。只有一个版本的operator delete(),所以没有办法说,“为这个对象使用我特殊的释放器你想调用析构函数,但是你不想让动态内存机制释放内存,因为它没有在堆上分配。

答案是一个非常特殊的语法。您可以显式调用析构函数,如

xp->X::∼X();            // Explicit destructor call

这里需要一个严厉的警告。有些人认为这是一种在作用域结束之前销毁对象的方法,而不是调整作用域或者(更准确地说)使用动态对象创建,如果他们想在运行时确定对象的生存期。

如果你为一个在堆栈上创建的普通对象这样调用析构函数,你会遇到严重的问题,因为析构函数会在作用域的末尾被再次调用。如果以这种方式为在堆上创建的对象调用析构函数,析构函数会执行,但不会释放内存,这可能不是您想要的。可以这样显式调用析构函数的唯一原因是为了支持operator new的放置语法。

还有一个位置operator delete(),只有当位置new表达式的构造器抛出异常时才会调用它(,以便在异常期间自动清理内存)。位置operator delete()有一个参数列表,对应于构造器抛出异常之前调用的位置operator new()

这个主题将在关于异常处理的第 17 章中探讨。

审查会议

  1. 在堆栈上创建自动对象是方便的最优有效的,但是要解决一般的编程问题,你必须能够在程序执行期间的任何时候创建和销毁对象,特别是响应来自程序外部的信息。
  2. 虽然 C 的动态内存分配将从堆中获得存储,但它不提供 C++ 中所必需的易用性和有保证的结构。通过使用 new 和 delete 将动态对象创建引入语言的核心,您可以像在堆栈上创建对象一样容易地在堆上创建对象。
  3. 此外,你还可以获得很大的灵活性。如果 new 和 delete 不符合您的需要,您可以更改它们的行为,特别是当它们不够高效时。
  4. 此外,您还可以修改当堆用尽存储空间时会发生什么。*