C++-数据抽象入门

一、假定数据是如何存储的

  隐藏某些实现逻辑时,我们是想要隐藏绘制子弹的细节。我们是通过使用一个可以调用的函数,而不是直接写出绘制子弹到屏幕上的代码来实现的。这里同样可以使用一个函数来隐藏棋盘存储的细节。不直接访问数组,而是调用一个访问数组的函数。例如,你可以写一个像下面这个getPiece一样的函数:

  

int getPiece (int x, int y)
{
  return board[x][y];
}

  我们发现上面的函数需要两个参数,然后它返回一个数值,就像访问数组一样。这样做并没有让你少写代码,因为需要传入的参数和之前一样——一个x坐标和一个y坐标。所不同的是访问棋盘的方式现在被隐藏在这个函数中了。你其余的代码中可以(并且应该)调用这个函数来访问数组。然后,如果你决定改变棋盘的存储方式,可以仅仅修改这个函数——其他的地方不受影响。

  使用函数来隐藏细节的思想有时称为函数抽象。应用函数抽象意味着你应当把任何重复的操作放到一个函数中——让这个函数为调用者指定输入和输出,但避免让调用者知道这个函数是如何实现的。这里的如何实现可以是使用的算法,或者是使用的数据结构。该函数允许它的调用者利用它所提供的接口的可靠性承诺,从而不需要知道这个函数是如何实现的。

  这里有一些使用函数来隐藏数据和算法的好处。

  1.让以后的工作更加轻松。你只需要使用一个之前写的函数就行了,而不是一直记着怎样实现算法逻辑。只要你相信该函数对于合法的输入都能正常工作,就可以信任它的输出而不需要记得它是如何工作的。
  2.一旦你能够信任某个函数“可以工作”,就可以开始一遍遍地使用它来写代码解决问题。你无需担心任何细节(像如何访问棋盘),这样就可以专注于解决新的问题(比如如何实现AI)。
  3.如果发现逻辑中有个错误,你不需要修改代码中的很多地方,只需要修改一个函数而已。
  4.如果通过函数来隐藏数据结构,你同样也会增强自己存储和表现数据的灵活性。你可以先用效率不高但是便于编写的方式,然后如果有需要的话,再把它替换成更快速高效的实现方式,完成这些只需要修改少数几个函数,别的都不用动。

  隐藏结构化数据的表示

  到目前为止,你已经看到如何隐藏存储在全局变量或者数组中的数据。隐藏数据并不局限于这几个例子。创建结构体时往往是你最想隐藏数据的时候之一。这可能让你觉得奇怪:毕竟一个结构体有一个非常特殊的布局和可以存储的一系列数值。当你以一组字段的方式看待它们时,结构体无法提供隐藏实现细节的方式(例如它们以何种形式存储哪些字段)。实际上,你可能觉得奇怪:“难道一个结构体的全部意义不是为了提供一些特定的数据吗?为什么要隐藏这些数据的表示呢?”事实证明,还可以用另外一种方式来思考结构体,在这种方式下的确需要隐藏数据。

  大部分时候,当有一堆相关的数据,真正重要的并不是你如何存储这些数据而是用这些数据做什么。这一点非常重要,它可以成为一个观念变革。所以我将再重复一遍:真正重要的并不是如何存储数据,而是如何使用数据

  由于粗体文本并不总是能够一看就明了,让我们举一个简单例子——字符串。除非你真正自己实现字符串类,否则无所谓怎么存储字符串。对于任何一段运用字符串的代码,重要的是如何得到字符串的长度、访问单个字符或者显示字符串。字符串的实现可能使用一个字符数组,然后用另一个变量来存储长度,也可以使用一个链表,或者使用一个你从来没听说过的C++的特性。

  作为字符串的使用者,无所谓字符串是怎么实现的——重要的是可以用字符串做什么。你可以做许多事,但就算是C++字符串也只可以做约35种操作——而且大部分时候它们中的大部分操作都是用不到的。

  你将经常需要的是在不暴露实现某个数据类型的原始数据的基础上创建新的该数据类型的能力。例如,当创建一个字符串时,你不需要担心保存字符的缓冲区。STL向量和映射正是这样工作的;你不需要为了使用它们而去了解它们的实现方式——所要注意的是,当使用一个STL向量时,它的实现可能是像用胡萝卜来喂食超活跃的兔子,同时注意组织上的小技巧。

  使用函数来隐藏结构的布局

  你可以通过创建与结构体相关联的函数来隐藏具体的字段。例如,想象一个小棋盘代表局势和双方的移动(白色或黑色)。我们将使用枚举类型来存储棋子和将要走棋的玩家:

