Skip to content

编写易读的代码

About 5067 wordsAbout 17 min

架构

2025-04-18

如果没时间,可以直接阅读 编程的智慧

如果有时间,可以购买《Clean Code》

项目代码是“编写一次,阅读多次“。 阅读者包括代码编写者,架构师,审查人员,以及后来的维护人员。能让阅读代码更轻松,有利于增强项目或者产品的可维护性。是各种软件工程方法,面向对象实践,重构,以及新技术应用到到项目里的一个重要前提,如果代码难以阅读,所有这些方法和理论都难以在项目中实施,如果代码难以维护,那么性能优化也无从谈起

本章不是一个Java代码规范和设计原则,本章主要聚焦如何编写可读性强的代码。 可能会让代码变多,也可能会让代码变少,目的是让代码更容易被别人和3个月后的自己读懂。

  • 无效注释干扰阅读
  • 日志和埋点对阅读的帮助
  • 变量命名和变量的位置有助于阅读
  • 小方法代替代码段让阅读更轻松
  • If-else, 保持主逻辑畅通
  • 发现对象,使用对象。代替string和map

1.1 精简注释

在代码块里应该尽量减少注释编写,尤其是描述设计思想和实现过程的注释,这是因为代码会不停的随着业务需求变化而变化,注释往往在重构中被IDE忽略,也会被人为忽略,用《clean code》里的话来说,注释容易腐烂。避免注释腐烂需要我们尽力维护代码和注释的同步,代码中精简注释也是防止腐烂最好的办法。

1.1.1 注释优化

下列列举一些关于代码内注释使用原则

场景1:被注释的代码及时删除

被注释的代码应该及时删除掉,否则这段代码将传给一代又一代的代码维护者,无人敢删除这段代码,我在2018年曾维护一个工作流引擎,里面有一块07年代码让我一直很迷惑

//别删除这块代码 by WX,01.09
//Task[] tasks = taskService.query();
//.....
Task task = taskService.queryOne();

开发者不应该注释代码块,应该尽快删除。如果想恢复,可以通过git工具来恢复

场景2 通过注释解释代码行为

代码注释应该说明代码的动机,而不是再次用文字描述一边代码过程,如下注释相当于没有

/*字符内容符合18个数字或者15个数字*/
public static final String REGEX_ID_CARD = "(^\\d{18}$)|(^\\d{15}$)";
不如改成
/*身份证号验证*/
public static final String REGEX_ID_CARD = "(^\\d{18}$)|(^\\d{15}$)";

有一个《如何编写无法维护的代码》就提到一个原则,只记录 How 而不是 Why ,就会让代码无法维护

如下代码,对账户状态值进行注释

/* 状态 0 正常 1 异常 2 未知 */
private Integer status;

然而,如果status随后添加更多的含义,比如添加3表示冻结,则很可能忽略修改这里的注释。最好的办法是用枚举,并且取消注释

private Integer status = UserStatus.Nomral.getValue();

场景3 不要在代码中记录更改历史

//原来版本使用StringBuffer,改成StringBuilder bt WX 20190302
StringBuilder sb = ....

这段注释毫无存在必要,可以通过git这样的工具来查看代码更新记录和更新人,以及更新原因。没有必要在代码里标注代码相关人,如果用idea,可以很方便的查看代码的更新人。在Editor 左侧代码行空白处点击右键,在弹出菜单选择Annotation

场景4 方法不仅仅是为了复用,把代码块提取到一个方法里,通过方法名来解释代码作用

//发送短信给用户

StringBuilder sb = new StringBuilder("您的余额是");
sb.append(user.getBalance()).append("元-");
sb.append(platformName).append("")
sms.send(sb.toString(),user.getMobile());

可以提取到一个短方法里,代码块调用此方法即可

sendUserBalanceBySms(user,platformName);

