C++之歌——噢,我亲爱的++

编程是艺术,这无可否认。不信的去看看高大爷的书就明白了。艺术对于我们这些成天挤压脑浆的程序员而言,是一味滋补的良药。所以,在这个系列中,每一篇我打算以艺术的形式开头。啊?什么形式?当然是最综合的艺术形式。好吧好吧,就是歌剧。当然,我没办法在一篇技术文章的开头演出一整部歌剧,所以决定用一段咏叹调来作为开始。而且,还会尽量使咏叹调同文章有那么一点关联,不管这关联是不是牵强。

噢,我亲爱的++

普契尼的独幕歌剧歌剧《贾尼·斯基基》完成于1918年,同年初演于纽约。

本剧的剧情取自意大利诗人但丁(1265 1321)的长诗《神曲·地狱篇》中的一个故事:富商多纳蒂死了。其遗嘱内,将遗产全数捐献给某一教堂。在场亲友大失所望。众人请贾尼·斯基基假扮多纳蒂,骗过公证人,另立遗嘱,遗产由众亲友均分。公证人到场。结果斯基基将少量遗产分与众人,大部分留给了自己。遗嘱录毕,公证人离去。众大哗,斯基基从病榻跃起,持棒驱散众人。

剧中斯基基的女儿劳蕾塔为表达对青年努奇奥的爱情,对其父唱起了这首美妙绝伦的咏叹调——“我亲爱的爸爸”:

“啊 ! 我亲爱的爸爸,我爱那美丽少年。
我愿到露萨港去,买一个结婚戒指。
我无论如何要去,假如您不答应,
我就到威克桥上,纵身投入那河水里。
我多痛苦,我多悲伤。
! 天哪 ! 我宁愿死去 !
爸爸,我恳求你 !
爸爸,我恳求你 !

按照C/C++中对于后置操作符++的定义,操作数增加1,并返回原来的值。于是,有人根据这个给C++遍了一段笑话,流传甚广。那么,C++是否相对C加了那么一点点,然后还是返回原来的值呢?那就让我们来“实地考察”一下,了解这个++究竟加了多少。

我不打算罗列C++的各种纷繁复杂的特性。已经有无数书籍文章做了这件事,肯定比我做的好得多。我要做的,是探索如何运用C++的一些机制,让我们能够更方便、快捷、容错地开发软件。这些特性很多都是非常简单的,基本的。正因为它们基本,很容易为人们所忽略。另一些则是高级的,需要多花些时间加以掌握的。但是,这些特性也具有一些简单,但却非常实用、灵活和高效的用法。

相对于CC++最主要的变化就是增加了类。严格地讲,类是一种“用户定义类型”,是扩展类型系统的重要手段。类从本质上来说,是一种ADTAbstract Data Type,抽象数据类型)。笼统地讲,ADT可以看作数据和作用在这些数据上的操作的集合。

类提供了一种特性,称为可见性。意思是说,程序员可以按自己的要求,把类上的数据或函数隐藏起来,不给其他人访问。于是,通过可见性的控制,可以让一个类外部呈现一种“外观”,而内部可以使用任何可能的方法实现类的功能。这称为“封装”。

呵呵,听烦了吧。这些东西是学过C++(或者任何时髦的OOP语言)的都已经烂熟于胸了。这样的话,我们就来点实际的,做个小案例,复习复习。温故而知新嘛。:)

案例非常简单,做一个圆类。让我们从“赤裸”的C结构开始吧:

struct Cycle
{
float center_x;
float center_y;
float radius;
};
很传统的表示, < 圆心坐标,半径 > ,便可以立刻定义出一个圆形。现在,假设我们需要计算圆形的面积。于是,我写了一个函数执行这项任务:
float Area( const Cycle & rc) ... {
returnPI*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() ... {returnget_center_x()-get_radius();}

这样在改变数据存储的情况下,修改get_left()等函数了。不过,get_center_x()等函数本来就是从left等成员数据上计算获得,get_left()再逆向计算回去,显得有些奇怪。

这还只是小问题。更重要的是增加了这些冗余的函数,使得类在接口的灵活性上变差。假设我们在Cycle类上增加一个offset()函数,实现平移:

class Cycle
{
public :

void offset(pointo){center_x += x;center_y += y;}

};

Cycle的使用代码中,调用了offset()

Cyclec;

c.offset(
20 , 100 );

假设,此时来了一个需求,要求offset()可以接受size类的对象作为参数。那么就必须修改Cycle类的定义,改变或重载offset()。如果这个Cycle是别人写的,不是我们所能改变的,那么事情就比较麻烦。

按照现代的Multiple-paradigm的设计理念,这类操作应当以non-member non-friend的形式出现,而类仅仅保持最小的、无冗余的接口集合:

void offset(Cycle & c,pointo){
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 < typenameT >
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);
}

