文章出处:http://www.limodev.cn/blog
做者联系方式:李先静 <xianjimli at hotmail dot com>程序员
“快”是指开发效率高,“好”是指软件质量高。呵呵,写得又快又好的人就是高手了。记得这是林锐博士下的定义,读他那篇著名的《C/C++高质量编程》时,我仍是个初学者,印象特别深。我如今仍然赞同他的观点,不过这里标题改成成为高手的秘诀,感受就有点像标题党了,因此仍是用比较通俗的说法吧。废话少说,请读者回顾一下这段时间的编程经验,回答下面两个问题:面试
1.快与好是什么关系?写得快就不能写得好?写得好就不能写得快?仍是写得好才能写得快?是否是绕晕了?不过这确实是值得思考的问题。算法
2.咱们的时间花在哪里了?记得刚来深圳时到华为面试,面试的人是个人学长。他问我,你一天能写多少行代码?我想了想说,100行吧。他用看外行的眼光看着我说,能写100行吗?我知道说错话了,赶快补充说,嗯,从整个项目来看可能没有吧。他才点了点头。一天只写100行代码?初学者可能以为难以想象,以同时应付10个网友聊天的速度,写100行代码不用三分钟。不过,通过这段时间的练习后,咱们想你们已经明白,敲代码不是花时间最多的地方,那时间又花到哪里去了呢?编程
1.好与快的关系小程序
几年前和一个朋友聊天时,他抱怨他的上司说,要我写得好又要写快,那怎么可能呢?我当时一愣,反问到,写很差怎么可能写得快?他也一愣。数组
传统观点认为在功能、成本(人*时间)和质量这个铁三角中,提升质量就意味投入更多成本或者减小一些功能。在功能不变的状况下,不可能在提升质量的同时下降开成成本(对我的来说就是缩短开发时间)。个人朋友持的正是这种传统观点。安全
而根据个人经验来看,结论偏偏相反。每次我想以牺牲质量来加快速度的时候,结果反而花了更多时间,甚至可能到最后搞不定而放弃了。有了屡次这样的经验以后,我决定把每一步都作好,从开发的前期来看,我花的时间比别人多一点,但从整个任务来看,我反而能以别人几倍的速度完成任务。时间长了,我造成了这样的观念:只有写得好才可能写得快。服务器
两种观点截然相反,因此咱们都愣了。虽然我相信个人经验没有错,但传统的铁三角定律是大师们总结出来的,也不可能出错。那是怎么回事呢?我开始处处查资料,可是没有一我的支持个人观点。我又不想这样放弃,后来我用了一个简单的办法进行推理,结果证实两个观点都有各自的适用范围。网络
这个推理过程很简单,把两种观点推向极端:数据结构
先看看以牺牲质量来追求进度的例子。我之前参加过两个大项目,其一个项目的BUG总数达到17000多个,耗时近三年后项目被取消。另外一个项目的BUG总数也超过10000个,三年以后带着不少BUG发布了,结果可想而知,产品很快从市场上消失了。这两个项目在开始阶段都制定了极其好笑的项目计划,为了赶在这个根本不可能的最后期限前,都采用了牺牲质量的方式来提升开发速度,前期进展都很“顺利”,基本功能点很快就完成了,可是项目立刻陷入了无止境的debug之中,开发人员的士气一下跌到谷底,管理层开始暴跳如雷。
若是这两个项目有超过170000个BUG,即便项目不取消,再作时间十年也作不完。因而可知:质量低到必定限度时,下降质量会延长项目时间,若是质量降到最低,那项目永远也不可能完成。这和个人观点一致:写很差就写不快。
再看看追求完美质量的例子。之前参与一个手机模拟器的开发,咱们很快达到88%的真实度,半年以后达到95%的真实度,客户要98%的真实度。可是怎么努力也达不到这个标准,花了极大的代价才达到96%多一点,到后来项目被取消了。
若是要达到99%的真实度,即便项目不取消,再作时间十年也作不完。因而可知:质量高到必定程度,提升质量会延长项目时间,若是质量要高到最高,那任务远也不可能完成。这和传统观点一致,提升质量就要延长开发时间。
从两个极端往中间走,咱们能够找到一个中间质量点。低于这个质量点,想以牺牲质量来赶进度,那只会拔苗助长,质量越低耗时越长。高于这个质量点,想提升质量就得增长成本,质量越高开发时间越长。这样两种观点就统一块儿来了。
若是在大多数项目中,这个中间质量点是能够做为高质量接受的,那咱们就找到了又快又好的最佳方法。这个质量点究竟是多少?呵,我能够告诉你,那是87.5。可是谁知道怎么去度量呢?没有人知道,只能凭感受和经验了。
2.咱们的时间花在哪里
通过这段时间的练习,大多数人都体会到敲代码不是耗费时间最多的地方,一个高效率的程序员,并非打字比别人快,而他节省了别人浪费了的时间。我常说达到别人五倍的效率并不难,由于在软件开发中,大部分人的大部分时间都浪费掉了,你只要把别人浪费的时间省下来,你的效率就提升上去了。像在优化软件性能时采用的方法同样,要优化程序员的性能,咱们要找出性能的瓶颈。也就是弄清楚咱们的时间花在哪些地方,而后想办法省掉某些浪费了的时间。根据个人经验,耗费时间最多的地方有:
o 分析
需求分析一般是SPEC工程师(或者所谓的系统分析员)的任务,程序员也会参与到这个过程当中,但程序员的任务主要是理解需求,而后分析如何实现它们,这个分析工做也就是软件设计。不管你是在计算机上用设计工具画出正规的软件架构图,还在纸上用天然语言描述出算法的逻辑,甚至在脑海中一闪而过的想法都是设计。设计其实就是打草稿,把你的想法进行推敲,最后获得可行的方案。设计文档只是设计的结果,是设计的表现形式,没有写设计文档,并不表明没有作设计(可是写设计文档能够加深你的思考)。
设计自己是一个思考过程,须要耗费大量时间,对于新手来讲更是如此。前面几节中的需求并不难,理解它们只须要不多的时间,但要花很多时间去思考其实现的方法。这个时间因人而异,有的读者到最后也没有想出办法,这没有关系,没有人天生就会的,不会的缘由只是由于你暂时还不知道经常使用的设计方法,甚至连基本数据结构和算法都不熟悉。
在后面的章节中,咱们会一步步的深刻学习各类经常使用设计方法,反复练习基本数据和算法,熟能生巧,软件设计也同样,在你什么都不懂的时候,不可能作出好的设计。你要学习各类经典的设计方法,开始可能生搬硬套,多写多练多思考,到后来就为所欲为了,设计的时间就会大大缩短。
o测试
要写得好天然离不开测试,初学者都有这个概念。他们忠实的使用了教科书上讲的方法,用scanf输入数据,作些操做以后,用printf打印来,这是一个完美的输入-处理-输出的过程。测试也就是要保证正确的输入能产生正确的输出,这种方法的原理是没有错的,但它们确实耗费了咱们大量时间。
若是测试只须要作一次,这种方法仍是可取的,问题是每次修改以后都要重复这个过程,耗费的时间就更多了。这种工做单调乏味,并且很难坚持作下去,单元测试作得不全面,就有更多BUG等着就调试了。时间久了,或者换人维护了,谁也搞不清楚什么样输入产生什么样的输出,结果多是连测试也省了,那就等着把大量的时间浪费在调试上吧。总而言之,这种测试方法很差,咱们须要更有效的测试方法才行。
o调试
测试时发现程序有BUG,天然要用调试器了,对一些人来讲,调试是一件充满挑战和乐趣的事。而对大部分人来讲,特别是对我这种作过两年专职调试的人来讲,调试是件无趣无聊无用的工做。熟练使用调试器是必要的,在分析现有软件时,调试器是很是有用的工具。但在开发新软件时,调试器在浪费咱们的时间。
调试器是最后一招,只有无可奈何时才使用。一位敏捷方法的高手说他已经记不得上次使用调试器是何时了,我想这就是为何敏捷方法可以提升开发速度的缘由吧。由于没有什么比一次性写好,不用调试器更快的方法了。
知道了浪费时间的地方,接下来几节中,咱们将介绍避免浪费时间的方法。学完这些方法以后,我但愿读者也能达到普通工程师五倍的效率,呵,读完本系列文章后之,但愿你会达到更高。
代码阅读法
软件工程实践已经证实Code Review是提升代码质量最有效的手段之一,极限编程(XP)更是把Code Review推向极致,造成著名的结对编程工做方式,两个程序员在一台电脑前面工做,一我的编写程序,另外一个Review输入每一行代码,写程序人的专一于目前细节上的工做,Review的人同时要从高层次考虑如何改进代码质量,两我的的角色会常常互换。
惋惜我即没有结对编程的经验,也没有在CMM3(及以上)团队中工做过。不过如今我要介绍比结对编程更敏捷更轻量级,可是一样有效的Review方法。这种方法不须要其余程序员配合,有你本身就够了。为了把这种方法与传统的Code Review区分开来,我把它称为代码阅读法吧。
不少初学者包括一些有经验的程序员,在敲完代码的最后一个字符后,立刻开始编译和运行,迫不急待的想看到本身的工做成果。快速反馈有助于知足本身的成就感,可是同时也会带来一些问题:
让编译器帮你检查语法错误能够省些时间,但程序员每每太专一这些错误了,觉得改完这些错误就万事大吉了。其实否则,不少错误编译器是发现不了的,像内存错误和线程死锁等等,这些错误可能逃过简单的测试而遗留在代码中,直到集成测试或者软件发布以后才暴露出来,那时就要花更大代价去修改它们了。
修改完编译错误以后就是运行程序了,运行起来有错误,就轮到调试器上场了。花了很多时间去调试,发现无非是些低级错误,或许你会自责本身粗枝大叶,可是下次可能仍是犯一样的错误。更严重的是这种debug & fix的方法,每每是头痛医头脚痛医脚,致使低质量的软件。
让编译器帮你检查语法错误,让调试器帮你查BUG,这是天经地义的事,但这确实是又慢又烂的方法。就像你要到离家东边1000米的地方开会,结果你往西边走,又是坐车又是搭飞机,花了一周时间,也绕着地球转了一周,终于到了会议室,你还大发感慨说,现代的交通工具真是发达啊。其实你往东走,走路也只要十多分钟就到了。无论你的调试技巧有多高,都不如一次性写好更高效。
我之前也同样,想赶时间结果花了更多时间,在通过不少痛苦的经历以后,我开始学会放松本身,让本身慢下来。写完程序以后,我会花些时间去阅读它,一遍两遍甚至多遍以后,才开始编译它,只要有时间,在经过测试以后,我还会阅读它们,每读一遍都有不一样的收获,有时候会发现一些错误,有时候会作些改进,有时候也有新的想法。
下面是我在阅读本身代码时的一些方法:
o检查常见错误。
第一遍阅读时主要关注语法错误、代码排版和命名规则等等问题,只要看不顺眼就修改它们。读完以后,你的代码不多有低级错误,看起来也比较干净清爽。第二遍重点关注常见编程错误,好比内存泄露和可能的越界访问,变量没有初始化,函数忘记返回值等等,在后面的章节中,我会介绍这些常见错误,避免这些错误能够为你省大量的时间。若是有时间,在测试完成以后,还能够考虑是否有更好的实现方法,甚至尝试从新去实现它们。说了读者可能不相信,在学习编程的前几年,我常常重写整个模块,只我以为能作得更好,能验证个人一些想法,或提升个人编程能力,即便连续几天加班到晚上十一点,我也要重写它们。
o模拟计算机执行。
常见错误是比较死的东西,按照检查列表一条一条的作就好了。有些逻辑一般不是这么直观的,这时能够本身模拟计算机去执行,假想你本身是计算机,读入这些代码时你会怎么处理。这种方法能有效的完善咱们的思路,考虑不一样的输入数据,各类边界值,这能帮助咱们想到一些没有处理的状况,让程序的逻辑更严谨。
o假想讲给朋友听。
听说在Code Review时发现错误的,每每不是Review的人而是程序员本身。我也有不少这样的经历,在讲给别人听的时候,别人尚未听明白,本身已经发现里面存在的错误了。上大学时,我经常把写的或者学到的东西讲给隔壁寝室的一个同窗听,他说他从我这里学到不少知识,其实我从讲的过程当中,常常发现一些问题,对提升本身的能力大有帮助。惋惜并非随时都能找到好的听众,幸亏咱们有另一个替代办法,记得刚开始写程序时看过一本书(忘记名字了),做者说他在写程序时,经常把思路讲给他的布娃娃听。我没有布娃娃当听众,讲给鼠标听老是有点怪怪的,因此就假想旁边有个朋友,我把本身的思路讲给他听,同时也假想他来质疑我。这种方法很效,可以让本身的思路更清晰,听说一些大师也常用这种方法。
这种代码阅读法会花你一些时间,可是能够省下更多调试时间,并且可以提升代码质量,能够说是名符其实的“又快又好的” 秘诀之一。至于读几遍合适,要根据状况而定,我的以为读两到三遍是最佳的投资。
避免常见错误
在C语言中,内存错误是最为人诟病的。这些错误让项目延期或者被取消,引起无数的安全问题,甚至出现人命关天的灾难。抛开这些大道理不谈,它们确实浪费了咱们大量时间,这些错误引起的是随机现象,即便有一些先进工具的帮助,为了找到重现的路径,花上几天时间也不足为怪。若是可以在编写代码的时候避免这些错误,开发效率至少提升一倍以上,质量能够提升几倍了。这里列举一些常见的内存错误,供新手参考。
o 内存泄露
你们都知道,在堆上分配的内存,若是再也不使用了,应该把它释放掉,以便后面其它地方能够重用。在C/C++中,内存管理器不会帮你自动回收再也不使用的内存。若是你忘了释放再也不使用的内存,这些内存就不能被重用了,这就形成了所谓的内存泄露。
把内存泄露列为首位,倒并非由于它有多么严重的后果,而由于它是最为常见的一类错误。一两处内存泄露一般不至于让程序崩溃,也不会出现逻辑上的错误,加上进程退出时,系统会自动释放该进程全部相关的内存(共享内存除外),因此内存泄露的后果相对来讲仍是比较温和的。可是,量变会致使质变,一旦内存泄露过多以至于耗尽内存,后续内存分配将会失败,程序可能所以而崩溃。
如今PC机的内存够大了,加上进程有独立的内存空间,对于一些小程序来讲,内存泄露已经不是太大的威胁。但对于大型软件,特别是长时间运行的软件,或者嵌入式系统来讲,内存泄露仍然是致命的因素之一。
无论在什么状况下,采起谨慎的态度,杜绝内存泄露的出现,都是可取的。相反,认为内存有的是,对内存泄露听任自流都不是负责的。尽管一些工具能够帮助咱们检查内存泄露问题,我认为仍是应该在编程时就仔细一点,及早排除这类错误,工具只是用做验证的手段。
o 内存越界访问
内存越界访问有两种:一种是读越界,即读了不属于本身的数据,若是所读的内存地址是无效的,程度马上就崩溃了。若是所读内存地址是有效的,在读的时候不会出问题,但因为读到的数据是随机的,它会产生不可预料的后果。另一种是写越界,又叫缓冲区溢出,所写入的数据对别人来讲是随机的,它也会产生不可预料的后果。
内存越界访问形成的后果很是严重,是程序稳定性的致命威胁之一。更麻烦的是,它形成的后果是随机的,表现出来的症状和时机也是随机的,让BUG的现象和本质看似没有什么联系,这给BUG的定位带来极大的困难。
一些工具能够够帮助检查内存越界访问的问题,但也不能太依赖于工具。内存越界访问一般是动态出现的,即依赖于测试数据,在极端的状况下才会出现,除非精心设计测试数据,工具也无能为力。工具自己也有一些限制,甚至在一些大型项目中,工具变得彻底不可用。比较保险的方法仍是在编程是就当心,特别是对于外部传入的参数要仔细检查。
咱们来看一个例子:
#include <stdlib.h> #include <string.h> int main(int argc, char* argv[]) { char str[10]; int array[10] = {0,1,2,3,4,5,6,7,8,9}; int data = array[10]; array[10] = data; if(argc == 2) { strcpy(str, argv[1]); } return 0; }
这个例子中有两个错误是新手常犯的:
其一:int array[10] 定义了10个元素大小的数组,因为C语言中数组的索引是从0开始的,因此只能访问array[0]到array[9],访问array[10]就形成了越界错误。
其二:strcpy(str, argv[1]);这里是否存在越界错误依赖于外部输入的数据,这样的写法在正常下可能没有问题,但受到一点恶意攻击就完蛋了。除非你肯定输入数据是在你控制内的,不然不要用strcpy、strcat和sprintf之类的函数,而要用strncpy、strncat和snprintf代替。
o 野指针。
野指针是指那些你已经释放掉的内存指针。当你调用free(p)时,你真正清楚这个动做背后的内容吗?你会说p指向的内存被释放了。没错,p自己有变化吗?答案是p自己没有变化。它指向的内存仍然是有效的,你继续读写p指向的内存,没有人能拦得住你。
释放掉的内存会被内存管理器从新分配,此时,野指针指向的内存已经被赋予新的意义。对野指针指向内存的访问,不管是有意仍是无心的,都为此会付出巨大代价,由于它形成的后果,如同越界访问同样是不可预料的。
释放内存后当即把对应指针置为空值,这是避免野指针经常使用的方法。这个方法简单有效,只是要注意,固然指针是从函数外层传入的时,在函数内把指针置为空值,对外层的指针没有影响。好比,你在析构函数里把this指针置为空值,没有任何效果,这时应该在函数外层把指针置为空值。
o 访问空指针。
空指针在C/C++中占有特殊的地址,一般用来判断一个指针的有效性。空指针通常定义为0。现代操做系统都会保留从0开始的一块内存,至于这块内存有多大,视不一样的操做系统而定。一旦程序试图访问这块内存,系统就会触发一个异常/信号。
操做系统为何要保留一块内存,而不是仅仅保留一个字节的内存呢?缘由是:通常内存管理都是按页进行管理的,没法单纯保留一个字节,至少要保留一个页面。保留一块内存也有额外的好处,能够检查诸如p=NULL; p[1]之类的内存错误。
在一些嵌入式系统(如arm7)中,从0开始的一块内存是用来安装中断向量的,没有MMU的保护,直接访问这块内存好像不会引起异常。不过这块内存是代码段的,不是程序中有效的变量地址,因此用空指针来判断指针的有效性仍然可行。
o 引用未初始化的变量。
未初始化变量的内容是随机的(有的编译器会在调试版本中把它们初始化为固定值,如0xcc),使用这些数据会形成不可预料的后果,调试这样的BUG也是很是困难的。
对于态度严谨的程度员来讲,防止这类BUG很是容易。在声明变量时就对它进行初始化,是一个好的编程习惯。另外也要重视编译器的警告信息,发现有引用未初始化的变量,当即修改过来。
在下面这个例子中,全局变量g_count是肯定的,由于它在bss段中,自动初始化为0了。临时变量a是没有初始化的,堆内存str是没有初始化的。但这个例子有点特殊,由于程序刚运行起来,不少东西是肯定的,若是你想把它们看成随机数的种子是不行的,由于它们还不够随机。
#include <stdlib.h> #include <string.h> int g_count; int main(int argc, char* argv[]) { int a; char* str = (char*)malloc(100); return 0; }
o 不清楚指针运算。
对于一些新手来讲,指针经常让他们犯糊涂。
好比int *p = …; p+1等于(size_t)p + 1吗
老手天然清楚,新手可能就搞不清了。事实上, p+n 等于 (size_t)p + n * sizeof(*p)
指针是C/C++中最有力的武器,功能很是强大,不管是变量指针仍是函数指针,都应该很是熟练的掌握。只要有不肯定的地方,立刻写个小程序验证一下。对每个细节了然于胸,在编程时会省下很多时间。
o 结构的成员顺序变化引起的错误。
在初始化一个结构时,老手可能不多像新手那样老老实实的,一个成员一个成员的为结构初始化,而是采用快捷方式,如:
Struct s { int l; char* p; }; int main(int argc, char* argv[]) { struct s s1 = {4, "abcd"}; return 0; }
以上这种方式是很是危险的,缘由在于你对结构的内存布局做了假设。若是这个结构是第三方提供的,他极可能调整结构中成员的相对位置。而这样的调整每每不会在文档中说明,你天然不多去关注。若是调整的两个成员具备相同数据类型,编译时不会有任何警告,而程序的逻辑可能相距十万八千里了。
正确的初始化方法应该是(固然,一个成员一个成员的初始化也行):
struct s { int l; char* p; }; int main(int argc, char* argv[]) { struct s s1 = {.l=4, .p = "abcd"}; return 0; }
(有的编译器可能不支持新标准)
o 结构的大小变化引起的错误。
咱们看看下面这个例子:
struct base { int n; }; struct s { struct base b; int m; };
在OOP中,咱们能够认为第二个结构继承了第一结构,这有什么问题吗?固然没有,这是C语言中实现继承的基本手法。
如今假设第一个结构是第三方提供的,第二个结构是你本身的。第三方提供的库是以DLL方式分发的,DLL最大好处在于能够独立替换。但随着软件的进化,问题可能就来了。
当第三方在第一个结构中增长了一个新的成员int k;,编译好后把DLL给你,你直接把它给了客户了,让他们替换掉老版本。程序加载时不会有任何问题,在运行逻辑可能彻底改变!缘由是两个结构的内存布局重叠了。
解决这类错误的惟一办法就是从新编译所有代码。由此看来,动态库并不见得能够动态替换,若是你想了解更多相关内容,建议你阅读《COM本质论》。
o 分配/释放不配对。
你们都知道malloc要和free配对使用,new要和delete/delete[]配对使用,重载了类new操做,应该同时重载类的delete/delete[]操做。这些都是书上反复强调过的,除非当时晕了头,通常不会犯这样的低级错误。
而有时候咱们却被蒙在鼓里,两个代码看起来都是调用的free函数,实际上却调用了不一样的实现。好比在Win32下,调试版与发布版,单线程与多线程是不一样的运行时库,不一样的运行时库使用的是不一样的内存管理器。一不当心连接错了库,那你就麻烦了。程序可能动则崩溃,缘由在于在一个内存管理器中分配的内存,在另一个内存管理器中释放时就会出现问题。
o 返回指向临时变量的指针
你们都知道,栈里面的变量都是临时的。当前函数执行完成时,相关的临时变量和参数都被清除了。不能把指向这些临时变量的指针返回给调用者,这样的指针指向的数据是随机的,会给程序形成不可预料的后果。
下面是个错误的例子:
char* get_str(void) { char str[] = {"abcd"}; return str; } int main(int argc, char* argv[]) { char* p = get_str(); printf("%s/n", p); return 0; }
下面这个例子没有问题,你们知道为何吗?
char* get_str(void) { char* str = {"abcd"}; return str; } int main(int argc, char* argv[]) { char* p = get_str(); printf("%s/n", p); return 0; }
o 试图修改常量
在函数参数前加上const修饰符,只是给编译器作类型检查用的,编译器禁止修改这样的变量。但这并非强制的,你彻底能够用强制类型转换绕过去,通常也不会出什么错。
而全局常量和字符串,用强制类型转换绕过去,运行时仍然会出错。缘由在于它们是放在.rodata里面的,而.rodata内存页面是不能修改的。试图对它们修改,会引起内存错误。
下面这个程序在运行时会出错:
int main(int argc, char* argv[]) { char* p = "abcd"; *p = '1'; return 0; }
o 误解传值与传引用
在C/C++中,参数默认传递方式是传值的,即在参数入栈时被拷贝一份。在函数里修改这些参数,不会影响外面的调用者。如:
#include <stdlib.h> #include <stdio.h> void get_str(char* p) { p = malloc(sizeof("abcd")); strcpy(p, "abcd"); return; } int main(int argc, char* argv[]) { char* p = NULL; get_str(p); printf("p=%p/n", p); return 0; }
在main函数里,p的值仍然是空值。固然在函数里修改指针指向的内容是能够的。
o 重名符号。
不管是函数名仍是变量名,若是在不一样的做用范围内重名,天然没有问题。但若是两个符号的做用域有交集,如全局变量和局部变量,全局变量与全局变量之间,重名的现象必定要坚定避免。gcc有一些隐式规则来决定处理同名变量的方式,编译时可能没有任何警告和错误,但结果一般并不是你所指望的。
下面例子编译时就没有警告:
t.c
#include <stdlib.h> #include <stdio.h> int count = 0; int get_count(void) { return count; } main.c #include <stdio.h> extern int get_count(void); int count; int main(int argc, char* argv[]) { count = 10; printf("get_count=%d/n", get_count()); return 0; }
若是把main.c中的int count;修改成int count = 0;,gcc就会编辑出错,说multiple definition of `count’。它的隐式规则比较奇妙吧,因此仍是不要依赖它为好。
o 栈溢出。
咱们在前面关于堆栈的一节讲过,在PC上,普通线程的栈空间也有十几M,一般够用了,定义大一点的临时变量不会有什么问题。
而在一些嵌入式中,线程的栈空间可能只5K大小,甚至小到只有256个字节。在这样的平台中,栈溢出是最经常使用的错误之一。在编程时应该清楚本身平台的限制,避免栈溢出的可能。
o 误用sizeof。
尽管C/C++一般是按值传递参数,而数组则是例外,在传递数组参数时,数组退化为指针(即按引用传递),用sizeof是没法取得数组的大小的。
从下面这个例子能够看出:
void test(char str[20]) { printf("%s:size=%d/n", __func__, sizeof(str)); } int main(int argc, char* argv[]) { char str[20] = {0}; test(str); printf("%s:size=%d/n", __func__, sizeof(str)); return 0; } [root@localhost mm]# ./t.exe test:size=4 main:size=20
o 字节对齐。
字节对齐主要目的是提升内存访问的效率。但在有的平台(如arm7)上,就不光是效率问题了,若是不对齐,获得的数据是错误的。
所幸的是,大多数状况下,编译会保证全局变量和临时变量按正确的方式对齐。内存管理器会保证动态内存按正确的方式对齐。要注意的是,在不一样类型的变量之间转换时要当心,如把char*强制转换为int*时,要格外当心。
另外,字节对齐也会形成结构大小的变化,在程序内部用sizeof来取得结构的大小,这就足够了。若数据要在不一样的机器间传递时,在通讯协议中要规定对齐的方式,避免对齐方式不一致引起的问题。
o 字节顺序。
字节顺序从来是设计跨平台软件时头疼的问题。字节顺序是关于数据在物理内存中的布局的问题,最多见的字节顺序有两种:大端模式与小端模式。
大端模式是高位字节数据存放在低地址处,低位字节数据存放在高地址处。
小端模式指低位字节数据存放在内存低地址处,高位字节数据存放在内存高地址处;
在普通软件中,字节顺序问题并不引人注目。而在开发与网络通讯和数据交换有关的软件时,字节顺序问题就要特殊注意了。
o 多线程共享变量没有用valotile修饰。
关键字valotile的做用是告诉编译器,不要把变量优化到寄存器里。在开发多线程并发的软件时,若是这些线程共享一些全局变量,这些全局变量最好用valotile修饰。这样能够避免由于编译器优化而引发的错误,这样的错误很是难查。
o 忘记函数的返回值
函数须要返回值,若是你忘记return语句,它仍然会返回一个值,由于在i386上,EAX用来保存返回值,若是没有明确返回,EAX最后的内容被返回,因此EAX的内容是随机的。
自动测试
手工测试比没有测试强一点,可是它存在的问题让它很难在实践中应用:手工输入数据的过程单调乏味,很难长期坚持。每次都要从新输入数据,浪费大量时间。测试用例不能累积,测试每每不完整。用人脑判断输出的正误,浪费人力也存在偏差。要写得好测试天然不能省,要写得快就须要更好的测试方法。
更好的测试方法固然是自动测试了。幸运的是,刚进入这个行业我就接触了自动的测试 (呵,读本文的初学者就更幸运了),个人第一份正式工做是在测试组写测试程序。当时测试组也算是人才辈出了,竟然有几个北大毕业的,不过她们都不懂Linux,因此我被指派去为移植到Linux上的模块写测试程序。这些模块都有测试程序,但这些测试程序的功能太弱了,个人上司要求开发人员改进,但那些开发人员太自觉得是了,根本不理咱们,因此咱们只好本身重写这些测试程序。模块不少,大概有50多个模块,熟悉这些模块也须要很多时间,按每两个工做日写一个测试程序,上司给我5个月时间。
记得第一个模块是RDFParser,RDF(资源描述框架)是XML的一种应用,RDFParser其实是一个XML解析器,并包装成RDF要求的接口。因为我对C/C++还不太熟悉,对RDF更不熟悉了,花了两周时间才写出这个测试程序。运行起来有些不正常,我确信不是测试程序的问题,就去请开发人员帮忙来看一下。负责RDFParser的那个程序员是人大毕业,我没有见过第二个比他更自觉得是的程序员了,他刚在我座位上坐下就很大声说,大家QA的人太蠢了!
当时一听就愣了,不过我是新来的,见上司都没反应,天然就忍了。我列举了一些证据是模块里面的问题,他听也不听,只是不断重复的说,不多是我程序的问题,大家QA的人太蠢了,老是浪费个人时间。过了一下子,他终于闭上了嘴巴,又等了一下子才说,等会儿从新发个版本给你吧。后来又请他过来四五次,结果每次都是他的问题。
以后我再没有听到他说过大家QA的人太蠢了的话。为了不让他抓到把柄来嘲笑测试组,我决定请他来查问题以前作更详细的测试。当时我写的测试程序和如今初学者写的测试程序没有两样,都是从教科书上学来的,先经过scanf从终端输入数据,调用被测函数,再把结果printf出来,这花了我太多时间。想到后面还有50多个模块的测试程序要写,这样下去不行,必定得想个办法。
后来我把输入的数据和指望的结果都写到一个INI文件中,测试程序从这个文件中读入数据,运行测试,再和预期结果比较,整个过程都自动化了。写了一个INI文件的解析器花了我一周时间,又重写了那个测试程序,整整花了我一个月时间完成RDFParser的测试程序。进度天然大落后了,还好上司知道后并无责备我,让我慢慢作就行了。
写第二个测试程序时把INI解析的代码拷贝过去,再加一些调用模块的代码就写好了,第三个也是如此。写了几个以后,我发现了INI解析有个BUG,结果每一个测试程序我都要去修改,想到维护起来太麻烦了,就把INI解析器的接口规范化了,编译成一个独立共享库。又写了几个测试程序,我写烦了,缘由是测试程序无非就是读入数据,调用被测函数,再检查结果,这个过程太无聊了。想到后面还要把这个过程重复几十遍,郁闷了几天以后,忽然灵机一动,我决定写了一个代码产生器来产生这些代码。开始的代码产生器用C写的,用一个简单的规则来描述被测函数,经过这些规则来产生测试程序。我把这些东西和INI解析器放在一个独立的库中,把它叫做TesterFrameWork,通过几个测试程序的验证和完善,后来利用这个TesterFrameWork,只要一两个小时就能完成一个测试程序了。有次请开发人员那边一个高手帮我查一个问题,他看一下子个人TesterFrameWork以后,盯着我说,你太聪明了。我笑了笑说,刚刚开始写C/C++程序。
一年以后我知道了有个CPPUnit以后,为了赶时髦我把TesterFrameWork更名为CxxUnit,非典的时候放假无聊就把它重写了一遍放在cosoft上了(以后没有管过它,或许还在吧)。
一个大系统很难自动测试,而一个独立的模块则是最佳的自动测试单元。自动测试和单元测试几乎成了等价的概念,不少人都觉得自动测试就是利用CPPUnit这样的单元测试框架写个测试程序而已,这彻底是错误的,就像有人觉得有个设计文档的模板,照着填空就能填出好设计同样。
我本身实现过单元测试框架,不是像有些人出于模仿去实现,而彻底出于实际的须要,后来我也研究其它测试框架,应该说我对测试程序框架的认识比通常程序员要深入。我认为测试程序框架能够减化一些测试程序的工做,但它与自动测试没有密切关系,用不用测试程序框架彻底是我的喜爱。用测试程序框架未必能写出好的测试程序,就像用C++未必能写出好的面向对象的程序同样。
虽然我顺利的完成了那个写测试程序的任务,但我一直被一个问题困扰:如何写测试用例,如何去检测结果?这是测试程序框架帮不上忙的。写测试用例还好说,经过边界值法,等价类法和路径覆盖法找到最经常使用的测试用例。检测结果呢?有人说很简单啊,判断返回值就行了。那我问一下dlist_insert返回OK,就真的OK了吗?若是一个函数根本没有返回值,那你怎么判断呢?
测试程序框架是敏捷论者提倡的,在我看来它根本不够敏捷:你要去学习它,了解它的运行机制,要包含它的头文件,连接它的库,有比不用它更敏捷么?重要的是它根本帮不上什么有用的忙。前面的问题折磨了我一段时间,因而得出一个可能有点偏激的结论:测试程序框架都是愚蠢的,你真正须要的,它根本帮不了你(我知道这样说会得罪一些用测试程序框架的朋友,若是你想找我讨论的话,请看完本节的附带示例代码再说)。
就在那个时候,我看到了孟岩老师翻译的《契约式设计(Design by Contract)》,读完以后豁然开朗。或许我尚未明白契约式设计的本质,但我确实知道了写自动测试程序的方法,下面我介绍一下:
o 在设计时,每一个函数只完成单一的功能。单一功能的函数容易理解,也容易预测其行为。对测试来讲,给定一些输入数据,就知道它的输出和影响,这样函数是最容易测试的。
o 在设计时,把函数分为查询和命令两类。查询函数只查询对象的状态,而不改变对象的状态。命令函数则只修改对象的状态,只返回其操做是否成功的标志,而不返回对象的状态。好比,dlist_length查询双向链表的长度,它不修改双向链表的任何状态。dlist_delete修改对象的状态(删除结点),并返回其操做是否成功,而不返回当前长度或者删除的结点之类的状态。
o 在设计时,把查询分为基本查询和复合查询两类。基本查询函数只查询单一的状态,而复合查询能够同时查询多个状态。好比,window_get_width返回窗口的宽度,这是基本查询函数,widget_get_rect返回窗口的左上角坐标,宽度和高度,这是复合查询函数。
o在实现时,检验输入数据,确认使用者正确的调用了函数。契约式设计规定了调用者和实现者双方的责任,调用者须要使用正确的参数,才能保证有正确的结果。政治家告诉咱们,信任但要检查,因此做为实现者就须要检查输入参数是否违背了契约。那怎么检查呢?有人说,若是检查到无效参数就返回一个错误码。这固然能够,只是不太好,由于大多数人都没有检查返回值的习惯,若是每一个地方都检查函数的返回值,也是件很繁琐的事,代码看起来也比较乱。一般咱们只检查一些关键的地方,对于无效参数这样的错误,可能就无声无息的隐藏起来了,这样很差,由于隐藏得越深,发现的时间越晚,修改的代价越大。
在C++和Java里,若是参数不正确,一般是throw一个无效参数之类的异常,C语言里面没有异常这个概念,咱们须要其它办法才行。有人推荐用assert来检查,这是一个好办法,assert只在调试版本中有效(没有定义NDEBUG),这样任何无效调用都在调试版本中暴露出来了。若是配合前面返回错误码的方法,在发布版本中也可能避免程序粗暴的死掉。使用方法以下:
assert(thiz != NULL); if(thiz == NULL) { return DLIST_RET_INVALID_PARAMS; }
我一直使用这种方法,可是有个问题:没法用自动测试验证assert是否正常的触发了,当用错误的参数测试时,我指望assert被触发,但若是assert被触发了,自动程序测试就死掉了,自动测试程序死掉了,就没法继续验证下一个assert。这是一个悖论!
后来我从glib里面学了一招,它检查时不用assert,只是打印出一个警告,代码也简明了,按它的方式,咱们这样检查:
return_val_if_fail(cursor != NULL, DLIST_RET_INVALID_PARAMS);
咱们须要定义两个宏,一个用于无返回值的函数,一个用于有返回值的函数:
#define return_if_fail(p) if(!(p)) / {printf("%s:%d Warning: "#p" failed./n", / __func__, __LINE__); return;} #define return_val_if_fail(p, ret) if(!(p)) / {printf("%s:%d Warning: "#p" failed./n",/ __func__, __LINE__); return (ret);}
这样一来,遇到无效参数时,能够看到一个警告信息,同时又不会影响自动测试。
o在测试时,用查询来验证命令。命令通常都有返回值,但只检查返回值是不够的。好比dlist_delete返回OK,它真的OK了吗?咱们信任它,但仍是要检查。怎么检查?很简单,用查询函数来检查对象的状态是否是预期的。
对于dlist_delete,咱们预期:
1.输入无效参数,指望返回DLIST_RET_INVALID_PARAMS。 2.输入正确参数,指望: 函数返回DLIST_RET_OK 双向链表的长度减一。 删除的位置的下一个元素被移到删除的位置。
在测试程序中检查时,由于任何不符合指望的结果都是BUG,因此咱们用assert检查。这样有问题立刻暴露出来了,定位错误比较容易,一般都不须要调试器。咱们这样来检查:
assert(dlist_length(dlist) == (n-i)); assert(dlist_delete(dlist, 0) == DLIST_RET_OK); assert(dlist_length(dlist) == (n-i-1)); if((i + 1) < n) { assert(dlist_get_by_index(dlist, 0, (void**)&data) == DLIST_RET_OK); assert((int)data == (i+1)); }
(完整的例子请看本节的示例代码)
o在测试时,用基本查询去验证复合查询。基本查询和复合查询返回的应该一致。好比:
Rect rect = {0}; widget_get_rect(widget, &rect); assert(widget_get_width(widget) == rect.width); assert(widget_get_height(widget)== rect.height);
o在测试时,预期结果依赖其执行上下文,咱们要按逻辑组织测试用例。前面调用的函数可能改变了对象的状态,为了简化测试,在每组测试用例开始时,都重置对象到初始状态。
o 在测试时,第一次只写基本的测试用例,之后逐渐累积,每次发现新的BUG就把相应的测试用例加进去。每次修改了代码就运行一遍自动测试,保证修改没有引发其它反作用。
按着上面的原则,应付正常模块的测试没有问题了,可是下面的状况仍然比较棘手:
o 带有GUI的应用程序。有GUI的程序会给自动的输入数据和检查结果带来困难,有些工具能够部分解决这个问题,特别是针对Win32下的GUI,我不多在Windows下写程序,因此对这方面了解很少。不过最好的办法仍是用MVC模型等分离界面和实现,由于界面一般相对比较简单,能够手工测试,而实现的逻辑比较复杂,这部分能够自动测试。后面咱们会专门讲解分离界面和实现的方法。
o 有随机数据输入。若是有些输入数据是内部随机产生的,那你根本没法预测它的输出结果和影响。好比游戏随机的步骤和无线网络信号的变化。对于咱们能够控制的随机数据,能够提供额外的函数去获取这些数据。对于没法控制的随机输入数据,能够把它们隔离开,在自动测试中,使用固定的数据。
o 多线程运行的程序。多线程的程序也很难自动测试,好比向链表中插入一个元素,当你检查的时候,根本没法知道链表的长度是否增长,也没法知道刚才插入的位置是不是你插入的元素,由于这个时候,可能有另一个线程已经把它删除了,或者又加入了新的数据。不过在单线程的自动测试经过以后,多线程的问题会大大减小,剩下的问题咱们能够经过其它方式加以免。
写自动测试程序会花你一些时间,但这个投资能带来最大的回报:减小后面调试时的浪费,提升代码的质量,更重要的是你能够安稳的睡个觉了。
Save your work
“Ernst和Young所在的小组决定使用正规的开发理论—他们经常使用削减法,分阶段进行开发并具备中途交付能力。他们的步骤包括细致的分析和设计—正如本章描写的基本原则同样。而其余竞争者径直开始了编码,在开始几个小时里,Ernst和Young小组落后了。但到中午时Ernst和Young小组倒是遥遥领先了,而到了这一天的最后,他们却失败了。致使失败的缘由不是由于他们的正规方法,而是他们偶然错误的把工做文件覆盖了,最终他们比午饭时所作的估计少交付了一些功能,他们是被没有使用有效的源程序版本控制这个典型的错误给战胜了。”
–摘自《快速软件开发》
前段时间看探索频道的《荒野求生秘技(Man & wild)》,我很喜欢这个节目也喜欢那个英国佬,甚至连重播都不会放过。他展现在沙漠、丛林、冰河和雪山等各类环境的求生秘技,他吃蜘蛛、白蚁、蝎子和蜥蜴,边吃边说这东西很恶心,可是里面含有很是的维生素,蛋白质和糖份,可以Save your life,因此要吃下去。
在Man & Code的世界里,环境好多了,不用面临危险,寻找水源和食物根本不须要什么秘诀。这里咱们不须要求生秘技去Save your life,但咱们须要一些习惯去Save your work。我说过做为一名高效的程序员,不是由于他打字比别人快,而是由于他省下了别人浪费的时间,有什么比成果被毁,从头再来更浪费时间呢?下面我介绍一些习惯,它们简单有效,根本算不上什么秘技,但它们可以Save your work,让你的工做稳步前进。
o 随时存盘
每次停电时,我都会听到有人惊呼,完了,个人代码没有保存!补回半小时或一个小时的工做不难,在一个好的工做环境里,这种状况一年也就会遇到几回,浪费的时间彻底能够忽略不计。可是那种感受很难受,会影响你的工做情绪,平白无故的让你重作你的工做,和由于要改进去重作彻底是两回事。在我之前工做过的一个公司,有段时间常常跳闸,每周都要停好几回,怎么也找不到缘由,后来请人来查,听说是线路太长,静电引发的跳闸。通过那段时间的折磨,我养成了一个习惯:写代码的时候,平均30秒钟存盘一次。如今遇到停电,别人惊呼的时候,我开始闭目养神了。
o 使用版本控制系统
和一些老程序员聊天时(呵,其实我也老了),他们常常问起咱们项目有没有使用版本控制系统,我说固然有了,大二的时候就我用Sourcesafe来管理用powerbuilder写的代码了,后来的工做中一直在使用不一样的版本控制系统。接着他们开始讲述他们惨痛的经历…这些经历小则让他们项目延期,大则致使整个项目失败。
版本控制系统有不少功能,但对我我的来讲,它最重要的功能是备份代码。每完成一个小功能,我都会把它提交(checkin)进去,若是我不当心删除了本地文件,或者某个作尝试的修改失败了,我能够恢复代码到前一个版本。不一样团队有不一样的规则,有的团队是不容许这样checkin的,他们只容许checkin通过严格测试的代码。若是是那样,你能够在本地创建本身的版本控制系统,初学者在学习时也能够这样作。如今有不少免费的版本控制系统可用,像CVS、SVN和Git等等,我我的习惯用CVS,SVN是CVS的改进版,未来确定会替代SVN的,因此推荐你们使用它。
o 按期备份
温伯格在《Quality Software Management: System Thinking》讲了一个有趣的故事,他之前去研究一些失败的案例,发现这些项目的失败都是由于欠佳的运气引发的:好比遭受到洪水、地震、火灾和流行感冒等灾害,项目主管们把本身描述成外部问题的受害者。他又对另一些成功的项目进行研究,发现其中有些项目一样经历这些天然灾害,可是他们成功的完成了任务。区别只是在于成功项目的主管,采用积极预防措施,按期备份代码,把它们放到不一样的地点。
之前在学校的时候,我有两台电脑,一台赛扬和一台486。我常常在上面重装系统,一下子装Linux,一下子装NT,一下子又装Netware。虽然我常常把代码备份到不一样的分区上,结果还不当心把全部分区全干掉了,让我痛心不已。那只是写的一些小程序,重写一遍问题也不大,可是对于专业程序员或一个软件团队来讲,重写整个代码就不能接受了,因此须要更可靠的备份机制。
使用源代码管理系统还不能保证代码的安全,好比服务器硬盘损坏和办公室发生火灾等都是可能发生。团队里必定要有人负责按期备份源代码管理系统系统上的资料,做为初学者也应该有这种意识。另外,我发现有些朋友把重要的资料放在邮箱里,如今的邮箱容量很大,由于提供商会按期备份,很是安全,这却是一个不错的主意。
o 状态很差就作点别的
女同胞有按期状态不佳的时候,男同胞也不是天天状态都很好。感冒了、丢东西了、或者家人争吵了,都会影响你的状态。状态很差的时候作事,每每是进一步退两步,甚至犯下严重的错误。有次我得了重感冒,竟然在服务器的根目录下运行rm * -rf(删除所有文件),因为删除的时间太长,才让我发现删错地方了,吓得我出了一身冷汗,还好那台服务器不是运行着源代码管理系统,但仍是浪费了我两天时间去重建服务器上的环境。
状态很差的时候编程也会犯一些低级错误,让你花费更多时间去调试。总要言之,状态很差的去作重要的事有害无益,这时你不防去作点别作的,好比看看其它模块的代码之类的,甚至彻底放松去休息都比犯下严重的错误强。