这个短方法的意义使得代码容易维护

  • 如果需要修改发送短息的内容,可以直接定位到sendUserBalanceBySms,而不需要从上百行代码里找到
  • 在阅读sendUserBalanceBySms调用所在代码块的的时候,sendUserBalanceBySms无关紧要,可以习惯性的忽略,如果没有这个方法,你需要阅读数行这种代码,哪怕你已经熟悉代码了。用小方法减轻了阅读负担

场景5 使用一个临时变量代替注释

对于一段计算逻辑,或者一个方法调用,使用临时变量来说明其结果含义,比注释更容易维护,如下代码

//返回一年总的费用

return calcPay(user,type);

可以用一个临时变量表示

BigDecimal payByYear = calcPay(user,type);
return payByYear;

场景6 取消没有必要的注释

有些方法过于简单,没有必要添加注释,比如JavaBean得getter和setter方法,如下没有必要的注释

/*用户名称*/
private String userName;

1.1.2 使用日志和埋点

日志和埋点有效,不会像代码注释那样容易腐烂

老板以为的HelloWorld

public static void main(String[] args){
  System.out.println("hello "+args[0]);  
}

实际的HelloWorld

public static void main(String[] args){
    if(ags.length==0){
      log.error("ags is null");//记录日志
      Metric.submitEvent("hello-world-ags-error"); 
      return  ;
    }
    
    if(ags.length>1){
      log.error("ags is "+Arrays.asList(ags));
      Metric.submitEvent("hello-world-ags-error-2");
      //继续执行
    }
    Metric helloMetric = Metric.of("hello-world"); 
    try{
        
        helloMetric.start();
        System.out.println("hello "+args[0]);
        log.info("hello world success "+args[0]);
    }catch(Exception ex){
        if(MyRatelimiter.tryAcquire()){
            //阻止大量error日志打爆磁盘
            log.error("helloworld error ",ex);
        }else{
             Metric.submitEvent("too-many-error-log");
        }     
        //TODO 如何降级处理?
        helloMetric.error(ex.getMessage()); 
    }finally{
        helloMetric.end();
    }

}

1.2 变量

变量命名规范已经有大量书籍和文档说明,本章主要解释在容易阅读的变量命名

1.2.1 变量命名

变量名字需要尽量表达其真实含义,不要缩写或者简写

//总的支付费用
BigDecimal  p ;

代码阅读到这里,无法知晓含义,如果加上注释,我们在上一节也说过,注释容易腐烂,因此可以改一下,适用有一个有意义的名字

BigDecimal pay ;

现实情况,命名如果较长,能准确表达含义,应该使用较长得名字,比如

public class HelloworldService{
}

就比下面代码好

public class HelloworldServ{ 
}

或者

public class Helloworld{ 
}

不需要担心书写费劲,IDE具备智能提示,能提高书写速度。也不要担心阅读费劲,真正费劲的是不常规的命名,比如“HelloworldServ“

有时候变量名太长,意味着想赋予变量得含义太多,如下变量命名原因是想说明类型为Map的变量包含的Key和Value类型

Map strKeyAreayValueMap = getAreaData()

实际上应该是

Map<String,AreaData> areaMap = getAreaData();

如下代码,处理返回结果,由于使用了一个数组而没有采用对象,所以给人感觉命名很别扭

Object[] codeAndObject = rpc.query....

如此命名,固然使得代码阅读者不需要查看rpc.query方法的实现,根据名字就知道该如何处理返回对象,但更好的办法新建一个对象用来表示调用结果,这样命名就简单多了,阅读代码得人可以查看RpcResult的代码和Javadoc了解应该如何处理ret

RpcResult ret = rpc.query....

有些情况下,短名字还是很合适的,比如循环中使用的计数器,通常命名为i,坐标通常命名为x,y等,业务系统也有自己约定习俗得简化的名字,比如wf表示工作流,sku表示商品。

1.2.2 变量的位置

变量的位置应该尽量靠近使用用的地方,如果代码在方法开始处定义了变量,然后在100行代码后才使用,那么代码阅读者心里总是悬着的,觉得不是在看代码,而是在看一本悬疑小说-最后的变量会在哪里用呢

