top 10 most-overlooked rules for readable code

对于如果提高代码质量,目前互联网上“充斥”着各种文章,不论是大牛还是新人都有自己的一番见解,本文只是从可读性角度分析提高代码质量的一些易忽视或难以坚持的原则,行文以一些简单示例或参与的开源软件中的一些代码示例/交流为参考。

1  查看参与项目的代码风格

代码风格是提高代码可读性的根基,从不存在一种统一的代码风格,即使对于同种语言,也存在多样流行风格:

Java Sun http://www.oracle.com/technetwork/java/codeconvtoc-136057.html)
Java Google http://google-styleguide.googlecode.com/svn/trunk/javaguide.html
c Bjarne Stroustrup http://www.stroustrup.com/bs_faq2.html
c Google https://google-styleguide.googlecode.com/svn/trunk/cppguide.html
c GNU https://gcc.gnu.org/wiki/CppConventions

在众多风格中,很难评选出最好的style,例如对于变量名的首字符是否大小写,不能说大写一点比小写好,但是如果在代码里面一会大写一会小写肯定带来糟糕的体验,所以从始至终一致至关重要。

在融入新的项目之时,一定要熟悉所加入的项目所遵从的代码风格。例如netty项目中,对于没有set方法的成员,其get方法的命名是不带get前缀,这点与很多其他项目不同,例如:

https://github.com/netty/netty/blob/3e5dcb5f3efbb26d5e6cf4cd229b03c285d62462/transport/src/main/java/io/netty/channel/WriteBufferWaterMark.java#L77

     /**
      * Returns the low water mark for the write buffer.
       */
      public int low() {
         return low;
     }

另外它的toString()方法也有自己的风格;

    @Override
    public String toString() {
        StringBuilder builder = new StringBuilder(55)
            .append("WriteBufferWaterMark(low: ") //here is ( instead of [
            .append(low)
            .append(", high: ")
            .append(high)
            .append(")");
        return builder.toString();
    }

如果认为之前的风格不一致,不管放弃自己的还是抛弃过去的,都要始终保持一致,而不是抛弃过去,直接书写自己的风格。


就近与分段

2.1 就近

“就近”的优势在于阅读者不需要保持记忆的时间太长,且不容易受干扰,常见的例子有以下两种:

(1) 声明和使用变量:在反面例子中,声明和使用userIndex之间还间隔了其他无关代码,使得阅读者在第一次遇到userIndex之后不仅拉长了短时记忆时间且受到了信息干扰。

反面例子:

int userIndex=10;
int classroomIndex=20;
getUserNameByIndex(userIndex);

正面例子:

int userIndex=10;
getUserNameByIndex(userIndex);
int classroomIndex=20;

(2)调用者与被调用者:

反面例子1:


int caller(){
call();
}

void other(){}

private void call(){}

正面例子1:


int caller(){
call();
}

private void call(){}
void other(){}

例子2: 存在多个调用者:

反面例子2:


int caller1(){
call();
}

void caller2(){
call();
}

private void call(){}

正面例子2:


int caller1(){
call();
}

private void call(){}

void caller2(){
call();
}

2.2 分段 分段能让阅读者更容易归类信息。
反面例子:


check(name == null, "name may be null")
buy(name);
pay(name);
waiting(name);
updateCache(name)
updateStorge(name)


正面例子:


check(name == null, "name may be null")

buy(name);
pay(name);
waiting(name);

updateCache(name)
updateStorge(name)


保持顺序

保持顺序可以让阅读者减少记忆负担,例如在下面的反面例子中,传入参数是abcd的顺序,但是在进一步方法调用时,采用的是dcb,这样会使得阅读者必须牢记这两个方法使用的dcb顺序是不同的,对于同种类型的参数,特别容易因记错而出现bug。

反面例子:


public void caller(int a, String b, float c, String d) {
call(d, c, b);
}
public void call(String d, float c, String b)

正面例子:

public void caller(int a, String b, float c, String d) {
call(b, c, d);
}
public void call(String b, float c, String d)

4  给自己的代码定义一个布局

