专业程序员必知必会的技巧:驯服复杂代码

你从入职第一天起就要应对复杂代码。

若是还未遇到过无法理解的程序,那说明你编程的年头还不够长。在行业里,要不了多久你就会碰到让人发懵的混乱代码:巨兽、面条工厂、来自地狱的遗留系统。我曾接手过一个程序,它的前任在听说要增加一个分量不轻的新特性时,选择了辞职。(我并不怪他。)

软件系统的复杂度是不可避免的。有些问题就是很难,它们的解决方案很复杂。然而,你在软件中找到的大多数复杂度是我们自己造成的。在《The Mythical Man-Month》(人月神话)[Bro95]里,Fred Brooks将复杂度的两个来源分成必然(necessary)复杂度和偶然(accidental)复杂度。

这里有一种区分必然复杂度和偶然复杂度的思考方法:什么复杂度是问题域固有的?假设你面对的是一个日期/时间处理代码散落各处的程序。在处理时间时,存在一些必然复杂度:每月的天数不同,必须考虑闰年,等等。但多数我碰到的程序充斥着大量与处理时间相关的偶然复杂度:用不同格式保存的时间,加减时间的新奇(同时也是充满Bug的)方法,不一致的时间打印格式,说都说不完。

复杂度的死亡螺线

编程时常会遇到这种情况:产品代码库中的偶然复杂度渐渐压倒必然复杂度。情况在某一时刻会自我放大,我称这种现象为复杂度的死亡螺线,如图1所示。


图1 复杂度的死亡螺线

问题1:代码规模

构建产品时,它的代码规模最终将远超任何在学校或消遣项目中所遇到的。行业中的代码库的度量结果从成千到上百万代码行(Line of Code, LOC)不等。

John Lions在《Lions’ Commentary on UNIX 6th Edition》一书中写道:单个程序员能够理解和维护的程序大小的实际限制规模是1万行代码。于1975年发布的UNIX第6版的规模大约是9000行代码(不算机器特定的设备驱动程序)。

相比而言,Windows NT在1993年有4百万~5百万行代码。10年后,Windows Server 2003配备了2000名开发人员和2000名测试人员,他们管理多达5千万行代码。大多数行业项目并不像Windows那样巨大,但它们也都轻易地跨过了Lions设定的1万行代码的警戒线。这样的规模意味着公司内部没有人能理解整个代码库。

问题2:复杂度

随着代码规模的增长,最初想法的概念优雅性消失了。曾经对于车库中两个小伙水晶般清澈的想法变成了大批开发人员艰难跋涉其中的阴暗沼泽。

复杂度并不是代码规模的必然产物。大型代码库完全有可能被拆分成许多模块,其中每个模块都有清晰的用途、优雅的实现和为人熟知的与邻近模块的交互。

然而,即使设计良好的系统也会在它们变大时变得复杂。一旦没有一个人可以理解整个系统,这时多个人必须去理解系统中自己那部分—且没有人的理解跟其他人是完全一样的。

问题3:Bug

产品复杂度飙升,Bug也就不可避免地出现了。这是注定的—就算是伟大的程序员也不是完人。但每个Bug并非生而平等:高度复杂系统里的那些Bug尤其难觅踪迹。总是听到程序员说:“真搞不懂,伙计,系统刚刚崩溃了。”欢迎来到这糟糕的调试世界!

问题4:快速修补

问题并不在于产品是否有Bug—它肯定有,关键在于工程团队在出现Bug之后如何响应。在推出产品的压力之下,大多数程序员经常求助于快速修补。

快速修补是给问题打补丁,而非解决其根本原因。甚至常常不寻找根本原因。

程序员:在试图往网络队列中放入一个任务(job)且队列在10秒内无响应时,程序崩溃了。

经理:重试队列操作100次。

根本原因是什么?天知道,只要重试次数够多,你就可以掩盖任何问题。但如车身修补一样,某一位置的霸道胶水(Bondo)比实际残留的车本身部件还要多。