User seller,buyer;
seller = .....
// 50行代码后
buyer = .....

如上代码,最好在使用buyer地方再定义,尤其是业务系统,业务复杂,涉及了很多变量,就近定义变量,减轻阅读负担

代码块的变量命名不要与类变量重名,这会导致阅读困难

public class Point{
    private int x;
    private int y;
    public void calc(Point p){
        //定义一个变量
        int x = p.getX();
        //50行代码后,很难知道x指的哪个
        return calcLine(x,y);
    }
}

如上代码,在调用return方法的时候,x很容易被误解,误以为传入的类变量x

1.2.3 中间变量

使用一些中间变量来增强代码可读性

return a*b+c/rate+d*e;

上面的代码一气呵成,且只用了一行,但没有下面的代码更容易阅读

int yearTotal = a*b;
int lastYearTotal = c/rate;
int todayTotal = d*e;
int total = yearTotal+lastYearTotal+todayTotal;
return total

看似其他人一行代码完成似乎更牛,你用了多行代码才完成了一个功能,但你的代码显然更容易被后来人阅读。我一直觉得写代码就跟写小说一样,要看得懂才是真正的小说,如果从任何地方切入小说都能看懂,那就是本好小说。

1.3 方法

1.3 .1 方法签名

程序员阅读代码,遇到陌生的方法,第一样看的是方法签名,如果方法签名明确表达了函数的功能,输入和输出,则省去了程序员阅读方法注释的可能,甚至是阅读代码的必要!

public Map buildArea(List<Area> areas)

当代码维护者看到buildArea时候,并不清楚Map返回的是什么,他必须查看方法体才了解返回值是什么,如果方法非常复杂,则让阅读者陷入了代码泥潭。正确的优化方式加上范型

public Map<String,Area> buildArea(List<Area> areas)

方法签名的参数数量应该也是可控,多一个参数,理解多一份难度,可以通过提供默认参数加以改进。比如,启动工作流,,定义如下接口

public String startWorkflow(String workflowType,String userName);

有可能启动工作流还涉及工作流得版本还有用户的身份信息,那么如下方法签名就很长了

public String startWorkflow(String workflowType,int version,String userName,String orgId);

则应该考虑适用对象封装,userName 和 orgId 封装为Participant,而工作流相关封装为StartWorklowInfo

public class StartWorklowInfo{
  String workflowType;
  int version;
}
public class Participant{
  String userName;
  String orgId;
}

则startWorkflow 重新定义为

public String startWorkflow(StartWorklowInfo workflowInfo,Participant user);

在重构这个接口后不久,启动工作流需求又更改了,用户还需要用工作流角色标识,因此,我们后来为Participant对象增加了了一个roleId ,就解决问题了。

1.3.2 小方法

为什么要拆分方法,显然的道理。就像我们读书一样,分成章节阅读,无法想象,一本精彩《天龙八部》的小说,如果没有分册分章和相应的标题,不知道你是否还能愉快的阅读。

有一个需求,需要导入excel倒数据库,excel包含了多个业务领域的数据,如下代码看起来就很清爽

public void parse(Sheet sheet){
    User user = readUserInfo(sheet);
    List<Order> orders = readUserOrderInfo(sheet);
    UserCredit credit = readUserCreditInfo(sheet);
}

parse方法一看就很简单,读取用户信息,订单信息还有信用积分信息,在重构parse方法前,此方法并不简单,大概有1000多行,后期的任何维护都是个灾难,比如,excel的中涉及用户领域的信息修改了,你不得不翻看1000多行代码,定位出需要修改的地方,而重构后,只需要顺着readUserInfo方法里去找。

如下方法,处理异常部分实际上也要拆分