enum ChessPiece { EMPTY_SQUARE, WHITE_PAWN /* 其他变量 */ };
enum PlayerColor { PC_WHITE, PC_BLACK };
struct ChessBoard
{
  ChessPiece board[ 8 ][ 8 ];
  PlayerColor whose_move;
};

  你可以创建操作棋盘的函数,把棋盘作为该函数的参数:

  

ChessPiece getPiece (const ChessBoard *p_board, int x, int y)
{
  return p_board->board[ x ][ y ];
}
PlayerColor getMove (const ChessBoard *p_board)
{
  return p_board->whose_move;
}
void makeMove (ChessBoard* p_board, int from_x, int from_y, int to_x, int to_y)
{
// 通常情况下,我们首先需要写点代码验证移动棋子的合法性
  p_board->board[to_x][to_y] = p_board->board[from_x][from_y];
  p_board->board[from_x][from_y] = EMPTY_SQUARE;
}

  你可以把它们当做其他任何一个函数一样使用:

ChessBoard b;
// 首先需要初始化棋盘
// 接下来就可以像下面这样使用它了
getMove( & b );
makeMove( & b, 0, 0, 1, 0 ); // 把一个棋子从0,0 移动到1,0 

  这是一个好方式,事实上,C语言程序员使用这种方式已经很多年了。另一方面,这些函数只与ChessBoard结构体相关联,因为它们正好把ChessBoard作为一个参数。没有地方明确地表示:“这个函数应该被当做该结构体的核心部分。”一个结构体不仅包含数据,而且包含了操纵数据的函数,这么说不是很好吗?
  C++认真考虑了这个想法并且直接把它构建到了语言中。为了支持这种风格,C++引入了方法的概念——方法就是作为某个结构体的一部分来声明的函数(在之前关于STL的部分我们接触过方法)。不像不受约束的函数和结构体没有什么关联,方法可以很简单地操作存储在结构中的数据。方法的作者把方法作为结构体的一部分来声明,这样就直接把方法与结构体联系在了一起。声明了结构体的方法部分以后,方法的调用者就不需要把该结构体作为一个单独的参数了!虽然这需要特殊的语法 。

  方法声明和调用的语法
  来看看如果把函数变成方法会怎么样:

enum ChessPiece { EMPTY_SQUARE, WHITE_PAWN /* 及其他 */ };
enum PlayerColor { PC_WHITE, PC_BLACK };
struct ChessBoard
{
  ChessPiece board[ 8 ][ 8 ];
  PlayerColor whose_move;
ChessPiece getPiece (int x, int y)
{
  return board[ x ][ y ];
}
PlayerColor getMove ()
{
  return whose_move;
}
void makeMove (int from_x, int from_y, int to_x, int to_y)
{
  // 通常情况下,我们首先需要写点代码验证移动棋子的合法性
  board[ to_x ][ to_y ] = board[ from_x ][ from_y ];
  board[ from_x ][ from_y ] = EMPTY_SQUARE;
}
};

  首先可以看到,方法是在结构体里面声明的。这很明显,这些方法应被作为该结构体的基本组成部分来看待。

  此外,这些方法声明不需要单独接收一个ChessBoard类型的参数——在方法里面,结构体所有的字段都可以直接使用。写下board[ x ][ y ]就可以直接访问该方法所在结构体的棋盘。可是代码怎么知道它所使用的方法属于哪个结构体的实例呢?(如果有不止一个ChessBoard怎么办?)

  像下面这样调用一个方法:
  

