来源:远方网络 | 2005-3-7 22:27:20 | (有2938人读过)
抽象(Abstraction)
除了在表述计算过程和分配对象等方面拥有方便、高效的机制以外,我们还需要一些能把握程序复杂性的设施。这即是说,我们还需要一些用于创建型别的语言机制,使得被创建的型别比低级的内建特性更能符合我们人类在解决问题时的思维方式。
4.1 具象型别(Concrete Types)
在许多实际应用中一些“小的”抽象机制都被频繁使用。这些抽象包括字符、整形数、浮点数、复数、点、指针、坐标、数学变换、(指针,偏移量)配对、日期、时间、范围、链接、关联、结点、(数值,域)配对、盘址(disc location)、源代码地址、BCD字符、流量、线、四边形、定点数、集合数、字符串、向量以及数组等。每一个应用总会用到其中的几个,但很少会频繁使用其全部。一个典型的应用仅直接使用其中极少的几个,而会通过程序库间接的使用其中很大一部分。
通用目的的程序设计语言的设计者不可能预见到每一个应用在细节方面的所有需求。因此,这种语言必须提供一些机制,使得用户可以自己定义像上述那样一些“小的”具象型别。设计C++的一个明确目标就是要能很好的支持对这种用户自定义数据型别的定义和有效使用。这种特性被认为是真正优雅的程序设计之基础。从实际的角度来看,简单并普适的东西总比复杂繁琐的东西好得多。
许多具象型别都被频繁使用,但确实也有限制。因此,支持这些具象型别之构造的语言设施,是以它们的可适应性和它们在时间、空间上没有额外损耗为设计重点的。当需要更便易、更高级或者更安全的型别时,可以将其建立在那些简单、高效的具象型别之上。而反过来——在较为复杂的“高级”型别之基础上建立没有额外性能损耗的型别——却是不可能实现的。由此,那些不提供设施以支持高效的自定义具象型别的语言就需要通过特别的语言规则来提供更多的内建型别,如表、串和向量等。
关于具象型别的一个经典范例就是复数型别:
class complex{
public: // 接口
// 构造函数
complex(double r, double i) { re = r; im = i; } // 由两个标量构造复数
complex(double r) { re = r; im = 0; } // 由一个标量构造复数
complex() { re = im = 0; } //缺省的复数:complex(0,0)
// 用以访问复数的函数
friend complex operator + (complex, complex);
friend complex operator - (complex, complex); // 二元运算符“减”
friend complex operator - (complex); // 一元运算符“负”
friend complex operator * (complex, complex);
friend complex operator / (complex, complex);
// …
private:
double re, im; // 对复数概念的表述
};
这段代码定义了一个简单的复数型别。与Simula一样,C++中使用class关键字表示用户自定义型别。这里的complex类表达了复数的结构以及可以施于其上的操作集合。实现复数结构的是private(私有的)部分;这即是说,re和im只能被complex类中声明的函数访问。像这样把“对型别之表述的访问权限”限制在一个特定的函数集合之内,可以简化概念,减轻调试和测试工作的负担,并使我们可以更容易的按照需要来对型别进行其它方式的实现。
与类的名称同名的成员函数叫做构造函数。对于大部分用户自定义型别来说,构造函数是相当重要的。构造函数负责初始化对象,即建立基本的不变量(invariant),以便使成员函数可以基于此来对型别的具体表述施以适当的操作。上面例子中的complex类提供了三个构造函数:其中一个利用一个双精度浮点数(即double型别的对象)来创建复数;第二个利用两个双精度浮点数来进行创建;第三个则利用自己的缺省值来进行创建。例如:
complex a = complex(1, 2);
complex b = 3; // 通过complex(3,0)进行初始化
complex c; // 通过complex(0,0)进行初始化
我们可以使用friend关键字来把一个普通的函数(即非成员函数)定义成一个型别的友元,使得这个函数可以访问该型别的具体表述量。这种友元函数的定义及实现与其它普通函数一样。例如:
complex operator + (complex a1, complex a2) // 将两个复数相加
{
return complex(a1.re+a2.re, a1.im+a2.im);
}
这个简单的complex型别可以像这样使用:
void f()
{
complex a = 2.3;
complex b = 1 / a;
complex c = a + b * complex(1, 2, 3);
// …
c = - (a / b) + 2;
}
complex类的声明定制了一种表述方法,而这对于用户自定义型别来说并不是必需的(详见§4.2)。然而对于complex类来说,处理数据的效率和对数据的控制却是很重要的。一个像complex这样简单的类还不会由于要掌管系统提供的“日常家务事(housekeeping)”之信息而承受空间上的负荷。这是因为,complex的声明中包含了对其型别的一种具体表述,其在栈中分配空间,而真正的局部变量之实现也并无实质性的意义。更为甚之的是,即便使用简陋的编译器,以单独编译的方式来处理上述代码,其对那些简单操作的内联处理都是很简单的。而在为具有高性能要求的系统提供合适的低级型别(比如complex、string和vecotr)时,语言在处理数据的效率和对数据的控制能力就显得尤为重要了[Stroustrup,1994]。
通常,具象型别的记法(notation)是需要斟酌的重要因素。程序员希望在对复数进行数学计算时,也能使用诸如+和*这样传统的运算符。同样,程序员还希望能用熟悉的运算符(通常是+)来连接串,用[]或()来表示向量的下标,用()来调用代表着函数的对象,等等。为了满足这样的要求,C++提供了为用户自定义型别定制运算符操作的功能。有趣的是,最常被使用也最有用的运算符竟然是[]和(),而非多数人可能会猜想到的+和-。
在标准C++程序库中提供的complex型别就是用本节讲到的技术来定义和实现的(§6.4.1)。
4.2 抽象型别(Abstract Types)
在上面的例子中,对具象型别的具体表述被包含在其声明中。这样一来,在栈上为具象型别的对象分配空间,以及对施于这些对象上的操作进行内联处理,就显得没什么实质意义了。毕竟我们所能得到的效率上的收益才是重点。而如果不重新编译那些利用优化处理之优势的代码,那么对一个对象的表述就是无法改变的。这总还是不够理想。一种显而易见的替代方案就是,将这种表述排除到类的声明之外,避免用户获得关于该种表述的任何信息,消除用户对该种表述的依赖性。例如:
class Character_device{
public:
virtual int open(int opt) = 0; // “=0”意即这是一个所谓的“纯虚拟函数”
virtual int close(int opt) = 0;
virtual int read(char* p, int n) = 0;
virtual int write(const char* p, int n) = 0;
virtual int ioctl(int …) = 0;
virtual ~Character_device() { } //析构函数(详见§4.2.1)
};
在Simula和C++中,virtual关键字意味着“会在派生自这个类的另外一个类当中予以实现”。见下面的代码,一个派生自Character_device的类提供了对Character_device之接口的一种实现代码。奇怪的“=0”语法的意思是说,派生自Character_device的类必须实现“=0”所修饰的那个函数。
Character_device是一个仅定义了接口的抽象类。这种接口可以在不影响用户(译注:即不为用户所知)的情况下用多种方法实现。例如,在一个假想的系统中,程序员可能会将这个接口用于设备驱动器:
void user(Character_device* d, char* buffer, int size)
{
char* p = buffer;
while (size > chunk_size){
if (d->write(p, chunk_size) == chunk_size() { // 对整个chunk写
size -= chunk_size; //写入了chunk_size个字符
p += chunk_size; //移到下一个chunk
}
else{ //对部分chunk施以写操作
// …
}
}
// …
}
真正的驱动器将会在派生自Character_device的类中被具体实现:
class Dev1:public Character_device {
// 对Dev1的表述
public:
int open(int opt); // 打开Dev1
int close(int opt); // 关闭Dev1
int read(char* p, int n); // 读取Dev1
// …
};
class Dev2:public Character_device {
// 对Dev2的表述
public:
int open(int opt); // 打开Dev2
int close(int opt); // 关闭Dev2
int read(char* p, int n); // 读取Dev2
// …
};
各个类之间的关系可用下图表示:
图中的箭头代表“派生自”的关系。用户的user()函数不需要了解到底是哪一个实现了Character_device之接口的派生类被使用。
void f(Dev1& d1, Dev2& d2, char* buf, int s)
{
user(d1, buf, s); //使用Dev1
user(d2, buf, s); //使用Dev2
}
在一个派生类中声明的函数会覆写(override)其基类中的一个同名且同型别的函数。由C++语言本身来保证:对Character_device中诸如write()这样的虚拟函数之调用确实唤起了(invoke)来自实际被使用的相应派生类的覆写函数。在C++中,这样做所带来的负荷已被尽量减到了最小,并可以被精确的预见。由虚拟函数引起的额外的运行期负荷也只占用普通函数调用之消耗的一小部分而已。
下图显示了类中各个对象的典型实现:
由此可以看出,对虚拟函数的调用只不过是对普通函数的一种间接调用。在运行期间,并不需要为调用正确的函数版本而进行某种搜索。
在许多具体情况中,使用抽象类是表述一个系统主要内部接口的理想方法。这种方法简单、高效,具有“强型别”(strong type)特性,使得“同时使用同一接口的不同实现方案”成为可能,并且能将这些实现中的任何改变所产生的影响与用户完全隔离开来。
4.2.1析构函数(Destructors)
对于一个给定的对象,构造函数为类中的成员函数建立了一种“工作环境”。通常,要建立这种“工作环境”需要获取一些资源(比如内存、锁或者文件等)。一个程序要正常运作,还需要在对象被销毁的时候能正常的释放这些资源。因而,有必要声明一个函数,让其实现与构造函数相反的功能。这样的函数被顺理成章的称为析构函数(译注:之所以说“顺理成章”,是因为英文中con-structor 和de-structor这两个单词的拼法是遵循同一规则的,de-structor系根据con-structor创造出来的,因而在以英语为母语的人眼里,这是很自然的一种词语派生方法)。对于一个类X,其析构函数的名称就是~X();在C++中,~是求补运算符。
一个用来存放字符的简单的栈可以像这样定义:
class Stack {
char* v;
int max_size;
int top;
public:
Stack(int s) { top = 0; v = new T[max_size = s]; } //构造函数获取空间
~Stack() { delete [] v; } //析构函数释放空间
void push(T c) { v[top++] = c; }
T pop() { return v[--top]; }
}
为了举例子简单起见,在这个Stack类中没有提供任何错误处理的功能。然而,我们仍然可以像这样使用:
void f(int n)
{
stack s2(n); // n个字符的栈
s2.push(‘a’);
s2.push(‘b’);
char c = s2.pop();
// …
}
在f()函数的起始处,为了创建s2,调用了构造函数Stack::Stack()。该构造函数为n个字符分配足够的内存。当要退出f()函数时,析构函数Stack::~Stack()被隐式的调用,释放了先前由构造函数所获得的内存。
采用这种资源管理方案是很重要的。因为,诸如Character_device这样的抽象类之对象将会经由指针或引用被操纵,并且通常会在某些函数里被删除,而这些函数往往并不知道这个抽象类的接口具体是被什么型别的对象实现的。因此,我们不能指望Character_device的使用者能够了解具体需要用什么来释放一个设备。一般来说,释放一个设备涉及到与操作系统或其它系统资源之维护程序的交互。而将Character_device的析构函数声明为virtual则可以保证:Character_device型对象的删除工作是由来自相应的派生类之相应函数完成的。例如:
void some_user(Character_device* pd)
{
//…
delete pd; //隐式的调用对象的析构函数
}
4.3 面向对象程序设计(Object-Oriented Programming)
面向对象程序设计涉及到一系列技术,这些技术基于类层次机制,提供可扩展性和可适应性。面向对象程序设计使用到的基本语言设施包括从一个类派生出另一个类的能力、虚拟函数(详见§4.2)以及用户自定义型别。这些特性使得程序员可以在不知道接口内部具体实现的情况下使用这个接口(这里说的“接口”即是指类,且通常是抽象类),并且可以在不影响原来的类之使用者的情况下,直接在原来的类之基础上建立新的类。举个例子来说:考虑一个简单的任务,其目标是通过某种用户接口系统获取来自用户的一个整型值,并将其传给应用程序。假设我们希望使应用程序独立于用户接口的实现细节,于是我们可以提供一个Ival_box类来作为交互的手段:
class Ival_box {
public:
virtual int get_value() = 0; // 将数值取回应用程序
virtual void prompt() = 0; // 提示用户输入
// …
};
显然,可能会有各种属于Ival_box型别的新型别出现:
class Ival_dial:public Ival_box { /* … */ };
class Ival_slider:public Ival_box { /* … */ };
// …
这几个类之间的关系可以用下图表示:
这个应用层次(application hierarchy)独立于用户接口系统的实现细节。应用程序的编写独立于输入/输出的实现细节;在不影响应用层次的情况下,我们可以将应用程序加入到实现层次当中(implementation hierarchy):
虚线箭头代表着protected抽象类。protected抽象类是其派生类之实现的一部分,通用的用户代码无法对其进行访问。这种设计使得应用程序的代码独立于实现层次,实现层次的改动不会影响到应用程序代码。
出于现实因素的考虑,我在代码里的名称中使用了BB这个前缀;因为现今各主要程序库大凡都采用添加易识别的标志这样一种传统方式来增加可读性和易辨识性。更好的替代方案是使用namespace关键字(§5.2)。
将应用程序的类加入实现层次的类声明,其一般是像下面这样的:
class BB_ival_slider:public ival_slider, protected BB_slider {
public:
// 在这里,我们根据实现应用程序特定概念的需要,对Ival_slider的函数进行覆写
protected:
// 为了符合用户接口的标准,这里的函数覆写了BB_slider和BB_window的函数
private:
//这里是型别的表述和其它具体实现细节
};
这种结构通过覆写BB_window层次结构中的虚拟函数来表现用户接口系统要显示的细节内容。对一个用户接口系统而言,这也许并不是一种理想的组织结构,但好在这种结构并不常见。
派生类会继承其基类的属性。因此,派生有时候也被称为“继承”。当一种语言(比如C++)允许一个类直接拥有多个基类的时候,我们就说这种语言支持多重继承。
4.3.1 运行期型别识别(Run-time Type Identification)
在上面定义的Ival_box的一种可行的使用方法就是:在应用程序中将Ival_box对象转交给一个能控制屏幕的系统,并使该系统在屏幕出现任何变动的时候将对象交还给应用程序。这也正是很多用户接口的工作原理。然而,就像使用Ival_box的应用程序对用户接口系统一无所知一样,用户接口系统对我们的Ival_box也是一无所知的。我们以系统本身包含的类和对象为蓝本来定制系统接口,而不是以我们的应用程序中的类为蓝本。这是必要的,也是理所当然的。诚然这样的确也会造成一些不良的副作用,即丢失关于某些对象之型别的信息——这些对象先被传递给系统,之后又被返还回来,从而造成了丢失型别信息的情况。
要重新获得对象丢失了的型别信息,我们需要使这个对象能够体现自己型别。我们总是需要通过与某个对象之型别相匹配的指针或引用来对这个对象进行操作,因此要在运行期察看一个对象的型别,最先想到也最有用的方法就是施行一种型别转换操作,其在“对象之型别是预期的型别”时返回一个有效的指针,否则返回一个空指针(null pointer)。dynamic_cast运算符正是用来实现这个操作的。例如我们假设一个系统以指向BBwindow的指针作为参数来调用my_event_handler(),代码如下:
void my_event_handler(BBwindow* pw)
{
if (Ival_box* pb = dynamic_cast<Ival_box*>(pw)) { //指针pw指向的是一个Ival_box型对象吗?
int i = pb->get_value();
//…
}
else {
//噢欧,无法预料的事件
}
}
可以这样来解释代码中发生的事情:dynamic_cast把用户接口系统所能理解的面向实现的语言“翻译”成了应用程序所能理解的语言。有很重要的一点是,在这里例子中,没有涉及到对象的真实型别。该对象是Ival_box的一种(比如Ival_slider),是由一种特定的BBwindow(比如BBslider)来实现的。在系统与应用程序的交互过程中,我们并不需要也不必要明确对象的具体型别。一个接口被用来代表一种交互过程中最重要的部分或全部细节。特别的,一个设计良好的接口还能够隐藏不重要的细节。
从基类重塑(cast)到其派生类的过程通常被称为向下重塑(downcast),因为这个转换过程在表示继承关系的树结构中显示了从上到下的移动方向。同样从派生类重塑到其基类的过程被称为向上重塑(upcast)。像BBwindow重塑到Ival_box这样,从基类重塑到其兄弟类的过程被称为交叉重塑(crosscast)
4.4 范型程序设计(Generic Programming)
利用类和类层次机制,我们可以优雅并高效的表达单一的概念,还可以表达在某种层次体系中相互联系着的多个概念。然而有一些常见的重要概念却既不具有单一性又不属于某种层次体系。例如“整型vector”和“复数vector”,它们都是vector(这即是说,它们之间存在某种关系),但它们又因为各自的元素型别不同而被区分开来。像这样的抽象概念最好用参数化的概念来表达。比如,我们可以把元素的型别作为参数而将其参数化。
C++通过模板来提供型别的参数化能力。有一个极为重要的设计准则是:在使用模板定义基本的container时,模板应该在严格的性能要求下仍具有足够的可适应性和高效率。具体来说,其设计目标就是提供一种vector模板类,并且其与内建型别相比,又并不带来额外的运行时间负荷或者空间负荷。
4.5 Container
我们可以通过如下方法把§4.2.1中描述的那个“字符栈”型别修改成“由任意型别的元素组成的栈”型别:使用template关键字将其变成模板,并把型别char替换为一个模板的参数。例如可以这样实现:
template<class T>class Stack {
T* v;
Int max_size;
Int top;
public:
Stack(int s) { top = 0; v = new T[max_size = s]; } //构造函数
~Stack() { delete[] v; } //析构函数
void push(T c) { v[top++] = c; }
T pop() { v[--top]; }
};
代码中class Stack的前缀template<class T>使T成为class Stack的参数。
现在我们可以像这样使用这个模板栈型别:
Stack<char>sc(100); // 元素为字符的栈
Stack<complex>scplx(200); // 元素为复数的栈
Stack<list<int>>sli(400); // 元素为整型list的栈
void f()
{
sc.push(‘c’);
if (sc.pop() != ‘c’) error(“impossible”);
scplx.push(complex(1,2));
if (scplx.pop() != complex(1,2)) error(“can’t happen”);
}
使用类似的方法,我们可以把list、vector、map(这是一种关联数组associative array,其元素是一对key/value的组合)等都定义成模板。包含着某种型别元素之集合的类通常被称为container类,或简称为container。
模板是一种在编译期间发生作用的机制,因此与所谓“手写的代码(hand written code)”相比,并不会带来任何运行期负荷。
4.5.1 算法(Algorithms)
有了各种在语义上类似的型别——比如一个container的集合,其中的container都能为元素的插入和访问提供近似的操作——我们就可以编写出对所有这些型别都适用的代码。例如,我们可能要在一个以first和last为限定范围的元素序列中计算数值val出现的次数,代码可以像这样写:
template<class In, class T>int count(In first, In last, const T& val)
{
int res = 0;
while (first != last) if (*first++ == val) ++res;
return res;
}
这段代码只基于这样几个假设:型别T的对象可以使用==来进行比较;一个In型别的对象可以通过使用++来移向下一个元素以遍历整个元素序列;可以通过*p来获取由名为p的iterator所指向的元素。例如:
void f(vector<complex>& vc, string s, list<int>& li)
{
int c1 = count(vc.begin(), vc.end(), complex(0));
int c2 = count(s.begin(), s.end(), ‘x’);
int c3 = count(li.begin(), li.end(), 42);
// …
}
这段代码先计算complex型别的值在vector里出现的次数,又计算了x在string里出现的次数,还计算了42在list中出现的次数。
上面代码中具有In的属性的型别之对象被称为iterator。最简单的iterator就是一个内建型别的指针。诸如vector、string和list之类的标准程序库中的container都提供了begin()和end()函数,这两种操作分别返回序列中第一个元素和最末的元素;如此一来,begin()…end()就描述出了一个半开的(half-open)序列(§6.3)。显然,++和*的实现随container不同而不同,但是这些实现细节并不影响我们编写代码的方式。
|