论坛首页 编程语言技术论坛

朴实的C++设计

浏览 54589 次
该帖已经被评为精华帖
作者 正文
   发表时间:2010-08-12  
C++
朴实的C++设计

(这篇文章写于 2008 年底,“去年”指的是 2007 年。)

去年8月入职,培训了4个月,12月进入现在这个部门,到现在工作正好一年了。工作内容是软件开发,具体地说,用C++开发一个网络应用(TCP not Web),这是我们的外汇交易系统的一个部件。这半年来,和一两位同事合作把原有的一个C++程序重写了一遍,并增加了很多新功能,重写后的代码不长,不到15000行,代码质量与性能大大提高。实际上,重写只花了三个月,9月我们交付了第一个版本,实现了原来的主要功能,吞吐量提高4倍。后面这三个月我们在增加新功能,并准备交付第二个版本。这个项目让我对C++的使用有了新的体会,那就是“实用当头,朴实为贵,好用才是王道”。

C++是一门(最)复杂的编程语言,语言虽复杂,不代表一定要用复杂的方式来使用它。对于一个金融交易系统,正确性是首要的,价格/数量/交割日期弄错了就会赔钱。在编写代码时,我们特别注意把代码写得尽量简单直白,让人一看就懂。为了控制代码的复杂度,我们采用了基于对象的风格,也就是具体类加全局函数,把C++程序写得如C语言一般清晰,同时使用一些C++特性和库来减少代码。

项目中基本没有用到面向对象,或者说没有用到继承和多态的那种面向对象,不一定非得有基类和派生类的设计才是好设计。引入基类和派生类,或许能带来灵活性,但是代码就不如原来透彻了。在不需要这种灵活性的场合,干嘛要付出这样的代价呢?我宁愿花一天时间把几千行 C 代码弄懂,也不愿在几十个类组成的继承体系里绕来绕去浪费脑力。定义并使用清晰一致的接口很重要,但“接口”不一定非得是抽象基类,一个类的成员函数就是它的接口。如果看头文件就能明白这个类在干什么、该怎么用固然很好,如果不明白,打开实现文件,东西都在那儿摆着呢,一望而知。没必要非得用个抽象的接口类把使用者和实现隔开,再把实现隐藏起来,这除了让查找并理解代码变麻烦之外没有任何好处。一个进程内部的解耦意义不大,相反,函数调用是最直接有效的通信方式。或许采用接口类/实现类的一个可能的好处是依赖注入,便于单元测试。经过权衡比较,我们发现针对各个类写测试的意义不大。另外,如果用白盒测试,那么功能代码和测试代码就得同步更新,会增加不少工作量,碍手碍脚。

程序里边有一处用到了继承,因为它能简化设计。这是一个strategy,涉及一个基类和3、4个派生类,所有的类都没有数据成员,只有虚函数。这几个类的代码加起来不到200行。这个设计不是一开始就有的,而是在项目进行了一大半的时候,我们发现代码里有若干处针对请求类型的switch/case,于是我们提炼出了一个strategy,把好几处switch/case替换为了strategy对象的虚函数调用,从而简化了代码。这里我们纯粹把OO当做函数指针表来用的。

程序里还有几处用了模板,甚至是type traits,这都是为了简化代码,少敲键盘。这些代码都藏在一个角落里,对外只暴露出一个全局函数的接口,使用者不会被其困扰。

项目里,我们惟一仰赖的C++特性是确定性析构,即一个对象在离开其作用域之后会保证调用析构函数。我们利用这点大大简化了代码,并确保资源和内存的回收。在我看来,确定性析构是C++区别其他主流开发语言(Java/C#/C/动态脚本语言)的最主要特性。

为了确保正确性,我们另外用Java写了一个测试夹具(test harness)来测试我们这个C++程序。这个测试夹具模拟了所有与我们这个C++程序打交道的其他程序,能够测试各种正常或异常的情况。基本上任何代码改动和bug修复都在这个夹具中有体现。如果要新加一个功能,会有对应的测试用例来验证其行为。如果发现了一个bug,先往夹具里加一个或几个能复现bug的测试用例,然后修复代码,让测试通过。我们积累了几百个测试用例,这些用例表示了我们对程序行为的预期,是一份可以运行的文档。每次代码改动提交之前,我们都会执行一遍测试,以防低级错误发生。

我们让每个类有明确的职责范围,一个类代表一个概念,不能像个杂货铺一样什么都装。在增加或修改功能的时候,仔细考虑在哪儿下手才最合理。必要时可以动大手脚,而不是每次都选择最简单的修补方式,那样只会使代码越来越臭,积重难返,重蹈上一个版本的覆辙。有时我们会提炼出一个新的类,把原来分散在多个类里的代码集中到一起,从而优化结构。我们有测试夹具保障,并不担心修改会破坏什么。

设计不是一开始就形成的,而是随着项目进展逐步演化出来。我们的设计是基于类的,而不是基于类的继承体系。我们是在写应用,不是在写框架,在C++里用那么多继承对我们没好处。一开始我们只有三四个类,实现了基本的报价功能,然后增加了一个类,实现了下单功能。这时我们把报价和下单的共同数据结构提炼成一个新的类,作为原来两个类的成员(而不是基类!),并把解析客户输入的代码移到这个类里。我们的原则是,可以有特别简单的类,但不宜有特别复杂的类,更不能有大怪兽。一个类太大,我们就看看能不能把它拆成两个,把责任分开。两个类有共同的代码逻辑,我们会考虑提炼出一个工具类来用,输入数据的验证就是这么提炼出来的一个类。勿以善小而不为,所以始终能让代码保持清晰易懂。

让代码保持清晰,给我们带来了显而易见的好处。错误更容易暴露,在发布前每多修复一个错误,发布后就少一次半夜被从被窝里叫醒查错的机会:)