ChessBoard b;
// 初始化棋盘的代码
b.getMove();

  调用与某个结构体相关联的函数时看上去和访问该结构体的字段几乎是一样的。

  在内部,是编译器在处理如何让方法访问它所在结构体中的数据的细节。从概念上讲,< variable >.< method >的语法是将< variable >传递给< method >的简写形式。现在你明白了为什么在讲STL那一章中我们需要这个语法了吧,那些函数就像这些方法一样运作。

  把方法的定义从结构体中移出来

  把所有的函数体都包含在结构体中真的会很乱而且让人难以理解。所幸,你可以把方法拆分成一个在结构体中的声明和一个放在结构体之外的定义。例子如下:

  

enum ChessPiece { EMPTY_SQUARE, WHITE_PAWN /* 及其他*/ };
enum PlayerColor { PC_WHITE, PC_BLACK };
struct ChessBoard
{
  ChessPiece board[ 8 ][ 8 ];
  PlayerColor whose_move;
  // 在结构体中声明方法
  ChessPiece getPiece (int x, int y);
  PlayerColor getMove ();
  void makeMove (int from_x, int from_y, int to_x, int to_y);
};

  现在方法的声明在结构体内部了,但是其他方面看上去像普通函数的原型。

  方法的定义需要一些方式回头来把它们自身与结构体联系起来——我们可以使用一个特殊的“范围”语法来表示该方法是属于某个结构体的。这个语法就是像<structure name>::<method name>这样来写方法的名字,但是从其他方面来看代码没有变化:

  

ChessPiece ChessBoard::getPiece (int x, int y)
{
  return board[ x ][ y ];
}
PlayerColor ChessBoard::getMove ()
{
  return whose_move;
}
void ChessBoard::makeMove (int from_x, int from_y, int to_x, int to_y)
{
  // 通常情况下,首先需要写点代码验证移动棋子的合法性
  board[ to_x ][ to_y ] = board[ from_x ][ from_y ];
  board[ from_x ][ from_y ] = EMPTY_SQUARE;
}

  Bjarne Stroustrup在创造C++的时候,真正想强化的是由方法来定义结构体的思想,而不是实现结构体时碰巧用到的那些数据。他本来可以通过扩展已有结构体的概念来实现他想要的,但是他没有,相反他创造了一个新的概念:类。

  类就如同一个结构体,只不过它能够定义哪些方法和数据是属于类内部,哪些方法是为了提供给该类的使用者的。你应当把类的意思想作和种类一样,定义一个类的时候就是在创造一个新类别的东西或者说新种类的东西。它不再具有作为结构化数据的内涵性,相反,类是由那些它作为接口向外部提供的方法来定义的。类甚至能够防止你不小心使用其具体的实现细节。

  是这样的——在C++中,阻止不属于某个类的方法使用该类的内部数据是可以实现的。实际上,当你声明一个类的时候,默认情况就是除了该类自身的那些方法以外,没有人能够使用该类的任何内容!你得明确地表示哪些内容可以被公共访问。使数据在类以外不可访问的功能可以让编译器检查程序员没有在使用那些他们不该碰的数据。这对于程序的可维护性来说可谓是神来之笔。你可以修改类的一些基本的东西,比如棋盘的存储方式,而不用担心这样会破坏类以外的代码。

  就算项目只有你一个人在做,保证没有人能“作弊”以及看到方法的内部实现,实际上也是一件美事。其实,说方法很有用还有另外一个原因,你很快就会看到的,只有方法才能访问“内部”数据。

  从这里往后,在我想要隐藏数据存储方式的时候我都会使用类,在绝对没理由隐藏的时候我会使用结构体。你可能会惊讶于结构体用的有多稀少——数据隐藏就是这么有价值。在实现类并且需要一个辅助性的结构体来存放部分数据时,是唯一要使用结构体的时候。由于辅助性的结构体仅仅是针对这一个类的,并且不需要公开暴露,所以通常没有必要把它写成一个完整的类。如我所说,没有硬性的需求一定要这样做,但是这么做是约定俗成的。

  隐藏数据的存储方式

  我们来研究一下类里面隐藏数据的语法——你如何使用一个类来隐藏一些数据同时把一些方法提供给所有人呢?类可以让你把每个方法和字段(通常被称为类的成员)归结为公共或者私有——公共成员所有人都可以访问,私有成员只有该类中其他的成员可以访问。

  下面是个例子,将方法都声明为公共的,而所有的数据都声明成私有的:

  

