工程师的自我修养

​ 新年伊始,回顾之前看了一些文章和课程,有关于代码编程风格,一直都想做个简单的总结。同时结合工作以来中code review,一些常见问题进行分析,补充。聊聊作为一名工程师,如何写出稍微不那么难看的代码的一些方法和看法,欢迎小伙伴们一起讨论补充。

​ 目录如下

  • 好代码的定义
  • 坏味道以及如何改进
  • 总结

好代码的定义

​ 关于好代码的定义,各路大神都给出了自己的定义和见解

  • 整洁的代码如同优美的散文。—— Grady Booch
  • 任何一个傻瓜都能写出计算机可以理解的代码。唯有写出人类容易理解的代码,才是优秀的程序员。—— Martin Fowler

​ 相信大家都有看过好的代码,这些代码可能有如下优点,优雅,简单,工整,整洁。这里可能有些小伙伴会想,代码写的好看和不好看有什么用,还不是一样的运行。那么我们为什么要写好代码呢?主要有以下两个

面向开发者,便于后续维护

​ 是的,代码写出来第一是运行,但是其实在软件的生命周期中,运维的占比并不低。更多的时候是给人读的,可能是后续的维护人员,更多时候是一段时间后的作者本人。(所以不要给自己埋坑)。

面向机器,能够高效运行

​ 另外能运行和 能否正确运行以及高效运行也是也要求我们写出不那么差的代码。性能怎么样,怎么写出有利于cpu、内存 、IO。

以怎么写出缓存友好的代码为例,如下面的两种遍历数组方式,可能性能会存在差异。

//case 1
for(i = 0; i < N; i+=1) {
  for(j = 0; j < N; j+=1) {
        array[i][j] = 0;
  }
}
//case 2
for(i = 0; i < N; i+=1) {
  for(j = 0; j < N; j+=1) {
        array[j][i] = 0;
  }
}

​ 怎么写好代码?低耦合高内聚,单一职责,简单,这些回答可能都太抽象了,那么如果说什么样的代码是不好的,可能会简单些。

坏味道以及如何改进

​ 下面我们来讨论下,什么情况会导致我们写出具有坏味道的代码。主要从命名、结构,类、函数几个方面展开。

命名、注释不简单

命名

​ 这里的命名指的是 类、方法、变量的命名,常用的命名规则类名、变量名是一个名词,表示一个对象,而方法名则是一个动词,或者是动宾短语,表示一个动作。一个命名最好能表达尽可能多的业务含义,例如作为变量,不需要看上下文就能知道作用。如果更严格的要求自己的话,就是能够自解释,不需要通过注释的来额外补充。举个例子,随便打开一个项目全局搜下代码关键字list,map,可能会发现一堆。挑一个来看下

public List<ValidateRecord> getList(String id){
  List<Record> list = recordDao.queryList(id);
  
  List<ValidateRecord> list1 = Lists.newArrayList();
  for(Record record:list){
    if(record.getStatus().equals("1")){
      //模拟业务逻辑
      list1.add(doSomething(record));
    }
  }
  return list1;
}

​ 上面我们假设整体逻辑没问题,能够正常运行,但是当作为一个阅读者去看这段代码的时候,可能会有如下几个疑问点

  • id是什么,我们需要点击下该方法查询下该方法的调用处入口,是什么id,staffId还是record相关的id
  • 是list是什么意思,代表了什么,我们需要点击queryList方法去查看具体获取了什么
  • list1又是什么,返回去怎么使用getList
  • record.getStatus()==’1’,1是代表什么意思

​ 这里的问题是命名过于宽泛,不能精准描述,这是很多代码在命名上存在的严重问题,也是代码难以理解的根源所在。我们可以给泛化的名字附上业务含义, 来看看我们优化后的展示。

public List<ValidateRecord> getValidateRecordList(String staffId){
  List<Record> staffRecordList = recordDao.queryAllStaffRecordList(staffId);
  
  List<ValidateRecord> validateRecordList = Lists.newArrayList();
  for(Record record:staffRecordList){
    if(record.isValidated()){
      //模拟业务逻辑
      validateRecordList.add(toValidateRecord(record));
    }
  }
  return validateRecordList;
}

​ 经过我们简单的改造后的代码是不是已经比较顺眼了,我们可以大致看出,这段代码的逻辑是通过员工id获取Record列表,再过滤出需要处理的有效Record进行处理转换。当然这里还有优化空间(命名上例如list,在实际的代码中,技术名词的出现,往往就代表着它缺少了一个应有的模型),我们下面再展开。命名的选择也不是不变的,一旦发现有更好的,可以持续优化。在开发中要警惕以下泛用的词语(data、info、flag、process、handle、build、maintain、manage、modify)