不要因为某个技术流行而去用它,除非它确实能降低程序的复杂性。毕竟,软件开发的首要技术使命是控制复杂度,防止脑袋爆掉。对于继承要特别小心,这条贼船上去就下不来,除非你是继承boost::noncopyable 讲解面向对象的书里,总会举一些用继承的精巧的例子,比如矩形、正方形、圆形继承自形状,飞机和麻雀继承自“能飞的”,这不意味着继承处处适用。我认为在C++这样需要自己管理内存和对象生命期的语言里,大规模使用面向对象、继承、多态多是自讨苦吃。还不如用C语言的思路来设计,在局部用一用继承来代替函数指针表。而GoF的《设计模式》与其说是常见问题的解决方案,不如说是绕过(work around)C++语言限制的技巧。当然,也是一些人挂在嘴边用来忽悠别人或麻痹自己的灵丹妙药。
   发表时间:2010-08-13  
我也赞同实用主义,喜欢扁平化的类体系。我觉得使用面向对象其实是把平面复杂度转换成立体复杂度,从流程理解变成交互模型理解。复杂性并被没有消除,只是转化成另一种形式。

面向对象具有模拟现实的优势,所以人普遍觉得它更容易理解和把握。优势不多说,我只弹一些缺点。

1、它对系统的诠释远没有结构化分析来得严格和精确。不视角得到不同的结果,这种视角差异可能会导致别人看你的设计别扭,缺乏认同感,甚至很难接受。或者,有时过一段时间,自己也会看自己的设计不顺眼。做出一个好的面向对象设计不容易。

2、它设计的失误很难纠正。面向对象的抽象粒度比过程式大,就注定它牺牲了灵活性。到后来发现抽象有点问题的时候,整个系统都依赖于这种设计了,这个时候已经很难纠正了。要么弄些邋遢的补丁,要么用一些复杂设计来补救,这里可能费心思用某某设计模式,但原本其实是不必要存在的。

3、可能会设计过度。成熟的设计师或者没这方面的担忧,但还是有不少人对设计追求过度了。这不是面向对象特有的现象,却在它这表现最为突出。因为它很鼓励抽象,“完美设计”太吸引人。
做出些比如:超出了问题域的设计,只增加复杂度不带来价值的设计,过分“远见”代价大的设计,不能抽象的也抽象,交互模型过度复杂的抽象等等。
1 请登录后投票
   发表时间:2010-08-13  
所以我认为面向对象对于哪些模拟为主的任务,或者已经有明确实体概念的领域较为擅长,发挥效用最大。

而当没有现实概念对应,依靠作者本人理解,又或者对系统演化不太明确的情形下,就要十分小心了。推翻设计代价是很大的。
0 请登录后投票
   发表时间:2010-08-13  
我只能认为楼主还没有搞明白设计,导致对设计模式的理解也是因果颠倒了。
oo这个东西太多缺陷,对接口而言,直观而简洁的设计只能是template。
0 请登录后投票
   发表时间:2010-08-13  
我正在看C++
0 请登录后投票
   发表时间:2010-08-13  
oo既然这么流行肯定有他的道理,搂主包括楼上几位应该尝试深入了解一下OO。oo花样比面向过程的多,但这些花样常常能带了一些好处。话说回来,如果面向对象设计不好,面向对象不如过程。
0 请登录后投票
   发表时间:2010-08-13   最后修改:2010-08-13
实用为王。不要本本主义!

根据开发的需要和软件的需求,采用恰当的设计思路,不管是OO还是其他,都是为开发所需要。

非常欣赏hyf 对OO和结构化这这两种开发理念的理解。

尤其是“我觉得使用面向对象其实是把平面复杂度转换成立体复杂度,从流程理解变成交互模型理解。复杂性并被没有消除,只是转化成另一种形式。”,很经典!