try{
    sku = rpc.getSkuInfo(id);
    return ResponseBuilder.success(sku);
}catch(AppException ex){
    if(ex.getType==1){
        //省略10行代码
    }else if(ex.getType==2){
        //省略20行代码
    }else{
        //省略20行代码
    }
    return ResponseBuilder.error(ex);
}

这是因为代码主要逻辑是通过rpc调用获取商品信息,错误处理应该是次要部分,但这个代码块有点喧宾夺主,使得代码阅读者不由自主的注意到错误处理部分,可以重构,用一个方法来处理异常

try{
    sku = rpc.getSkuInfo(id);
}catch(AppException ex){
   handle(ex);
   return ResponseBuilder.error(ex);
}

除了通过方法拆分,完成小说章节,也推荐使用小方法,提高的代码的可维护性,我们在精简注释里已经展现了,把发送短信的代码块抽出成一个方法。如下代码片段

boolean success = user!=null&&user.status==1&&!"admin".equals(user.getName())

这个代码尽管很简单,但也可以考虑抽象倒一个方法里

public boolean validate(User user){
    return user!=null&&user.status==1&&!"admin".equals(user.getName())
}

这样,只需要调用validate方法即可。代码阅读者,可以选择性的忽略这一行代码,轻松愉快。

boolean success = validate(user);

1.4 分支

1.4.1 if else

代码里有大量的分支语句,分支语句应该尽量保证少的嵌套以方便阅读代码,如下是一个不必要的嵌套

CallInfo info = ....;
if(info!=null){
    if(info.isSuccess()){
       return true
    }else{
        log.info(xxxx);
        return false;
    }
   
}else{
    log.info(yyy);
    return false;
}

上面代码,对返回结果进行处理,返回结果可能为空,返回结果值里的状态也可能失败。嵌套了俩层分支,其实可以简化一下,只用一层分支,

CallInfo info = ....;
if(info==null){
    log.info(yyy);
    return false;
}

if(!info.isSuccess()){
    log.info(xxxx);
    return false;
}
.....

return true;

重构后的代码保持了最少的分支嵌套,同时**,最重要的是保持主逻辑畅通**,优先处理错误路径分支,剩下的为正常结果,最后再处理。如果维护者看到这儿,无论他是否熟悉这块代码,他总能快速定位到他需要维护的地方

1.4.2 switch case

switch case 用于多分支情况,使用最常见的问题是每个分支 包含了太多的代码 使得代码不容易阅读.这也许初期业务较为简单,开发者把业务逻辑放到case里处理就可以了,但如果一开始不这么做,后来者会沿用这种错误习惯,在分支上增加越来越多的代码。正确的方法是一开始就case分支里只包含方法调用

switch(status){
  case START:handleTaskStart(taskId,user);break;
  case PAUSE:handlePause(taskId); break;
  ......
}

1.5 发现对象

对于程序员来说,面向对象设计技巧,相比于对象的组合,继承,多态,或者是设计模式,发现对象能力是一个更重要的OOD技能,在1.3.1,启动工作流的调用里,我们发现了对象Participant表示工作流的参与者,也发现了对象StartWorklowInfo用于代表启动工作流实例的流程定义信息,本章节介绍一些发现对象的例子

当项目里大量有拼接的String和Map的时候,意味着应该用对象来代替

1.3.1 不要用String

在1.3.2提到导入Excel,在我实际项目里,曾经是这个样子

public void parse(Sheet sheet,StringBuilder error){
  User user = readUserInfo(sheet,error);
  List<Order> orders = readUserOrderInfo(sheet,error);
  UserCredit credit = readUserCreditInfo(sheet,error);
}

之所以提供一个StringBuilder 参数,因为需求是如果解析出错,需要显示出错的的位置,项目开发人员因此将错误信息拼接成字符串,最后返回给前端。

如果审查其代码,你会发现该解析方法方法里有数十个类似如下代码

error.append(""+line+""+col+"列错"+"messsage").append("\n");

