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自身就不需要来实现析构函数了。






 

 




 



  

 

  

  

 

  

 

 

 






 






转载于:https://www.cnblogs.com/lemaden/p/10162222.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
这本经典、畅销的数据结构教材详细介绍了数据抽象的基础知识,强调作为面向对象方法基础原理的规范和实施之间的区别。书中使用的软件工程原则和概念以及UML图便于增强学生的理解。 ◆ 详细介绍了数据抽象,强调规范和实现之间的区别 ◆ 广泛介绍了各种面向对象的编程技术 ◆ 重点是核心的数据结构,而不是非必要的C++语言语法 ◆ 说明了类和ADT在问题解决过程中的作用 ◆ 诠释了ADT的主要应用,如查找航班图、事件驱动的模拟和八皇后问题 ◆ 大部分章节中的例子都使用了标准模板库(STL) ◆ 介绍了递归 ◆ 附录中提供了基本的C++语法,以帮助学生从其他语言转换为C++ 第1章 数据抽象:墙 1 1.1 面向对象的概念 2 1.1.1 面向对象分析与设计 2 1.1.2 面向对象解决方案的特征 3 1.2 获得更好的解决方案 4 1.2.1 内聚 5 1.2.2 耦合 5 1.3 规范 6 1.3.1 操作契约 7 1.3.2 特殊情况 8 1.3.3 抽象 9 1.3.4 信息隐藏 10 1.3.5 最小且完整的接口 11 1.4 抽象数据类型 12 1.4.1 设计ADT 14 1.4.2 涉及其他ADT的ADT 17 1.5 ADT包 18 1.5.1 确定行为 18 1.5.2 指定数据和操作 19 1.5.3 ADT的模板接口 22 1.5.4 使用ADT包 24 C++片段1 C++类 29 C1.1 待解决的问题 30 C1.1.1 私有数据字段 31 C1.1.2 构造函数和析构函数 32 C1.1.3 方法 32 C1.1.4 防止编译错误 33 C1.2 实现解决方案 34 C1.3 模板 35 C1.4 继承 37 C1.4.1 基类和派生类 38 C1.4.2 重写基类方法 40 C1.5 虚方法和抽象类 42 C1.5.1 虚方法 42 C1.5.2 抽象类 43 第2章 递归:镜子 45 2.1 递归解决方案 46 2.2 返回值的递归 48 2.2.1 递归值函数:n的阶乘 49 2.2.2 箱式跟踪 52 2.3 执行动作的递归 55 2.4 递归与数组 62 2.4.1 逆置数组项 63 2.4.2 折半查找 64 2.4.3 查找数组中的最大值 68 2.4.4 查找数组中第k个最小值 69 2.5 组织数据 71 2.6 更多示例 75 2.6.1 Fibonacci数列(兔子繁殖) 75 2.6.2 组织游行队伍 78 2.6.3 从n个事物中选出k个 79 2.7 递归和效率 81 第3章 基于数组的实现 91 3.1 办法 92 3.1.1 核心方法 93 3.1.2 使用大小固定的数组 93 3.2 ADT包的基于数组的实现 94 3.2.1 头文件 95 3.2.2 定义核心方法 96 3.2.3 测试核心方法 98 3.2.4 实现更多方法 101 3.2.5 删除项的方法 103 3.2.6 测试 106 3.3 在实现中使用递归 107 3.3.1 getIndexOf方法 107 3.3.2 getFrequencyOf方法 108 C++片段2 指针、多态和内存分配 113 C2.1 变量的内存分配和方法的前期绑定 114 C2.2 需要解决的问题 115 C2.3 指针与程序的自由存储 116 C2.3.1 释放内存 118 C2.3.2 避免内存泄漏 119 C2.3.3 避免悬挂指针 122 C2.4 虚方法和多态 124 C2.5 数组的动态分配 126 第4章 基于链表的实现 129 4.1 预备知识 130 4.2 ADT包的基于链表的实现 133 4.2.1 头文件 134 4.2.2 定义核心方法 135 4.2.3 实现更多方法 138 4.3 在基于链表的实现中使用递归 143 4.4 测试多个ADT实现 145 4.5 比较基于数组的实现和基于链表的实现 148 第5章 作为问题求解技术的递归 155 5.1 定义语言 156 5.1.1 语法知识基础 156 5.1.2 两种简单的语言 158 5.2 代数表达式 160 5.2.1 代数表达式的类型 160 5.2.2 前缀表达式 162 5.2.3 后缀表达式 166 5.2.4 完全括号化表达式 168 5.3 回溯 168 5.3.1 查找航线 168 5.3.2 八皇后问题 173 5.4 递归和数学归纳法的关系 179 5.4.1 递归阶乘函数的正确性 179 5.4.2 Hanoi塔的工作量 180 第6章 栈 189 6.1 ADT栈 190 6.1.1 在设计解决方案期间开发ADT 190 6.1.2 ADT栈的规范 192 6.2 栈的简单应用 197 6.2.1 检查括号匹配 197 6.2.2 识别语言中的字符串 199 6.3 栈在代数表达式中的应用 200 6.3.1 计算后缀表达式 201 6.3.2 中缀表达式与后缀表达式的等价转换 202 6.4 使用栈查找航班图 205 6.5 栈和递归的关系 212 C++片段3 异常 221 C3.1 背景知识 222 C3.2 断言 223 C3.3 抛出异常 224 C3.4 处理异常 227 C3.4.1 多个catch块 228 C3.4.2 未捕获的异常 229 C3.5 程序员定义的异常类 232 第7章 实现ADT栈 235 7.1 基于数组的实现 236 7.2 基于链表的实现 239 7.3 在实现中使用异常 243 第8章 列表 247 8.1 指定ADT列表 248 8.2 使用列表操作 252 8.3 ADT列表的模板接口 255 第9章 实现列表 259 9.1 基于数组的ADT列表实现 260 9.1.1 头文件 261 9.1.2 实现文件 262 9.2 基于链表的ADT列表实现 266 9.2.1 头文件 266 9.2.2 实现文件 268 9.2.3 在LinkedList的方法中使用递归 275 9.3 两种实现的比较 279 第10章 算法的效率 283 10.1 什么是好的解决方案 284 10.2 测量算法的效率 285 10.2.1 算法的执行时间 286 10.2.2 算法增长率 287 10.2.3 分析与大O表示法 288 10.2.4 正确分析问题 291 10.2.5 查找算法的效率 293 第11章 排序算法及其效率 299 11.1 基本排序算法 300 11.1.1 选择排序 300 11.1.2 起泡排序 303 11.1.3 插入排序 305 11.2 较快排序算法 307 11.2.1 归并排序 307 11.2.2 快速排序 312 11.2.3 基数排序 319 11.3 各种排序算法的比较 321 C++片段4 类关系和重用 325 C4.1 回顾继承 326 C4.1.1 类的公有、私有和受保护部分 331 C4.1.2 公有、私有和受保护继承 332 C4.1.3 is-a和as-a关系 333 C4.2 包含:has-a关系 334 C4.3 回顾抽象基类 335 第12章 有序表及其实现 339 12.1 指定ADT有序表 340 12.1.1 ADT有序表的模板接口 342 12.1.2 使用有序表的操作 343 12.2 基于链表的实现 344 12.2.1 头文件 344 12.2.2 实现文件 345 12.2.3 基于链表的实现的效率 348 12.3 使用ADT列表的实现 348 12.3.1 包含 349 12.3.2 公有继承 352 12.3.3 私有继承 356 第13章 队列和优先队列 363 13.1 ADT队列 364 13.2 ADT队列的简单应用 367 13.2.1 读取字符串 367 13.2.2 识别回文 368 13.3 ADT优先队列 369 13.4 应用:模拟 371 13.5 面向位置和面向值的ADT 379 第14章 队列和优先队列的实现 387 14.1 ADT队列的实现 388 14.1.1 使用ADT列表的实现 388 14.1.2 基于链表的实现 390 14.1.3 基于数组的实现 394 14.1.4 比较实现 399 14.2 ADT优先队列的实现 400 C++片段5 运算符重载和友元访问 405 C5.1 重载运算符 406 C5.1.1 重载=进行赋值 408 C5.1.2 重载+进行连接 410 C5.2 友元访问和<<的重载 411 第15章 树 415 15.1 术语 416 15.1.1 树的类型 417 15.1.2 树的高度 419 15.1.3 满二叉树、完全二叉树和平衡二叉树 421 15.1.4 二叉树的最大和最小高度 422 15.2 ADT二叉树 425 15.2.1 二叉树的遍历 425 15.2.2 二叉树的操作 428 15.2.3 ADT二叉树的模板接口 430 15.3 ADT二叉查找树 432 15.3.1 二叉查找树的操作 433 15.3.2 查找二叉查找树 434 15.3.3 创建二叉查找树 435 15.3.4 遍历二叉查找树 437 15.3.5 二叉查找树操作的效率 437 第16章 树的实现 443 16.1 二叉树中的节点 444 16.1.1 基于数组的表示 444 16.1.2 基于链表的表示 446 16.2 ADT二叉树基于链表的实现 447 16.2.1 头文件 447 16.2.2 实现 450 16.3 ADT二叉查找树基于链表的实现 458 16.3.1 ADT二叉查找树操作的算法 458 16.3.2 BinarySearchTree类 469 16.4 在文件中保存二叉查找树 471 16.5 树排序 474 16.6 一般树 474 C++片段6 迭代器 479 C6.1 迭代器 480 C6.1.1 常见的迭代器操作 481 C6.1.2 使用迭代器操作 482 C6.1.3 实现迭代器 483 C6.2 迭代器的高级功能 485 第17章 堆 489 17.1 ADT堆 490 17.2 堆的基于数组的实现 493 17.2.1 基于数组的堆操作的算法 494 17.2.2 实现 498 17.3 ADT优先队列的堆实现 502 17.4 堆排序 504 第18章 字典及其实现 511 18.1 ADT字典 512 18.2 可能的实现 517 18.2.1 ADT字典的基于数组的有序实现 519 18.2.2 ADT字典的二叉查找树实现 521 18.3 选择实现 523 18.4 散列 529 18.4.1 散列函数 532 18.4.2 解决冲突 534 18.4.3 散列的效率 539 18.4.4 如何确立散列函数 542 18.4.5 字典遍历:散列的低效操作 543 18.4.6 使用散列和分离链实现ADT字典 544 第19章 平衡查找树 551 19.1 平衡查找树 552 19.2 2-3树 553 19.2.1 遍历2-3树 555 19.2.2 查找2-3树 556 19.2.3 在2-3树中插入数据 558 19.2.4 从2-3树中删除数据 562 19.3 2-3-4树 567 19.3.1 查找和遍历2-3-4树 569 19.3.2 在2-3-4树中插入数据 569 19.3.3 从2-3-4树中删除数据 572 19.4 红-黑树 573 19.4.1 查找和遍历红-黑树 575 19.4.2 红-黑树的插入和删除 575 19.5 AVL树 577 第20章 图 583 20.1 术语 584 20.2 将图作为ADT 587 20.3 图的遍历 591 20.3.1 深度优先查找 592 20.3.2 广度优先查找 593 20.4 图的应用 595 20.4.1 拓扑排序 595 20.4.2 生成树 598 20.4.3 最小生成树 600 20.4.4 最短路径 603 20.4.5 回路 606 20.4.6 一些复杂问题 608 第21章 外部存储中的数据处理 615 21.1 了解外部存储 616 21.2 排序外部文件的数据 618 21.3 外部字典 624 21.3.1 确定外部文件的索引 626 21.3.2 外部散列 629 21.3.3 B-树 632 21.3.4 遍历 639 21.3.5 多索引 640 C++片段7 标准模板库 647 C7.1 STL容器 648 C7.1.1 STL容器适配器 649 C7.1.2 顺序容器 650 C7.1.3 关联容器 654 C7.2 STL算法 657 附录A 回顾C++基础 659 附录B 编程中的重要主题 697 附录C 统一建模语言 719 附录D 软件生命周期 727 附录E 数学归纳法 733 附录F 算法验证 737 附录G C++文件基础 741 附录H C++头文件和标准函数 751 附录I C++文档系统 755 附录J ASCII字符代码 757 附录K 针对Java编程人员的C++知识 759 附录L 针对Python编程人员的C++知识 767

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值