编程
是艺术,这无可否认。不信的去看看高大爷的书就明白了。艺术对于我们这些成天挤压脑浆的程序员而言,是一味滋补的良药。所以,在这个系列中,每一篇我打算
以艺术的形式开头。啊?什么形式?当然是最综合的艺术形式。好吧好吧,就是歌剧。当然,我没办法在一篇技术文章的开头演出一整部歌剧,所以决定用一段咏叹
调来作为开始。而且,还会尽量使咏叹调同文章有那么一点关联,不管这关联是不是牵强。
噢,我亲爱的++
普契尼的独幕歌剧歌剧《贾尼·斯基基》完成于1918年,同年初演于纽约。
本剧的剧情取自意大利诗人但丁(1265- 1321)的长诗《神曲·地狱篇》中的一个故事:富商多纳蒂死了。其遗嘱内,将遗产全数捐献给某一教堂。在场亲友大失所望。众人请贾尼·斯基基假扮多纳蒂,骗过公证人,另立遗嘱,遗产由众亲友均分。公证人到场。结果斯基基将少量遗产分与众人,大部分留给了自己。遗嘱录毕,公证人离去。众大哗,斯基基从病榻跃起,持棒驱散众人。
剧中斯基基的女儿劳蕾塔为表达对青年努奇奥的爱情,对其父唱起了这首美妙绝伦的咏叹调——“我亲爱的爸爸”:
“啊! 我亲爱的爸爸,我爱那美丽少年。
我愿到露萨港去,买一个结婚戒指。
我无论如何要去,假如您不答应,
我就到威克桥上,纵身投入那河水里。
我多痛苦,我多悲伤。
啊! 天哪! 我宁愿死去!
爸爸,我恳求你!
爸爸,我恳求你!”
按照C/C++中对于后置操作符++的定义,操作数增加1,并返回原来的值。于是,有人根据这个给C++遍了一段笑话,流传甚广。那么,C++是否相对C加了那么一点点,然后还是返回原来的值呢?那就让我们来“实地考察”一下,了解这个++究竟加了多少。
我不打算罗列C++的各种纷繁复杂的特性。已经有无数书籍文章做了这件事,肯定比我做的好得多。我要做的,是探索如何运用C++的一些机制,让我们能够更方便、快捷、容错地开发软件。这些特性很多都是非常简单的,基本的。正因为它们基本,很容易为人们所忽略。另一些则是高级的,需要多花些时间加以掌握的。但是,这些特性也具有一些简单,但却非常实用、灵活和高效的用法。
相对于C,C++最主要的变化就是增加了类。严格地讲,类是一种“用户定义类型”,是扩展类型系统的重要手段。类从本质上来说,是一种ADT(Abstract Data Type,抽象数据类型)。笼统地讲,ADT可以看作数据和作用在这些数据上的操作的集合。
类提供了一种特性,称为可见性。意思是说,程序员可以按自己的要求,把类上的数据或函数隐藏起来,不给其他人访问。于是,通过可见性的控制,可以让一个类外部呈现一种“外观”,而内部可以使用任何可能的方法实现类的功能。这称为“封装”。
呵呵,听烦了吧。这些东西是学过C++(或者任何时髦的OOP语言)的都已经烂熟于胸了。这样的话,我们就来点实际的,做个小案例,复习复习。温故而知新嘛。:)
案例非常简单,做一个圆类。让我们从“赤裸”的C结构开始吧:
struct Cycle
{
float center_x;
float center_y;
float radius;
};
很传统的表示,<圆心坐标,半径>,便可以立刻定义出一个圆形。现在,假设我们需要计算圆形的面积。于是,我写了一个函数执行这项任务:
float Area(const Cycle & rc) ...{
return PI*rc.radius*rc.radius;
}
很好。但是突然有一天,我心血来潮,把圆形类的存储改成外切正方形的<左上角,右下角>形式,那么这个函数就不能用了。为了让我这么一个三心二意的人能够得到满足,就得运用封装这个特性了:
class Cycle
{
public:
float get_center_x() { return left; }
float get_center_y() { return top; }
float get_radius() { return bottom; }
private:
float center_x;
float center_y;
float radius;
};
然后,面积计算公式稍作改动就行了:
float Area(const Cycle & rc) {
return PI*rc. get_radius()*rc. get_radius();
}
这时,如果我改变了Rectangle的数据存储方式,也不会影响Area函数:
class Cycle
{
public:
float get_center_x() { return (left+right)/2; }
float get_center_y() { return (top+bottom)/2; }
float get_radius() { return (right-left)/2; }
private:
float left;
float top;
float right;
float bottom;
};
运用了封装之后,类的实现和接口分离了。于是我们便可以在使用方神不知鬼不觉的情况下,改变我们的实现,以获得更好的利益,比如效率的提升、代码维护性的提高等等。
当我们尝到封装的甜头之后,便会继续发扬光大:
class Cycle
{
public:
float get_center_x() { return left; }
float get_center_y() { return top; }
float get_radius() { return bottom; }
float get_left() { return center_x-radius; }
float get_right() { return center_x+radius; }
float get_top() { return center_y-radius; }
float get_bottom() { return center_y+radius; }
float area() { return PI*get_radius()*get_radius(); }
private:
…
};
作为一个思想纯正的OOP程序员而言,这是一个漂亮的设计。不过,对于我这样一个同样思想纯正的Multiple-paradigm程序员而言,这是个不恰当的设计。
我承认,这个设计完成了工作,达到了设计目标。但是,这种被Herb Sutter称为“单片式”的设计是一种典型的过度OO的行为。Sutter在他的《Exceptional C++ Style》一书中,用了最后四个条款,详细地批判了以std::string为代表的这种设计。
这里,没有那么复杂的案例,我就简单地介绍其中存在的一些问题,其余的,请看Sutter的书。首先,当Cycle的内部存储形式发生变化时,需要修改不只一个地方:
class Cycle
{
public:
float get_center_x() { return (left+right)/2; }
float get_center_y() { return (top+bottom)/2; }
float get_radius() { return (right-left)/2; }
float get_left() { return left; }
float get_right() { return top; }
float get_top() { return right; }
float get_bottom() { return bottom; }
private:
float left;
float top;
float right;
float bottom;
};
当然,如果get_left()等成员函数通过get_center_x()等成员函数计算获得:
float get_left() ...{ return get_center_x()-get_radius(); }
这样在改变数据存储的情况下,修改get_left()等函数了。不过,get_center_x()等函数本来就是从left等成员数据上计算获得,get_left()再逆向计算回去,显得有些奇怪。
这还只是小问题。更重要的是增加了这些冗余的函数,使得类在接口的灵活性上变差。假设我们在Cycle类上增加一个offset()函数,实现平移:
class Cycle
{
public:
…
void offset(point o) { center_x+=x; center_y+=y; }
…
};
在Cycle的使用代码中,调用了offset():
Cycle c;
…
c.offset(20, 100);
假设,此时来了一个需求,要求offset()可以接受size类的对象作为参数。那么就必须修改Cycle类的定义,改变或重载offset()。如果这个Cycle是别人写的,不是我们所能改变的,那么事情就比较麻烦。
按照现代的Multiple-paradigm的设计理念,这类操作应当以non-member non-friend的形式出现,而类仅仅保持最小的、无冗余的接口集合:
void offset(Cycle& c, point o) {
c.set_center_x(c.get_center_x()+o.x); //如果有属性,就更好了:)
c.set_center_y(c.get_center_y()+o.y);
}
此时,如果需求改变,那么只需编写一个函数重载,便可以解决问题,而无需考虑类的修改了。
关于这方面的问题,Meyes有一篇很有见地的文章:http://www.ddj.com/cpp/184401197。作者认为,冗余的成员函数实际上只会降低类的封装性,而不是提高。这看似一个哗众取宠的论点,但是Meyes所给出的论据却非常具有吸引力。他给出了一个“封装性”的具体度量:封装性的好坏取决于类实现变化时,对使用代码产生的影响。类的接口的冗余度越大,越容易受到实现变化的影响。
所以,现在主流的C++社群都提倡用小类+non-member non-friend函数实现,以提高灵活性。这一点反过来也更接近计算机软件“数据+操作”的本质。
经过长时间的开发工作,我们逐步积累起很多圆类,都是面向不同实现。有的通过传统的<圆心,半径>存放数据;有的通过外接正方形坐标保存数据;有的通过一个长轴等于短轴的椭圆存放数据;有的通过内接正方形保存数据;…。不过它们的接口都是相同的,即<圆心,半径>形式。
面对这些圆的实现,为它们各自开发一套算法实在让人泄气。大量的重复代码,和重复劳动,简直是对程序员的智慧的侮辱。我们需要开发一套算法,然后用于所有圆类。这就需要动用C++的MDW(大规模杀伤性武器)——模板:
template<typename T>
void offset(T& c, float x, float y) {
c.set_center_x(c.get_center_x()+o.x);
c.set_center_y(c.get_center_y()+o.y);
}
这样,同一个算法便可以用于(我们)所有的圆类:
CycleA c1;
CycleB c2;
CycleC c3;
…
offset(c1, 10, 20);
offset(c2, 2, 700);
offset(c3, 99, 9);
…
不过,有些顽固的人认定一个圆应当用外接正方形的形式定义(接口形式是外接正方形的坐标)。并且基于这种构造,开发了一堆有用的函数模板。比如说inflate<>()。
可我们这些理智的人已经开发了<圆心,半径>形式的Cycle。只是看中了顽固派的哪些操作函数,希望能够重用一下,免得自己重复劳动。同时,我们又不希望重做一个Cycle类,来符合那些缺乏理智的Cycle定义。
怎么办?设计模式告诉我们,可以用Adapter解决问题:
class CycleAdapter
{
public:
CycleAdapter(Ours::Cycle const& c) : m_cycle(c){}
float get_left() { return Ours::getLeft(m_cycle); }
void set_left(float left) { return Ours::setLeft(m_cycle, left); }
…
private:
Cycle& m_cycle;
};
此后,我们便可以使用顽固派的函数了:
Ours::Cycle c;
…
CycleAdapter ca(c);
Theirs::inflate(ca, 1.5);
唉,世事难料,上头下命令,必须同时使用我们自己的圆类和顽固派的圆类。(肯定是收了他们的好处了)。没办法,命令终究是命令。可从今往后,我们就得同时开发两套算法。痛苦。不过相比使用算法的人来说,我们还算幸运的。他们必须不断地在Ours和Theirs命名空间里跳来跳去,时间长了难保不出错。
算法使用者希望一个算法就是一个名字,在同一个命名空间,以免混乱。幸运的是,在一种未来技术的支持下,我们做到了。这就是C++的BM(弹道导弹,MDW的运载器)——concept:
concept OurCycle<typename T> {
float T::get_left();
void T::set_left(float left);
…
}
concept TheirCycle<typename T> {
float T::get_left();
void T::set_left(float left);
…
}
concept_map OurCycle<Ours::CycleA>;
concept_map OurCycle<Ours::CycleB>;
…
concept_map TheirCycle<Theirs::CycleA>;
concept_map TheirCycle<Theirs::CycleB>;
…
template<OurCycle T> void move(T& c, point const& p) {…} //#1
template<TheirCycle T> void move(T& c, point const& p) {…} //#2
…
在concept和特化的共同作用下,我们便可以很方便地(不需考虑我们的,还是他们的)使用这些算法了:
Ours::CycleA c1;
Ours::CycleB c2;
Theirs::CycleA c3;
…
move(c1, point(20,3)); //调用#1
move(c2, point(5, 111)); //调用#1
move(c3, point(7, 22)); //调用#2
随着应用的发展,我们不仅仅需要操作一个图形,还要把它画出来。这件事不算难。但是,面对不同的需求,我们有完全不同的两套方案。
先看一下常见的方案——OOP。这是经典的OOP案例,我就简单地描述一下,诸位别嫌我罗嗦J。为了方便,这里用mfc作为绘图平台,尽管我讨厌mfc。
定义一个抽象类:
class Graph
{
…
public:
virtual void Draw(CDC& dc)=0;
};
所有图形类从Graph继承而来,并且重写(override)Draw():
class Cycle : public Graph
{
…
public:
void Draw(CDC& dc) {
… //绘制圆
}
};
class Rectangle : public Graph
{
…
public:
void Draw(CDC& dc) {
… //绘制矩形
}
};
…
此后,便可以创建一个对象并绘制:
但这同不用虚函数有什么区别?请看以下代码:
typedef shared_ptr<Graph> GraphPtr;
vector<GraphPtr> gv;
gv.push_back(GraphPtr(new Cycle));
gv.push_back(GraphPtr(new Rectangle));
gv.push_back(GraphPtr(new Line));
…
for_each(gv.begin(), gv.end(), mem_fun(&Graph::Draw));
(附注:我这里不辞辛劳地用了智能指针,为的是无忧无虑地编写代码,不必为资源的安全而烦恼。同时,标准算法for_each和成员函数适配器mem_fun的使用也是为了获得更简洁、更可靠的代码。这些都是应当广泛推荐的做法,特别是初学者)。
抛开智能指针,gv中包含的是基类Graph的指针,当各种继承自Graph的对象插入gv时,多态地转换成基类Graph的指针。当后面for_each算法执行时,它会依次取出gv的每一个元素,并通过mem_fun适配器调用每个元素(即Graph指针)上的Draw成员函数。(关于for_each和mem_fun的奇妙原理,我这里就不说了,有很多参考书都有很详细的解释,比如《C++ STL》、《C++ Standard Library》等等)。
这里的核心在于,当我们调用Graph指针上的Draw成员函数时,实际上被转而定向到继承类(Cycle、Rectangle等)的Draw()成员函数上。这个功能非常有用,也就是说,当一组类(Cycle等)继承自同一个基类(Graph)后,可以通过覆盖基类上的虚函数(Draw)实现对基类行为的修改和扩充。同时,基类(Graph)成为了继承类(Cycle等)的共同接口,通过接口我们可以将不同类型的对象放在同一个容器中。这种技术可以避免大量switch/case的硬编码分支代码,(也称为tag dispatch),大大简化我们软件的构架。同时也可以大幅提高性能,操作分派可以从O(n)复杂度变成O(1)(hash_map)或O(logN)(map)。
这种通常被称为“动多态”的OOP机制,允许我们在运行时,根据某些输入,比如从一个图形脚本文件中读取图形数据,创建对象,并统一存放在唯一容器中,所有图形对象都以一致的方式处理,极大地优化了体系结构。
有“动”必有“静”。既然有“动多态”,就有“静多态”。所谓“静多态”是指模板(或泛型)带来的一种多态行为。关于模板前面我们已经小有尝试,现在我们通过模板上的一些特殊机制,来实现一种多态行为。
作为独立于OOP的一种新的(其实也不怎么新,其理论根源可以追溯到1967年以前)范式,模板(泛型)相关的编程被称为“泛型编程”(GP)。gp最常用的一种风格就是算法独立于类,这在前面我们已经看到过了。所以,这里的Draw也作为自由函数模板:
template<typename T> void Draw(CDC& dc, T& g);
template<> void Draw<Cycle>(CDC& dc, Cycle& g) { //#1
dc. Ellipse(get_left(g), get_top(g), get_right(g), get_bottom(g));
}
template<> void Draw<Rectangle>(CDC& dc, Rectangle& g) { //#2
dc. Rectangle(get_left(g), get_top(g), get_right(g), get_bottom(g));
}
…
当用不同的图形对象调用Draw时,编译器会自动匹配不同的版本:
Cycle c;
Rectangle r;
…
Draw(dc, c); //#1
Draw(dc, r); //#2
这里使用了函数模板特化这种特性,促使编译器在编译时即根据特化的情况调用合适的函数模板版本。不过,仔细看函数模板的声明,会发现这同函数的重载几乎一样。实际上此时使用函数重载更加恰当。(函数重载通常也被认为是一种多态)。这里使用模板,是为了引出未来的concept的方案:
template<OurCycle T> void Draw(CDC& dc, T& g) { //#1
dc. Ellipse(get_left(g), get_top(g), get_right(g), get_bottom(g));
}
template<OurRectangle T> void Draw(CDC& dc, T& g) { //#2
dc. Rectangle(get_left(g), get_top(g), get_right(g), get_bottom(g));
}
同前面的move模板一样,这里的Draw也实现了编译期的操作分派(以类型为tag)。此时,我们便可以看出,引入了concept之后,模板的特化(针对concept)不仅仅使得代码重用率提高,而且其形式同函数重载更加类似。也就是说,重载多态和函数模板的静多态有了相同的含义(语义),两者趋向于统一。
以上代码另一个值得注意的地方是get_left()等函数。这些函数实际上是函数模板,分别针对不同的类型特化。这使得所有相同语义的操作,都以同样的形式表现。对于优化开发,提高效率,这种形式具有非常重要的作用。
模板的这种静多态同OOP的动多态有着完全不同的应用领域。更重要的是,两者是互补的。前者是编译时执行的多态,具有很高的灵活性、扩展性和运行效率;后者是运行时执行的多态,具备随机应变的响应特性。所以,通常情况下,凡是能在开发时确定的多态形式,比如上述代码中get_left是可以在编译时明确调用版本,适合使用模板。反之,只能在运行时确定的多态行为,比如从图形脚本文件中读取的图形数据,则应当使用OOP。
最后,这里还将涉及一种非常简单,但却极其实用的C++特性:RAII。所谓RAII,是Bjarne为一种资源管理形式所起的笨拙的名字,全称是Resource Acquisition Is Initialization。其实这个名称并不能表达这种技术的特征。简单地讲,就是在构造函数中分配资源,在析构函数中加以释放。由于C++的自动对象,包括栈对象、一个对象的子对象等等,在对象生成和初始化时调用构造函数,在对象生命期结束时调用析构函数。所以,RAII这种资源管理形式是自动的和隐含的。下面用文件句柄来做一个说明:
class file
{
public:
file(string const& fn) {
m_hFile=open_file(fn.c_str());//假设open_file是C函数,close也一样
if(0==m_hFile)
throw exception(“open file failed!”);
}
~file() {
close(m_hFile);
}
private:
handle m_hFile;
};
在一个函数中,当我们使用这个类时,可以无需考虑如何获取和释放资源,同时也保证了异常的安全:
void fun() {
file f(“x.txt”);
… //利用f进行操作,可能会抛出异常
} //当函数返回或异常抛出,栈清理的时候,会自动调用file::~file(),释放文件句柄
这样,资源管理会变得非常简单、方便,即便是最铁杆的C程序员,也能从中获得很大的好处。
而且,RAII不仅仅可以用来管理资源,还可以管理任何类似资源的东西(也就是有借有还的东西)。我们还是拿绘图作为案例。
用过mfc的都知道,有时我们需要改变dc的设置,比如pen的宽度、brush的颜色等等,在绘图完成之后在回到原来的设置。mfc(确切地说是Win32)提供了一对函数,允许我们把原先的dc设置保存下来,在完成绘图后在恢复:
void Draw(CDC& dc, Cycle& c) {
int old_dc=dc.SaveDC();
… //使用dc,可能抛异常
dc.RestoreDC(old_dc);
}
这种“赤裸裸”地使用Save/RestoreDC并非是件好事,程序员可能忘记调用RestoreDC返回原来状态,或者程序抛出异常,使得dc没机会Restore。利用RAII,我们便可以很优雅地解决这类问题:
class StoreDC
{
public:
StoreDC(CDC& dc): m_dc(dc) {
m_stored=m_dc.SaveDC();
if(0==m_stored)
throw exception(“DC is not saved.”);
}
~StoreDC() {
m_dc.RestoreDC(m_stored);
}
private:
CDC& m_dc;
int m_stored;
};
此后,可以很简单地处理dc的Restore问题:
void Draw(CDC& dc, Cycle& c) {
StoreDC sd(dc);
… //使用dc,可能抛异常
} //函数结束时,会自动RestoreDC,无论正常退出还是抛出异常
除此以外,RAII还可以用于维持commit or rollback语义等等方面。关于这些内容,可以参考一本非常实用的书:《Imperfect C++》。
C++拥有很多非常好的和实用的机制,限于篇幅(以及我未来的文章J)只能就此打住。这里我蜻蜓点水般的扫描了一下C++的一些主要的特性,意图告诉大家,如果你觉得C++并没有加多少,那么还是请认真地了解一下真正的C++。尽管C++在这些特性之外,存在很多弊病,并非那么容易掌握。但是,了解这些基本的特性,对于程序员,无论是否使用C++,都有非常大的帮助。
最近,一次普通的开发活动,让我突然意识到其实有很多实际开发中的问题,还是仰赖一些非常基础和简单的特性。关键在于如何认识和正确使用这些特性。于是,我开始渐渐地将一部分目光从高深的技术和特性转向如何更好地使用这些基本特性。从这一点上来看,C++社群需要Abrams、Alexandrescu这类牛人,但更需要Matthew Wilson这样的实践者。大多数情况下,Matthew这样的实践牛人对于整个社群更重要。
对于C++的各种负面误解可能并不会对C++产生实质性的伤害。而伤害最大的,反而是过度宣扬和不切实际地推广那些极端机巧的技术和方法。这些技术的作用无可否认,但在尚未掌握C++基本使用技能的人群中推广,就好比教小学生写学术论文那样不切实际。结果很容易造成,要么钻入技术牛角尖,要么被吓跑。
总之一句话,基础更重要。