分类: C/C++
2008-05-31 08:38:09
“磨刀不误砍柴工”这句老话用在C++身上是再合适不过了。如果把C++比喻成一把刀,那么它会是一把材质和形状都非常好的刀——只是没有开锋。所以我们要“磨刀”。
C++这把刀材质坚硬,强度也高,或许还进行过表面处理。那自然很难磨,费时费力。不过,一旦磨好,便锋利无比,持久耐用。这还是值得的。
C++的“磨刀”实际上就是开发库,各种可能的库,从基础库开始,到各类应用库。库越多,刀磨得越快。当然了,开发库是有代价的。需要花时间,花精力,以及无限的耐心。
此时,我们便需要做一些估计和四则运算,以便选择如何磨这把刀。
最关键的因素,是某件工作被重复的次数,或者近似的工作的数量。某件工作被重复的次数的含义很明显,如果一再重复自己已经做过的事,明显是愚蠢的行为。“copy-paste神功”利用源代码的可复制性,很容易地避免了重复编码。但是,这也只是稍稍“不那么愚蠢”而已。
当这些被重复的代码发生变化,那么,每一处paste的地方都需要被修改或替换。于是,聪明的人们发明了子程序、函数、类、继承、多态、模板等等五花八门的技术手段。目的便是消除这种“愚蠢”或“不那么愚蠢”的做法。一旦某件工作被做成子程序、函数、类、模板等,实际上便形成了一个库,只是库的应用范围有所差异而已。
相比之下,近似的工作的含义则复杂、含混得多。我们编码时,时常会发现某些工作具有不同程度的相似性。比如,我们写一个排序算法,用于int类型;下次写同样的排序算法,用于double类型;…。有多少需要排序的类型,就要写多少次算法。这些算法并非完全相同(在类型上有所差异),但其结构完全一样。由此,我们可以用一个泛型算法实现所有类型的排序(暂不考虑性能问题和类型concept需求)。
当然,并非所有的代码都具有如此高的相似性。代码的相似性越少,创建抽象的库代码的难度越大。所以,库是有限度的。综合考虑创建库的代价和效用,便可以指导我们是否建立库,或者如何建立库。
对于完全一模一样的代码重复,自不必说,只管做成库代码。因为做这些库代码的工作量,只比编写一次代码的工作量多那么一点。修改也是如此。
而对于相似的代码,情况则复杂得多。一般而言,如果这些相似代码仅有少量的出现,比如3、4处,通常没有必要创建相应的库代码。特别是这些相似代码的相似程度较小,或者代码复杂的时候。此时,创建库的代价很大,但获得的收益也仅有这么3、4处而已。
但必须指出的是,我们在考虑是否创建库时,还必须认真地考虑其他项目,或者未来项目中应用的可能性。如果是某个非常常用的功能,尽管在当前项目中只出现一次,考虑到未来其他项目的应用,也应当将其开发成库。
回到砍柴的比喻。一把没有开封的刀,在一定程度上也能砍下一些树枝,只是砍起来费劲些,也无法砍下较粗的树枝。如果我只需要砍那么几根枝丫,不需要很多,而且以后也不会再去砍柴。那么,一把钝刀也够用了。在这种情况下,如果还费劲地磨刀,着实是一种浪费。相反,如果我今天要砍一整担柴火,或者需要日复一日地砍柴。那么,我最好还是把刀磨磨好再说。
磨刀也有难有易。材质坚硬(俗称“钢火”好)的刀,磨起来费力。但更锋利,更耐用。材质较软的刀,尽管磨起来快。但要使其锋利和耐用,比较困难。(因为材质软的刀,在磨到一定程度后,刃口会向上卷起。如果再反过来磨,又会向反方向卷)。 [Page]
C++就属于那种材质坚硬的刀。(而且生产厂家出于成本考虑,也没有为其开锋)。于是,作为“职业砍柴人”,有必要好好地磨砺一下这把好刀。(当然啦,也有很多“职业砍柴人”转而使用那些容易磨,或者出厂时已经开锋的“软质刀”)。
磨刀也是有讲究的。(呵呵,我自认为在磨刀方面还是有那么一两手的)。越是硬的刀,越是不能急,一般需要循序渐进。为了不耽误柴火的产量,只能磨一点,用一点。一开始先用大角度,在锋口上磨出快口。尽管大角度的锋口不如小角度的来的锋利,但要比没开锋来得好。更重要的是,大角度锋口所花的时间要比小角度的少很多。由于我手中的是一把好刀,在砍柴的过程中,基本上不会有什么损耗。等到第二天,我再以小角度磨刀。同样,也不打算在第二天就全部搞定,继续用磨了一半的刀砍柴。经过第二天的磨砺,刀会比第一天好用些。然后第三天同第二天一样。以此类推,直到若干天后,刀完全磨好。此后,只需定期打磨一下,维持刀具的锋利即可。相比之下,那些软质的刀则需要更频繁地磨,以维持锋利程度。
好了,刀就磨到这里吧。我们来看看如何“磨”C++。这里就用一个现实的案例来加以说明吧。
现在很多应用软件,特别是MIS类软件,都需要访问数据库,然后把数据提取出来,进行进一步加工,或者直接显示在界面上。下面这样的代码,在软件中想必是随处可见的:
void OnQueryClicked()
{
DataConnection dc_(…);
Rowset rs_(dc_, “select … from …”);
int j(0);
m_resGrid.Clear();
m_resGrid.SetColNumber(rs_.ColNumber());
while(rs_.MoveNect())
{
for(int i=0; i
m_resGrid.AddRow();
m_resGrid.Cells(j, i)=rs_.field(i);
}
++j;
}
}
很常规的代码。这里m_resGrid是一个界面上的Grid控件。同时也假定rs_已经采用字符串绑定,可以直接输出字符串。
现在,让我们发挥一下想象力,如果结果集rs_和Grid控件m_resGrid都支持标准STL容器的接口,事情会怎样?很简单,我们可以采用以下的方式重写上述代码:
void OnQueryClicked()
{
DataConnection dc_(…);
Rowset rs_(dc_, “select … from …”);
int j(0);
m_resGrid.Clear();
m_resGrid.SetColNumber(rs_.ColNumber());
std::transform(rs_.begin(), rs_.end(), std::back_inserter(m_resGrid), CopyRStoGrid());
}
明显简单多了,不是吗?不过有问题。这个transform仅仅替代了原来的外层循环,即那个while()。而内存循环,也就是rs_的Column上的循环还没有做。
解决的方法也不复杂,也就是CopyRStoGrid的作用:一个执行内循环的函数或函数对象即可:
struct CopyRStoGrid
[NextPage]
{
Grid::Row operator()(Rowset::Row const& row) {
Grid::Row gridRow_(row.FieldNum());
std::copy(rs_.begin(), rs_.end(), gridRow_.begin());
return gridRow_;
}
};
这个函数对象编写完成后,便可以一直使用,无需再写。所以,我们可以用一行代码代替原来的两个循环/五行代码(不算花括号)。这个好处看起来或许不算太大,但是考虑到软件中大量存在的结果集向显示控件的数据拷贝,这种差异积累起来就很可观了。 [Page]
另外一个问题是性能。std::transform()在性能上不会有太大的问题。问题在于CopyRStoGrid上。根据std::transform()的要求,operator()的返回值得是Grid::Row,而且是值返回。非平凡类型的值返回在性能方面是臭名昭著的。现代编译器通常会执行NRV优化,而且像CopyRStoGrid这样的简单函数对象,这种优化是可以得到保证的。即便是无法得到NRV优化,下面会提供一种更直接的优化方案。未来,可以利用C++09的右值引用彻底消除这类性能隐患。
结果集和Grid等控件STL化后,带来的好处不仅仅在于数据复制方面,在其他方面,比如数据过滤、删除、转换、查询、排序等等,都会带来大量的简化。在长时间地开发后,我们已经对循环,手工数据拷贝或转换、查询等等已经习以为常了。很多时候,我们一次又一次地编写一些隐性的算法。我经常看到有人用赤裸裸的循环,在一个Grid上查找某个特定的数据。(这些人也包括我自己)。我也不止一次地在文档中寻找某个控件、容器、或类上,是否有叫Find、Search之类名称的成员函数。相比之下,能够直接用find或find_if这样拥有唯一语义的算法,来查询数据,而不用考虑数据所在的类型,是一件多么惬意的事啊。
下一个问题便是:如何使结果集、界面控件等容器STL化。这便回到了本文的主题——磨刀。也就是做库。我们可以基于ODBC或OleDB开发完全STL化的数据库访问库。但是如此兴师动众,也不见得很划算。不错,磨刀当然是必要的,但是我们也还没有笨到去把一块钢锭磨成柴刀的份上。好歹可以从已经锻打成型的坯料开始嘛。
现有的很多数据库访问库都提供了很好的,很成熟的数据库访问组件。我们可以利用这些现成的库,在它们的基础上,使其STL化。具体的方法就是做适配器。实际上,所有的数据访问库所提供的结果集组件,都拥有一个容器最基本的特征。只是其接口还不能符合STL的标准。所以,我们只需增加一个中间层,将所有相关的接口转化成STL的形式即可:
class stl_rowset
{
public:
stl_rowset(CRecordset const& rs) : m_rs(rs) {}
…
iterator begin();
iterator end();
void push_back();
void insert();
…
private:
CRecordset&m_rs;
};
通过这样的适配器,我们便可以把一个结果集“打扮”成STL容器。于是,stl的那些标准算法,便可以直接使用了。这里的关键是迭代器。制作迭代器是一个比较费劲的事。而boost::iterator正好可以帮助我们快速方便地构建一个迭代器。
迭代器还为我们带来一个附加的好处。对于forward_only游标,可以采用Forward迭代器。对于其它类型的Scrollable游标,则可以采用radom迭代器。于是,当我打算在一个forward_only游标的结果集上做逆向操作时,迭代器便可以在编译时阻止我,让我得以在第一时间拦截这种错误。
同样,我们也可以针对界面控件,或其他任何类似容器的组件,创建相应的适配器。事实上,也已经有人这么做了。Matthew Wilson开发的STLSoft系列库便提供了针对不同平台系统的适配器,使得我们可以很方便地将标准算法用于界面或其他组件。
更进一步,我们还可以创建一些泛型适配器,使得一些具备共同接口的组件,可以共享一个适配器,而无需重复劳动。我在过去的一个帖子《强大的C++——千人一面》中,做过一番尝试()。在现有的C++中还是比较麻烦的。将来,在C++09的Concept和Concept-Map的支援下,可以非常方便地实现这类泛型适配器。 [Page]
现在,我们回过头来,看一下前面提到的性能问题。由于我们创建了适配器,而且适配器是我们自己做的。所以,我们可以在适配器的value_type上做些手脚。STL规定,每个容器必须有一个typedef … value_type;。而这个类型则是迭代器的值的类型。我们可以这样定义这个类型:
class stl_grid
{
class stl_row
{
template
copy_row(v, *this);
}
template
copy_row(v, *this);
}
typedef stl_row value_type;
…
}
…
};
其中,copy_row是一个辅助函数模板,用于复制不同类型的row:
template
void copy_row(From const& from, To& to){
std::copy(from.begin(), from.end(), to.begin());
}
这样,便可以用std::copy,而不是std::transform复制数据,更加直接,高效:
void OnQueryClicked()
{
DataConnection dc_(…);
Rowset rs_(dc_, “select … from …”);
int j(0);
m_resGrid.Clear();
m_resGrid.SetColNumber(rs_.ColNumber());
stl_rowset(rs_) stlrs_;
stl_grid(m_resGrid) stlgrid_;
std::copy(stlrs_.begin(),stlrs_.end(),std::back_inserter(stlgrid_));
}
如果有特殊需要,可以对copy_row进行重载或特化。如果需要局部特化,那么把copy_row做成函数对象即可。
在这些适配器的基础上,还可以衍生出各种复杂的用途和算法。比如,可以做一个结果集的列适配器,将结果集的一个列剥离出来。用一组列适配器,可以剥离出一组列,然后便可以对这组列执行各种算术和逻辑运算,甚至是矩阵运算。必要时还可以动用模板表达式,以优化运算性能。
如此用途不胜枚举。但总的来讲,我们可以在软件开发的过程中,额外花费一些时间和精力,开发一些有用的库。而这些库则会在当前的项目,或将来的项目中为我们带来大量的好处。不过,如同世间其他事物那样,通过开发库提高软件开发效率,也服从2-8原则。80%的效果来源于20%的努力。也就是说,当我们实现关键的20%的工作时,我们会得到很大的好处,而其余大多数的工作,则只会为我们带来剩余20%的效率提升。所以,开发库应当从关键的一些地方入手,如STL适配器,而无需全面地开发一个数据库访问,或GUI库。这样,我们才会得到最大的效益。尽管开发一个完整的、全面的和最完美的GUI,会显得那么光彩、辉煌和荣耀,但从现实角度讲,这并不是最经济的手段,也并非是我辈这些整天忙碌于赚钱养家的平凡程序员的最佳手段。特别是在C++这种比较难缠的语言上。