C-到-C---迁移手册-六-

49 阅读22分钟

C 到 C++ 迁移手册(六)

原文:Moving From C to C++

协议:CC BY-NC-SA 4.0

十三、动态对象创建

有时您知道程序中对象的确切数量、类型和生命周期,但并不总是如此。一个空中交通系统需要处理多少架飞机?一个 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*
#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);

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

对数组 使用 new 和 delete

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

当使用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 的默认行为是抛出一个异常,这个主题在第十七章中有所涉及。然而,如果您在程序中使用堆分配,明智的做法是至少用一条消息替换 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异常。

这是你在这本书里看到的第一个例外的例子。由于异常的详细讨论被推迟到第十七章中,这是它们的一个非常简单的用法。在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的通用内存分配方案要快得多。但是,你应该注意,如果使用了继承,它不会自动工作(继承在第十四章中有所涉及)。

重载数组 的新建和删除

如果你重载了一个类的操作符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()

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

审查会议

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

十四、继承与组合

C++ 最引人注目的特性之一是代码重用。但是要成为革命性的,你需要做的不仅仅是复制和修改代码。

与 C++ 中的大多数东西一样,解决方案围绕着类。您通过创建新的类来重用代码,但是您不是从头开始创建它们,而是使用其他人已经构建和调试的现有类。

诀窍是使用这些类而不需要修改现有的代码。在这一章中,你将看到实现这一点的两种方法。第一种非常简单:只需在新类中创建现有类的对象。这被称为复合,因为新类是由现有类的对象组成的。

第二种方法更微妙。您创建一个新类作为现有类的类型。您实际上是采用现有类的形式并向其添加代码,而不修改现有类。这种神奇的行为被称为继承 ,大部分工作由编译器完成。继承是面向对象编程的基石之一,它还有其他的含义,将在下一章探讨。

