《代码整洁之道-上》
3084
本文主要摘记《代码整洁之道》与代码风格相关部分,并未涉及与多线程相关的章节,文中的章节并不遵循原著,读者欲了解更多,可以好好研读原著。
命名:有意义 方法:有层次 函数:够短小 类 :合理封装 注释:够简洁 格式:能统一 重构:持续改进
整洁代码 代码混乱的代价 作者开篇提到的代码混乱的代价:
有些团队在项目初期进展迅速,但有那么一两年的时间后却开始慢如蜗牛。对代码的每次修改大多都影响到其他两三处代码,导致代码越来越混乱,再也无法理清,最后束手无策。 随着混乱的增加,团队的生产力也持续下降,当生产力下降到一定界限时,管理层不得不多加人手到项目中,以期望提升生产力。可是新人不熟悉系统的设计,他们搞不清楚什么样的修改符合设计意图,于是他们制作更多的混乱,驱动生产力向零那端下降,如图1-1所示。 毕业两年多的时间里,在两家IC原厂的工作中也深有挺会到,代码的质量对于企业的开发效率至关重要,特别是在平稳期时,糟糕的代码将导致开发效率向零端下降,通过增加人手和增加个人工作时长的方式并不能有效提高单位人均产出。
很多代码并不是一开始就混乱,而是在持续的增加功能过程中,由于早期设计可扩展的问题或者后续开发人员水平问题,持续的混乱改动导致代码整体越来越混乱,工作久了你就会体会到花时间保持整洁代码不但有关效率,还有关生存。
态度 好的代码会很快变成糟糕的代码,理由有很多:
我们抱怨需求变化背离了初期设计 我们哀叹进度太紧张,没法干好活 我们把问题归咎于那些愚蠢的项目经理、苛求的客户、没用的营销方式和那些电话的摧残 不过其实是我们在自作自受,我们太不专业了。 这话可不太中听,怎么会是自作自受呢?难道不关需求的事吗?难道不关进度的事?难道不关那些愚蠢经理和没用的营销手段的事?难道他们就不该负责吗?
不,经理和营销人员指望从我们这里得到必须的信息,然后才能做出承诺和保证,即便他们没开口问,我们也不该羞于告知自己的想法。用户指望我们验证需求是否都在系统中实现了。项目经理指望我们遵守进度。我们与项目的规划脱不了干系,对失败有极大的责任;特别是当失败与糟糕的代码有关时尤为如此!并且,程序员遵从不了解混乱风险的经理的意愿,也是不专业的做法。
在实际工作中,在产品需要抢占市场先机时,为赶进度带来代码的一些混乱并不为过,但是在平稳期时,程序员应该对代码进行重构,减少混乱的代码,这样可以加快后续的开发进度,因为一直混乱的代码就会导致后续的开发进度缓慢。
谜题 有那么几年经验的开发者都知道,之前的混乱托乱了自己的后腿。但开发者背负期限的压力,只好制造混乱,简而言之,他们没有时间让自己做得更快!
其实第二部分说错了,制造混乱无助于赶上期限,混乱只会立刻拖慢你,叫你错过期限。赶上期限的唯一方法、做得快的唯一方法就是始终尽可能保持代码整洁。
什么是整洁代码 代码逻辑直截了当,叫缺陷难以隐藏; 尽量减少依赖关系,使之便于维护; 依据某种分层策略完善错误处理代码; 性能调至最优,省得引诱别人做没规矩的优化,搞出一堆混乱来; 整洁的代码只做好一件事,整洁的代码力求集中,每个函数、每个类和每个模块都专注于一事,完全不受四周细节的干扰和污染。 有单元验收测试; 使用有意义的命名; 要明确定义和提供清晰、尽量少的API; 代码应通过其本身表达含义,不用借有过多的注释来说明; 能通过所有测试; 减少重复代码,提高表达力,提早构建简单抽象; 体现系统中的全部设计理念; 包括尽量少的实体,比如类、方法、函数等 童子军军规 光把代码写好可不够,必须时时保持代码整洁。我们都见过代码随着时间流逝而腐坏。我们应当积极地阻止腐坏的发生。
借用美国童子军一条简单的军规,应用到我们的领域:
让营地比来时更干净!
如果每次签入(check in)时,代码都比签出(check out)时干净,那么代码就不会腐坏。清理并不一定要花多少工夫,也许只是改好一个变量名,拆分一个有点过长的函数,消除一点点重复的代码,清理一个嵌套if语句。
在实际的工作中,很多人都有在抱怨原来的代码太混乱,无意义的变量命名,一个函数有两三千行,一个if语句有七八百行等。但很少有人会用实际行动去一点点消除这些混乱。我认为这当中有两点原因:
(1)领导层没有意识到混乱代码风险,没有制定有效的激励措施鼓励程序员去消除混乱;
->让领导意识到混乱代码的风险,与领导探讨提出解决混乱代码的方法,未尝不是体现自身能力和得到提拔的一个 门道。
(2)程序员本身水平能力有限,看到问题的能力很多人都具备,但是具备解决问题能力的人就会少很多。
->程序员需要多读经典的专业书籍,多与设计经验丰富的同事请教问题解决方法,对于个人的提高会有所帮助。
有意义的命名 软件中随处可见命名,我们给变量、函数、参数、类、封装包和文件夹命名。我们命名、命名、不断命名,既然有那么多命名要做,不妨做好它,下文给出了取个好名字的几条简单规则。
名副其实 选个好名字要花时间,但省下来的时间比花掉的多。
注意重命名,而且一旦发现有更好的名称,请换掉旧的。这么做,读你代码的人(包括你自己)都会更开心。
变量、函数或类的名称应该已经答复了所有的大问题。它该告诉你,它为什么会存在,它做什么事,应该怎么用,。如果名称需要注释来补充,那就不算是名副其实了。
int d; //消逝的时间,以日计 名称d什么也没有说明,它没有引起对时间消逝的感觉,更别说以日计了。我们应该选择指明了计量对象和计量单位的名称:
int elapseTimeInDays; 做有意义的区分 如果名称相异,则意思也应该不同才对。 比如 void copyChars(char a1[], char a2[])
{
for(int i=0; i { a2[i] = a1[i]; } } 如果参数名改为source和destination,这个函数就会像样许多。 另一产生混淆的代码如下所示: getActiveAccount(); getActiveAccounts(); getActiveAccountInfo(); 程序员怎么能知道该调用哪个函数呢? 所以对于命名,名称相异是一定要传达出不同的含义。 使用读得出来的名称 如果名称读不出来,讨论的时候就会像个傻鸟。 有家公司,程序里面写了个genymdhms(生成日期,年、月、日、时、分、秒),他们一般读作“gen why emm dee aich emm ess”。我有个见字照度的恶习,于是开口就念“gen-yah-mudda-hims”。后来好些设计师和分析师都有样学样,我们知道典故,所以会觉得搞笑。搞笑归搞笑,实际在强忍糟糕的命名。 比较 class Customer { private Data genymdhms; private Data modymdhms; private final String pszqint = "102"; /* ... */ }; 和 class Customer { private Data generationTimestamp; private Data modificationTimestamp; private final String recordID = "102"; /* ... */ }; 现在读起来就像人话了。 避免误导 程序员必须避免留下掩藏代码本意的错误线索。应当避免使用与本意相悖的词。例如,hp、aix 和 sco 都不该用做变量名,因为它们都是 UNIX 平台或类 UNIX 平台的专有名称。即便你是在编写三角计算程序,hp 看起来是个不错的缩写,但那也可能会提供错误信息。 别用 accountList 来指称一组账号,除非它真的是 List 类型。List 一词对程序员有特殊意义。如果包纳账号的容器并非真是个 List,就会引起错误的判断。所以,用 accountGroup 或 bunchOfAccounts,甚至直接用 accounts 都会好一些。 使用可搜索的名称 单字母名称和数字常量有个问题,就是很难再一大片文字中找出来。 找MAX_CLASSES_PER_STUDENT很容易,但想找数字7就不容易了,它可能是常量定义的一部分,出现在因不同意图而采用的各种表达式中,如果该常量是个长数字,又被人错该过,就会逃过搜索,从而造成错误。 同样,e也不是个便于搜索的好变量名。 窃以为单字母名称仅用于短方法中的本地变量。名称长短应与其作用域大小相对应。若变量或常量可能在代码中多处使用,则应赋其以便于搜索的名称。再比较 for(int j=0; j<34; j++) { s += (t[j]*4)/5; } 和 int realDaysPerIdealDay = 4; const int WORK_DAYS_PER_WEEK = 5; int sum = 0; for(int j=0; j { int realTaskDays = taskEstimate[j] * realDaysPerIdalDay; int realTaskWeeks = (realTaskDays / WORK_DAYS_PER_WEEK); sum += realTaskWeeks; } 貌似拉长了函数代码,但要想想看,WORK_DAYS_PER_WEEK要比数字5好找得多,而列表中也只剩下作者意图的名称。 避免使用编码 编码已经太多,无谓再自找麻烦。把类型或作用域编进名称里面,徒然增加了解码的负担。没理由要求每位新人都在弄清要应付的代码之外(那算是正常的),还要再搞懂另一种编码“语言”。这对于解决问题而言,纯属多余的负担。带编码的名称通常也不便发音,容易打错。 匈牙利语标记法 现代编程语言具有更丰富的类型系统,编译器也记得并强制使用类型。而且,人们趋向于使用更小的类、更短的方法,好让每个变量的定义都在视野范围之内。 Java 程序员不需要类型编码。对象是强类型的,代码编辑环境已经先进到在编译开始前就侦测到类型错误的程度! 所以,如今 HN 和其他类型编码形式都纯属多余。它们增加了修改变量、函数或类的名称或类型的难度。它们增加了阅读代码的难度。它们制造了让编码系统误导读者的可能性。 PhoneNumber phoneString; // 类型变化时,名称并不变化! 成员前缀 也不必用 m_前缀来标明成员变量。应当把类和函数做得足够小,消除对成员前缀的需要。你应当使用某种可以高亮或用颜色标出成员的编辑环境。 public class Part { private String m_dsc; // The textual description void setName(String name) { m_dsc = name; } } 对比 public class Part { String description; void setDescription(String description) { this.description = description; } } 代码读得越多,就只看到名称中有意义的部分,眼中就越没有前缀。最终,前缀变作了不入法眼的废料,变作了旧代码的标志物。 接口和实现 有时也会出现采用编码的特殊情形。比如,你在做一个创建形状用的抽象工厂(Abstract Factory)。该工厂是个接口,要用具体类来实现。你怎么来命名工厂和具体类呢?IShapeFactory 和ShapeFactory 吗?我喜欢不加修饰的接口。前导字母 I 被滥用到了说好听点是干扰,说难听点根本就是废话的程度。我不想让用户知道我给他们的是接口。我就想让他们知道那是个 ShapeFactory。如果接口和实现必须选一个来编码的话,我宁肯选择实现。ShapeFactoryImp,甚至是丑陋的CShapeFactory,都比对接口名称编码来得好。 避免思维映射 不应当让读者在脑中把你的名称翻译为他们熟知的名称。这种问题经常出现在选择是使用问题领域术语还是解决方案领域术语时。 单字母变量名就是个问题。在作用域较小、也没有名称冲突时,循环计数器自然有可能被命名为 i 或 j 或 k。(但千万别用字母 l!)这是因为传统上惯用单字母名称做循环计数器。然而,在多数其他情况下,单字母名称不是个好选择;读者必须在脑中将它映射为真实概念。仅仅是因为有了 a 和 b,就要取名为 c,实在并非像样的理由。 聪明程序员和专业程序员之间的区别在于,专业程序员了解,明确是王道。专业程序员善用其能,编写其他人能理解的代码。 类名 类名和对象名应该是名词或名词短语,如Customer、WikiPage、Account和AddressParser。避免使用Manager、Processor、Data或Info 这样的类名。类名不应当是动词。 方法名 方法名应当是动词或动词短语,如postPayment、deletePage或save。属性访问器、修改器和断言应该根据其值命名,并依Javabean 标准加上get、set和is 前缀。 string name = employee.getName(); customer.setName("mike"); if (paycheck.isPosted())... 重载构造器时,使用描述了参数的静态工厂方法名。例如, Complex fulcrumPoint = Complex.FromRealNumber(23.0); 通常好于 Complex fulcrumPoint = new Complex(23.0); 可以考虑将相应的构造器设置为 private,强制使用这种命名手段。 每个概念对应一个词 给每个抽象概念选一个词,并且一以贯之。例如,使用fetch、retrieve和get来给在多个类中的同种方法命名。你怎么记得住哪个类中是哪个方法呢?很悲哀,你总得记住编写库或类的公司、机构或个人,才能想得起来用的是哪个术语。否则,就得耗费大把时间浏览各个文件头及前面的代码。 别用双关语 避免将同一单词用于不同目的。同一术语用于不同概念,基本上就是双关语了。如果遵循 “一词一义”规则,可能在好多个类里面都会有add方法。只要这些 add 方法的参数列表和返回值在语义上等价,就一切顺利。 但是,可能会有人决定为“保持一致”而使用add这个词来命名,即便并非真的想表示这种意思。比如,在多个类中都有add方法,该方法通过增加或连接两个现存值来获得新值。假设要写个新类,该类中有一个方法,把单个参数放到群集(collection)中。该把这个方法叫做add吗?这样做貌似和其他add方法保持了一致,但实际上语义却不同,应该用insert或append之类词来命名才对。把该方法命名为 add,就是双关语了。 代码作者应尽力写出易于理解的代码。我们想把代码写得让别人能一目尽览,而不必殚精竭虑地研究。我们想要那种大众化的作者尽责写清楚的平装书模式;我们不想要那种学者挖地三尺才能明白个中意义的学院派模式。 使用解决方案领域名称 记住,只有程序员才会读你的代码。所以,尽管用那些计算机科学术语、算法名、模式名、数学术语吧。依据问题所涉领域来命名可不算是聪明的做法,因为不该让协作者老是跑去问客户每个名称的含义,其实他们早该通过另一名称了解这个概念了。 对于熟悉访问者(VISITOR)模式的程序来说,名称AccountVisitor富有意义。哪个程序员会不知道 JobQueue 的意思呢?程序员要做太多技术性工作。给这些事取个技术性的名称,通常是最靠谱的做法。 使用源自所涉问题领域的名称 如果不能用程序员熟悉的术语来给手头的工作命名,就采用从所涉问题领域而来的名称吧。至少,负责维护代码的程序员就能去请教领域专家了。 优秀的程序员和设计师,其工作之一就是分离解决方案领域和问题领域的概念。与所涉问题领域更为贴近的代码,应当采用源自问题领域的名称。 添加有意义的语境 你需要用有良好命名的类、函数或名称空间来放置名称,给读者提供语境。当这样名称还是不能自我表达时,给名称添加前缀就是最后一招了。 设想你有名为firstName、lastName、street、houseNumber、city、state 和zipcode的变量。当它们搁一块儿的时候,很明确是构成了一个地址。不过,假使只是在某个方法中看见孤零零一个state变量呢?你会理所当然推断那是某个地址的一部分吗? 可以添加前缀addrFirstName、addrLastName、addrState等,以此提供语境。至少,读者会明白这些变量是某个更大结构的一部分。当然,更好的方案是创建名为Address的类。这样,即便是编译器也会知道这些变量隶属某个更大的概念了。 不要添加没用的语境 再比如,你在GSD应用程序中的记账模块创建了一个表示邮件地址的类,然后给该类命名为GSDAccountAddress。稍后,你的客户联络应用中需要用到邮件地址,你会用GSDAccountAddress吗?这名字听起来没问题吗?在这17个字母里面,有10个字母纯属多余和与当前语境毫无关联。 只要短名称足够清楚,就要比长名称好。别给名称添加不必要的语境。 对于Address类的实体来说,accountAddress和customerAddress都是不错的名称,不过用在类名上就不太好了。Address是个好类名。如果需要与 MAC 地址、端口地址和 Web 地址相区别,我会考虑使用PostalAddress、MAC和URI。这样的名称更为精确,而精确正是命名的要点。 函数 短小 函数的第一规则是要短小。第二条规则是还要更短小。重要的事情要多说几遍。 长度尽量控制在200行内,当函数过长时,就应该考虑对函数中的语句进行函数封装了。 只做一件事 要判断函数是否不止做了一件事,有一个方法,就是看是否能再拆出一个函数,该函数不仅只是单纯地重新诠释其实现。 每个函数一个抽象层级 要确保函数只做一件事,函数中的语句都要在同一抽象层级上。 自顶向下读代码:向下规则 switch 语句 写出短小的switch语句很难。即便是只有两种条件的switch语句也要比我想要的单个代码块或函数大得多。写出只做一件事的switch语句也很难。Switch天生要做N件事。不幸我们总无法避开switch语句,不过还是能够确保每个switch都埋藏在较低的抽象层级,而且永远不 重复。当然,我们利用多态来实现这一点。 请看代码清单3-4。它呈现了可能依赖于雇员类型的仅仅一种操作。 代码清单3-4 Payroll.java public Money calculatePay(Employee e) throws InvalidEmployeeType { switch(e.type) { case COMMISSIONED: return calculateCommissionedPay(e); case HOURLY: return calculateHourlyPay(e); case SALARIED: return calculateSalariedPay(e); default: throw new InvalidEmployeeType(e.type); } } 该函数有好几个问题。首先,它太长,当出现新的雇员类型时,还会变得更长。其次,它明显做了不止一件事。第三,它违反了单一权责原则(Single Responsibility Principle, SRP),因为有好几个修改它的理由。第四,它违反了开放闭合原则(Open Closed Principle , OCP ),因为每当添加新类型时,就必须修改之。不过,该函数最麻烦的可能是到处皆有类似结构的函数。 例如,可能会有 isPayday(Employee e, Date date), 或 deliverPay(Employee e, Money pay), 如此等等。它们的结构都有同样的问题。 该问题的解决方案(如代码清单3-5所示)是将switch语句埋到抽象工厂底下,不让任何人看到。该工厂使用switch语句为Employee的派生物创建适当的实体,而不同的函数,如calculatePay、isPayday 和 deliverPay 等,则藉由Employee接口多态地接受派遣。 对于switch语句,我的规矩是如果只出现一次,用于创建多态对象, 而且隐藏在某个继承关系中,在系统其他部分看不到,就还能容忍。当然也要就事论事,有时我也会部分或全部违反这条规矩。 代码清单3-5 Employee与工厂 代码清单3-5 Employee与工厂 public abstract classEmployee { public abstract boolean isPayday(); public abstract Money calculatePay(); public abstract void deliverPay(Money pay); } ----------------- public interface EmployeeFactory { public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType; } ----------------- public class EmployeeFactoryImpl implements EmployeeFactory { public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType { switch(r.type) { case COMMISSIONED: return new CommissionedEmployee(r) ; case HOURLY: return new HourlyEmployee(r); case SALARIED: return new SalariedEmploye(r); default: throw new InvalidEmployeeType(r.type); } } } 使用描述性的名称 别害怕长名称。长而具有描述性的名称,要比短而令人费解的名称好。长而具有描述性的名称,要比描述性的长注释好。使用某种命名约定,让函数名称中的多个单词容易阅读,然后使用这些单词给函数取个能说清其功用的名称。 别害怕花时间取名字。你当尝试不同的名称,实测其阅读效果。在Eclipse或IntelliJ等现代IDE中改名称易如反掌。使用这些IDE测试不同名称,直至找到最具有描述性的那一个为止。 选择描述性的名称能理清你关于模块的设计思路,并帮你改进之。追索好名称,往往导致对代码的改善重构。 命名方式要保持一致。使用与模块名一脉相承的短语、名词和动词给函数命名。例如,includeSetupAndTeardownPages、includeSetupPages、includeSuiteSetupPage和includeSetupPage等。 函数参数 最理想的参数数量是零(零参数函数),其次是一(单参数函数),再次是二(双参数函数),应尽量避免三(三参数函数)。有足够特殊的理由才能用三个以上参数(多参数函数)—所以无论如何也不要这么做。 参数对象 如果函数看来需要两个、三个或三个以上参数,就说明其中一些参数应该封装为类了。例如, 下面两个声明的差别: Circle makeCircle(double x, double y, double radius); Circle makeCircle(Point center, double radius); 从参数创建对象,从而减少参数数量,看起来像是在作弊,但实则并非如此。当一组参数被 共同传递,就像上例中的x和y那样,往往就是该有自己名称的某个概念的一部分。 无副作用 副作用是一种谎言。函数承诺只做一件事,但还是会做其他被藏起来的事。有时,它会对自己类中的变量做出未能预期的改动。有时,它会把变量搞成向函数传递的参数或是系统全局变量。 无论哪种情况,都是具有破坏性的,会导致古怪的时序性耦合及顺序依赖。 以代码清单 3-6 中看似无伤大雅的函数为例。该函数使用标准算法来匹配userName和password。如果匹配成功,返回true,如果失败则返回 false。但它会有副作用。你知道问题所在吗? 代码清单3-6 UserValidator.java public class UserValidator { private Cryptographer cryptographer; public boolean checkPassword(String userName, String password) { User user = UserGateway.findByName(userName); if (user != User.NULL) { String codedPhrase = user.getPhraseEncodedByPassword(); String phrase = cryptographer.decrypt(codedPhrase, password); if ("Valid Password".equals(phrase)) { Session.initialize(); return true; } } return false; } } 当然了,副作用就在于对Session.initialize()的调用。checkPassword函数,顾名思义,就是用来检查密码的。该名称并未暗示它会初始化该次会话。所以,当某个误信了函数名的调用者想要检查用户有效性时,就得冒抹除现有会话数据的风险。 这一副作用造出了一次时序性耦合。也就是说,checkPassword只能在特定时刻调用(换言之,在初始化会话是安全的时候调用)。如果在不合适的时候调用,会话数据就有可能沉默地丢失。时序性耦合令人迷惑,特别是当它躲在副作用后面时。如果一定要时序性耦合,就应该在函数名称中说明。在本例中,可以重命名函数为checkPasswordAndInitializeSession,虽然那还是违反了“只做一件事”的规则。 输出参数 参数多数会被自然而然地看作是函数的输入。如果你编过好些年程序,我担保你一定被用作输出而非输入的参数迷惑过。例如: appendFooter(s); 这个函数是把s添加到什么东西后面吗?或者它把什么东西添加到了s后面?s是输入参数还是输出参数?稍许花点时间看看函数签名: public void appendFooter(StringBuffer report) 事情清楚了,但付出了检查函数声明的代价。你被迫检查函数签名,就得花上一点时间。应该避免这种中断思路的事。 在面向对象编程之前的岁月里,有时的确需要输出参数。然而,面向对象语言中对输出参数的大部分需求已经消失了,因为 this也有输出函数的意味在内。换言之,最好是这样调用appendFooter:report.appendFooter(); 普遍而言,应避免使用输出参数。如果函数必须要修改某种状态,就修改所属对象的状态吧。 分隔指令与询问 函数要么做什么事,要么回答什么事,但二者不可得兼。函数应该修改某对象的状态,或是返回该对象的有关信息。两样都干常会导致混乱。看看下面的例子: public boolean set (String attribute, String value); 该函数设置某个指定属性,如果成功就返回true,如果不存在那个属性则返回false。这样就导致了以下语句: if (set("username", "unclebob"))... 从读者的角度考虑一下吧。这是什么意思呢?它是在问username属性值是否之前已设置为unclebob吗?或者它是在问username属性值是否成功设置为unclebob呢?从这行调用很难判断其含义,因为set是动词还是形容词并不清楚。 作者本意,set是个动词,但在 if 语句的上下文中,感觉它像是个形容词。该语句读起来像是说“如果username属性值之前已被设置为 uncleob”,而不是“设置username 属性值为unclebob,看看是否可行,然后„„”。要解决这个问题,可以将set函数重命名为setAndCheckIfExists,但这对提高if 语句的可读性帮助不大。真正的解决方案是把指令与询问分隔开来,防止混淆的发生: if(attributeExists("username")) { setAttribute("username", "unclebob"); ... } 使用异常替代返回错误码 从指令式函数返回错误码轻微违反了指令与询问分隔的规则。它鼓励了在if语句判断中把指令当作表达式使用。 if(deletePage(page) == E_OK) 这不会引起动词/形容词混淆,但却导致更深层次的嵌套结构。当返回错误码时,就是在要求调用者立刻处理错误。 if(deletePage(page) == E_OK) { if(registry.deleteReference(pag e.name) == E_OK) { if(configKeys.deleteKey(page.name.makeKey()) == E_OK) { logger.log("page deleted"); } else { logger.log("configKey not deleted"); } } else { logger.log("deleteReference from registry failed"); } } else { logger.log("delete failed"); return E_ERROR; } 另一方面,如果使用异常替代返回错误码,错误处理代码就能从主路径代码中分离出来,得到简化: try { deletePage(page); registry.deleteReference(page.name); configKeys.deleteKey(page.name.makeKey()); } catch (Exception e) { logger.log(e.getMessage()); } 抽离Try/Catch代码块 Try/catch代码块丑陋不堪。它们搞乱了代码结构,把错误处理与正常流程混为一谈。最好把try和catch代码块的主体部分抽离出来,另外形成函数。 public void delete(Page page) { try { deletePageAndAllReferences(page); } catch(Exception e) { logError(e); } } private void deletePageAndAllReferences(Page page) throws Exception { deletePage(page); registry.deleteReference(page.name); configKeys.deleteKey(page.name.makeKey()); } private void logError(Exception e) { logger.log(e.getMessage()); } 在上例中,delete函数只与错误处理有关。很容易理解然后就忽略掉。deletePageAndAllReference 函数只与完全删除一个page有关。错误处理可以忽略掉。有了这样美妙的区隔,代码就更易于理解和修改了。 错误处理就是一件事 函数应该只做一件事。错误处理就是一件事。因此,处理错误的函数不该做其他事。这意味着(如上例所示)如果关键字try在某个函数中存在,它就该是这个函数的第一个单词,而且在catch/finally代码块后面也不该有其他内容。 Error.java 依赖磁铁 返回错误码通常暗示某处有个类或是枚举,定义了所有错误码。 public enum Error { OK, INVALID, NO_SUCH, LOCKED, OUT_OF_RESOURCES, WAITING_FOR_EVENT; } 这样的类就是一块依赖磁铁(dependency magnet);其他许多类都得导入和使用它。当Error枚举修改时,所有这些其他的类都需要重新编译和部署。这对Error类造成了负面压力。程序员不愿增加新的错误代码,因为这样他们就得重新构建和部署所有东西。于是他们就复用旧的 错误码,而不添加新的。 使用异常替代错误码,新异常就可以从异常类派生出来,无需重新编译或重新部署。 别重复自己 重复可能是软件中一切邪恶的根源。许多原则与实践规则都是为控制与消除重复而创建。例如,全部考德(Codd)数据库范式都是为消灭数据重复而服务。再想想看,面向对象编程是如何将代码集中到基类,从而避免了冗余。面向方面编程(Aspect Oriented Programming)、面向组件编程(Component Oriented Programming)多少也都是消除重复的一种策略。看来,自子程序发明以来,软件开发领域的所有创新都是在不断尝试从源代码中消灭重复。 结构化编程 有些程序员遵循 Edsger Dijkstra 的结构化编程规则。Dijkstra认为,每个函数、函数中的每个代码块都应该有一个入口、一个出口。遵循这些规则,意味着在每个函数中只该有一个return语句,循环中不能有break或continue语句,而且永永远远不能有任何goto语句。 我们赞成结构化编程的目标和规范,但对于小函数,这些规则助益不大。只有在大函数中,这些规则才会有明显的好处。所以,只要函数保持短小,偶尔出现的 return、break 或 continue 语句没有坏处,甚至还比单入单出原则更具有表达力。另外一方面, goto只在大函数中才有道理,所以应该尽量避免使用。 如何写出这样的函数 写代码和写别的东西很像。在写论文或文章时,你先想什么就写什么,然后再打磨它。初稿也许粗陋无序,你就斟酌推敲,直至达到你心目中的样子。 我写函数时,一开始都冗长而复杂。有太多缩进和嵌套循环。有过长的参数列表。名称是随意取的,也会有重复的代码。不过我会配上一套单元测试,覆盖每行丑陋的代码。 然后我打磨这些代码,分解函数、修改名称、消除重复。我缩短和重新安置方法。有时我还拆散类。同时保持测试通过。 最后,遵循本章列出的规则,我组装好这些函数。我并不从一开始就按照规则写函数。我想没人做得到。 小结 每个系统都是使用某种领域特定语言搭建,而这种语言是程序员设计来描述那个系统的。函数是语言的动词,类是名词。这并非是退回到那种认为需求文档中的名词和动词就是系统中类和函数的最初设想的可怕的旧观念。其实这是个历史更久的真理。编程艺术是且一直就是语言设计的艺术。 本章所讲述的是有关编写良好函数的机制。如果你遵循这些规则,函数就会短小,有个好名字,而且被很好地归置。不过永远别忘记,真正的目标在于讲述系统的故事,而你编写的函数必须干净利落地拼装到一起,形成一种精确而清晰的语言,帮助你讲故事。