​ 除了泛化,用技术命名,同时还存在另一种情况就是,在一些命名上可能会出现两个词都可以表达意思,会导致代码中不一致的,产生混乱。譬如mobile和phone, 都可以用来表示手机,这就需要团队在共同维护一份语言。也就是DDD中提到的统一语言(DDL),统一词汇表。

​ 简单总结下关于命名的要求,好的命名要体现出这段代码在做的事情,而无需展开代码了解其中的细节,这是最低的要求。再进一步,好的命名要准确地体现意图,而不是实现细节。更高的要求是,用业务语言写代码。

注释

​ 关于注释文档这个,这里也提一下,不是说注释不重要或者多余,而是要根据情况讨论。我认为以下情况是必须要有文档注释补充的。

  • 接口协议字段含义,http接口,服务接口协议
  • 程序中复杂逻辑、流程
  • 技术债或者业务的特殊逻辑,防止后人踩坑,给予提醒。(当然如果有坑尽快解决)

​ 当然javadoc中有许多有用的语法,注解我们也可以使用,常见的有

  • 某个类已经过期或者准备废弃可以使用@Deprecated标注,告诉其他小伙伴不要使用,当然同时如果能在注释中标识出why,when就更好了(@deprecated,@since ),同时如果有替代的方法或者类也可以标注下(使用@see)。
  • 我们为某个类的字段type定义了枚举,我们也可以使用@see来标识下。
  • 在idea中会自动帮我们生成入参(@param)和出参(@Return)都有对应的,同时如果有抛出的异常(@throws)也需要标识下,怎么触发,怎么处理。
  • 以及如果有时候项目进度比较紧,某些功能、问题暂时挂起,或者有优化空间可以使用TODO,FIXME标注下
  • ….

​ 同时注释和文档如果有变更,也需要同步更改,最差的情况是注释和代码逻辑不一致。第二坏的情况是不写注释。第三是注释比较啰嗦,有和没有等于没说。所以可以看出简单的命名和注释,如果想写好也不简单。这里的最佳实践是,先竭力把代码写到不需要用注释,而把注释当作最后的选择。

结构

下面我们从结构方面来看看,先来看一张图

img

嵌套if

​ 嵌套的代码,可能看到上述的图,我们都会觉得印象深刻,但其实在代码工程中,虽然没有那么夸张,但到处有它的影子,例如我们来看下面这段代码

public void visitTreeNode() {
  ConfigTreeNode treeNode = this.queryConfigTreeNode();
  if (treeNode != null && !CollectionUtils.isEmpty(tree.getChildrenNodes())) {
    List<ConfigTreeNode> childrenNodes = treeNode.getChildrenNodes();
    for (ConfigTreeNode subNode : childrenNodes) {
      if (subNode.isValid()) {
        //do sth
        visit(subNode);
      }
    }
  }
}

​ 这段代码的大概意思是遍历树节点,对节点的子节点进行遍历。很常见的代码,代码逻辑并不复杂,上述代码只是短短的两层if一层for,

但如果逻辑在复杂一点,例如visit方法是平铺展开的,里面又嵌套了if,是不是会感觉到阅读会是种很大的负担。这在一些代码检测中是会给出提示的,圈复杂度(Cyclomatic complexity,简称 CC)。这里我们可以用一种方式来优化这种嵌套的层级,以卫语句取代嵌套的条件表达式(Replace Nested Conditional with Guard Clauses)。

​ 改进后的代码如下

public void visitTreeNode() {
  ConfigTreeNode treeNode = this.queryConfigTreeNode();
  if (treeNode == null || CollectionUtils.isEmpty(tree.getChildrenNodes())) {
          return;
  }
  List<ConfigTreeNode> childrenNodes = treeNode.getChildrenNodes();
  for (ConfigTreeNode subNode : childrenNodes) {
    visit(subNode);
  }
}
public void visit(ConfigTreeNode treeNode){
  if(!treeNode.isValid()){
    return ;
  }
  //do sth
}

​ 这里的缩减层级就只有一层,其中if (subNode.isValid()) 的判断我们放在函数visit中去处理。当代码里只有一层缩进时,代码的复杂度就大大降低了,理解成本和出现问题之后定位的成本也随之大幅度降低。

可穷尽else