这两段代码的阅读者困惑之处就是error作为一个StringBuilder,不能说明如何处理解析错误,阅读者不得不看清楚具体实现才恍然大悟--原来我的前任用StringBuilder是想这么干。另外一个困惑之处就是在解析excel的时候,就已经写死了错误输出的样子,如果想更改,就需要改每一处地方 ,我们知道业务的excel解析,几百行代码算是少的了。要阅读者几百行代码重构对后来者并非易事。

有什么模式或者设计原则能解决这个吗?

我想说的是,并没有模式和设计原则能解决,开发者缺少的仅仅是发现和归纳对象的能力,设计模式是锦上添花,对于excel解析的错误信息,实际上就应该定义一个”错误信息“这样的对象。比如

public class ExcelParseError{
    public void addError(ParseErrorInfo info ){}
    public void addSimpleError(String line,String col,String message ){}
    public List<ParseErrorInfo> getMessages(){}
    public String toString(){
        .....
    }
}
因此,excel解析最后是这个样子
public void parse(Sheet sheet,ExcelParseError error){
    User user = readUserInfo(sheet,error);
    List<Order> orders = readUserOrderInfo(sheet,error);
    UserCredit credit = readUserCreditInfo(sheet,error);
    
}

处理解析错误的代码则变成如下

error.addSimpleError(line,col,message);

在很多项目里,都会出现试图用String来代表对象,本书第一章也有一个使用省+地区的字符串来代表一个对象的问题。因此,我们需要避免用String来表示对象。难以维护和重构,性能也是糟糕的。

一个负面例子,当时系统配置通过一个字符串传入,程序员通过如下代码实现其逻辑

if(config.isopen()){
}

if(config.charAt(7)=='E'||config.charAt(7)=='X'){
}

这种代码也同样难以看到和维护,那怎么做更好呢,其答案还是面向对象,可以定义一个Config对象,这样,任何人都能看懂代码,都能随时维护代码

public class Config{
        String str;
  public Config(String str){
    this.str = str;
  }
  public boolean isOpen(){
          return config.charAt(3)=='D';
  }
  public boolean isApppend(){
          config.charAt(7)=='E'||config.charAt(7)=='X'
  }
}

1.3.2 不要用数组,Map和JSONObject

当程序中出现String 参数,数组参数,以及Map,JSONObject的时候,已经在提醒我们是遗漏了系统的对象。 这三个类型参数当然非常灵活,能容纳下任何数据结构,但有可能遗漏了系统隐含的对象。尤其是数组和Map。如下例子

Object[] rets = call();
boolean  success = (Boolean)rets[0];
String msg = (String)rets[1];

就没有采用对象定义返回结果好

CallResult rets = call();
boolean  success = rets.isSuccess();
String msg =  rets.getMessage();

如果CallResult包含了某个返回值,那么,将CallResult定义成泛型就更加容易阅读,比如返回CallResult

public CallResult  getUser();
//更好的方式
public CallResult<User>  getUser()

关于这一点,我们已经在1.2.1 方法签名中已经出现过类似的例子了

同样,使用Map来表示对象也是非常糟糕的,代码阅读者根本不知道Map里有多少对象的属性,必须阅读完所有代码才知道如何使用Map,如下代码,使用Map表示user对象,在我刚入行的2001年前,Map还是被很多开发者推崇,认为“一个Map走天下",但这实际上是“潇潇洒洒走自己的路,让维护者无路可走“

Map user = new HashMap();
user.put("id",1);
user.put("name","badcode");
user.put("gender",true);

当代码维护者在阅读代码,想知道user对象的的“gender“ 属性是在什么地方设置的,他无法用IDE的 查找引用,他只能搜索整个工程代码,期望能定位到。更糟糕的是,如果需要重构gender从boolean类型变成int类型,比如用boolean值得true和false分别表示性别男和性别女,如果改为男,女和未知三个值,需要更改为int类型,他也不得不小心谨慎的找遍每一处代码。如果一旦漏掉,可能损失巨大。

参考

知行合一