在完成一个class/cpp的书写后,我们会遇到各种元素:静态成员变量、成员变量、静态成员方法、成员方法外、内部类等,合理的布局会带来阅读者良好的体验,例如保持静态成员变量在整个类的第一位:这样当阅读者想查找一个类的静态方法时,自然会首先定位到整个页面的前部分,同时一致的布局也会给阅读者良好的体验。Sun的《Java Coding Style Guide》提供了一种布局方案(如下图),相比较简单的按访问级别(Public、Protected等)更清晰。

note: 对于同种“元素”,可以按照重要性从前往后,相关系由近及远的方式进行放置。

5 惯用约定俗成”

除了java对包、类、方法名等各个元素的名称规范外,约定俗成更侧重使用的名称或者方式本身的含义,用好约定俗成的规则,则让懂得规则的人直接了解代码的功能目标:

5.1 名称的定义约定俗成

例如《Effctive Java》中对于静态构造器的命名规范:

valueOf 类型转化,返回的实例与参数
of 同上,EnumSet中使用并流行起来
getInstance 返回的实例是通过方法的参数来描述,对于单例,该方法没有参数且返回唯一实例
newInstance 同上,但确保返回的实例都不相同
getType 同getInstance, 但工厂方法不在类中
newType 同newInstance, 但工厂方法不在类中

5.2 设计模式的含义约定俗成:例如以下几种常见模式,最好采用以下关键字,让阅读者一目了然代码意图。

singleton 创建对象,对象只有一个实例
factory 创建对象
builder 创建对象,但是很可能伴随很多可选参数
template 模板实现,可能具体某个步骤的具体实现不同
strategy 同种目标,不同实现方法
proxy 隐藏具体实现,代理实现
chain 流行线式执行,可插拔某个步骤

6 合理注释和commit

6.1 注释是提高代码可读性的双刃剑,注释写的好,有助阅读者快速理解代码,但是需要注意几点:

(1)考虑注释的初衷:如果添加代码注释是因为代码没有清晰表达意图,首先需要做的是重构而非添加注释来解释。

(2)不写无用注释:SCM工具可以记录的信息都可以不去注释,例如Change Start/End, Changed by XXX.

(3)同步更新注释:注释本身也是代码的一部分,需要维护,如果修改代码逻辑,而不同步更新注释,就会造成误解。

(4)对待注释如同代码:例如对于class级别注释的首句,要求简短精炼。

nettyissue_1

用好注释能让阅读者的阅读事半功倍,如果用的不好则适得其反,所以一定要合理注释。

6.2 commit代码时要注意两点:

(1)follow一定格式:例如为什么要改,怎么改的,改后的效果和影响;

例如

fix-one-java-doc-issue_netty

(2)减少相关commit次数,squash related temp commits into one:

很多时候,不可能一次完成task,多次提交后,commit就会特别多,但是最终提交时,应尽量将commit控制在1个: 包括所有相关代码、测试、readme的changes等,如此非常清晰,让以后的维护者一目了然。

反面例子:

multi_commits

7 对称与一致

7.1 方法名定义对称性:

方法名的对称性不仅可以帮忙阅读者理解代码,而且使人在泛读代码时忽略很多信息,最常见的如set/get组,toString/fromString组,如果一份代码全是这样的组,意味着阅读者至少可以忽略一半的方法。

7.2 方法实现的对称性:

反面例子:


      preHandleOrder(order);
      printOrderInfo(order);
      if (order!= null) {
         transfer(order)
      }

正面例子:


      preHandleOrder(order);
      printOrderInfo(order);
      transferOrder(order);

