代码质量随想录(四):排版,不只是为了漂亮
写了前三篇(一、二、三)之后,发现比我预想的效果要好。关注代码质量的朋友还蛮多的,而且很多意见和建议也很有益,指出了我文章中的一些问题。
我这种家庭妇男型的自由职业者来说,在平常写代码的时候可以多停下来,思考一些代码质量与软件设计方面的问题。当然啦,由于具体的工作环境、关注领域、自身阅历等原因,小翔在文中提出的许多观点难免书生之见,请诸位多包涵。
针对排版这个问题,不同的公司、团队都有自己的一套方案,有时网络上也能下载到很多大型的权威代码规范,其中亦含有程序排版相关的规则,我也经常与众友人一起讨论某个项目所用的排版约定。在看到《The Art of Readable Code》一书中有关此话题的章节时,我的感觉是,很难总结出一套万用的“宇宙排版律”来,多半要根据自身环境、团队和项目的特点来拟定,所给出的建议仅仅是参考,并不能强行照搬。
1. 功能相似的代码,版式也应相似
public class PerformanceTester { public static final TcpConnectionSimulator wifi = new TcpConnectionSimulator( 500 /* 以Kbps计量的吞吐量 */, 80 /* 以毫秒计的网络延迟 */, 200 /* 包抖动 */, 1 /* 丢包百分比 */); public static final TcpConnectionSimulator t3Fiber = new TcpConnectionSimulator( 45000 /* 以Kbps计量的吞吐量 */, 10 /* 以毫秒计的网络延迟 */, 0 /* 包抖动 */, 0 /* 丢包百分比 */); public static final TcpConnectionSimulator cell = new TcpConnectionSimulator( 100 /* 以Kbps计量的吞吐量 */, 400 /* 以毫秒计的网络延迟 */, 250 /* 包抖动 */, 5 /* 丢包百分比 */); }
上面这个例子是ARC书中所举的,我认为很恰当。该类的三个静态字段功能类似,都指代某种环境下的网络模拟器,所以排版也应该相似。每行都只写一个实参,而且后面用行内注释的形式解释该实参的意思。在垂直方向上的对齐做得也很好:字段申明前面空2格,实例化语句前面空4格,各实参前面空6格(以上数字非实指,仅是举例而已)。这样要修改某个参数,很快就能定位到它,而且以后如果增加类似的字段,如badWIFI,也可以比照这个格式来,便于维护。
由以上范例还可引出一个问题,那就是在实例化或方法调用中,经常会遇到一些孤立的魔法数字(magic number),如果确有必要为它起名,那么不妨执行一个小的重构,以常量来代替它。反之,如果是大段的硬数值,则不一定非要为每个值都起一个名字,例如:
TcpConnectionSimulator wifi = new TcpConnectionSimulator( WIFI_KBPS_THROUGHPUT, WIFI_LATENCY, WIFI_JITTER, WIFI_PACKET_LOSS_PERCENT);
这样反而显得累赘。不妨像上例那样采用行内注释的办法来解释这些硬值的意思。
承上,ARC的作者又推导出一条建议,就是将相似的方法调用参数注释提取到一处,例如:
public class PerformanceTester { // TcpConnectionSimulator(throughput, latency, jitter, packet_loss) // [Kbps] [ms] [ms] [percent] public static final TcpConnectionSimulator wifi = new TcpConnectionSimulator(500, 80, 200, 1); public static final TcpConnectionSimulator t3Fiber = new TcpConnectionSimulator(45000, 10, 0, 0); public static final TcpConnectionSimulator cell = new TcpConnectionSimulator(100, 400, 250, 5); }
说实在的,以前在工作中还没太重视这个问题,一来是觉得我在写Javadoc时一贯非常完备,出现这种情况时只需靠鼠标悬停就可知道某个方法或构造器的具体信息了;二来嘛,也是想着如果使用大量数值的调用代码多到无法管控,我可能会祭出配置文件这个大旗来,将它们全部纳入配置中了事。所以关于以上例子中谈到的这些问题,我觉得还是根据大家的具体实践来理解为好,不要机械地寻求一致。
2. 将大量相似的嵌套式、接续式调用逻辑整合到共用方法之中,即利于排版,又可凸显重要数据
在测试用例等代码中,经常会出现类似下面这种状况:
// 某受测类中: // 将类似"Doug Adams"这样的不完整称呼进行补全,扩展为"Mr. Douglas Adams"的形式。 // 如若不能(查不到数据或无法补完),则于error参数中填充错误信息并返回空串。 // 此方法会置空错误信息接收参数。 public String expandToFullName(DatabaseConnection conn, String partialName, ErrorMessageReceiver error){...} // 某测试方法中: DatabaseConnection connection=...; ErrorMessageReceiver error=...; assertEquals(expandToFullName(connection,"Doug Adams" ,error) , "Mr. Douglas Adams"); assertEquals(error.getMessage() , ""); assertEquals(expandToFullName(connection,"Jake Brown" ,error) , "Mr. Jacob Brown III"); assertEquals(error.getMessage() , ""); assertEquals(expandToFullName(connection,"No Such Guy“,error) , ""); assertEquals(error.getMessage() , "no match found"); assertEquals(expandToFullName(connection,"John“, error) , ""); assertEquals(error.getMessage() , "more than one result");
这符合上面所说的“量大”、“形似”、“嵌套”等特征,而且诸如输入字串、预期结果、预期错误消息等重要的数据,被埋没于connection、error、getMessage()等技术细节之中。所以可以借由美化版式之机进行重构:
checkPartialToFull("Doug Adams" , "Mr. Douglas Adams" , ""); checkPartialToFull("Jake Brown" , "Mr. Jake Brown III", ""); checkPartialToFull("No Such Guy", "" , "no match found"); checkPartialToFull("John" , "" , "more than one result"); private void checkPartialToFull(String partialName, String expectedFullName, String expectedErrorMessage) { // connection已被提取为测试固件类的成员变量 ErrorMessageReceiver error=...; String actualFullName = expandToFullName(connection, partialName, error); assertEquals(expectedErrorMessage, error.getMessage()); assertEquals(expectedFullName , actualFullName); }
如此一来一举三得:既消除了重复代码,同时美化了版式,凸显了输入字串、预期结果、预期错误消息等重要数据,顺带着还方便了后续测试数据的维护。这种藉由版式整理带来的重构,我看可以有!
3. 明智地使用纵向对齐来减少拼写错误、厘清大量同组数据。
我觉得这一条和第1条有重复,其实也属于类似功能的代码应具类似版式之意,不过既然ARC作者将它单列,我想可能是为了强调纵向对齐的好处吧。
// 将POST参数中的属性分别提取至各个局部变量中 ServletRequest request=...; String details = request.getParameter("details"); String location = request.getParameter("location"); String hone = request.getParameter("phon"); String email = request.getParameter("email"); String url = request.getParameter("url");
经由纵向对齐,很容易看出第三个局部变量这行的错误:将变量名“phone”误写为“hone”,参数名的“phone”则错成了”phon“。
另外,在进行结构体数据、数组成员等这种同组数据排列时,也可以充分利用版式来厘清每个元素的意义。ARC的作者就大赞wget这个命令行工具在指定参数结构体时,代码排列地很工整。
// 非原文,小翔以Java形式改写 Object[][] commands = { //参数名 , 默认值 , 类型 { "timeout", null, TIMEOUT }, { "timestamping", defOpt.timestamp, BOOLEAN }, { "tries", defOpt.tryCount, NUMBER }, { "useproxy", defOpt.useProxy, BOOLEAN }, { "useragent", null, USER_AGENT } };
这一条建议如果与第1条合并起来说,那就是:任务相似的代码块应该具有相似的轮廓(ARC的作者叫它silhouette),如行数、缩进、纵向对齐等。
4. 使用适当空行与注释,将代码按功能分段
有时候经常在考虑代码与散文或诗的联系,如果从隐喻(metaphor)的观点来看,的确有相似性:都是信息的载体,都可以用一定的段落来整合文意。要说区别嘛,前者服务于软件需求,后者服务于社会关系。前者为了向更低阶的执行机制去接合,所以更加注重语法格式。我可不是第一个进行这种思维比拟的人,记得台湾的技术畅销书作者侯捷先生(侯俊杰)就曾写过一本《左手程序右手诗》的书。
class FrontendServer { public: FrontendServer(); void ViewProfile(HttpRequest* request); void OpenDatabase(string location, string user); void SaveProfile(HttpRequest* request); string ExtractQueryParam(HttpRequest* request, string param); void ReplyOK(HttpRequest* request, string html); void FindFriends(HttpRequest* request); void ReplyNotFound(HttpRequest* request, string error); void CloseDatabase(string location); ~FrontendServer(); };
上面的代码挺蜗居的,如果加上适当的空行与说明,就显得清晰多了。
class FrontendServer { public: FrontendServer(); ~FrontendServer(); // 与用户配置相关的处理函数 void ViewProfile(HttpRequest* request); void SaveProfile(HttpRequest* request); void FindFriends(HttpRequest* request); // 回覆及应答工具函数 string ExtractQueryParam(HttpRequest* request, string param); void ReplyOK(HttpRequest* request, string html); void ReplyNotFound(HttpRequest* request, string error); // 数据库操作工具函数 void OpenDatabase(string location, string user); void CloseDatabase(string location); };
上述类将声明区按照构建子/析构子、社交功能函数、工具函数这个标准划分为的三大思维区段,工具函数区又按题材划分为消息操作与数据库操作两小段。这样一来,以后再要维护这份声明代码就会很清爽了。同理,如果声明一个集合类的接口,也应该按照“增、删、改、查”等概念来将API划分为若干小组,以便帮助代码阅读者理顺思路。
就算是在流水式的业务代码中,也可以用段落来衬托出逻辑的“起、承、转、合”。
// 导入用户电子邮件账户中联系人,同本产品中已有的联系人相比对。 // 然后展示正在使用本产品但未与用户建立朋友关系的联络人列表。 public ListDataModel suggestNewFriends(User user,Password emailPassword){ SocialCircle friends = user.friends(); Emails friendEmails = friend.dumpAllEmails(); Contacts contacts = importContacts(user.email, emailPassword); Emails contactEmails = contacts.extractAllEmails(); Emails productUserEmails = UserDataCenter.selectEmails(contactEmails); Emails suggestedFriends = productUserEmails.subtract(friendEmails); ListDataModel displayModel = new ListDataModel(user,friends,suggestedFriends); return displayModel; }
上面的代码给人的压迫感很强列,没有思维喘息的机会。不如把注释拆解,按其逻辑将代码分成小段,为每一段冠以简短标题。
public ListDataModel suggestNewFriends(User user,Password emailPassword){ // 取得当前用户全部朋友的邮件地址 SocialCircle friends = user.friends(); Emails friendEmails = friend.dumpAllEmails(); // 引入当前用户电子邮件账户中的联系人 Contacts contacts = importContacts(user.email, emailPassword); Emails contactEmails = contacts.extractAllEmails(); // 找出正在使用本产品但尚未与本用户建立朋友关系的联系人 Emails productUserEmails = UserDataCenter.selectEmails(contactEmails); Emails suggestedFriends = productUserEmails.subtract(friendEmails); // 返回待显示列表的数据模型 ListDataModel displayModel = new ListDataModel(user,friends,suggestedFriends); return displayModel; }
欣赏一下上面这段代码吧,每小段以一句概括性的注释引领,然后是两句实现代码,排列得非常整齐,代码的阅读者根据此的版式,很容易就能抓住代码的思维走向:“2(获取朋友列表)-2(获取联系人邮箱)-2(找出潜在友人)-2(返回数据模型)”。怎么样,是不是有点儿“起、承、转、合”的意思了?照这样写下去,可以山寨一个小的Google+了吧?我这个SocialCircle类比谷加的还厉害,它那个只能是同级别的平行关系,我这个还能像组合体模式那样,互相嵌套呢!(大误)
由上例可见,适当地进行代码分段并通过注释来充当代码段的概括语,有助于梳理代码阅读者的思路,也有助于代码修改、维护和后续查错。比如想做一个“向未使用本社交网站的电邮联络人发送邀请”的功能,扫一眼上述这段清晰排版的代码,大家立刻就能看出,只需要写好测试用例,复制一份suggestNewFriends的代码,把selectEmails改成excludeEmails,就能找到这些潜在的被邀请人了。给新的方法起个名字,叫inviteContacts,删去多余的程序,然后通过重构提取一下共用代码,再确保测试无误,就可以收工了。思路顺了,编码的过程自然也就更加流畅了。
好了,小小总结一下吧。其实代码排版这种略带个人化的东西,不仅仅是让代码看起来更漂亮,其根本目的还是着眼于代码的可读性,要有助于代码的理解、维护、纠错。具体到执行层面,除了可以参考上述4条建议外,还要注意两方面的问题。
第一个问题,ARC的作者也提到了,那就是很多朋友对代码排版有排斥心理,不愿意认真排版。有一部分原因是怕浪费时间,还有就是担心代码管理系统会将排版后的代码与排版之前的代码判定为两份截然不同的程序,在版本比对时导致满屏的diff,非常难看。其实,在现有的成熟IDE之中(抑或各位Geek们惯用的文本编辑器之中)已经有非常完备的功能来支援代码版式的调整了。比如Eclipse、Netbeans等开发环境,都可以把版式定义文件导出为xml等数据格式,到了陌生的环境时,只需导入即可。而且代码排版一旦确定,就可以一次性地更改所有项目源码的版式然后提交,这样就可以避免在版本比对时显示过多的修改提示了。
第二个问题就是应该在必要的范围内保持代码排版的一致性。虽然我刚也说了,代码排版没有绝对的真理,不过,它却应该有一个相对的底线。在公司与公司之间、团队与团队之间,的确没有必要强行要求一致的版式。例如我们不宜妄自菲薄,说I记或G社的代码排得如何如何漂亮,同时也不能过分地自高自大,说自己团队的版式是天下最美观、最养眼的。但是,如果具体到某个项目,尤其是中小型项目里面,那么就要想方设法达成一致的版式规范了,否则将会给代码的阅读、理解与维护造成不必要的障碍。为此,项目组的成员应该富有的妥协精神,在坚持个人风格这个问题上稍作让步,以求达成大家对代码版式的共识。比如,小翔在个人项目或由我带队的项目中,通常使用以下版式:
public class MyArrayList extends MyAbstractList implements MyCollection{ // 静态部分在前: // 静态内部类型区。同区成员按存取级别排序,高者在前。 /** * 列表容量参数。 */ public static class CapacityOptions{ /** 初始容量。 */ private final int initialElementCount; /** 扩容时新增的容量与扩容前容量之比。 */ private final int expandRatio; } ... // 静态初始化块与静态字段区。 private static final Map<String, CapacityOptions> commonCapacityOptions=...; ... static{ commonCapacityOptions.put("normal" , new CapacityOptions(12,1)); ...; } ... // 静态方法区。 /** * 从既有数组中构建列表。 * @param elements 用以构建的数组,不能为null。 * @return 构建好的列表 */ public static List create(Object[] elements){ ...; } ... // 动态部分在后: // 动态内部类型区。 public class MyIterator{ public Object next(){ ...; } } // 动态初始化块与实例成员变量区。 { ...; } private int count; ... // 构造器区。 public MyList(){ ...; } ... // 实例方法区。 // 先写本类方法。 void expand(){ ...; } // 其次,从直接超类开始,层层追溯至Object,将各层级上的覆写方法列出。 @Override public boolean add(Object e){ ...; } // 然后按由进至远的顺序,实现接口中的方法。 // 同等层级的接口,按其出现在implements、extends子句中的先后顺序,依次实现其方法。 @Override public void clear(){ ...; } // 最后覆写Object类中的方法。 @Override public String toString(){ ...; } //准析构方法区。 protected void finalize() throws Throwable{ ...; } }
上述这个“3+5式分段法”(静态部分:静态内部类型、静态初始化块与字段、静态方法;动态部分:动态内部类性、动态初始化块与实例成员变量、构造器、实例方法、准析构方法),小翔在六年多的工作中一直用着,我觉得它对于代码阅读来说,还算满流畅的,在此也分享给大家。不过,如果现有项目大多数成员要求将左花括号放于新行之首,并要求动态部分出现在静态部分的前边,那么我就会在这个项目中按照大家喜欢的格式来办(并不是随意放弃自己认为合理的版式,而是在某个项目的具体语境下为了求得共识而妥协),同理,类似新行符是\n、\r还是\n\r,空白符是空格还是制表符之类问题,我觉得只要大家认为合适,就没有必要过分争执,定出一个项目内部易于统一管理的规范就好。
再多说一句吧,有同学可能会问,既然Eclipse等IDE中已经可以通过类结构导览视图来显示类代码中的各种成员,那么为何还要如此在乎代码版式呢?因为不管具体代码怎么排列,视图中都可以调整显示顺序呀。对于这个问题,我想有时我们不仅仅要通过导览视图来看宏观结构,还需要进行微观的具体代码审读与维护,所以微观代码的排列终究还是为了易读。当然啦,排列方法可以商量,比如你可以说不必按照“静态、动态”那样分,而是按照“内部类、变量、方法”这样来分。
从下午开始写,断断续续到了深夜,微笑地浏览了一遍之后,顿时觉得这一篇文章讲的话题有点儿文艺了。嗯,接下来,将和大家聊聊代码注释。