enum ChessPiece { EMPTY_SQUARE, WHITE_PAWN /* 及其他 */ };
enum PlayerColor { PC_WHITE, PC_BLACK };
class ChessBoard
{
public:
  ChessPiece getPiece (int x, int y);
  PlayerColor getMove ();
  void makeMove (int from_x, int from_y, int to_x, int to_y);
private:
  ChessPiece _board[ 8 ][ 8 ];
  PlayerColor _whose_move;
};
// 方法的定义和之前完全相同!
ChessPiece ChessBoard::getPiece (int x, int y)
{
  return _board[ x ][ y ];
}
PlayerColor ChessBoard::getMove ()
{
  return _whose_move;
}
void ChessBoard::makeMove (int from_x, int from_y, int to_x, int to_y)
{
  //通常情况下,首先需要写点代码验证移动棋子的合法性
  _board[ to_x ][ to_y ] = _board[ from_x ][ from_y ];
  _board[ from_x ][ from_y ] = EMPTY_SQUARE;
}

  

  我们发现这个类的声明和之前结构体的声明看上去很像,除了一个主要的区别。我使用了两个新的关键字:public和private。任何在public关键字之后声明的东西,所有人都可以通过该类的对象来使用(在这里就是getPiece、getMove 和makeMove这些方法)。任何出现在private之后的东西,都只能被ChessBoard类自身的方法访问到(_board和_whose_move)。

  声明一个类的实例

声明一个类的实例就如同声明一个结构体的实例一样:

  

ChessBoard b;

  在类上进行方法的调用也是和结构体的一模一样:

b.getMove();

  虽然有一个小的术语上的差别。你声明某个类的一个变量时,那个变量通常被称为对象。对象这个词应当代表现实世界中事物的抽象,比如方向盘——这种暴露一个很小的接口而后面隐藏了很多复杂的东西。当你要把汽车往左转的时候,只需要打方向盘——不必担心那些齿轮是怎么工作的。你所要做的就是转动方向盘并且踩油门。所有的细节都被隐藏在一个基本的用户界面之后。

  在C++中,一个对象所有的实现细节都被隐藏在一系列公共方法的调用之后——这些方法就是组成类的“用户接口”的东西。

  一旦你定义了一个接口,类可以随意地去实现它——怎么存储数据以及方法如何去实现,都由你来决定。

  类的职责

  在你创建一个C++类的时候,把它想作创建了一个新型变量——一个新的数据类型。你的新数据类型就如同一个整型或者一个字符串,但是功能更强大。你已经看到过这种思想——在C++中,字符串是一个类,实际上,字符串类是你可以使用的一个新的数据类型。公共和私有的思想在你想要创建新的数据类型时非常有意义:你是想要为外部提供一些特定的功能和一个特定的接口。举个例子,一个字符串提供了显示自己,处理子字符串或者单个的字符,以及获取字符串长度这样的基本属性等功能。字符串自身是如何实现真的无关紧要了。
  类的生命周期