这样,同一个算法便可以用于(我们)所有的圆类:

CycleAc1;
CycleBc2;
CycleCc3;

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::Cyclec;

CycleAdapterca(c);
Theirs::inflate(ca,
1.5 );

唉,世事难料,上头下命令,必须同时使用我们自己的圆类和顽固派的圆类。(肯定是收了他们的好处了)。没办法,命令终究是命令。可从今往后,我们就得同时开发两套算法。痛苦。不过相比使用算法的人来说,我们还算幸运的。他们必须不断地在OursTheirs命名空间里跳来跳去,时间长了难保不出错。

算法使用者希望一个算法就是一个名字,在同一个命名空间,以免混乱。幸运的是,在一种未来技术的支持下,我们做到了。这就是C++BM(弹道导弹,MDW的运载器)——concept

conceptOurCycle < typenameT > {
float T::get_left();
void T::set_left( float left);

}

conceptTheirCycle
< typenameT > {
float T::get_left();
void T::set_left( float left);

}

concept_mapOurCycle
< Ours::CycleA > ;
concept_mapOurCycle
< Ours::CycleB > ;


concept_mapTheirCycle
< Theirs::CycleA > ;
concept_mapTheirCycle
< Theirs::CycleB > ;


template
< OurCycleT > void move(T & c,point const & p){…} // #1
template < TheirCycleT > void move(T & c,point const & p){…} // #2

concept和特化的共同作用下,我们便可以很方便地(不需考虑我们的,还是他们的)使用这些算法了:

Ours::CycleAc1;
Ours::CycleBc2;
Theirs::CycleAc3;

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继承而来,并且重写(overrideDraw()

class Cycle: public Graph
{

public :
void Draw(CDC & dc){
// 绘制圆
}
};

class Rectangle: public Graph
{

public :
void Draw(CDC & dc){
// 绘制矩形
}
};

此后,便可以创建一个对象并绘制:

Cyclec;
c.Draw(dc);

但这同不用虚函数有什么区别?请看以下代码:

typedefshared_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_eachmem_fun的奇妙原理,我这里就不说了,有很多参考书都有很详细的解释,比如《C++ STL》、《C++ Standard Library》等等)。

这里的核心在于,当我们调用Graph指针上的Draw成员函数时,实际上被转而定向到继承类(CycleRectangle等)的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 < typenameT > 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时,编译器会自动匹配不同的版本:

Cyclec;
Rectangler;

Draw(dc,c);
// #1
Draw(dc,r); // #2

这里使用了函数模板特化这种特性,促使编译器在编译时即根据特化的情况调用合适的函数模板版本。不过,仔细看函数模板的声明,会发现这同函数的重载几乎一样。实际上此时使用函数重载更加恰当。(函数重载通常也被认为是一种多态)。这里使用模板,是为了引出未来的concept的方案:

template < OurCycleT > void Draw(CDC & dc,T & g){ // #1
dc.Ellipse(get_left(g),get_top(g),get_right(g),get_bottom(g));
}

template
< OurRectangleT > 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(“openfilefailed ! ”);
}
~ file(){
close(m_hFile);
}
private :
handlem_hFile;
};

在一个函数中,当我们使用这个类时,可以无需考虑如何获取和释放资源,同时也保证了异常的安全:

void fun(){
filef(“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 notsaved.”);
}
~ StoreDC(){
m_dc.RestoreDC(m_stored);
}
private :
CDC
& m_dc;
int m_stored;
};

此后,可以很简单地处理dcRestore问题:

void Draw(CDC & dc,Cycle & c){
StoreDCsd(dc);
// 使用dc,可能抛异常
} // 函数结束时,会自动RestoreDC,无论正常退出还是抛出异常

除此以外,RAII还可以用于维持commit or rollback语义等等方面。关于这些内容,可以参考一本非常实用的书:《Imperfect C++》。

C++拥有很多非常好的和实用的机制,限于篇幅(以及我未来的文章J)只能就此打住。这里我蜻蜓点水般的扫描了一下C++的一些主要的特性,意图告诉大家,如果你觉得C++并没有加多少,那么还是请认真地了解一下真正的C++。尽管C++在这些特性之外,存在很多弊病,并非那么容易掌握。但是,了解这些基本的特性,对于程序员,无论是否使用C++,都有非常大的帮助。

最近,一次普通的开发活动<

你可能感兴趣的:(C++,c,算法,C#,oop)