新增方法:


      void transferOrder(Order order){
           if (order!= null) {
           transfer(order)
      }

7.3 命名/实现的一致性:

(1)名称: 例如经常看到在class/cpp 1中命名delete,在class/cpp 2中命名remove,尽量应该保持一致;
(2)大小写:例如ID有三种形式,id, Id, ID等,到底用哪种,统一可以避免很多困惑;
(3)实现一致:例如在redis java client项目中:

https://github.com/xetorthio/jedis/pull/1396/files

反面例子:


      if (null != this.password ) {
        jedis.auth(this.password);
      }
      if (database != 0) {
        jedis.select(database);
      }
      if (clientName != null) {
        jedis.clientSetname(clientName);
      }

正面例子:


      if (password != null) {
        jedis.auth(password);
      }
      if (database != 0) {
        jedis.select(database);
      }
      if (clientName != null) {
        jedis.clientSetname(clientName);
      }

8  处理好字符

(1)添加关键字符

FIXME 需要修正的功能 JDK
XXX 需要改进的功能 JDK
TODO
JDK
@Beta beta版 guava
@VisibleForTesting 为了测试目的提高可见性 guava
 @UnstableApi  不稳定  netty

(2)消除魔术字符

9  完善代码不是一味精简代码:冗余与精简结合

秉承少即是多的原则,一方面要坚持用更少的代码来表达,另外一方面可以适当增加一些代码行来提供可读性。

9.1 精简代码:主要包括二方面:

(1) 删除无用代码:这主要包括以下几个方面:

废弃的功能  整个功能不再需要,但是代码还没有被注释
注释掉的代码  不用了,又怕删了找不回来,所以临时注释了,但一直未清理
临时的一些测试方法  最常见的莫过于加一个main方法然后写点测试
冗余重复的逻辑 https://github.com/influxdata/influxdb-java/pull/233

(2)用新语法/新方法更有力的表达:随着语言本身或第三方类库的发展,会添加不少“语法糖”和新的实用方法,这促使可用更简短、稳定的方式来替换精简代码,例如JDK8新引入的lambda表达式:

Collections.sort(domainDOList, new Comparator<DomainDO>() {
@Override
public int compare(DomainDO o1, DomainDO o2) {
return o1.getPriority() - o2.getPriority();
}
});

//change to 
Collections.sort(domainDOList,(o1, o2) -> o1.getPriority() - o2.getPriority());
for (URI uri : uris) {
if (uri.getPriority() == 1.0)
return true;
}
return false;
}

//change to
return uris.stream().anyMatch((uri)->uri.getPriority() == 1.0);

再如,使用guava的cache模块,替换自己实现的local cache;使用jdk8自带的base64统一其他所有纷繁众多的base64。

9.2  增加代码:提高代码质量,不意味着就是“缩减”,适当增加一些代码反而能提高代码可读性,例如引入解释性变量或者方法:

反面例子:

if (student.getAge() >= 16 && student.getAddressCityCode() == 3)
      return "can attend this game free";

正面例子:

boolean isAdult = student.getAge() >= 16;
boolean isHefei = student.getAddressCityCode == 3;
if (isAdult && isHefei)
      return "can attend this game free";

10 结束可能才是开始:自省与他审

代码写完之后,可能洋洋得意于代码设计之精妙、阅读之流畅,但是务必谨记这仅是自己的感觉,如果想代码脍炙人口,还要经过不断的自省与他审,具体修改代码的需求来源于以下四方面:

10.1 工具检查,例如checkstyle,PMD等工具查看代码风格或复杂度。

10.2  阅读其他类似经典实现,比较差距:不管代码的实现功能是什么,基本上总能找到类似实现,毕竟我们创造轮子的时间比重复轮子的时间要远远小。所以大体总是能找到类似的“专家”实现,从而比较出差距以完善。

10.3 他人的评审:他人可以是一个对业务的入门者,也可以是经验丰富的同伴,前者能反映代码的易懂性,后者能高屋建瓴,给予更多经验性修正意见。

10.4 间隔一定时间的自省:随着时间的推移,自己对之前代码会存在遗忘,同时自己的知识经验也会有一定提升,这给代码的重新自省带来了好的基础,间隔的阅读必然带来一番新的感受和体验。

 

总结:  通过以上的十条建议,我们可知,好的代码表达不仅能让阅读者轻松阅读和记忆代码,同时也能引导阅读者去养成和自己代码风格匹配的阅读习惯,从而发生共鸣。好的代码也是进一步优化代码性能的前提,适合机器执行的不见得适合阅读,但是代码更大部分消耗的时间不是创建而是维护,所以优化代码可读性至关重要。