更难找的问题发生在补丁并没有解决问题根本原因的时候,问题通常根本没有消失—它只是转移到别处。在前面的对话中,重试100次可能很好地掩盖了问题,但万一需要101次重试怎么办?经理只是随便捏了个数字,这种膏药式修补只会让问题更难查。

沿着“快速修补”上行,我们现在得到了一个增加代码规模的完整闭环。

走向清晰

提起复杂的反面,人们通常会想到简单。但由于领域的必然复杂度,我们并不是总能写出简单的代码。应对复杂更好的方法是清晰。你是不是明白自己的代码要做什么?

明确两点会有助于我们减少软件偶然复杂度:清晰思考和清晰表达。

清晰思考

在分析问题的原因时,我们试图做出像“保存时间的方式应该只有一种”这样的清晰陈述。那为何UNIX C代码里还混杂着像time_t、struct timeval和struct timespec这样的结构呢?那并不是太清晰。

如何调和你的清晰陈述和UNIX计时功能的复杂度?你需要隔离复杂度,或将其抽象到单个模块中。在C里,这可能是结构体和操作它的函数;在C++里,它会是一个类。模块化设计让程序的其余部分可以用一种清晰的方式推导时间,而不用了解系统计时功能的内部机制。

一旦能将时间作为程序的一个单独模块进行对待,你也就能证明你的计时机制的正确性。完成这一工作的最佳方式就是单独测试,但是同行评审或书写规格说明也行。当一组逻辑是隔离的而不是内嵌在一大段代码体内时,它的测试和严格证明要容易得多。

清晰表达

随着你清晰地思考模块并将它与其余程序隔离,最终程序也就能更清晰地表达它的用途。处理问题域的代码应该真正专注于问题域。

将辅助代码抽出放入自己的模块之后,剩余逻辑读起来应该越来越像问题域的规格说明(虽然有更多分号)。

让我们看看前后对比。我已经无数次看到过如下这种C++代码:

[cpp]  view plain copy
  1. Time.cpp  
  2. void do_stuff_with_progress1()  
  3. {  
  4.     struct timeval start;  
  5.     struct timeval now;  
  6.     gettimeofday(&start, 0);  
  7.     // 干活,每半秒钟打印一条进度消息  
  8.     while (true) {  
  9.         struct timeval elapsed;  
  10.         gettimeofday(&now, 0);  
  11.         timersub(&now, &start, &elapsed);  
  12.         struct timeval interval;  
  13.         interval.tv_sec = 0;  
  14.         interval.tv_usec = 500 * 1000; // 500ms  
  15.         if (timercmp(&elapsed, &interval, >)) {  
  16.             printf("still working on it...\n");  
  17.             start = now;  
  18.         }  
  19.         // 干活……  
  20.     }  
  21. }  
循环的关键是“干活”部分,但在实际干活之前有20行的POSIX计时代码块。这并没有什么不对,但……就没有一种方法让循环保持对其问题域而不是对计时的关注吗?

让我们把所有时间代码放入它自己的类:

[cpp]  view plain copy
  1. Time.cpp  
  2. class Timer  
  3. {  
  4. public:  
  5.     Timer(const time_t sec, const suseconds_t usec) {  
  6.         _interval.tv_sec = sec;  
  7.         _interval.tv_usec = usec;  
  8.         gettimeofday(&_start, 0);  
  9.     }  
  10.       
  11.     bool triggered() {  
  12.         struct timeval now;  
  13.         struct timeval elapsed;  
  14.         gettimeofday(&now, 0);  
  15.         timersub(&now, &_start, &elapsed);  
  16.         return timercmp(&elapsed, &_interval, >);  
  17.     }  
  18.       
  19.     void reset() {  
  20.         gettimeofday(&_start, 0);  
  21.     }  
  22.   
  23. private:  
  24.     struct timeval _interval;  
  25.     struct timeval _start;  
  26. };  