事实证明,对于组合和继承来说,许多语法和行为都是相似的(,这是有道理的;它们都是从现有类型创建新类型的方法。在这一章中,你将学习这些代码重用机制。

作文语法

实际上,你一直在使用组合来创建类。你只是主要用内置类型(有时是string s)来编写类。事实证明,使用用户定义类型的组合几乎一样容易。考虑清单 14-1 中的,它显示了一个由于某种原因而有价值的类。

清单 14-1 。一个有价值且有用的可重用类

//: C14:Useful.h
// A class to reuse
#ifndef USEFUL_H
#define USEFUL_H

class X {
  int i;
public:
  X() { i = 0; }
  void set(int ii) { i = ii; }
  int read() const { return i; }
  int permute() { return i = i * 47; }
};
#endif // USEFUL_H ///:∼

这个类中的数据成员是private,所以在一个新类中嵌入一个类型为X的对象作为public对象是完全安全的,这使得接口变得简单明了,正如你在清单 14-2 中看到的。

清单 14-2 。使用组合重用代码

//: C14:Composition.cpp
// Reuse code with composition
#include "Useful.h" // To be INCLUDED from Header FILE above

class Y {
  int i;
public:
  X x;              // Embedded object
  Y() { i = 0; }
  void f(int ii) { i = ii; }
  int g() const { return i; }
};

int main() {
  Y y;
  y.f(47);
  y.x.set(37);      // Access the embedded object
} ///:∼

访问嵌入对象(称为子对象)的成员函数只需要选择另一个成员。

更常见的是制作嵌入对象private,因此它们成为底层实现的一部分(这意味着如果你愿意,你可以改变实现)。新类的public接口函数涉及到嵌入对象的使用,但是它们不一定模仿对象的接口;参见清单 14-3 。

清单 14-3 。带有私有嵌入对象的组合

//: C14:Composition2.cpp
// Private embedded objects
#include "Useful.h"

class Y {
  int i;
  X x;              // Embedded object
public:
  Y() { i = 0; }
  void f(int ii) { i = ii; x.set(ii); }
  int g() const { return i * x.read(); }
  void permute() { x.permute(); }
};

int main() {
  Y y;
  y.f(47);
  y.permute();
} ///:∼

这里,permute()函数被带到新的类接口,但是X的其他成员函数在Y的成员中使用。

继承语法

合成的语法是显而易见的,但是执行继承有一种新的不同的形式。

当你继承时,你在说,“这个新的类就像那个旧的类。”您可以像往常一样在代码中给出类的名称,但是在类体的左括号之前,您要加上一个冒号和基类的名称 ( 或基类,用逗号分隔,表示多重继承)。当你这样做的时候,你自动获得了基类中的所有数据成员和成员函数。清单 14-4 显示了一个例子。

清单 14-4 。说明简单继承

//: C14:Inheritance.cpp
// Simple inheritance
#include "Useful.h"
#include <iostream>
using namespace std;

class Y : public X {
  int i;           // Different from X's i
public:
  Y() { i = 0; }
  int change() {
    i = permute(); // Different name call
    return i;
  }
  void set(int ii) {
    i = ii;
    X::set(ii);    // Same-name function call
  }
};

int main() {
  cout << "sizeof(X) = " << sizeof(X) << endl;
  cout << "sizeof(Y) = "
       << sizeof(Y) << endl;
  Y D;
  D.change();
  // X function interface comes through:
  D.read();
  D.permute();
  // Redefined functions hide base versions:
  D.set(12);
} ///:∼

你可以看到Y继承自X,这意味着Y将包含X中的所有数据元素和X中的所有成员函数。事实上,Y包含了一个X的子对象,就好像你在Y内部创建了一个X的成员对象,而不是从X继承而来。成员对象和基类存储都被称为子对象*。*

X的所有private元素仍然是Y中的private;也就是说,Y继承X并不意味着Y可以打破保护机制。Xprivate元素仍然存在,它们占据了空间——只是你不能直接访问它们。

main()中,你可以看到Y的数据元素与X的数据元素组合在一起,因为sizeof(Y)sizeof(X)的两倍大。

您会注意到基类前面有public。继承时,一切默认为private。如果基类前面没有public,这意味着基类的所有public成员都是派生类中的private。这几乎从来不是你想要的;期望的结果是将基类public的所有public成员保留在派生类中。您可以在继承过程中使用public关键字来实现这一点。

change()中,基类permute()函数被调用。派生类可以直接访问所有的public基类函数。

派生类中的set()函数重新定义了基类中的set()函数。也就是说,如果您为类型为Y的对象调用函数read()permute(),您将获得这些函数的基类版本(您可以在main()中看到这种情况)。但是如果你为一个Y对象调用set(),你会得到一个重新定义的版本。这意味着如果你不喜欢在继承过程中得到的函数版本,你可以改变它的功能。

image 注意你也可以像change()一样添加全新的功能。

然而,当你重定义一个函数时,你可能仍然想调用基类版本。如果在set()中,你简单地调用set(),你将得到函数的本地版本——递归函数调用。若要调用基类版本,必须使用范围解析运算符显式命名基类。

构造器初始化列表

您已经看到了在 C++ 中保证正确的初始化是多么重要,在组合和继承期间也是如此。创建对象时,编译器保证调用其所有子对象的构造器。在迄今为止的例子中,所有的子对象都有默认的构造器,这就是编译器自动调用的。但是,如果您的子对象没有默认构造器,或者如果您想更改构造器中的默认参数,会发生什么情况呢?这是一个问题,因为新的类构造器没有权限访问子对象的private数据元素,所以它不能直接初始化它们。

解决方案很简单:调用子对象的构造器。C++ 为此提供了一个特殊的语法,构造器初始化列表。构造器初始化列表的形式呼应了继承的行为。有了继承,你可以把基类放在冒号之后,类体的左括号之前。在构造器初始化列表中,将对子对象构造器的调用放在构造器参数列表和冒号之后,但在函数体的左括号之前。对于继承自Bar的类MyType,它可能看起来像这样

MyType::MyType(inti) : Bar(i) { // ...

如果Bar有一个接受单个int参数的构造器。

成员对象初始化

事实证明,在使用复合时,您也使用这种完全相同的语法来初始化成员对象。对于组合,您给出对象的名称,而不是类名。如果在初始化列表中有不止一个构造器调用,用逗号分隔调用,如:

MyType2::MyType2(int i) : Bar(i), m(i+1) { // ...

这是类MyType2的构造器的开始,它继承自Bar,包含一个名为m的成员对象。请注意,虽然您可以在构造器初始化列表中看到基类的类型,但您只能看到成员对象标识符。

初始化列表中的内置类型

构造器初始化列表允许你显式调用成员对象的构造器。事实上,没有其他方法可以调用这些构造器。这个想法是,在你进入新类的构造器体之前,所有的构造器都被调用。这样,您对子对象的成员函数的任何调用都将始终指向已初始化的对象。如果不为所有成员对象和基类对象调用某个构造器,就无法到达构造器的左括号,即使编译器必须对默认构造器进行隐藏调用。这进一步加强了 C++ 的保证,即没有对象(对象的一部分)可以在不调用其构造器的情况下离开起始门。

这种在到达构造器的左括号时初始化所有成员对象的想法也是一种方便的编程帮助。一旦您点击了左大括号,您就可以假设所有的子对象都被正确地初始化了,并专注于您想要在构造器中完成的特定任务。然而,有一个问题:内置类型的成员对象怎么办,它们没有构造器?

为了保持语法的一致性,你可以将内置类型视为只有一个构造器,这个构造器只有一个参数:一个与你正在初始化的变量类型相同的变量,如清单 14-5 所示。

清单 14-5 。演示伪构造器

//: C14:PseudoConstructor.cpp
class X {
  int i;
  float f;
  char c;
  char *s;
public:
  X() : i(7), f(1.4), c('x'), s("howdy") {}
};
int main() {
  X x;
  int i(100);  // Applied to ordinary definition
  int* ip = new int(47);
} ///:∼

这些“伪构造器调用”的动作是执行一个简单的赋值。这是一种方便的技术,也是一种很好的编码风格,所以您会经常看到它的使用。

在类外创建内置类型的变量时,甚至可以使用伪构造器语法,如下所示:

int i(100);
int* ip = new int(47);

这使得内置类型的行为有点像对象。但是请记住,这些不是真正的构造器。特别是,如果没有显式地进行伪构造器调用,就不会执行初始化。

结合组合与继承

当然,你可以一起使用合成和继承。清单 14-6 展示了使用它们创建一个更复杂的类。

清单 14-6 。说明组合的组合和继承

//: C14:Combined.cpp
// Inheritance & composition

class A {
  int i;
public:
  A(int ii) : i(ii) {}
  ∼A() {}
  void f() const {}
};

class B {
  int i;
public:
  B(int ii) : i(ii) {}
  ∼B() {}
  void f() const {}
};

class C : public B {
  A a;
public:
  C(int ii) : B(ii), a(ii) {}
  ∼C() {} // Calls ∼A() and ∼B()
  void f() const {  // Redefinition
    a.f();
    B::f();
  }
};

int main() {
  C c(47);
} ///:∼

C继承自B并有一个A类型的成员对象(“由……组成”)。您可以看到构造器初始化列表包含对基类构造器和成员对象构造器的调用。

函数C::f()重新定义了它继承的B::f(),也调用了基类版本。另外,它叫a.f()。请注意,您唯一可以谈论函数重定义的时间是在继承期间;对于成员对象,你只能操作对象的公共接口,而不能重定义它。此外,如果没有定义C::f(),调用C类的对象f()将不会调用a.f(),而会调用B::f()

自动析构函数调用

虽然你经常需要在初始化列表中进行显式的构造器调用,但是你从来不需要进行显式的析构函数调用,因为任何类都只有一个析构函数,而且它不需要任何参数。然而,编译器仍然确保调用了所有的析构函数,这意味着调用了整个层次结构中的所有析构函数,从派生程度最高的析构函数开始,一直到根。

值得强调的是,构造器和析构函数很不寻常,因为层次结构中的每个人都被调用,而普通的成员函数只调用那个函数,而不调用任何基类版本。如果你还想调用你正在重写的普通成员函数的基类版本,你必须显式地这样做。

构造器和析构函数调用和的顺序

当一个对象有许多子对象时,知道构造器和析构函数调用的顺序是很有趣的。清单 14-7 展示了它是如何工作的。

清单 14-7 。演示构造器/析构函数调用的顺序

//: C14:Order.cpp
// Constructor/destructor order
#include <fstream>
using namespace std;
ofstream out("order.out");

#define CLASS(ID) class ID { \
public: \
  ID(int) { out << #ID " constructor\n"; } \
  ∼ID() { out << #ID " destructor\n"; } \
};

CLASS(Base1);
CLASS(Member1);
CLASS(Member2);
CLASS(Member3);
CLASS(Member4);

class Derived1 : public Base1 {
  Member1 m1;
  Member2 m2;
public:
  Derived1(int) : m2(1), m1(2), Base1(3) {
    out << "Derived1 constructor\n";
  }
  ∼Derived1() {
    out << "Derived1 destructor\n";
  }
};

class Derived2 : public Derived1 {
  Member3 m3;
  Member4 m4;
public:
  Derived2() : m3(1), Derived1(2), m4(3) {
    out << "Derived2 constructor\n";
  }
  ∼Derived2() {
    out << "Derived2 destructor\n";
  }
};

int main() {
  Derived2 d2;
} ///:∼

首先,创建一个ofstream对象,将所有输出发送到一个文件。然后,为了节省一些打字和演示一个宏技术,这个宏技术将在第十六章中被一个改进的技术所取代,一个宏被创建来构建一些类,这些类然后被用于继承和组合。每个构造器和析构函数都向跟踪文件报告自己。请注意,构造器不是默认构造器;他们各自有一个int论点。参数本身没有标识符;它存在的唯一原因是强迫你显式调用初始化列表中的构造器。

image 注意消除标识符可以防止编译器警告信息。

这个程序的输出是

Base1 constructor
Member1 constructor
Member2 constructor
Derived1 constructor
Member3 constructor
Member4 constructor
Derived2 constructor
Derived2 destructor
Member4 destructor
Member3 destructor
Derived1 destructor
Member2 destructor
Member1 destructor
Base1 destructor

您可以看到,构造是从类层次结构的最根开始的,在每一层,首先调用基类构造器,然后是成员对象构造器。析构函数的调用顺序与构造器完全相反——由于潜在的依赖性,这一点很重要(在派生类的构造器或析构函数中,您必须能够假设基类子对象仍然可用,并且已经被构造——或者还没有被销毁)。

有趣的是,成员对象的构造器调用顺序完全不受构造器初始化列表中调用顺序的影响。该顺序由成员对象在类中的声明顺序决定。如果你可以通过构造器初始化列表改变构造器调用的顺序,你可以在两个不同的构造器中有两个不同的调用序列,但是可怜的析构函数不知道如何正确地颠倒析构函数调用的顺序,你可能会以一个依赖问题结束。

姓名隐藏

如果您继承了一个类并为它的一个成员函数提供了一个新的定义,有两种可能。第一个是在派生类定义中提供与基类定义中完全相同的签名和返回类型。对于普通成员函数,这叫做重定义 ,当基类成员函数是virtual函数时,叫做重写(virtual函数是正常情况,将在第十五章中详细介绍)。但是如果你改变了派生类中的成员函数参数列表或者返回类型会怎么样呢?参见清单 14-8 。

清单 14-8 。说明隐藏重载名称(在继承过程中)

//: C14:NameHiding.cpp
// Hiding overloaded names during inheritance
#include <iostream>
#include <string>
using namespace std;

class Base {
public:
  int f() const {
    cout << "Base::f()\n";
    return 1;
  }
  int f(string) const { return 1; }
  void g() {}
};

class Derived1 : public Base {
public:
  void g() const {}
};

class Derived2 : public Base {
public:
  // Redefinition:
  int f() const {
    cout << "Derived2::f()\n";
    return 2;
  }
};

class Derived3 : public Base {
public:
  // Change return type:
  void f() const { cout << "Derived3::f()\n"; }
};

class Derived4 : public Base {
public:
  // Change argument list:
int f(int) const {
cout << "Derived4::f()\n";
return 4;
  }
};

int main() {
string s("hello");
  Derived1 d1;
  int x = d1.f();
  d1.f(s);
  Derived2 d2;
  x = d2.f();
//!  d2.f(s);    // string version hidden
  Derived3 d3;
//!  x = d3.f(); // return int version hidden
  x = d3.g();
  Derived4 d4;
//!  x = d4.f(); // f() version hidden
 x = d4.f(1);
} ///:∼

Base中,你可以看到一个重载的函数f(),Derived1没有对f()做任何改变,但是它重新定义了g()。在main()中可以看到f()的两个重载版本在Derived1中都有。然而,Derived2重新定义了f()的一个重载版本,而没有重新定义另一个,结果是第二个重载形式不可用。在Derived3中,改变返回类型隐藏了两个基类版本,Derived4显示改变参数列表也隐藏了两个基类版本。一般来说,每当你从基类重定义一个重载函数名时,所有其他版本都会自动隐藏在新类中。在第十五章的中,你会看到virtual关键字的增加会对函数重载产生更多的影响。

如果您通过修改基类成员函数的签名和/或返回类型来更改基类的接口,那么您使用该类的方式与继承通常支持的方式不同。这并不一定意味着你做错了,只是继承的最终目的是支持多态,如果你改变了函数签名或者返回类型,那么你实际上是在改变基类的接口。如果这是您想要做的,那么您使用继承主要是为了重用代码,而不是维护基类的公共接口(这是多态的一个重要方面)。一般来说,当你以这种方式使用继承时,这意味着你正在获取一个通用类,并根据特定的需求对其进行特殊化,这通常(但不总是)被认为是组合领域。

例如,考虑来自第九章的Stack类。该类的一个问题是,每次从容器中获取指针时,都必须进行强制转换。这不仅乏味,而且不安全;你可以把指针指向任何你想要的东西。乍一看似乎更好的一种方法是使用继承来专门化通用的Stack类。参见清单 14-9 中使用第九章中的类的例子。

清单 14-9 。使用继承专门化通用堆栈类

//: C14:InheritStack.cpp
// Specializing the Stack class
#include "../C09/Stack4.h"    // Refer Chapter 9
#include "../require.h"       // To be INCLUDED from *Chapter 9*
#include <iostream>
#include <fstream>
#include <string>
using namespace std;

class StringStack : public Stack {
public:
  void push(string* str) {
    Stack::push(str);
  }
  string* peek() const {
    return (string*)Stack::peek();
  }
  string* pop() {
    return (string*)Stack::pop();
  }
  ∼StringStack() {
    string* top = pop();
    while(top) {
      delete top;
      top = pop();
    }
  }
};

int main() {
  ifstream in("InheritStack.cpp");
  assure(in, "InheritStack.cpp");
  string line;
  StringStack textlines;
  while(getline(in, line))
    textlines.push(new string(line));
  string* s;
  while((s = textlines.pop()) != 0) { // No cast!
    cout << *s << endl;
    delete s;
  }
} ///:∼

因为Stack4.h中的所有成员函数都是内联的,所以不需要链接任何东西。

StringStack专门处理Stack,这样push()将只接受String指针。以前,Stack会接受void指针,所以用户没有类型检查来确保插入了正确的指针。此外,peek()pop()现在返回String指针而不是void指针,所以使用指针不需要强制转换。

令人惊讶的是,这种额外的类型检查安全在push()peek()pop()中是免费的!编译器得到了它在编译时使用的额外的类型信息,但是函数是内联的,没有生成额外的代码。

名字隐藏在这里起作用,特别是因为push()函数有不同的签名:参数列表是不同的。如果在同一个类中有两个版本的push(),那将会是重载,但是在这种情况下,重载不是你想要的,因为它仍然允许你将任何类型的指针作为void*传入push()。幸运的是,C++ 在基类中隐藏了push(void*)版本,支持在派生类中定义的新版本,因此它只允许将push()string指针指向StringStack

因为您现在可以保证您确切地知道容器中是什么类型的对象,析构函数工作正常,所有权问题得到了解决——或者至少是所有权问题的一种解决方法。这里,如果您将一个string指针push()放在StringStack上,那么(根据StringStack的语义)您也将该指针的所有权传递给了StringStack。如果你pop()了这个指针,你不仅得到了这个指针,还得到了这个指针的所有权。当调用析构函数时,任何留在StringStack上的指针都会被这个析构函数删除。由于这些总是string指针,并且delete语句是在string指针而不是void指针上工作的,所以会发生适当的销毁,一切都会正常工作。

有一个缺点:这个类只对string指针的有效。如果你想让一个Stack与其他类型的对象一起工作,你必须写一个新版本的类,这样它只能与你的新类型的对象一起工作。这很快变得乏味,最终使用模板解决,正如你将在第十六章中看到的。

我们可以对这个例子做一个额外的观察:它在继承的过程中改变了Stack的接口。如果接口不同,那么一个StringStack就真的不是一个Stack,你将永远无法正确地使用一个StringStack作为一个Stack。这使得继承的使用在这里受到质疑;如果你没有创建一个属于 Stack类型StringStack,那么你为什么要继承?更合适的版本StringStack将在本章稍后展示。

不自动继承的函数

并非所有函数都自动从基类继承到派生类中。构造器和析构函数处理对象的创建和析构,它们只能知道如何处理特定类的对象方面,因此必须调用它们下面层次结构中的所有构造器和析构函数。因此,构造器和析构函数不继承,必须专门为每个派生类创建。

此外,operator=不继承,因为它执行类似构造器的活动。也就是说,仅仅因为你知道如何从一个右边的对象分配一个=左边的对象的所有成员,并不意味着分配在继承后仍然有相同的意义。

如果您没有自己创建这些函数,编译器会合成这些函数来代替继承。

image 注意对于构造器,你不能为了让编译器合成默认构造器和复制构造器而创建任何构造器。

这在第六章中有简要描述。合成的构造器使用基于成员的初始化,合成的operator=使用基于成员的赋值。清单 14-10 展示了一个由编译器合成的函数的例子。

清单 14-10 。图示合成函数

//: C14:SynthesizedFunctions.cpp
// Functions that are synthesized by the compiler
#include <iostream>
using namespace std;

classGameBoard {
public:
  GameBoard() { cout << "GameBoard()\n"; }
  GameBoard(constGameBoard&) {
    cout << "GameBoard(constGameBoard&)\n";
  }
  GameBoard& operator=(constGameBoard&) {
    cout << "GameBoard::operator=()\n";
    return *this;
  }
  ∼GameBoard() { cout << "∼GameBoard()\n"; }
};

class Game {
  GameBoard gb; // Composition
public:
  // Default GameBoard constructor called:
  Game() { cout << "Game()\n"; }
  // You must explicitly call the GameBoard
  // copy-constructor or the default constructor
  // is automatically called instead:
  Game(const Game& g) : gb(g.gb) {
    cout << "Game(const Game&)\n";
  }
  Game(int) { cout << "Game(int)\n"; }
  Game& operator=(const Game& g) {
    // You must explicitly call the GameBoard
    // assignment operator or no assignment at
    // all happens for gb!
    gb = g.gb;
    cout << "Game::operator=()\n";
    return *this;
  }
  class Other {}; // Nested class
  // Automatic type conversion:
operator Other() const {
    cout << "Game::operator Other()\n";
    return Other();
  }
  ∼Game() { cout<< "∼Game()\n"; }
};

class Chess : public Game {};

void f(Game::Other) {}

class Checkers : public Game {
public:
  // Default base-class constructor called:
  Checkers() { cout << "Checkers()\n"; }
  // You must explicitly call the base-class
  // copy constructor or the default constructor
  // will be automatically called instead:
  Checkers(const Checkers& c) : Game(c) {
    cout << "Checkers(const Checkers& c)\n";
  }
  Checkers& operator=(const Checkers& c) {
    // You must explicitly call the base-class
    // version of operator=() or no base-class
    // assignment will happen:
    Game::operator=(c);
    cout << "Checkers::operator=()\n";
    return *this;
  }
};

int main() {
  Chess d1;      // Default constructor
  Chess d2(d1);  // Copy-constructor
//! Chess d3(1); // Error: no int constructor
  d1 = d2;       // Operator= synthesized
  f(d1);         // Type-conversion IS inherited
  Game::Other go; /* This declaration is only fordemonstrating to you the next line of code which has been commented out for obvious reasons!(otherwise, the program will not compile!!)*/
//!  d1 = go;    // Operator= not synthesized
                 // for differing types
  Checkers c1, c2(c1);
  c1 = c2;
} ///:∼

GameBoardGame的构造器和operator=声明它们自己,这样你就可以看到它们何时被编译器使用。另外,operator Other()执行从Game对象到嵌套类Other对象的自动类型转换。类Chess只是从Game继承而来,没有创建任何函数(看看编译器如何响应)。函数f()接受一个Other对象来测试自动类型转换函数。

main()中,调用派生类Chess的合成默认构造器和复制构造器。这些构造器的Game版本作为构造器调用层次的一部分被调用。即使看起来像继承,新的构造器实际上是由编译器合成的。正如您所料,没有带参数的构造器会被自动创建,因为这对编译器来说太复杂了。

operator=也被合成为一个使用成员赋值的Chess中的新函数(因此基类版本被调用),因为该函数没有显式地写在新类中。当然,析构函数是由编译器自动合成的。

由于所有这些关于重写处理对象创建的函数的规则,最初看起来可能有点奇怪,自动类型转换操作符继承的。但是这也不是太不合理——如果在Game中有足够多的片段来组成一个Other对象,那么这些片段仍然存在于从Game派生的任何东西中,并且类型转换操作符仍然有效(即使你可能实际上想要重新定义它)。

operator=是合成的只用于分配同类型的对象。如果你想把一种类型赋给另一种类型,你必须自己写这个operator=

如果你仔细观察Game,你会发现复制构造器和赋值操作符对成员对象复制构造器和赋值操作符有明确的调用。你通常会想这样做,因为否则,在复制构造器的情况下,将使用默认的成员对象构造器,而在赋值操作符的情况下,根本不会对成员对象进行赋值!

最后,看看Checkers,它明确写出了默认构造器、复制构造器和赋值操作符。在默认构造器的情况下,默认基类构造器被自动调用,这通常是您想要的。但是,这是很重要的一点,一旦你决定写你自己的复制构造器和赋值操作符,编译器就认为你知道你在做什么,不会像在合成函数中那样自动调用基类版本。如果你想让基类版本被调用(,你通常会这么做,那么你必须自己显式地调用它们。在Checkers复制构造器中,这个调用出现在构造器初始化列表中:

Checkers(const Checkers& c) : Game(c) {

Checkers赋值操作符中,基类调用是函数体的第一行,如:

Game::operator=(c);

这些调用应该是您在继承类时使用的规范形式的一部分。

继承和静态成员函数

static成员函数的作用与非static成员函数相同。

  1. 它们继承到派生类中。
  2. 如果重新定义静态成员,基类中所有其他重载函数都将隐藏。
  3. 如果你改变了基类中一个函数的签名,那么这个函数名的所有基类版本都被隐藏了(这实际上是上一点的一个变种)。

然而,static成员函数不能是virtual(这个话题在第十五章中有详细介绍)。

选择构图 vs .继承

组合和继承都将子对象放在新类中。两者都使用构造器初始化列表来构造这些子对象。您现在可能想知道这两者之间的区别,以及何时选择一个而不是另一个。

当您希望在新类中包含现有类的功能,而不是其接口时,通常会使用组合。也就是说,您嵌入了一个对象来实现新类的特性,但是新类的用户看到的是您定义的接口,而不是原始类的接口。要做到这一点,您需要遵循在新类中嵌入现有类的private对象的典型路径。

然而,有时允许类用户直接访问新类的组成是有意义的,也就是说,使成员对象public。成员对象自己使用访问控制,所以这是一件安全的事情,当用户知道你在组装一堆部件时,这使得界面更容易理解。

一个Car类就是一个很好的例子;见清单 14-11 。

清单 14-11 。图解公共构图

//: C14:Car.cpp
// Public composition

class Engine {
public:
  void start() const {}
  void rev() const {}
  void stop() const {}
};

class Wheel {
public:
  void inflate(int psi) const {}
};

class Window {
public:
  void rollup() const {}
  void rolldown() const {}
};

class Door {
public:
  Window window;
  void open() const {}
  void close() const {}
};

class Car {
public:
  Engine engine;
  Wheel wheel[4];
  Door left, right; // 2-door
};

int main() {
  Car car;
  car.left.window.rollup();
  car.wheel[0].inflate(72);
} ///:∼

因为Car的组成是问题分析的一部分(,而不仅仅是底层设计的一部分,所以使成员public有助于客户程序员理解如何使用该类,并且对于类的创建者来说,需要更少的代码复杂性。

稍微思考一下,你就会发现使用“车辆”对象来组成Car是没有意义的——汽车不包含车辆,它车辆。是-a 关系用继承表示,是-a 关系用合成表示。

子类型

现在假设您想要创建一种类型的ifstream对象,它不仅可以打开一个文件,还可以跟踪文件的名称。你可以使用组合并在新类中嵌入一个ifstream和一个string,如清单 14-12 中的所示。

清单 14-12 。使用复合嵌入 ifstream 和字符串(文件名)

//: C14:FName1.cpp
// An ifstream with a file name
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;

class FName1 {
 ifstream file;
  string fileName;
  bool named;
public:
  FName1() : named(false) {}
  FName1(const string &fname)
    : fileName(fname), file(fname.c_str()) {
    assure(file, fileName);
    named = true;
  }
string name() const { return fileName; }
void name(const string &newName) {
    if(named) return; // Don't overwrite
    fileName = newName;
    named = true;
  }
  operator ifstream&() { return file; }
};

int main() {
  FName1 file("FName1.cpp");
  cout << file.name() << endl;
  // Error: close() not a member:
//!  file.close();
} ///:∼

然而,这里有一个问题。通过包含从FName1ifstream&的自动类型转换操作符,试图允许在任何使用ifstream对象的地方使用FName1对象。但是在main(),这条线

file.close();

将不编译,因为自动类型转换只发生在函数调用中,而不是在成员选择期间。所以这种做法行不通。

第二种方法是将close()的定义添加到FName1中,如下所示:

void close() { file.close(); }

如果您只希望从ifstream类中引入几个函数,这将是可行的。在这种情况下,您只使用了类的一部分,并且组合是合适的。

但是如果你想让班上的所有人都通过呢?这被称为子类型化,因为你正在从一个现有类型创建一个新类型,并且你希望你的新类型具有与现有类型完全相同的接口(加上你想要添加的任何其他成员函数),所以你可以在你使用现有类型的任何地方使用它。这就是继承的重要性。在清单 14-13 中,你可以看到子类型完美地解决了前面例子中的问题(清单 14-12 )。

清单 14-13 。说明子类型解决了清单 14-12 中的问题

//: C14:FName2.cpp
// Subtyping solves the problem
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;

class FName2 : public ifstream {
  string fileName;
  bool named;
public:
  FName2() : named(false) {}
  FName2(const string &fname)
    : ifstream(fname.c_str()), fileName(fname) {
    assure(*this, fileName);
    named = true;
  }
  string name() const { return fileName; }
  void name(const string &newName) {
    if(named) return; // Don't overwrite
    fileName = newName;
    named = true;
  }
};

int main() {
  FName2 file("FName2.cpp");
  assure(file, "FName2.cpp");
  cout << "name: " << file.name() << endl;
  string s;
  getline(file, s); // These work too!
  file.seekg(-200, ios::end);
  file.close();
} ///:∼

现在,ifstream对象可用的任何成员函数都可以用于FName2对象。您还可以看到,像getline()这样期望使用ifstream的非成员函数也可以使用FName2。那是因为一个FName2 的一种ifstream;它不仅仅包含一个。这是一个非常重要的问题,将在本章末尾和下一章探讨。

私人继承

您可以通过在基类列表中去掉public或者显式地声明private来私有地继承一个基类(这可能是一个更好的策略,因为用户很清楚您的意思)。当你私有继承的时候,你是在“实现;也就是说,您正在创建一个新的类,它具有基类的所有数据和功能,但是该功能是隐藏的,所以它只是底层实现的一部分。类用户不能访问底层的功能,一个对象不能被当作基类的一个实例(就像在FName2.cpp中一样)。

您可能想知道private继承的目的是什么,因为使用 composition 在新类中创建一个private对象的替代方法似乎更合适。private继承被包含在语言中是为了完整性,但是如果仅仅是为了减少混乱,你通常会希望使用复合而不是private继承。但是,有时您可能希望生成与基类相同的接口的一部分,并且不允许将该对象视为基类对象。private遗传提供了这种能力。

登报私下继承成员

当你私有继承时,基类的所有public成员都变成了private。如果你想让它们中的任何一个可见,只需说出它们的名字(没有参数或返回值)以及派生类的public部分中的using关键字,如清单 14-14 所示。

清单 14-14 。展示私有继承

//: C14:PrivateInheritance.cpp

class Pet {

public:
  char eat() const { return 'a'; }
  int speak() const { return 2; }

  float sleep() const { return 3.0; }
  float sleep(int) const { return 4.0; }
};

class Goldfish : Pet { // Private inheritance

public:

  using Pet::eat;      // Name publicizes member
  using Pet::sleep;    // Both overloaded members exposed
};

int main() {
  Goldfish bob;
  bob.eat();
  bob.sleep();
  bob.sleep(1);
//! bob.speak();       // Error: private member function
} ///:∼

因此,如果你想隐藏基类的部分功能,private继承是有用的。

请注意,给出/公开重载函数的名称会公开基类中重载函数的所有版本。在用private继承代替复合之前,你要慎重考虑;当结合运行时类型识别时,继承有特殊的复杂性

image 运行时类型识别在第二十章的中讨论。

受保护的关键字

既然已经向您介绍了继承,关键字protected终于有了意义。在理想的世界中,private成员总是严格保密的,但是在实际项目中,有时你想对外界隐藏一些东西,但是允许派生类的成员访问。protected这个关键词是对实用主义的肯定;它说,“对于类用户来说,这个 private ,但是对于从这个类继承的任何人都是可用的。

最好的方法是保留数据成员private——您应该始终保留更改底层实现的权利。然后,您可以通过protected成员函数允许对您的类的继承者进行受控访问;参见清单 14-15 。

清单 14-15 。说明 protected 关键字的用法

//: C14:Protected.cpp
// The protected keyword
#include <fstream>
using namespace std;

class Base {
  int i;
protected:
  int read() const { return i; }
  void set(int ii) { i = ii; }
public:
  Base(int ii = 0) : i(ii) {}
  int value(int m) const { return m*i; }
};

class Derived : public Base {
  int j;
public:
  Derived(int jj = 0) : j(jj) {}
  void change(int x) { set(x); }
};

int main() {
  Derived d;
  d.change(10);
} ///:∼

你会在本书后面的例子中找到需要protected的例子。

受保护的继承

当你继承时,基类默认为private,这意味着所有的公共成员函数对新类的用户来说都是private。通常,你将继承public,这样基类的接口也是派生类的接口。但是,您也可以在继承过程中使用protected关键字。

受保护的派生对其他类来说意味着“??”实现了“??”,但是对于派生类和友元来说,“??”是“??”。这是一个你不经常使用的东西,但是为了完整起见,它在语言中。

运算符重载和继承

除了赋值运算符之外,其他运算符都会自动继承到派生类中。这可以通过从C12:Byte.h继承来证明,如清单 14-16 所示。

清单 14-16 。阐释重载运算符的继承

//: C14:OperatorInheritance.cpp
// Inheriting overloaded operators
#include "../C12/Byte.h"      // Refer Chapter 12
#include <fstream>
using namespace std;
ofstream out("ByteTest.out");

class Byte2 : public Byte {
public:
  // Constructors don't inherit:
  Byte2(unsigned char bb = 0) : Byte(bb) {}
  // operator= does not inherit, but
  // is synthesized for memberwise assignment.
  // However, only the SameType = SameType
  // operator= is synthesized, so you have to
  // make the others explicitly:
  Byte2& operator=(const Byte& right) {
    Byte::operator=(right);
    return *this;
  }
  Byte2& operator=(inti) {
    Byte::operator=(i);
    return *this;
  }
};

// Similar test function as in C12:ByteTest.cpp:
void k(Byte2& b1, Byte2& b2) {
  b1 = b1 * b2 + b2 % b1;

  #define TRY2(OP) \
    out << "b1 = "; b1.print(out); \
    out << ", b2 = "; b2.print(out); \
    out << ";  b1 " #OP " b2 produces "; \
    (b1 OP b2).print(out); \
    out << endl;

  b1 = 9; b2 = 47;
  TRY2(+) TRY2(-) TRY2(*) TRY2(/)
  TRY2(%) TRY2(^) TRY2(&) TRY2(|)
  TRY2(<<) TRY2(>>) TRY2(+=) TRY2(-=)
  TRY2(*=) TRY2(/=) TRY2(%=) TRY2(^=)
  TRY2(&=) TRY2(|=) TRY2(>>=) TRY2(<<=)
  TRY2(=) // Assignment operator

  // Conditionals:
  #define TRYC2(OP) \
    out << "b1 = "; b1.print(out);
    out << ", b2 = "; b2.print(out); \
    out << ";  b1 " #OP " b2 produces "; \
    out << (b1 OP b2); \
    out << endl;

  b1 = 9; b2 = 47;
  TRYC2(<) TRYC2(>) TRYC2(==) TRYC2(!=) TRYC2(<=)
TRYC2(>=) TRYC2(&&) TRYC2(||)

  // Chained assignment:
  Byte2 b3 = 92;
  b1 = b2 = b3;
}

int main() {
  out << "member functions:" << endl;
  Byte2 b1(47), b2(9);
  k(b1, b2);
} ///:∼

测试代码与C12:ByteTest.cpp(参见清单 12-4 )中的代码相同,除了使用Byte2代替Byte。通过这种方式,所有的操作符都可以通过继承与Byte2一起工作。

当您检查类Byte2时,您将看到构造器必须被显式定义,并且只有将Byte2分配给Byte2operator=被合成;你需要的任何其他赋值操作符,你必须自己合成。

多重继承

您可以从一个类继承,因此一次从多个类继承似乎是有意义的。确实可以,但是作为设计的一部分,它是否有意义是一个持续争论的话题。有一点是大家都同意的:除非你已经编程很长时间了,并且完全理解了这门语言,否则你不应该尝试这样做。到那个时候,你可能会意识到,无论你多么认为你绝对必须使用多重继承,你几乎总是可以逃脱单一继承。

最初,多重继承似乎很简单:在继承过程中,在基类列表中添加更多的类,用逗号分隔。然而,多重继承引入了许多模糊的可能性,这就是为什么后面的一章(事实上,本书的最后一章,第二十一章)专门讨论这个主题。

增量开发

继承和组合的优点之一是,它们支持增量开发,允许您引入新代码,而不会导致现有代码中的错误。如果 bug 确实出现了,它们会被隔离在新代码中。通过从(继承或用组合一个现有的函数类,添加数据成员和成员函数(,并在继承过程中重新定义现有的成员函数),您可以保留现有的代码——其他人可能仍在使用——不被触及,不被绑定。如果一个 bug 发生了,你知道它在你的新代码中,这比你修改现有代码要短得多,也更容易阅读。

令人惊讶的是,这些类是如此清晰地分开的。为了重用代码,您甚至不需要成员函数的源代码,只需要描述类的头文件和带有已编译成员函数的目标文件或库文件。

image 无论是继承还是作曲都是如此。

重要的是要认识到程序开发是一个渐进的过程,就像人类的学习一样。你可以做尽可能多的分析,但当你着手一个项目时,你仍然不会知道所有的答案。如果你开始像一个有机的、进化的生物一样“成长”你的项目,而不是像一个玻璃盒子摩天大楼一样一次完成,你会有更多的成功和更直接的反馈。

尽管实验性的继承是一种有用的技术,但是在事情稳定下来后的某个时刻,你需要重新审视你的类层次结构,着眼于将它压缩成一个合理的结构。请记住,在这一切之下,继承意味着表达这样一种关系:“这个新类是那个旧类的一种类型。”你的程序不应该关心推来推去,而是应该创建和操作各种类型的对象,用问题空间给你的术语来表达一个模型。

向上投射

在本章的前面,你看到了从ifstream派生的一个类的对象如何拥有一个ifstream对象的所有特征和行为。在FName2.cpp中,任何ifstream成员函数都可以被一个FName2对象调用。

然而,继承最重要的方面不是它为新类提供了成员函数。它表达了新类和基类之间的关系。这种关系可以概括为:“新的是现有的一种类型。”

这种描述不仅仅是一种解释继承的奇特方式——它直接受到编译器的支持。例如,考虑一个表示乐器的基类Instrument和一个派生类Wind。因为继承意味着基类中的所有函数在派生类中也是可用的,所以您可以发送到基类的任何消息也可以发送到派生类。所以如果Instrument类有一个play()成员函数,那么Wind仪器也会有。这意味着你可以准确地说Wind对象也是Instrument的一种类型。清单 14-17 显示了编译器是如何支持这个概念的。

清单 14-17 。说明继承和向上转换和

//: C14:Instrument.cpp
// Inheritance & upcasting
enum note { middleC, Csharp, Cflat }; // Etc.

class Instrument {
public:
  void play(note) const {}
};

// Wind objects are Instruments
// because they have the same interface:
class Wind : public Instrument {};

void tune(Instrument &i) {
  // ...
  i.play(middleC);
}

int main() {
  Wind flute;
 tune(flute); // Upcasting
} ///:∼

这个例子中有趣的是tune()函数,它接受一个Instrument引用。然而,在main()中,tune()函数是通过传递一个对Wind对象的引用来调用的。鉴于 C++ 非常注重类型检查,接受一种类型的函数很容易接受另一种类型,这似乎很奇怪,直到你意识到一个Wind对象也是一个Instrument对象,并且没有一个函数tune()可以调用一个不在Wind中的Instrument(这是继承所保证的)。在tune()内部,代码为Instrument和从Instrument派生的任何东西工作,将Wind引用或指针转换成Instrument引用或指针的行为被称为向上转换。

为什么是“上抛?”

这个术语的由来是历史的,是基于传统上绘制类继承图的方式:根在页面的顶部,向下生长。

image 当然,你可以用任何你觉得有用的方式来画你的图。

Instrument.cpp的继承图如图 14-1 中的所示。

9781430260943_Fig14-01.jpg

图 14-1 。仪器继承图

从 derived 到 base 的转换在继承图上向上移动,所以它通常被称为向上转换。向上转换总是安全的,因为你正在从一个更具体的类型转换到一个更一般的类型——类接口唯一可能发生的事情是它可能丢失成员函数,而不是获得它们。这就是为什么编译器允许向上转换,而不需要任何显式转换或其他特殊符号。

向上转换和复制构造器

如果你允许编译器为一个派生类合成一个复制构造器,它会自动调用基类的复制构造器,然后所有成员对象的复制构造器(对内置类型执行位复制)所以你会得到正确的行为,如清单 14-18 所示。

清单 14-18 。演示复制构造器的正确创建

//: C14:CopyConstructor.cpp
// Correctly creating the copy-constructor
#include <iostream>
using namespace std;

class Parent {
  int i;
public:
  Parent(int ii) : i(ii) {
   cout << "Parent(int ii)\n";
  }
Parent(const Parent& b) : i(b.i) {
  cout<< "Parent(const Parent&)\n";
  }
Parent() : i(0) { cout << "Parent()\n"; }
friend ostream&
    operator <<(ostream& os, const Parent& b) {
    return os << "Parent: " << b.i << endl;
}
};

class Member {
  int i;
public:
  Member(int ii) : i(ii) {
    cout << "Member(int ii)\n";
  }
  Member(const Member& m) : i(m.i) {
    cout << "Member(const Member&)\n";
  }
friend ostream&
    operator<<(ostream& os, const Member& m) {
    return os << "Member: " << m.i<< endl;
  }
};

class Child : public Parent {
  int i;
  Member m;
public:
  Child(int ii) : Parent(ii), i(ii), m(ii) {
    cout << "Child(int ii)\n";
  }
friend ostream&
  operator<<(ostream& os, const Child& c){
  return os << (Parent&)c << c.m
            << "Child: " << c.i << endl;
  }
};

int main() {
  Child c(2);
  cout << "calling copy-constructor: " << endl;
  Child c2 = c; // Calls copy-constructor
  cout << "values in c2:\n" << c2;
} ///:∼

Childoperator<<很有趣,因为它调用其中Parent部分的operator<<的方式:通过将Child对象强制转换为Parent&(如果您强制转换为基类对象而不是引用,通常会得到不希望的结果):

return os << (Parent&)c << c.m

由于编译器随后将其视为Parent,它调用operator<<Parent版本。

您可以看到Child没有显式定义的复制构造器。然后编译器通过调用Parent复制构造器和Member复制构造器来合成复制构造器(因为这是它将要合成的四个函数之一,还有默认构造器——如果你没有创建任何构造器的话——还有operator=和析构函数)。这显示在输出中:

Parent(int ii)
Member(int ii)
Child(int ii)
calling copy-constructor:
Parent(const Parent&)
Member(const Member&)
values in c2:
Parent: 2
Member: 2
Child: 2

然而,如果你试图为Child编写自己的复制构造器,却犯了一个无心的错误,而且做得很糟糕,比如

Child(const Child& c) : i(c.i), m(c.m) {}

然后,默认的构造器将被自动调用为Child的基类部分,因为当编译器没有其他选择调用构造器时,它就依靠默认的构造器(记住,必须总是为每个对象调用某个构造器,不管它是否是另一个类的子对象)。输出将会是

Parent(int ii)
Member(int ii)
Child(int ii)
calling copy-constructor:
Parent()
Member(const Member&)
values in c2:
Parent: 0
Member: 2
Child: 2

这可能不是您所期望的,因为通常您希望基类部分作为复制构造的一部分从现有对象复制到新对象。

要修复这个问题,您必须记住,每当您编写自己的复制构造器时,都要正确地调用基类复制构造器(就像编译器那样)。乍一看,这似乎有点奇怪,但这是向上投射的另一个例子:

Child(const Child& c)
    : Parent(c), i(c.i), m(c.m) {
    cout << "Child(Child&)\n";
 }

奇怪的地方在于Parent复制构造器被称为Parent(c)。将一个Child对象传递给一个Parent构造器是什么意思?但是Child是从Parent继承的,所以一个Child引用就是一个Parent引用。基类复制构造器调用将对Child的引用提升为对Parent的引用,并使用它来执行复制构造。当你写自己的复制构造器时,你几乎总是想做同样的事情。

组合与继承(重温)

确定应该使用复合还是继承的一个最清楚的方法是询问你是否需要从新类进行向上转换。在本章的前面,Stack类是使用继承来专门化的。然而,StringStack对象可能只会被用作string容器,永远不会向上转换,所以更合适的选择是组合;参见清单 14-19 。

清单 14-19 。比较合成和继承

//: C14:InheritStack2.cpp
// Composition vs. inheritance
#include "../C09/Stack4.h"
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;

class StringStack {
  Stack stack; // Embed instead of inherit
public:
  void push(string* str) {
    stack.push(str);
  }
  string* peek() const {
    return (string*)stack.peek();
  }
  string* pop() {
    return (string*)stack.pop();
  }
};

int main() {
  ifstream in("InheritStack2.cpp");
  assure(in, "InheritStack2.cpp");
  string line;
  StringStack textlines;
  while(getline(in, line))
    textlines.push(new string(line));
  string* s;
  while((s = textlines.pop()) != 0) // No cast!
    cout << *s << endl;
} ///:∼

该文件与InheritStack.cpp ( 清单 14-9 )相同,除了在StringStack中嵌入了一个Stack对象,并为嵌入的对象调用成员函数。仍然没有时间或空间开销,因为子对象占用相同的空间,所有额外的类型检查都发生在编译时。

尽管这可能会更令人困惑,但是您也可以使用private继承来表达“根据…实现”这也将充分解决问题。然而,它变得重要的一个地方是多重继承可能是正当的。在这种情况下,如果您看到一个设计中可以使用复合而不是继承,您可能能够消除多重继承的需要。

指针和参考向上投射

Instrument.cpp中,向上转换发生在函数调用期间——函数外部的Wind对象被引用,并成为函数内部的Instrument引用。向上转换也可能发生在对指针或引用的简单赋值过程中:

Wind w;
Instrument* ip = &w; // Upcast
Instrument& ir = w;  // Upcast

像函数调用一样,这两种情况都不需要显式强制转换。

危机时刻

当然,任何向上转换都会丢失关于对象的类型信息。如果你说

Wind w;
Instrument* ip = &w;

编译器只能将ip作为一个Instrument指针来处理,除此之外别无他法。也就是说,它不能知道ip?? 实际上恰好指向一个Wind对象。所以当你调用play()成员函数的时候

ip->play(middleC);

编译器只能知道它在为一个Instrument指针调用play(),并调用Instrument::play()的基类版本,而不是它应该做的,即调用Wind::play()。所以你不会得到正确的行为。

这是一个重大问题;在第十五章中通过引入面向对象编程的第三个基石:*多态,*借助virtual 函数在 C++ 中实现。

审查会议

  1. 继承和组合 都允许你从现有类型中创建一个新类型,并且都将现有类型的子对象嵌入到新类型中。
  2. 但是,通常情况下,当您希望强制新类型与基类的类型相同时,可以使用组合来重用现有类型,作为新类型和继承的基础实现的一部分(by the way,类型等价保证接口等价)。由于派生类有基类接口,它可以向上转换到基类,这对于多态是至关重要的,正如你将在第十五章中看到的。
  3. 尽管通过组合和继承的代码重用对于快速项目开发非常有帮助,但是在允许其他程序员依赖它之前,您通常会想要重新设计您的类层次结构。
  4. 你的目标是一个层次结构,其中每个类都有一个特定的用途,并且既不太大(也就是说,包含了太多的功能,以至于难以重用),也不太小(也就是说,你不能单独使用它或者不添加功能)。