但是不太赞同hyf对面向对象所提的三点缺陷。

问题的复杂度,和采用那种设计方式是无关的。问题的复杂度是需求的复杂度,是问题本身的特性。

采用某种设计方式只是为了将问题的复杂度逐步分解,也就是将复杂问题简单化处理。

因此,我在开发中,宏观上采用OO设计,这样使得整体上清晰且明确。在对象的操作上,比如某个方法,采用结构化,即顺序化设计思路。

记得有本UML书中说,面向对象的核心是给对象合理分配职责。我一直很推崇。

在此,抛砖引玉,与大家共勉了。
0 请登录后投票
   发表时间:2010-08-13  
hyf 写道
我也赞同实用主义,喜欢扁平化的类体系。我觉得使用面向对象其实是把平面复杂度转换成立体复杂度,从流程理解变成交互模型理解。复杂性并被没有消除,只是转化成另一种形式。

面向对象具有模拟现实的优势,所以人普遍觉得它更容易理解和把握。优势不多说,我只弹一些缺点。

1、它对系统的诠释远没有结构化分析来得严格和精确。不视角得到不同的结果,这种视角差异可能会导致别人看你的设计别扭,缺乏认同感,甚至很难接受。或者,有时过一段时间,自己也会看自己的设计不顺眼。做出一个好的面向对象设计不容易。

2、它设计的失误很难纠正。面向对象的抽象粒度比过程式大,就注定它牺牲了灵活性。到后来发现抽象有点问题的时候,整个系统都依赖于这种设计了,这个时候已经很难纠正了。要么弄些邋遢的补丁,要么用一些复杂设计来补救,这里可能费心思用某某设计模式,但原本其实是不必要存在的。

3、可能会设计过度。成熟的设计师或者没这方面的担忧,但还是有不少人对设计追求过度了。这不是面向对象特有的现象,却在它这表现最为突出。因为它很鼓励抽象,“完美设计”太吸引人。
做出些比如:超出了问题域的设计,只增加复杂度不带来价值的设计,过分“远见”代价大的设计,不能抽象的也抽象,交互模型过度复杂的抽象等等。


非常深刻的见解!

关于第 2 点,我提供两个注脚:
1. Linus 在 2007 年炮轰 C++ 时说“——低效的抽象编程模型,可能在两年之后你会注意到有些抽象效果不怎么样,但是所有代码已经依赖于围绕它设计的‘漂亮’对象模型了,如果不重写应用程序,就无法改正。”
http://thread.gmane.org/gmane.comp.version-control.git/57643/focus=57918

2. Google 的 Go 语言在设计时有意禁止了类型继承:
http://golang.org/doc/go_lang_faq.html#inheritance
这么做的原因是,如果有一棵类型继承树,人们在一开始设计时就得考虑各个 class 在树上的位置。
随着时间的推衍,原来正确的决定有可能变成错误的。但是更正这个错误的代价可能很高。要想把这个
class 在继承树上从一个节点挪到另一个节点,可能要触及所有用到这个 class 的客户代码,所有
用到其各层基类的客户代码,以及从这个 class 派生出来的classes 的代码。
简直牵一发而动全身,在 C++ 缺乏良好重构工具的语言下,有时候只好保留错误,用些 wrapper 或
者 adapter 来掩盖之。久而久之,设计越来越烂,最后只好推倒重来。
解决办法之一就是不采用基于继承的设计,而是写一些容易使用也容易修改的具体类。

总之,继承和虚函数是万恶之源,这条贼船上去就不容易下来。不过还好,在 C++ 里我们有别的办法:
http://blog.csdn.net/Solstice/archive/2008/10/13/3066268.aspx
0 请登录后投票
   发表时间:2010-08-13  
oo太阳春白雪了,太至高无上了,根本不是普通人能学会的。必定得好好的体会一百年,那也不过只是明白了一点皮毛。不经过N代的传承,根本不可能领会到oo的精华。但是只要你领会到了,那就能一口气的赛过芙蓉,力压凤姐,在地球上刮起一道旋风,让你的名字在宇宙间飘荡。
所以c++从03年开始就不谈oo了。
0 请登录后投票
   发表时间:2010-08-13  
15,000行代码;几百个测试用例;3个月开发时间;全部重写的代码。
确实,楼主的开发模式是可行的。

不过,C++开发在1万行,十万行,百万行的规模上,设计和开发思路是有很大不同的。
每个人都是经验主义者,尤其是软件开发人员,都只会认可自己熟悉的产品的熟悉的开发模式,包括Linus对于C++的批判。
0 请登录后投票
论坛首页 编程语言技术版

跳转论坛:
Global site tag (gtag.js) - Google Analytics