我们现在可以简化循环了:

[cpp]  view plain copy
  1. Time.cpp  
  2. void do_stuff_with_progress2()  
  3. {  
  4.     Timer progress_timer(0, 500 * 1000); // 500ms  
  5.     // 干活,每半秒钟打印一条进度消息  
  6.     while (true) {  
  7.         if (progress_timer.triggered()) {  
  8.             printf("still working on it...\n");  
  9.             progress_timer.reset();  
  10.         }  
  11.         // 干活……  
  12.     }  
  13. }  
计算机在上述两种情况下做的事情是相同的,但考虑第二个例子对程序可维护性带来的影响:

  • Timer类的测试和证明可独立于它在程序中的使用方式。
  • “干活”循环内的计时有了有意义的语义—triggered()和reset(),而不是一堆获取、增加和比较函数。
  • 现在对于计时的终止位置和(捏造的)循环实际起始位置都清晰了。

当工作在巨大丑陋的代码上时,依次考虑:这段代码想表达什么含义?它有没有办法说得更清楚一点?如果它是清晰表达的问题,你需要把那些碍事的代码段抽象出来,同前面展示的Timer类一样。若代码还是有点混乱,那可能是没有清晰思考的产品,需要在设计层面返工。

行动指南

聚焦于可被隔离和严格推导的一个编程方面,如计时。挖掘你正在从事的项目,识别出这样的代码段:若那部分逻辑被抽象到自己的模块,它能否表达得更清晰。

动手尝试更模块化的方法:选一组混乱的代码,分离必然复杂度和偶然复杂度。在这一刻不要操心细节,只看如何可以清晰地表达必要的业务逻辑,假设你有独立模块来处理支撑逻辑。

------------------------------------

本文节选自《程序员修炼之道:专业程序员必知的33个技巧》“技巧4:驯服复杂度”。

书名:《程序员修炼之道:专业程序员必知的33个技巧》

原书名:New Programmer's Survival Manual: Navigate Your Workplace, Cube Farm, or Startup

作者:Josh Carter

译者:胡键

页数:212

定价:49.00元

ISBN:9787111411642

豆瓣收藏:http://book.douban.com/subject/21323647/

PDF下载:http://vdisk.weibo.com/s/paFl8

当当购买:http://product.dangdang.com/main/product.aspx?product_id=23185207

内容简介:

这是每一位致力于成为专业程序员的软件开发新手都应该阅读的一本书。它是资深软件开发专家Josh Carter 20余年编程生涯的心得体会,从程序员成长的视角,系统总结和阐述了专业程序员在专业技能、编程工具、自我管理、团队协作、工作态度以及需要采取的行动等方面应该掌握的33个非常重要且实用的技巧。作者以自己以及身边的同事积累下来的经验、犯过的错误为素材,旨在为新人们引路,让他们在能力修炼的过程中少走弯路!

全书分为四个部分:第一部分(技巧1~14),从编程技能和工具使用两个方面总结了14个技巧,包含如何正确地书写代码、测试驱动设计、管理代码复杂度、改善遗留代码、代码评审、开发环境优化、自动化等;第二部分(技巧15~24),从自我管理和团队协作两个方面总结了10个技巧,包括如何树立自我形象、压力管理、建立良好人脉和高效会议等;第三部分(技巧25~30),介绍了典型高科技公司的组织结构以及你在整个公司中的位置,并且阐述了薪酬分配的问题;第四部分(技巧31~33),介绍了在日常工作中如何持续改善自己的工作和学习状态。

作者简介:

Josh Carter,资深软件设计师,具有超过20年编程行业从业经验。热衷于编程和追逐前沿技术,但同时谨记史蒂夫•乔布斯的箴言“真正的艺术家能让产品面市”。他还涉足工程管理领域,曾经主管大型企业软件开发团队。目前已出版多本关于计算机软件的技术书籍,同时他还在主流计算机杂志的技术专栏发表文章。

©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页