创建一个类的时候,你会想让它尽可能地易于使用。有三个基本的操作可能所有的类都需要支持:

  1.初始化自己;
  2.清理占用的内存或者别的资源;
  3.复制自己。

  这三点对于创建一个好的数据类型来说都很重要。拿字符串来做个例子:字符串需要能够初始化自身,哪怕初始化成一个空字符串。这个操作不应该依赖某些外部代码来完成——只要你声明了一个字符串,它立刻就可以为你所用。而且,在你用完字符串之后,它需要自我清理,因为字符串是分配过内存的。使用字符串时,你并不需要调用一个方法来做清理的工作;清理是自动搞定的。最后,允许变量之间相互复制也是需要的,就像一个整型数据可以从一个变量复制到另一个变量一样。综上所述,这三个功能应当成为每个类的组成部分,这样的话这些类就很容易被正确地使用并且不易被误用。
  我们一个个来分析这三个特性,从初始化对象开始,看看C++是如何让初始化很简单地实现的。
  对象构造
  可能之前你就注意到在ChessBoard的接口(类的公共部分)中并没有初始化棋盘的代码。来修正一下这个问题。

  当你声明一个类的变量时,需要有一些初始化这个变量的方式:

ChessBoard board;

  在C++中,在一个对象被声明时运行的代码称为构造函数。构造函数中应该会设置好相应的对象,这样在使用这个对象的时候就不需要再做进一步的初始化了。构造函数也可以接收参数,在声明特定大小的vector时你已经见识过了。
  

vector<int> v( 10 );

  这行代码带着参数10去调用vector的构造函数;vector的构造函数初始化一个新的vector这样它就立即可以存放10个整数。

  要创建一个构造函数,你只需简单地声明一个和类有着同样名字的方法,不接受参数也没有返回值。(返回值也不是void——字面上你都不需要为返回值指定一个类型。)
  

enum ChessPiece { EMPTY_SQUARE, WHITE_PAWN /* and others */ };
enum PlayerColor { PC_WHITE, PC_BLACK };
class ChessBoard
{
public:
  ChessBoard (); // <-- 根本没有返回值!
  PlayerColor getMove ();
  ChessPiece getPiece (int x, int y);
  void makeMove (int from_x, int from_y, int to_x, int to_y);
private:
  ChessPiece _board[8][8];
  PlayerColor _whose_move;
};
ChessBoard::ChessBoard () // <-- 仍然没有返回值
{
  _whose_move = PC_WHITE;
  // 开始先把整个棋盘清空,然后再填入棋子
for ( int i = 0; i < 8; i++ )
{
  for (int j = 0; j < 8; j++ )
  {
  _board[i][j] = EMPTY_SQUARE;
  }
}
  // 其他初始化棋盘的代码
}

  

(如果方法没有改变的话,我不会把它们所有的定义都写在这里,但会一直给你看完整的类声明,这样可以看到它们是如何整合在一起的。)

  注意,构造函数是属于类当中公共区域的一部分。如果ChessBoard构造函数不是公共的,那么就无法创建出该对象的实例。何以如此呢?每次创建对象的时候都会调用到构造函数,但是如果它是私有的,那就意味着类之外没有人能够调用到这个构造函数!由于所有的对象都必须调用构造函数来初始化,如果构造函数是私有的你根本就无法声明对象了。
  调用构造函数的地方正是创建对象的那行代码:
  

ChessBoard board; // 调用 ChessBoard 的构造函数

  或者在分配内存的地方:

  

ChessBoard *board = new board; // 调用ChessBoard的构造函数,分配内存

  如果你声明了多个对象:

ChessBoard a;
ChessBoard b;

  构造函数的运行顺序和对象声明顺序一致(先a然后b)。 

  就像普通函数一样,构造函数可以接收任意数量的参数,并且你也可以有多个参数类型不同的重载构造函数,如果想要对象可以用不同的方式来初始化的话。举个例子,你可以再写个ChessBoard的构造函数,接收棋盘的大小作为参数:
  