​ 如果我说尽量不要使用else,是不是会打破你的认知,大多数人印象中,if 和 else都是成对出现的,那么else可以不写吗,为什么?一起来看下下面这段代码(主要为了演示if else,其他细节忽略)

public void queryProductInfoByType(CutsomerEnterpriseProduct product){
        ProductInfo productInfo;
        if(ProductTypeEnums.WYD.getCode().equals(product.getType()){
            productInfo = queryWYD(product);
        }else if(ProductTypeEnums.GHD.getCode().equals(product.getType()){
            productInfo = queryGHD(product);
        }else if(ProductTypeEnums.DHD.getCode().equals(product.getType()){
            productInfo = queryDHD(product);
        }else if(ProductTypeEnums.KCD.getCode().equals(product.getType()){
            productInfo = queryKCD(product);
        }else{
            productInfo = queryOTHER(product);
        }
}

​ 这段代码大致逻辑就是入参数的产品类型根据类型,应该也算挺常见的一段代码,但这里的问题是分支过多,每个分支都是一种分支,因为一段代码的分支过多,其复杂度就会大幅度增加,当我们需要修改的时候,需要新增一种类型的时候,我们需要小心翼翼的检查每个分支的条件,直到最后的else(这里可能只是简单的类型判断,如果逻辑稍微复杂的呢,可想象一些数值计算)。所以,就这段代码而言,如果想不使用 else,一个简单的处理手法就是让每个逻辑提前返回

public void queryProductInfoByType(CutsomerEnterpriseProduct product){
        if(ProductTypeEnums.WYD.getCode().equals(product.getType()){
            return queryWYD(product);
        } 
    if(ProductTypeEnums.GHD.getCode().equals(product.getType()){
            return queryGHD(product);
        }
    if(ProductTypeEnums.DHD.getCode().equals(product.getType()){
            return queryDHD(product);
        }
    if(ProductTypeEnums.KCD.getCode().equals(product.getType()){
            return queryKCD(product);
        }
    //case 1
    return queryOTHER(product);
        
    //case 2
    //if(ProductTypeEnums.OTHER.getCode().equals(product.getType()){
    //  return queryOTHER(product);
    //}
    //throw new BizException(RspCodeEnums.ERROR,"unkonw productType");
}

​ 可以看到我们同样使用了return的方式代替了else的分支,再来说下else的另一个坏味道,因为else它隐藏着一种不确定性,else,一般会留给别人一个疑惑。例如会有上述代码中末尾中的case1 和case2两种情况,在代码不变的情况下,功能是相同的。

​ 但当新增一种类型需要处理的时候,这里的逻辑就会有不一致的情况了。case1的情况如果不改动,处理逻辑就是当成其他处理。而case2会去检验,提前暴露错误。所以如果分支判断中,如果能够穷尽类型,就请穷尽,慎用else,避免存在二义性。

​ 当然对于这种逻辑上还比较简单的代码,这么改造还是比较容易的,而对于一些更为复杂的代码,也许就要用到多态来改进代码了(可以将分支中的操作封装到不同的处理器中,根据类型路由到对应处理器处理,做到符合开闭原则。这里涉及到设计模式的就不展开了)

复杂表达式,反逻辑

​ 再来看看if 常见的踩坑问题还有哪些,例如产品人员和你沟通,想统计员工的工作时长的数据,这样描述9点之前和18点之后的不算,如果平铺直述,直接转译为代码,那么我们可能会写出这样的代码。

public void countStaffWorkTime(StaffWorkTime staffWorkTime){
            if(!(staffWorkTime.getLoginTime()<9||staffWorkTime.getLoginTime()>18)){
            doSth(staffWorkTime);
      }
}

​ 这里的逻辑判断中用了用了或逻辑和反逻辑,这里的业务逻辑还比较简单,但如果反逻辑里面很复杂,比如再加多几个条件,这一下我们的心智负担就会很大,逐个条件看完,最后还得取反逻辑。那为什么不简单优化下

public void countStaffWorkTime(StaffWorkTime staffWorkTime){
            if(staffWorkTime.getLoginTime()>=9 && staffWorkTime.getLoginTime()<=18){
            doSth(staffWorkTime);
      }
}

​ 另一个是,条件表达式如果是复杂的,上述只有两个判断,如果有多个的情况呢?如果需求在原来基础上再加个中午12点到2点也不算。那么我们的代码就会变更为

public void countStaffWorkTime(StaffWorkTime staffWorkTime){
            if((staffWorkTime.getLoginTime()>=9 && staffWorkTime.getLoginTime()<=12)||(staffWorkTime.getLoginTime()>=14 && staffWorkTime.getLoginTime()<=18)){
            doSth(staffWorkTime);
      }
}

​ 是不是开始复杂起来了,这时候就需要将这个表达式提取出来,我们可以这样优化下。

public void countStaffWorkTime(StaffWorkTime staffWorkTime){
          boolean ifStaffWorkTime = checkIfStaffWorkTime(staffWorkTime);
            if(ifStaffWorkTime){
            doSth(staffWorkTime);
      }
}
public boolean checkIfStaffWorkTime(StaffWorkTime staffWorkTime){
      boolean ifStaffWorkTime = (staffWorkTime.getLoginTime()>=9 && staffWorkTime.getLoginTime()<=12)||(staffWorkTime.getLoginTime()>=14 && staffWorkTime.getLoginTime()<=18);
  return ifStaffWorkTime;
}

使用异常替代返回错误码

最后我们再来看下,同样关于if,else

public int deletePageObject(){
  if(deletePage() == OK){
      if(registry.deleteReference(page.getName()) == OK){
          if(configKeys.deleteKey(page.getName().getMakeKey()) == OK){
              logger.log("page deleted");
              return OK;
          }else{
              logger.log("configKey not deleted");
              return Errors.ConfigKey;
          }
      }else{
          logger.log("deleteReference from registry failed")
            return Errors.Reference;
      }
  }else{
      logger.log("delete failed")
      return Errors.Page;
  }
}

​ 上述代码的大概意思,在方法中删除page,reference ,和key,并将结果返回。

​ 针对错误码的判断会导致更深层次的嵌套结构,返回错误码就意味着要求调用者跟着处理错误,一般我们还需要将try/Catch代码块给抽离出去,另外形成方法。防止代码块过多搞乱代码结构,分不清错误处理还是正常流程。同时因为方法只做一件事,错误处理就是一件事,因此错误处理的方法不应该在做其他事,也就是如果一个方法中有try关键字,那try就是方法的开头。catch/finally代码块后面也不应该再有内容。

try{
    deletePage(page);
    registry.deleteReference(page.getName());
    configKeys.deleteKey(page.getName().getMakeKey());
}catch(Exception e){
    logger.log(e.getMessage());
}

​ 通过抛出异常的方式,将正常流程和异常流程分离开来。

调用层级

​ 依赖关系的混乱,传统MVC的三层结构在面对复杂项目时候,本来就会出现调用混乱的关系,再加上如果不遵守,直接跨层级调用,会将更加放大这个问题,例如controller层直接调用Dao层,或是A-Servier,调用B-Dao。

重复

​ 关于重复代码,这里简单补充下,我们可能很简单可以分辨出重复的代码,这些代码的产生通常都是复制粘贴,我们应对这种情况的

通常的做法是,先提取出函数,然后,在需要的地方调用这个函数。其实,复制粘贴的重复代码是相对容易发现的,但有一些代码是有类

似的结构,这也是重复代码,但可能日常开发中会对这类坏味道忽略。例如看下面代码实例

@Task
public void sendBook() {
  try {
    this.service.sendBook();
  } catch (Throwable t) {
    this.notification.send(new SendFailure(t)));
    throw t;
  }
}

@Task
public void sendChapter() {
  try {
    this.service.sendChapter();
  } catch (Throwable t) {
    this.notification.send(new SendFailure(t)));
    throw t;
  }
}

@Task
public void startTranslation() {
  try {
    this.service.startTranslation();
  } catch (Throwable t) {
    this.notification.send(new SendFailure(t)));
    throw t;
  }
}

​ 这三段函数业务的背景是:一个系统要把作品的相关信息发送给翻译引擎。所以,结合着代码,我们就不难理解它们的含义,sendBook 是把作品信息发出去,sendChapter 就是把章节发送出去,而 startTranslation 则是启动翻译。

这三个函数可能在许多人看来已经写得很简洁了,但是,这段代码的结构上却是有重复的,请把注意力放到 catch 语句里。

​ 我们可以看到,虽然这三个函数调用的业务代码不同,但它们的结构是一致的,其基本流程可以理解为:

  • 调用业务函数;
  • 如果出错,发通知。

​ 我们可以优化为

private void executeTask(final Runnable runnable) {
  try {
    runnable.run();
  } catch (Throwable t) {
    this.notification.send(new SendFailure(t)));
    throw t;
  }
}

@Task
public void sendBook() {
  executeTask(this.service::sendBook);
}

@Task
public void sendChapter() {
  executeTask(this.service::sendChapter);
}

@Task
public void startTranslation() {
  executeTask(this.service::startTranslation);
}

​ 这样的好处是如果需要一些通用结构的调整,如运行前日志,异常处理变更,都可以只在executeTask中修改,把变化控制在一个地方。这个例子并不复杂,关键点在于,能不能发现结构上的重复。因为相比于直接复制的代码,结构上的重复看上去会有一些迷惑性。

​ 判断一个类的职责是否单一,有简单的几个方法

  • 多人协作开发时候,经常冲突的类
  • 使用构造函数的注入方式,当你的构造函数的列表很长的时候,你就会去思考,该类的职责是否太重了,是否需要将其中依赖的服务进行合并封装。
  • 这个类的行数

//todo

Primitive Obsession(基本类型偏执)https://mp.weixin.qq.com/s/wqDReesPh4WTSmzBLkcDKA

函数

​ 函数是我们代码中除了变量外最常见的家伙,那么和函数打交道有什么需要注意的地方吗,先来看下面这段代码(太长可以直接跳过)


public void executeTask() {
    ObjectMapper mapper = new ObjectMapper();
    CloseableHttpClient client = HttpClients.createDefault();
    List<Chapter> chapters = this.chapterService.getUntranslatedChapters();
    for (Chapter chapter : chapters) {
        // Send Chapter
        SendChapterRequest sendChapterRequest = new SendChapterRequest();
        sendChapterRequest.setTitle(chapter.getTitle());
        sendChapterRequest.setContent(chapter.getContent());

        HttpPost sendChapterPost = new HttpPost(sendChapterUrl);
        CloseableHttpResponse sendChapterHttpResponse = null;
        String chapterId = null;
        try {
            String sendChapterRequestText = mapper.writeValueAsString(sendChapterRequest);
            sendChapterPost.setEntity(new StringEntity(sendChapterRequestText));
            sendChapterHttpResponse = client.execute(sendChapterPost);
            HttpEntity sendChapterEntity = sendChapterPost.getEntity();
            SendChapterResponse sendChapterResponse = mapper.readValue(sendChapterEntity.getContent(), SendChapterResponse.class);
            chapterId = sendChapterResponse.getChapterId();
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            try {
                if (sendChapterHttpResponse != null) {
                    sendChapterHttpResponse.close();
                }
            } catch (IOException e) {
                // ignore
            }
        }


        // Translate Chapter
        HttpPost translateChapterPost = new HttpPost(translateChapterUrl);
        CloseableHttpResponse translateChapterHttpResponse = null;
        try {
            TranslateChapterRequest translateChapterRequest = new TranslateChapterRequest();
            translateChapterRequest.setChapterId(chapterId);
            String translateChapterRequestText = mapper.writeValueAsString(translateChapterRequest);
            translateChapterPost.setEntity(new StringEntity(translateChapterRequestText));
            translateChapterHttpResponse = client.execute(translateChapterPost);
            HttpEntity translateChapterEntity = translateChapterHttpResponse.getEntity();
            TranslateChapterResponse translateChapterResponse = mapper.readValue(translateChapterEntity.getContent(), TranslateChapterResponse.class);
            if (!translateChapterResponse.isSuccess()) {
                logger.warn("Fail to start translate: {}", chapterId);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            if (translateChapterHttpResponse != null) {
                try {
                    translateChapterHttpResponse.close();
                } catch (IOException e) {
                    // ignore
                }
            }
        }
    }

​ 这段代码的逻辑是 1.把没有翻译过的章节发到翻译引擎,2.启动翻译过程。在这里翻译引擎是另外一个服务,需要通过 HTTP 的形式向它发送请求。

​ 这里它的变量命名,结构都没什么问题,那么是不是就没什么问题了呢,其实不然,它也是有坏味道的。

​ 那么它存在什么问题呢?第一印象是不是有觉得这个函数太长了,对,这个就是坏味道的一种,长函数。 它遍布在我们的代码中,不知道大家在实际工作中遇到最长的函数有多长,几百上千行的函数应该是有看过的吧。

​ 那长函数会有什么问题呢?我觉得最大的危害是加大了后续维护的人的工作量,无论是去被迫理解一个长函数的含义,还是要在一个长函数中,小心翼翼地找出需要的逻辑,按照需求微调一下,都需要完整的读完整个函数,陷入不同的细节之中。那么如果我们要解决长函数这个问题,首先要知道它是怎么产生的。

主要有如下原因

  • 平铺直述:把多个业务处理流程放在一个函数里实现;把不同层面的细节放到一个函数里实现。
  • 需求迭代:一次加一点。

既然这里是因为把不同细节,层级都揉在一起,那么我们只要分离开就可以了,下面我们来看下是怎么解决

处理流程分离

​ 首先是不同业务处理流程的分离,这里发送章节和启动翻译是两个过程,显然,这是可以放到两个不同的函数中去实现的,不同函数只处理一个事情(单一职责无处不在),所以,我们只要做一下提取函数,就可以把这个看似庞大的函数拆开,而拆出来的几个函数规模都会小很多,像下面这样:

public void executeTask() { 
      ObjectMapper mapper = new ObjectMapper(); 
      CloseableHttpClient client = HttpClients.createDefault(); 
      List chapters = this.chapterService.getUntranslatedChapters(); 
      for (Chapter chapter : chapters) { 
          String chapterId = sendChapter(mapper, client, chapter); 
          translateChapter(mapper, client, chapterId); 
    }
}

​ 这样优化后是不是就很清晰了,如果维护者需要修改的是发送章节部分的内容。再来看下不同细节的分离。

抽象层级分离

我们以sendChapter为例子来看看做了什么优化

private String sendChapter(final ObjectMapper mapper,
                           final CloseableHttpClient client,
                           final Chapter chapter) {
    SendChapterRequest request = asSendChapterRequest(chapter);

    CloseableHttpResponse response = null;
    String chapterId = null;
    try {
        HttpPost post = sendChapterRequest(mapper, request);
        response = client.execute(post);
        chapterId = asChapterId(mapper, post);
    } catch (IOException e) {
        throw new RuntimeException(e);
    } finally {
        try {
            if (response != null) {
                response.close();
            }
        } catch (IOException e) {
            // ignore
        }
    }
    return chapterId;
}

private SendChapterRequest asSendChapterRequest(final Chapter chapter) {
    SendChapterRequest request = new SendChapterRequest();
    request.setTitle(chapter.getTitle());
    request.setContent(chapter.getContent());
    return request
}

​ 我们以 asSendChapterRequest 这个方法来说,我们可以很清楚的看到,SendChapterRequest的构建在函数中和发送相关的逻辑完全就是不同层级的,一个是构建的细节,一个是发送的动作。我们完全可以把构建请求的细节封装,抽取出来,不对外暴露相关细节,维护者在阅读代码的时候不会一头扎进细节,而是层层递进,就会清晰很多。这便是单一抽象层次原则(SLAP)。

​ 关于抽象层次这个再举个简单的例子说明下,比如有这样一个问题,把一头大象放进冰箱需要几步

//把大象装进冰箱
public void frozenElephant(){
  //1.捕获大象
  //2.运输大象
  //3.打开冰箱
  //4.放入大象
  //5.关闭冰箱
}

​ 我们可能会写出这样的代码,那么存在什么问题呢?显然这里的 步骤1、 2 的层级和3、4 、5的层级抽象层次是不一致的。步骤3、4 、5在这里暴露的细节,都是把大象放进冰箱的操作步骤,属于低层次的抽象。所以这里应该把3、4 、5合并聚合成一个步骤,统一层级,重构后的代码如下:

public void frozenElephant(){
    //1. 捕捉大象
    catchElephant();
    //2. 运输大象
    transportElephant();
    //3. 将大象放入冰箱
    putElephantInRefrigerator();
}
public void putElephantInRefrigerator(){
    //打开冰箱
    //放入大象
    //关闭冰箱
}

使用语言特性、第三方库

​ 因为语言本身的演化,每个阶段的正确写法(或者说更好的写法)是不一样的,就以JDK为例,举几个改变较大的特征

  • 在Java5版本,forEach语句改变了我们写遍历集合的方式,不用再通过下标的方式去获取对象,没有了越界的烦恼
  • 在Java7版本,新增AutoCloseable接口,不用再写重复复杂臃肿的try catch finally对资源的关闭
  • 在Java8版本,支持了lambda表达式,函数式编程支持,Optional对空指针的处理
  • 在Java9版本,参考Google Guava的优秀实现对集合类进行增强
  • 在Java10版本,类似JS可以通过var来修饰局部变量
  • 在Java11版本,Http Client重写,支持HTTP/1.1和HTTP/2 ,也支持 websockets,对Stream、Optional、集合API进行增强
  • 再到Java17版本,虽然JDK17也是一个LTS版本,但是并没有像JDK8和JDK11一样引入比较突出的特性,主要是对前几个版本的整合和完善。

​ 这里还是通过一段代码开头,来看看具体

List<Permission> permissions = new ArrayList<>();
permissions.add(Permission.BOOK_READ);
permissions.add(Permission.BOOK_WRITE);
//do sth use permissions

private static Map<Locale, String> codeMapping = new HashMap<>();
codeMapping.put(LOCALE.ENGLISH, "EN");
codeMapping.put(LOCALE.CHINESE, "CH");
//do sth use codeMapping

​ 这种代码是非常常见的,声明一个集合,然后,调用一堆添加的方法,将所需的对象添加进去。但这里声明和初始化是割裂开来的,并不是一个完整的对象,所以有更好的写法,在Java9中

List<Permission> permissions = List.of(
  Permission.BOOK_READ, 
  Permission.BOOK_WRITE
);
//do sth use permissions

​ 如果用的Java 9 之后的版本,使用 Guava(Google 提供的一个 Java 库)也是可以做成类似的效果:

List<Permission> permissions = ImmutableList.of(
  Permission.BOOK_READ, 
  Permission.BOOK_WRITE
);

语言特性

​ 鉴于我们现在大多数在用的java8的版本,主要来说下java8中的一些优化,主要是对Optional,函数式编程两个进行讨论

Optional

​ 首先来看看出问题最多的,NullPointerException,它经常出现在各种报错的上下文中,而为了避免这种错误我们会写出这样的代码

假设有这样一个例子

String name = book.getAuthor().getName();

这种链式调用,我们都要当心,因为其中getAuthor如果为null,那就会导致空指针异常了。所以更严谨的写法应该是这样

Author author = book.getAuthor();
String name = (author == null) ? null : author.getName();

但是这种严格校验的写法,很大程度依赖开发者的自觉,以及是否有意识到,需要小心翼翼的查看是否有可能返回null值。对于这个如此常见的问题,Java 8 中已经给出了一个解决方案,它就是 Optional

Optional 提供了一个对象容器,你需要从中“取出(get)”你所需要的对象,但在取出之前,你需要判断一下这个对象容器中是否真的存在一个对象,我们用Optional来优化上述代码,应该是这样的

Optional author = book.getAuthor();
String name = author.map(Author::getName).orElse(null);

​ 这种做法和之前做法的最大差别在于,你不会忘掉判断对象是否存在的过程,因为你需要从 Optional 这个对象容器中取出存在里面的对象。这里Optional提醒了后续使用的人,这里有可能返回null,记得处理。所以在项目中,可以通过约定(约定大于配置),所有可能为 null 的返回值,都要返回 Optional,以此减少犯错的几率。

​ 当然为了避免null,我们在返回的时候尽量返回一个初始化的空值,例如StringUtils.EMPTY , Lists.newArrayList()

函数式编程

​ 我们先来看下前面”命名”那一段的代码,我们说过有优化空间

public List<ValidateRecord> getValidateRecordList(String staffId){
  List<Record> staffRecordList = recordDao.queryAllStaffRecordList(staffId);
  
  List<ValidateRecord> validateRecordList = Lists.newArrayList();
  for(Record record:staffRecordList){
    if(RecordStatusEnums.VALID.getCode().equals(record.getStatus())){
      //模拟业务逻辑
      validateRecordList.add(toValidateRecord(record));
    }
  }
  return validateRecordList;
}

如果按照 Java 8 之前的版本理解,这段代码是一段很正常的代码。当 Java 的时代进入到 8 之后,这段代码就成了有坏味道的代码。我们用java8的语法来重写下这段代码。

public List<ValidateRecord> getValidateRecordList(String staffId){
  List<Record> staffRecordList = recordDao.queryAllStaffRecordList(staffId);
  
  List<ValidateRecord> validateRecordList = staffRecordList.stream()
      .filter(Record::isValidated)
      .map(toValidateRecord(record))
      .collect(Collectors.toList());
  
  //在jdk16中可以这样写
  // List<ValidateRecord> validateRecordList = staffRecordList.stream()
  //	.filter(Record::isValidated)
  //	.map(toValidateRecord(record))
  //	.toList();
  
  return validateRecordList;
}

​ 或许有人会说,这段代码看着还不如我原来的循环语句简单。不过,你要知道,两种写法根本的差别是侧重点不同,循环语句是在描述实现细节,而列表转换的写法是在描述做什么,二者的抽象层次不同。

​ 你看在java5的时候我们刚刚从下标的访问中走出来使用循环遍历的方式,到java8循环遍历的方式已经不是最优的选择了。甚至在java16中已经可以用toList();这种写法,这就是语言特性带来的变化。

​ 当然除了没用上java8特性的这种过时写法的,也有使用函数式编程,但没用对的,存在问题的写法。

函数式编程常见的问题

​ 自从 Java 里引入了 lambda,因为写起来实在是太容易了,很多人就直接在列表转换过程中写 lambda。lambda 本身相当于一个匿名函数,所以,很多人在写函数中犯的错误在 lambda 里也一样出现了,最典型的当然就是长函数。在map的转换中会犯的错误是直接写对象的转换逻辑,而不是将转换过程封装到toValidateRecord中,这里应该有个共识,最好的 lambda 应该只有一行代码。

​ 另一个常见问题是将一个集合转化为一个映射的时候,需要处理同key的情况

List<Record> records  =  Lists.newArrayList();
Map<String,String> valMapping  = list.stream().collect(
  Collectors.toMap(Record::getId,Record::getVal,(v1, v2)->v1));

​ 注意其中(v1, v2)->v1部分不能缺少,如果出现同key情况会抛出异常。

​ 最后再补充下常用高频的函数式编程最重要的其实大部分就是列表转换,而最核心的列表转换就是这几个

  • map
    • Map:通常用于
    • FlatMap :flatMap的可以处理更深层次的数据,入参为多个list,结果可以返回为一个list
  • filter
  • reduce
    • Reduce:常用于数值计算相关计算
    • Collect:各种集合的转换(toList()),另外groupingBy,joining都是比较实用的(更多可以看Collectors中的实现)
    • distinct:可以用于去重

第三方库

​ 关于第三方库,我们有时候会自己实现,写出很臃肿的代码,其实已经有现成,更优雅,健壮的代码实现了。警惕重复发明轮子,

常用的有

  • Apache 相关的Commons
  • Google Collections
  • 阿里的easyExcel

例如当我们在对数据库进行批量操作的时候,为了分散处理负载,会对一个数组进行切片,我们可以用

List<Record> records = queryRecords();
List<List<Record>> partitionRecords = Lists.partition(records,10);

总结

​ 我们再来简单回顾下,我们主要讨论了什么是好的代码,为什么要写好的代码,接着又讨论了什么在阻碍我们写出好的代码,怎么解决这些坏味道。

​ 当然,怎么去写出更好的代码,这个过程应该是不断精进的,写代码是一门手艺,需要不断地打磨。

​ 代码从写出来开始就在不断的腐烂,我们能做的就是减少腐烂带来的影响,并及时的优化。在程序员修炼之道书中提到的“破窗户理论”,即一个房子如果窗户破了,没有人去修补,隔不久其他窗户也会破,一面墙涂鸦如果没有清理,很快就会涂满整面墙。同理,代码中的坏味道如果不及时清理,后来者效仿很快就会充满整个项目。

​ 与之相对的是“红地毯理论”,说是一个拥有完美的房子,里面都是无价古董和收藏品的富翁家里着火了,消防人员冲到房间门口却停住了,他们不想弄脏地毯。如果项目中的代码都十分漂亮—编写整洁、设计良好,并且很优雅,你就很可能格外注意不去把它弄脏。

​ 那么什么时候该修改呢?很喜欢的一句话是

让营地比你来时更干净。—— 童子军军规

​ 就是我们在做为后来开发维护的人,面对坏味道的代码,只要是经过你看到了,或者是交给你来维护了,都应该及时改进,每一次提交代码都比之前的代码干净,那么这个代码就会越来越干净。

​ 作为个人平时的开发习惯是,只要有时间通常会review自己的代码,第一次实现都是会比较粗糙,通常需要修改几遍才能达到自己比较满意的状态,当然每次改进都要配合单元测试使用效果更佳。

​ 最后,能看到这里的各位,相信对写出好的代码都是有追求,送给自己和各位一句话当共勉(2023新年快乐)

取乎其上,得乎其中;取乎其中,得乎其下;取乎其下,则无所得矣

参考:


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 951488791@qq.com

文章标题:工程师的自我修养

字数:8.4k

本文作者:zhengyumin

发布时间:2023-01-01, 19:52:34

最后更新:2023-01-20, 10:55:59

原始链接:http://zyumin.github.io/2023/01/01/%E5%B7%A5%E7%A8%8B%E5%B8%88%E7%9A%84%E8%87%AA%E6%88%91%E4%BF%AE%E5%85%BB/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。