通常认为,面向对象编程有3个要素:数据抽象、继承以及动态绑定。这里有一个程序,虽然很小,但是非常完整地展现了这3个要素。
这些技术在大程序中比较有意义,特别是在规模大且不断修改的程序中更是如此。可惜这里没有足够的篇幅来讲解大程序,所以只给出了一个“玩具”程序。这个程序除了规模小一点之外,覆盖面是足够全面的,认真研究将会有所收获。
1. 问题描述
此程序涉及到内容是用来表示算术表达式的树。例如,表达式 (-5) * (3 + 4) 对应的树为:
一个表达式树包括代表常数、一元运算符和二元运算符的节点。这样的树结构在编译器和计算器程序中都可能用到。
我们希望能通过调用合适的函数来创建这样的树,然后打印该树的完整括号化形式。例如,我们希望
#include <iostream>
int main(){
Expr t = Expr("*", Expr("-", 5), Expr("+", 3, 4));
cout << t << endl;
t = Expr("*", t, t);
cout << t << endl;
}
打印
((-5) * (3 + 4))
((-5) * (3 + 4)) * ((-5) * (3 + 4))
作为输出。此外,我们不想为这些表达式的表示形式操心,更不想关心有关它们内存分配和回收的事宜。
这个玩具程序所做的事情在很多需要处理复杂输入的大型程序中是很典型的,例如编译器、编辑器、CAD/CAM 系统等等。此类程序中通常要花费很大的精力来处理类似树、图和类似的数据结构。这些程序的开发者永远需要面对诸如内存分配、灵活性和效率之类的问题。面向对象技术可以把这些问题局部化,从而确保今后发生的一系列变化不会要求整个程序中的其他各个部分随之做相应调整。
2. 面向对象的解决方案
第1节的图中有两个截然不同的对象:节点(用圆圈表示)和边(用箭头表示)。我们首先只考虑节点,看看能够理解到何种程度。
每个节点包含一个值-- 一个操作数或者一个操作符-- 并且每个节点又具有零个、一个或两个子节点。我们可以用一个联合来容纳具体值,用一个 List 来表示子节点,以包含联合(union)和 List 的类来表示节点。不过这种表示方法需要设置一个专门的字段来指示这个节点的类型。当需要用到一个类型字段的时候,请停下来,考虑如果定义一系列类,用继承组织起来,是否可以更有效地解决问题。
我们这里就按照这条思路考虑下去。这些类有一些共同点:每个类都要存储一个值以及一些子节点。当然也有不少不同点,比如它们存储的值的种类,子节点的数目。继承使得我们可以捕捉这些共同点,而动态绑定帮助各个节点知晓它们的身份,这样就不必让这些对象的每个操作都必须时刻留心这个问题了。
如果我们进一步考查这个树结构,会发现这里有 3 种节点。一种表示整数表达式,包含一个整数值,无子节点。另外两个分别表示一元表达式和二元表达式,包含一个操作符,分别有一个或两个子节点。我们希望打印各种节点,但是具体方式需要视要打印节点的类型而定。这就是动态绑定的用武之地了:我们可以定义一个 virtual 函数来指明应当如何打印各种节点。动态绑定将会负责在运行时基于打印节点的实际类型调用正确的函数。
那么这些节点之间的继承关系如何?每一种节点似乎都与其他节点相互独立。也就是说,一个没有子节点的节点不是“一种”有一个子节点的节点,反之也一样。显然,我们需要另一个类来表示“节点”这个概念,但是这个类并不是表示某个具体的节点。我们的所有实际类型都将从这个公共基类中派生而来。
我们将这个公共基类命名为 Expr_node。这个类相当简单:
class Expr_node{
friend ostream & operator<< (ostream &, const Expr_node &);
protected:
virtual void print(ostream &) const = 0;
virtual ~Expr_node() {}
};
我们已经知道所要创建的对象的类型都是派生自 Expr_node,所以提供了虚析构函数。这可以保证在删除由一个 Expr_node* 指针指向的对象时能够调用到正确的派生类析构函数。
我们知道需要用动态绑定来处理 print 操作,但是我们的例子程序用了 << 输出操作符来打印表达式树。动态绑定只用于成员函数,所以我们定义了一个虚函数 print,输出操作符可以调用它来完成实际的工作。既然我们希望用户使用输出操作符,而不是 print 函数,那么就把 print 函数设为 protected 的,把 operator<< 设为友元。
从函数 print 的声明可以看出,它是一个纯虚函数,这就使得 Expr_node 成为抽象基类。这就体现了我们的意图:不存在所谓的 Expr_node 对象,只有从其中派生出来的类有实际对象。Expr_node 类的存在只是为了获得公共接口。
最后,我们定义输出操作符,它要调用合适的 print 函数:
ostream & operator<< (ostream & o, const Expr_node & e)
{
e.print(o);
return o;
}
现在我们可以使用继承来声明我们的具体类型了。关于这些类型第一个要注意的是,此类型的对象必须能够应用户的要求而生成表达式树。虽然我们还没有考虑过用户会如何创建这些树结构,但是只要稍微思索一下,就可以知道 Expr 类应该起着关键作用。所以,我们应当把 Expr 声明为各具体类型的友元。
这些具体类型中最简单的一类是包含一个整数,没有子节点的节点:
class Int_node: public Expr_node{
friend class Expr;
int n;
Int_node(int k): n(k) {}
void print(ostream & o) const { o << n; }
};
其他类型又如何呢?每个类中都必须存储一个操作符(这倒简单),但是如何存储子节点呢?在运行时之前,我们并不知道子节点的类型会是什么,所以我们不能按值存储子节点,必须存储指针。假设我们有一个通用的 string 类来代表操作符,这样一来我们的 一元和二元节点类如下所示:
class Unary_node: public Expr_node{
friend class Expr;
string op;
Expr_node* opnd;
Unary_node(const string & a, Expr_node* b): op(a), opnd(b) {}
void print(ostream & o) const { o << "(" << op << *opnd << ")"; }
};
class Binary_node: public Expr_node{
friend class Expr;
string op;
Expr_node* left;
Expr_node* right;
Binary_node(const string & a, Expr *b, Expr *c): op(a), left(b), right(c) {}
void print(ostream & o) const { o << "(" << *left << op << *right << ")"; }
int eval() const;
};
这个设计方案可以用,不过有一个问题。用户要处理的不是值,而是指针,所以必须记住分配和释放对象。例如,我们需要这么创建表达式树:
Expr t = Expr ("*", Expr ("-", 5), Expr ("+", 3, 4));
但这不会凑效-- 创建一元和二元表达式的构造函数期望获得指针,而不是对象。于是我们可以动态分配节点:
Binary_node* t = new Binary_node("*",
new Unary_node("-", new Int_node(5)),
new Binary_node("+", new Int_node(3), new Int_node(4)));
当然,我们必须记住要删除这些节点。可是这是不可能的!我们不再拥有指向内层 new 调用所构造的对象的指针来!我们希望 Binary_node 和 Unary_node 的析构函数删除它们的操作数,但是这里同样不行。如果析构函数删除了其操作数,可能会多次删除对象,因为可能不止一个 Expr_node 指向同一个下层的表达式对象。
3. 句柄类
事情越说越混淆了。我们不仅把内存管理这类烦心事推给了用户,而且对用户来说也没有什么方便办法来处理这些事情。我们得好好想想了。
首先回过头来看看问题,如果我们再次回顾一下第一节的图,就可以发现,在 Expr_node 类族中,仅仅只表示了图中的圆圈,而没有对箭头建模。我们之所以陷入困境,正是因为这里把箭头描述成为简单的指针。我们还强迫这些类的用户必须亲自操作指针,这把事情搞得更加复杂。这些问题与我们在第 5 章《代理类》和第 6 章《句柄类》解决过的问题相似,在那里我们用一个句柄类来管理指针。在这个问题里,这样的句柄类对我们的抽象建模似乎更加精确。
理解了这些麻烦,我们就认识到,类 Expr 应当是一种句柄类,表示一个边,或者说该树结构根源于一个边。既然用户关心的其实只是树(子树),而不是树中的单个节点,就可以用 Expr 来隐藏 Expr_node 继承层次。既然用户所要生成的是 Expr 而不是 Expr_node,我们就希望 Expr 的构造函数能代表所有 3 种 Expr_node。每个 Expr 构造函数都将创建 Expr_node 的派生类的一个合适对象,并且将这个对象的地址存储在正在创建中的 Expr 对象中。Expr 类的用户不会直接看到 Expr_node 对象。
这样一来,我们有:
class Expr{
friend ostream & operator<< (ostream &, const Expr &);
Expr_node* p;
public:
Expr(int); // 创建一个 Int_node
Expr(const string &, Expr); // 创建一个 Unary_node
Expr(const string &, Expr, Expr); // 创建一个 Binary_node
Expr(const Expr &);
Expr & operator= (const Expr &);
~Expr() { delete p; }
};
此构造函数创建适当的 Expr_node,并且将其地址存储在 p 中:
Expr::Expr(int n)
{
p = new Int_node(n);
}
Expr::Expr(const string & op, Expr t)
{
p = new Unary_node(op, t);
}
Expr::Expr(const string & op, Expr left, Expr right)
{
p = new Binary_node(op, left, right);
}
析构函数负责释放在构造函数中分配的节点。这样我们就会发现,再也没有内存管理方面的麻烦了。
由于 Expr 构造函数为 Expr_node 分配了内存,我们需要实现复制构造函数和赋值操作符管理下层的 Expr_node。如果 Expr 的析构函数销毁了 p 所指向的对象,那么在复制或赋值一个 Expr 时就需要生成该对象的一个副本。正如我们在第 5 章中所见到的,可以向 Expr_node 派生类层次中加入一个虚函数 copy,在 Expr 对象中可以使用它。
不过在写代码之前,首先应该考虑我们是否真的需要复制操作。在这里,Expr 的操作并不改变下层的 Expr_node。如果能避免复制下层的 Expr_node,则可能会更有效率。
避免复制的常用方法是让每一个 Expr_node 包含一个引用计数,指明同时有多少 Expr 指向同一个 Expr_node。Expr 类和 Expr_node 类将协同管理引用计数,当且仅当一个 Expr_node 引用计数等于 0 时,该节点才被删除。
我们需要在 Expr_node 类中加入引用计数,当一个新的 Expr_node 派生类对象生成时,将引用计数初始化为 1。Expr 类将帮助管理引用计数,所以将其声明为友元:
class Expr_node{
friend class Expr;
friend ostream & operator<< (ostream &, const Expr &);
int use;
protected:
Expr_node(): use(1) {}
virtual void print(ostream &) const = 0;
virtual ~Expr_node() {}
};
当 Expr 类“复制”一个 Expr_node 时,该 Expr 将其引用计数增 1,当引用者为 0 时删除底层的 Expr_node:
class Expr{
// 和前面的一样
public:
Expr(const Expr & t) { p = t.p; ++p->use; }
Expr & operator= (const Expr & t);
};
复制构造函数递增引用计数,另 p 指向其复制目标所指向的同一个 Expr_node。析构函数递减引用计数,如果此引用是对该 Expr_node 的最后一个引用,则销毁该 Expr_node。赋值操作符必须分别递增右边和左边对象的引用计数。如果能够首先处理右边对象的引用计数,就可以保证在自我赋值的情况下仍然工作正常:
Expr & Expr::operator =(const Expr & rhs)
{
rhs.p->use++;
if(--p->use == 0)
delete p;
p = rhs.p;
return *this;
}
我们还得定义输出操作符,现在它是针对 Expr 而不是针对 Expr_node 操作的。下面的代码体现了额外的中间层:
ostream & operator<< (ostream & o, const Expr & t)
{
t.p->print(o);
return o;
}
最后,需要更改每个派生自 Expr_node 的类,另其操作为私有,将 Expr 类声明为友元,存储 Expr 而不是存储指向 Expr_node 的指针。例如:
class Binary_node: public Expr_node{
friend class Expr;
string op;
Expr left;
Expr right;
Binary_node(const string & a, Expr b, Expr c): op(a), left(b), right(c) {}
void print(ostream & o) const { o << "(" << left << op << right << ")"; }
};
有了这些,我们最早的那个 main 程序可以工作了,用户也可以自由地声明 Expr 类型的对象和临时对象。并且,用户可以构造任意复杂的表达式,并且打印它们,而无需考虑内存管理的问题。
4. 扩展1:新操作
目前我们的系统能力还十分有限-- 只能创建和打印表达式。一旦这个目标实现,我们的客户可能会要求计算表达式的值。把原来的程序稍微改改:
int main(){
Expr t = Expr("*", Expr("-", 5), Expr("+", 3, 4));
cout << t << " = " << t.eval() << endl;
t = Expr("*", t, t);
cout << t << " = " << t.eval() << endl;
}
运行此程序应该得到:
((-5) * (3 + 4)) = -35
((-5) * (3 + 4)) * ((-5) * (3 + 4)) = 1225
对于这个问题的简单表述给了我们有关解决方案的思路:计算一个 Expr 的方式与打印它相同。用户将会对某些 Expr 调用 eval;eval 可以将实际的工作委托给组成 Expr 的节点。
所以我们的 Expr 类如下:
class Expr{
friend ostream & operator<< (ostream &, const Expr &);
Expr_node* p;
public:
Expr(int);
Expr(const string &, Expr);
Expr(const string &, Expr, Expr);
Expr(const string &, Expr, Expr, Expr);
Expr(const Expr & t) { p = t.p; ++p->use; }
~Expr() {}
Expr & operator= (const Expr &);
int eval() const { return p->eval(); } // 添加的
};
其中 eval 所需要做的,就是把对表达式的求值请求传递给其指向的 Expr_node。
这样,Expr_node类就得添上另一个纯虚函数:
class Expr_node{
protected:
virtual int eval() const = 0;
// 和前面的一样
};
还必须向 Expr_node 的每一个派生类添加一个函数来实现求值运算。 Int_node 的求值是最简单的,只要直接返回其值即可:
class Int_node: public Expr_node{
friend class Expr;
int n;
Int_node(int k): n(k) {}
void print(ostream & o) const { o << n; }
int eval() const { return n; } // 添加的
};
原则上,Unary_node 的求值运算也很容易:我们先确定操作数,接着进行运算。
但是此时此刻,我们构建 Expr_node 时并没有将操作符存储在其中。为了计算表达式,我们只限于使用那些知道应当如何计算的操作符。我们只需检查所存储的操作符是否是几个操作符之一,如果不是就抛出一个异常。对于 Unary_node,我们允许负号:
class Unary_node: public Expr_node{
friend class Expr;
string op;
Expr opnd;
Unary_node(const string & a, Expr b): op(a), opnd(b) {}
void print(ostream & o) const { o << "(" << op << opnd << ")"; }
int eval() const; // 添加的
};
int Unary_node::eval() const
{
if(op == "-")
return -opnd.eval();
throw "error, bad op " + op + " int UnaryNode";
}
现在我们可以很好地对算术表达式求值了。
再反思一下我们必须做什么,还有,也许更重要的是,不必做什么。增加一个新的操作无须触及已有的操作的代码。因为我们的类抽象对算术表达式进行了精确建模,所以扩展该程序以计算表达式,所需要增加的代码是非常至少。跟打印表达式的情况相似,动态绑定机制使得计算表达式的过程简化到只需指出计算各个节点的方法,然后运行时系统就可以调用正确的 eval 函数。
5. 扩展2:增加新的节点类型
我们已经看到,数据抽象和动态绑定使得在系统中增加新操作变得非常容易。现在再看看这两项机制如何使得我们可以增加新的节点种类,而完全不必改变使用节点的代码。
假设我们希望添加一种 Ternary_node 类型来表示三元操作符,如 ?:(也就是 if-then-else 操作符)。首先,我们声明 Ternary_node,并且定义其操作为:
class Ternary_node: public Expr_node{
friend class Expr;
string op;
Expr left;
Expr middle;
Expr right;
Ternary_node(const string & a, Expr b, Expr c, Expr d): op(a), left(b), middle(c), right(d) {}
void print(ostream & o) const;
int eval() const;
};
void Ternary_node::print(ostream &o) const
{
o << "(" << left << " ? " << middle << " : " << right << ")";
}
int Ternary_node::eval() const
{
if(left.eval())
return middle.eval();
else
return right.eval();
}
这些声明与 Binary_node 的声明相似,实际上,就是首先拷贝一份 Binary_node 的代码,然后修改成这个样子的。下面,我们要为 Ternary_node 定义一个 Expr 构造函数:
class Expr{
friend class Expr_node;
friend ostream & operator<< (ostream &, const Expr &);
Expr_node* p;
public:
Expr(int);
Expr(const string &, Expr);
Expr(const string &, Expr, Expr);
Expr(const string &, Expr, Expr, Expr); // 添加的
Expr(const Expr & t) { p = t.p; ++p->use; }
~Expr() {}
Expr & operator= (const Expr &);
int eval() const { return p->eval(); }
};
Expr::Expr(const string & op, Expr left, Expr middle, Expr right)
{
p = new Ternary_node(op, left, middle, right);
}
搞定了!
有一位曾经做过 C 编译器的先生看到这个例子之后,感慨地说:“当时我们往 C 语言编译器里添加 ?:操作符的时候都快绝望了,前前后后花费了几个星期甚至几个月-- 而你居然只用区区 18 行代码就做到了!”虽然编译器比这个例子要庞大得多,但他的这番评论却十分中肯。这种扩展只需增加新的类和函数,已经写好的执行代码一行也不用动,只需要在已经存在的类定义添加一个声明。然后,我们惬意地发现,这个修改后的代码首次编译时即正确运行,这种情况在 C++ 及其强类型检查机制下是经常见到的。
6. 反思
我们已经看到了面向对象编程是如何简化程序的设计和更新过程的。解决方案的实质是要对希望模拟的下层系统中的对象进行建模。当我们分析出表达式树是由节点和边所构成,便可以设计数据抽象来对树进行建模。继承让我们抓住了各种节点类型之间的相似之处,而动态绑定帮助我们为各种类型节点定义操作,让编译器来负责安排在运行时能够调用正确的函数。这样,数据抽象加上动态绑定可让我们集中精力考虑每个类型的行为和实现,而不必关心与其他对象的交互。
我们已经看到,面向对象设计使得添加新的操作和新的类型都轻而易举。实际编程中我们还希望能够添加更多的东西,包括:
* 添加 Ternary_node 之后,我们还需要添加关系运算。这很容易:只需要修改 Bianry_node::eval 函数,使它支持关系操作符即可。
* 我们也希望添加赋值表达式。这就有点技巧来。赋值要求我们向表达式树中添加一种变量类型。我们可以添加一个新类来表示一个名字及其值的单元,从而避免全功能的符号表。这样一来,就需要定义两个新的表达式类型:一个表示变量,一个向变量赋值。
* 表达式树很有用,但是语句有时更强大。我们可以根据打印和执行语句的操作,设计一个平行的语句层次。
关键在于,这个程序可以非常优雅地进化。我们可以从一个简单的规范开始,设计一个解决方案,使用该设计,观察该模型的运行情况。当我们需要修改时,可以很轻松地增加新的操作和新的类型,以扩展应用程序和测试我们改变的部分。我们所需要做的修改是孤立的。每一个修改所需要做的工作跟修改的说明是相称的。
想想如果没有动态绑定,表示一个表达式树会是多么困难!