Class ChessBoard
{
  ChessBoard ();
  ChessBoard (int board_size);
};

  构造函数的定义和类当中其他任何方法一样:

  

ChessBoard::ChessBoard (int size)
{
  // ...代码
}

  像下面这样通过构造函数来传递参数:

  

ChessBoard board( 8 ); // 8 是传递给 ChessBoard 构造函数的一个参数

  当使用new关键字时,参数的传递就像你直接调用构造函数一样:

  

ChessBoard *p_board = new ChessBoard( 8 );

  语法上有个小的注意点——尽管你是使用括号来将参数传递给构造函数的,但是在声明一个构造函数不接受参数的对象时可不能还使用括号。

错误代码

ChessBoard board();

  上面代码正确的写法是:

  

ChessBoard board;

  然而,在使用 new 来创建对象时使用括号是没有问题的:

  

ChessBoard *board = new board();

  上面的这种情况是由于C++解析时的一个不好的怪招导致的(个中细节太过晦涩难懂)。在声明一个没有传参构造函数的对象时要避免使用括号。

  没有新建构造函数的结果

  如果你没有写构造函数,那么C++就会很友好地创造一个。自动创造的这个构造函数不接收参数,但是它会调用你类中所有字段的默认构造函数来初始化它们(虽然它不会初始化原始类型如整型或者字符串——所以要留心这一点)。我通常会建议写自己的构造函数,以确保所有的东西都按你的意愿来初始化。
  一旦为类声明了一个构造函数,C++就再也不会为你自动生成默认的构造函数了——编译器就会假定知道自己在做什么,并且假定你是想要为这个类创建所有的构造函数。尤其是,如果创建了一个接收参数的构造函数,代码就再也不会有一个默认的构造函数,除非你特地声明一个。

  这会产生吓人的后果。如果代码先前是使用自动生成的默认构造函数,然后你添加了一个自己的、接收一个或者更多参数的非默认构造函数,依赖之前自动生成的默认构造函数的代码将再也无法编译。你不得不手动地提供一个默认构造函数,因为编译器不再为你创造了。

  初始化类的成员

  类的每一个成员都需要在构造函数中来完成初始化。假设有个字符串作为ChessBoard类的一个成员:

  

class ChessBoard
{
public:
  ChessBoard ();
  string getMove ();
  ChessPiece getPiece (int x, int y);
  void makeMove (int from_x, int from_y, int to_x, int to_y);
private:
  PlayerColor _board[8][8];
  string _whose_move;
};

  当然可以简单地给_whose_move变量赋值:

  

ChessBoard::ChessBoard ()
{
  _whose_move = "white";
}

  

  尽管真正在这里执行的代码可能有点出乎我们的意料。首先,在ChessBoar d构造函数刚开始的时候,_whose_move的构造函数将会被调用。这样是有好处的因为它意味着在构造函数中你可以安全地使用类当中任何的字段——如果那些成员的构造函数不被调用,它们就无法使用——构造函数的全部意义就是让对象可以使用!
  可以给类成员的构造函数传参,如果你打算这么做,而不是直接使用默认构造函数的话。尽管这个操作的语法有点不同寻常,但是它是有效的:

ChessBoard::ChessBoard ()
// 跟在冒号后面的是变量的列表,带着传递给构造函数的参数
: _whose_move( "white" )
{
// 代码运行到这里的时候,_whose_move 的构造函数已经被调用了并且
//它已经有了值“white”
}

  

  上面语法的术语叫做初始化列表。我们后面会有几次遇到它们,并且我通常都会用这个语法来初始化类的成员。初始化列表的成员之间使用逗号分隔开。举个例子,如果给ChessBoard增加一个新的成员来计算已经走过的步数,可以像这样在初始化列表中对它进行初始化:
  