rules and traps for code refactor

先写个提纲吧,以后有空再写:

代码从来就不是一撮而就的,而是需要不断重构才能阻止代码腐烂保持健壮,具体到细节原因可能包含以下多种原因:

(1) Hard to add new logic due to complex
(2) Hard to do unit test
(3) Maintain effort huge
(4) Easy happen errors
(5) Code coverage is low
(6) look ugly
(7) New tech driving
(8) Have free time

总结起来,无外乎两种:“逼得”和“闲得”

重构的一些基本原则:

1 选择好时机:

1.1 主动出击式:
选择风险小的时候,例如新版本的开始阶段,而不是临近release阶段,集中时间去重构。
1.2 以逸待劳式:
做某项Task时,把需要修改代码的Scope部分或周边顺带做下重构。

2 做好充分的准备:

2.1 充分的技术储备

(1)所选择的技术方案是否成熟?
(2)是否有广泛应用的案例?
(3)是否有活跃的社区支持?
(4)是否有一些已知问题无法容忍等?

2.2 充分的评估

(1)需要多久完成?(2)需要得到其他的帮助么?

2.2 充分的风险规避

对于大风险的重构,考虑风险规避:

(1)Feature Toggle: 支持Feature Toggle来切换新老代码;
(2)分段提交:充分证明是稳定可靠后,再提交一部分;
(3)备份代码:留存有据可溯,出问题时一键rollback,或者面对质疑有据可查。(4)平稳切换:

2.3 充分的支持

(1)重构方案是否得到认可
(2)重构是否得到team的支持,或者有更高优先级的事情

3 充分的执行
3.1 具体的实施原则:
(1)重构还是重写?考虑是否直接重写,效益和速率更优;
(2)先大后小: 先解决主要矛盾,架构重构不要扣代码细节,细节重构不要扣架构;
(3)小步前进:避免大的风险;
(4)尽早提交:时间跨度越大,提交冲突的几率也越大,最后也容易被废弃。

3.2 充分的测试
3.3 充分的Review

4. 重构中容易犯的错

除了重构代码写错了,还有一些容易掉进的陷阱,例如:

4.1 编译器不提醒

例如重构了类名或者方法名等,导致用反射实现的一些功能不work.

(1) Spring AOP:

@Pointcut("within(com.xyz.someapp.trading..*)")
private void inTrading() {}

(2)Reflection for JDK

Class.forName("com.xyz.someapp.trading.test");

(3)Reflection for Test Lib

例如jmockit:

Deencapsulation.setField(OurInstance, "member", memberValue);

4.2 误确认IDE重构操作提示

有时候使用IDE提供的重构功能时,自己的误确认,会引发一些错误,例如重构时,修改某个变量名时,遇到配置文件里面也含有同名词,提示是否修改,不注意就确认容易引发难查问题。

4.3 别有用途

很多欠佳的设计,都会导致重构后发生一些错误。

(1)Log关键词抓取

某些特殊需求的应用例如监控服务以一些“关键词”检索做些逻辑,但是重构时,不小心去掉某些关键词,或者改名,导致这个功能不work.

09-02 2015 00:05:35:737 [metrics-logger-reporter-thread-1] INFO metrics – type=COUNTER, name=unavailables, count=0

(2) Empty Folder
一些应用在部署时,为了避免去创建某个特殊目录,在代码里面放置一个空目录,部署后刚好解压到预定位置,重构去掉后,导致不work.
empty_dir

还有一些比较隐蔽的逻辑,也容易让重构发生错误:

例如,一些dev在taskid后面追加一些其他信息,然后重构时,觉得没有用,直接删除,或者自己需要额外加一些功能时,直接也追加,导致原来别人追加的或被删除,或位置被置于中间,让原先的使用无所适从。

4.4 错上加错

例如下面的语句,单纯看,果断想去fix,但是fix后,引发了问题,因为基于这个错误已经做了一个逻辑。