class ChessBoard
{
public:
  ChessBoard ();
  string getMove ();
  ChessPiece getPiece (int x, int y);
  void makeMove (int from_x, int from_y, int to_x, int to_y);
private:
  PlayerColor _board[8][8];
  string _whose_move;
  int _move_count;
};
ChessBoard::ChessBoard ()
// 跟在冒号后面的是变量的列表,带着传递给构造函数的参数
: _whose_move( "white" )
, _move_count( 0 )
{
}

  用初始化列表初始化常量字段

  如果定义了类中的一个字段为常量,那么这个字段就必须在初始化列表中完成初始化工作:

  

class ConstHolder
{
public:
  ConstHolder (int val);
private:
  const int _val;
};
ConstHolder::ConstHolder ()
: _val( val )
{
}

  

  你无法通过直接赋值来初始化一个常量字段因为那些常量字段都已经被固化了。初始化列表是类尚未完全形成的唯一的地方,所以在这里设置一些不可改变的对象是安全的。同样道理,如果你有个字段是引用类型的,那么它同样必须在初始化列表中完成初始化的操作。

  在讲到继承的时候我们会学到初始化列表的又一个用途。

  解构对象

  

  正如同需要构造函数来初始化一个对象一样,有时你也需要有代码来清理那些不再需要使用的对象。举个例子,如果构造函数申请分配了内存(或者其他的任何资源),然后当你的对象不再使用的时候,这些资源最终需要归还给操作系统。进行这种清除的操作称为摧毁对象,它是在一个叫作析构方法的特殊的方法内部发生的。在一个对象不再需要的时候会调用析构方法——例如在对指向一个对象的指针调用 delete 时。
  我们来看一个例子,假设有个类用来表示一个链表。要实现这个类,可能需要有一个字段来存储列表当前的头节点:
  

struct LinkedListNode
{
  int val;
  LinkedListNode *p_next;
};
class LinkedList
{
public:
  LinkedList (); // 构造函数
  void insert (int val); // 插入一个节点
private:
  LinkedListNode *_p_head;
};

  

  如之前所见到的,链表中的头节点就如同别的元素一样,指向使用new关键字来分配出的内存。这表示在某个时候,如果不再需要使用这个LinkedLis t对象了,要有一个清理它们的方式。这就是析构函数要干的活。我们来看看为这个数据类型加一个析构函数会是什么样子。和构造函数一样,析构方法也有个特殊的名称:就是在类的名字之前加一个波浪号(~),如同构造函数,析构函数也没有返回值。和构造函数所不同的是,析构函数永远不会接收任何参数。

  

class LinkedList
{
public:
  LinkedList (); // 构造函数
  ~LinkedList (); // 析构函数,注意波浪号 (~)
  void insert (int val); // 插入一个节点
private:
  LinkedListNode \*_p_head;
};
LinkedList::~LinkedList ()
{
  LinkedListNode \*p_itr = _p_head;
while ( p_itr != NULL )
{
  LinkedListNode *p_tmp = p_itr->p_next;
  delete p_itr;
  p_itr = p_tmp;
}
}

  

析构函数的代码和之前见过的删除链表中所有条目的代码相似,唯一不同的就是利用了一个类中的一个特殊方法来专门做清理工作。但是等等, 每个节点都
去清除它自己的数据不是更有意义吗?这难道不是析构函数存在的所有意义吗?如果我们这么做会怎样呢?

  

class LinkedListNode
{
public:
  ~LinkedListNode ();
  int val;
  LinkedListNode *p_next;
};
LinkedListNode::~LinkedListNode ()
{
  delete p_next;
}

  

  不管你信不信,这段代码触发了一系列的函数递归调用。这里发生的事是,使用delete就调用了p_next所指向的对象的析构函数(或者如果p_next为空的话就什么都不做)。那个被调用的析构函数紧接着又去调用delete也就是调用下一个析构函数。但是基本案例是怎样的呢?这一系列的解构器调用如何结束呢?最终p_next将会为空,在那个时候调用delete就什么也不做了。所以是有个基本的案例存在的——它只不过正好被隐藏在对delete的调用之中了。一旦我们的LinkedListNode有了这个解构器,LinkedList自己的解构器只需要简单地加上这句代码:
  

LinkedList::~LinkedList ()
{
  delete _p_head;
}

  这里调用delete开始了递归链,直到链表的最后。

  现在你可能在思考——这么做是个很好的模式,但是为什么需要一个解构器呢?难道我们就不能写个自己的方法然后按喜好来命名它吗?当然可以,但是使用解构器有个好处:在对象不再需要的时候它会被自动调用。
  那么说一个对象“不再需要了”到底是什么意思呢?它意味着下面三种情况中的一种:

  1.当你删除了一个指向对象的指针;
  2.当这个对象超出了作用域;
  3.当拥有这个对象的类的析构函数被调用了的时候。

  delete时的解构

  调用delete很明显地反应了什么时候会调用析构函数,就如同你已经见过的:

LinkedList *p_list = new LinkedList;
delete p_list; // p_list 的 ~LinkedList (析构函数)被调用了

  超出作用域时的解构

  第二种情况,一个对象超出了作用域,这是个隐含的操作。每当对象声明在大括号中时,在括号结束以后它们就超出作用域了。

if ( 1 )
{
  LinkedList list;
} // 链表的析构函数在这里调用

  

  有种稍微复杂一点的例子就是当一个对象是在函数内部声明的时候。如果函数有返回语句,析构函数就会作为离开函数所进行的操作的一部分来调用。我想,对于在代码块中声明的对象的析构函数,它是在程序离开该代码块时“在走到右括号的地方”执行的。代码块的结束是在最后一个语句执行完毕的时候,或者由一个return语句或者break语句来实现退出代码块:
  

void foo ()
{
  LinkedList list;
// 一些代码。。。
if ( /* 某个条件 */ )
{
  return;
}
} // 链表的析构函数在这里调用

  

  这种情况下,即使return是在if语句当中的,我也认为析构函数在函数走到最后一个大括号时才运行。但是,对你而言要掌握的最重要的是析构函数只在对象超出作用域时才执行——当它一被引用就出现编译错误的时候。
  如果在某段代码块的末尾有多个对象需要执行解构器的话,那些解构器的运行顺序是正好与对象们的构建顺序相反的。举个例子,在下面的代码中:

{
  LinkedList a;
  LinkedList b;
}

  b的解构器是在a的解构器之前执行的。

  由其他析构函数导致的解构

  最后,如果有个对象包含在另一个类当中,那个对象的析构函数是在类的析构函数调用之后被调用的。举个例子,如果你有个很简单的类:

  

class NameAndEmail
{
/* 正常情况下这里会有一些方法 */
private:
  string _name;
  string _email;
};

  

  在这里,_name和_email字段的析构函数会在NameAndEmail的析构函数运行结束时被调用。这很方便——你无需做任何特殊的操作来清理类中的任何对象!
  你真的只需要调用一下delete来清理那些指针(或者别的资源如文件引用或者网络连接)。

  顺便说一下,即使没有给类加个析构函数,这种情况下编译器同样会确保去执行你类中所有对象的析构函数。

  使用构造函数来初始化一个类并且使用析构函数来清理属于这个类的内存或者别的资源,这个思想有个名称:资源分配既初始化或者叫RAII。基本的意思就是在C++中,你应该通过创建类来处理资源,并且在你创建类的时候,构造函数应当负责所有初始化的工作同时析构函数需要处理所有的清理工作。不应该要求使用这个类的人去做什么特定的处理。通常,这会导致像上面NameAndEmail那样的类:两个字符串在完成使命以后会自己进行清理,这样NameAndEmail自身就不需要来实现析构函数了。






 

 




 



  

 

  

  

 

  

 

 

 






 






posted @ 2018-12-22 22:31  lemaden  阅读(763)  评论(0编辑  收藏  举报