一文详解|如何写出优雅的代码

 

一、好代码的定义

谈到好代码,我的第一想法就是优雅,那我们如何该写出好的代码,让阅读的人感受到优雅呢?首先简单探讨一下优雅代码的定义。

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

  • 整洁的代码如同优美的散文。—— Grady Booch

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

首先要达成一致,我们写的代码,除了用于机器执行产生我们预期的效果之外,更多的时候是给人读的,可能是后续的维护人员,更多时候是一段时间后的作者本人,因此优雅面向不同的用户有两层含义的解读。

1.对人而言,代码的整洁,清晰的逻辑;

2.对机器而言,准确性、执行性能、异常处理机制等;

这次,我们就来聊一聊,什么代码是优雅的代码,怎样写出优雅的代码。
二、代码整洁

1. 有意义的命名

简单说就是类、方法、变量的命名要名副其实,要能描述清晰自己的职责。一个好的命名能输出更多的信息,它会告诉你,它为什么存在,它是做什么事的,应该怎么使用。一个简单的衡量标准是,如果命名完仍需要注释来补充语义,那就不是名副其实;
选个好名字要花时间,但省下的时间的时间比花掉的多,一旦发现有更好的名称,就换掉旧的。
举个例子:

public List<int[]> getItem() {
  List<int[]> list1 = new ArrayList<int[]>();
  for (int[] x: theList)
    if (x[0] == 4)
      list1.add(x);
  return list1;
}

整体逻辑没啥问题,读完之后,就有很多问题在脑海中产生

1. theList中是存储什么东西的数组?

2. theList第一个值是做什么的?

3. 值4的意义又是什么?

4. 返回的列表该怎么使用?

代码应该体现所处的情景,比方说上述的代码所处情景是我们正在开发一种扫雷游戏,盘面是名为theList的单元格列表,那就将其名称改为gameBoard。
盘面上每个单元格都用一个简单数组表示。零下标条目是一种状态值,而这种状态值为4代表“已标记”。只要改为有意义的名称,代码就得到了改进。
更进一步,不用int数组来表示单元格,而是另写一个类。该类包括一个名副其实的函数(称为isFlagged),从而掩盖住哪个魔术数4,得到新的函数版本。

public List<Cell> getFlaggedCells() {
  List<Cell> flaggedCells = new ArrayList<Cell>();
  for (Cell cell : gameBoard)
    if (cell.isFlagged())
     flaggedCells.add(cell);
  return flaggedCells;
}

 

2. 优雅的注释

实际上,只要我们的代码有足够的表达力,能清晰的通过命名来做到名副其实,就不太需要注释,或者根本不需要;注释的存在往往是弥补我们无法用代码清晰表达意图的情况。可以想象一下,每次自己发现需要写注释的时候,是什么心态,担心此处代码明天自己看不懂或者别人看不懂,那有没有考虑用更好的语义的代码来替代。
但尽管有注释,也有好有坏,有时候注释也会撒谎,通常注释存在的越久,就离其描述的代码越远,变得越来越离谱;因为代码在变动在迭代,在注释和代码间可能会插入新的代码,旧代码我们通常copy来copy去,分离又重组,但注释一般不会修改,就会造成注释和描述的代码分离,对阅读者造成更大的迷惑。
我们在需要写注释的时候就要告诉自己,能不能用代码来进行描述。以下是一些坏代码的注释bad case。
1. 一些被注释掉的代码
//something code
//something code

2. 位置标记
//begin
someting code;
//end

3. 签名标记
/** add by xiaoli*/

4. 非公用方法的javadoc
/**
* doSomething
*/
private void doSomething(){
}

5. 日志式注释
/** add xx
* update sometimes
* update sometimes
* update sometimes
*/

6. 误导性注释
//此处怎样xx

3. 优雅的函数

3.1 务必要短小

方法应该有多短小?没有明确约束,idea也不会限制你,但通常我们的方法不该长于一屏,至少多于一屏或者横向外溢到屏幕以外最直观的就会造成可读性体验差,读了下面忘记上面,左右拖拽等。对大多数笔记本来说一屏大概就30行左右。短小精简的方法要比30行短很多,比如:
public String renderPageWithSetupAndTeardowns(Page page, boolean isSuite) throws Exception{
  if(isTestPage(page)){
       includeSetupAndTeardownPages(page,isSuite);
    }
    return page.getHtml();
}
if语句、else语句、while语句等,其中的代码应该只有一行。
改行通常是一个调用语句,这样不但能保持短小,还可以给调用方法命名一个有说明性的名字,进一步增加代码的可读性

3.2 只做一件事

一事精,便可动人。这个普世法则甚至适用于各种场合。
像设计原则的单一职责模式,让类只有一个职责。如果一个类有一个以上的职责,这些职责就耦合在了一起。这会导致逻辑混乱,设计耦合。当一个职责发生变化时,可能会影响其它的职责。
另外,多个职责耦合在一起,会影响复用性。针对方法而言更是如此。方法作为程序的原子单元,保持单一会有效提升复用性。 
那怎么判断一个方法是否只做了一件事。最简单的规则就是看看该方法是否能在拆出一个方法,且拆出去的方法是不同于该方法的诠释和实现。但是要注意同一方法的逻辑层级务必要一致。

3.3 抽象层级一致

抽象层级一致也是对方法只做一件事的更高要求,抽象层级不一致的代码一定是做了多件事。
我们读代码通常是自顶向下阅读,我们想让每个方法后面都跟着位于下一层级的方法,这样我们可以依着抽象层级向下阅读了。我们也需要这样阅读代码,先有整体在展示细节,这种叫向下规则。这也是保持方法短小,确保只做一件事的诀窍。
一旦方法中混杂不同的抽象层级,会让人很迷惑,因为没办法这个方法中判断某个表达式是基础概念还是细节,更恶劣的是,一旦细节与基础概念混杂,更多的细节就会纠缠不清,举例子我们想写一个冰冻大象的需求:

//把大象装进冰箱
public void frozenElephant(){
    //1. 捕捉大象
    //2. 运输大象
    //3. 打开冰箱
    //4. 放入大象
    //5. 关闭冰箱
}
这个例子的1.2两步就不是一个层级的逻辑,是属于更高层级的抽象。3.4.5都是将大象放入冰箱的步骤,属于低层级的抽象。可以将代码拆分为如下实现,将高抽象层级的代码聚合提取出来,细节在分别单独实现,如下:
public void frozenElephant(){
    //1. 捕捉大象
    catchElephant();
    //2. 运输大象
    transportElephant();
    //将大象放入冰箱
    putElephantInRefrigerator();
}
public void catchElephant(){
}
public void transportElephant(){
}
public void putElephantInRefrigerator(){
    //打开冰箱
    //放入大象
    //关闭冰箱
}
3.4 使用异常替代返回错误码
针对错误码的判断会导致更深层次的嵌套结构,返回错误码就意味着要求调用者跟着处理错误,如下:
if(deletePage() == OK){
    if(registry.deleteReference(page.name) == OK){
        if(configKeys.deleteKey(page.name.makeKey) == OK){
            logger.log("page deleted")
        }else{
            logger.log("configKey not deleted")
        }
    }else{
        logger.log("deleteReference from registry failed")
    }
}else{
    logger.log("delete failed")
    return Error;
}
一般我们还需要将try/Catch代码块给抽离出去,另外形成方法。防止代码块过多搞乱代码结构,分不清错误处理还是正常流程。同时因为方法只做一件事,错误处理就是一件事,因此错误处理的方法不应该在做其他事,也就是如果一个方法中有try关键字,那try就是方法的开头。catch/finally代码块后面也不应该再有内容,如下:

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

3.5 使用第三方库

比如Lombok组件通过注解的方式,在编译时自动为属性生成构造器、getter/setter、equals、hashcode、toString方法 举例如下:
比如Apache Commons系列组件给我们提供了关于字符串、集合、IO操作等工具方法。这些组件是个大宝库,提供了不少轮子:
beanUtils
JavaBean进行各种操作,克隆对象、属性等等
codec
处理常用的编码方法的工具类包,例如DES、SHA1、MD5、Base64等.
collections
java集合框架操作
configuration
java应用程序的配置管理类库
io
io工具的封装
lang
Java基本对象方法的工具类包 如StringUtils、ArrayUtils等等.
logging
提供的日志接口
net
提供了客户端和服务器端的数据验证框架

三、代码重构

重构是对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
在重构之前一定要知道,一旦开始对类和方法进行重构,就需要事前有完备的单元测试用例来保障重构的准确性,每次重构之后都要去执行对应的单元测试用例,验证重构的正确性!

 

1. 识别代码的坏味道

 

1.1 重复的代码

如果在一个以上的地点看到相同的代码结构,可以肯定的是,想办法抽线出来合而为一,代码会变得更好。一般包含几个点的重复:

1.最单纯的重复代码就是“同一个类的两个函数含有相同的表达式”。这时候需要做的就是采用提炼函数提炼出重复的代码,然后让这两个地点都调用被提炼出来的那一段代码

2.如果重复代码只是相似而不是完全相同,需要先尝试用移动语句重组代码顺序,把相似的部分放在一起以便提炼。

3.如果重复的代码段位于同一个超类的不同子类中,可以使用函数上移来避免在两个子类之间互相调用。

1.2 过长的函数

遵循这样一条原则:每当感觉需要以注释来说明点什么的时候,就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名,可以对一组甚至短短一行代码做这件事。哪怕替换后的函数调用动作比函数自身还长,只要函数名称能够解释其用途,就要毫不犹豫地那样做,关键不在于函数的长度,而在于函数“做什么”和“如何做”之间的语义距离。

1.百分之九十九的场合里,要把函数变短,只需使用提炼函数。找到函数中适合集中在一起的部分,将它们提炼出来形成一个新函数。

2.如果函数内有大量的参数和临时变量,最终就会把许多参数传递给被提炼出来的新函数,导致可读性几乎没有任何提升。此时可以经常运用以查询取代临时变量来消除这些临时元素。引入参数对象和保持对象完整则可以将过长的参数列表变得更简洁一些。

3.如果有多个switch语句基于同一个条件 进行分支选择,就应该使用以多态取代条件表达式。

1.3 数据的可变性

对数据的修改经常导致出乎意料的结果和难以发现的bug。在一处更新数据,却没有意识到软件中的另一处期望着完全不同的数据,于是出现难以预料的bug,往往比较难排查(需要排查数据流转的整体链路),这就需要一些方法用于约束对数据的更新,降低数据可变性的风险。

1.可以用封装变量来确保所有数据更新操作都通过很少几个函数来进行,使其更容易统一监控和演进

2.如果一个变量在不同时候被用于存储不同的东西, 可以使用拆分变量将其拆分为各自不同用途的变量,从而避免危险的更新操作。

3.使用移动和提炼函数尽量把逻辑从处理更新操作的代码中搬移出来,将业务处理逻辑代码与执行数据更新操作的代码分开。

1.4 模块单一职责

所谓模块化,就是力求将代码分出区域,最大化区域内部的交互、最小化跨区域的交互。但是经常出现一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于与所处模块内部的交流,这就是模块功能不单一的典型情况。

1.总看到某个函数为了计算某个值,从另一个对象那儿调用半打的取值函数。如果这个函数需要跟这些数据待在一起,那就使用移动功能把它移过去。

2.一个函数往往会用到几个模块的功能,那么它究竟该被置于何处呢?原则是:判断哪个模块拥有的此函数使用的数据最多,然后就把这个函数和那些数据摆在一起。 如果先以提炼函数将这个函数分解为数个较小的函数并分别置放于不同类中,上面的步骤就会比较容易完成。

3.Strategy模式和Visitor模式是为了对抗发散式变化,但也能解决单一职责问题,最根本的原则是:将总是一起变化的东西放在一块儿。 数据和引用这些数据的行为总是一起变化的,如果有特殊情况,我们就搬移那些行为,保持变化始终只在一地发生。

2. 函数重构的方法

 

2.1 Extract Method

提取函数
这个是最常用的操作,将大函数按模块拆分为几个小的函数,在重构时提倡将代码模块细分,因为模块越小,可重用度就越大。不要写大函数,如果你的函数过大,那么这意味着你的函数需要重构了。因为函数过大,可维护性,可理解性就会变差。并且当你实现类似功能的时候就容易产生重复代码。写代码时,最忌讳的就是代码重复。

2.2 Inline Method

内联函数
这个和Extract Method是相对的,如果重构过程中对模块进行过度拆分的话,就需要使用该方法对函数进行中和,将过度拆分的函数在组装到一起。

2.3 Replace Temp with Query

以查询取代临时变量
说白了就是将有着复杂表达式赋值的逻辑使用函数查询取代,这样一来在实现类似功能的函数时,这些复杂的临时变量就可以进行复用,从而减少代码的重复率,使用合理的命名解释复杂的表达式逻辑也增强了可读性。

2.4 Inline Temp

内联临时变量
与2.3 Replace Temp with Query相对,就不过多赘述了。

2.5 Introduce Explaining Variable

引入解释性变量
引入变量是为了解释该表达式中的一部分的功能的,目的在于让该表达式具有更好的可读性。使用Introduce Explaining Variable规则,就相当于为该表达式添加上相应的注释

2.6 Split Temporary Variable

分解临时变量
具体说来就是在一个函数中一个临时变量不能做两种事情,也就是一个临时变量不能赋上不同意义的值。如果你这么做了,那么对不起,请对该重复使用的临时变量进行分解,也就是说你需要创建一个新的临时变量来接收第二次分配给第一个临时变量的值,并为第二个临时变量命一个确切的名字。

2.7 Remove Assignments to Parameters

移除对参数的赋值
就是在函数中不要对函数参数进行赋值,当直接对函数的参数进行修改时,就应该对此重构。因为这样会使参数的原始值丢失,我们需要引入临时变量,然后对这个临时变量进行操作。

2.8 Replace Method with Method Object

以函数对象取代函数
当一个特别长的函数,而且函数中含有比较复杂的临时变量,使用上述方法不好进行重构时,就要考虑将该函数封装成一个类了。这个对应的类的对象就是函数对象。我们可以将该场函数中的参数以及临时变量转变成类的属性,函数要做的事情作为类的方法。将函数转变成函数类后,我们就可以使用上述的方法对新类中的函数进行重构了。

 

四、小结

关于代码的逻辑和重构都是很基础的东西,在写代码之前我们就要思考如何做到整洁、优雅,并一直遵循这些经验来编写代码,所谓的“代码感”就自然而然的滋养而出,
要时刻提醒自己,仅仅编写出可运行的代码是远远不够的!要以一个分享者的角度去写代码,再换位成一个阅读者的视角去审视自己的代码,如果能做了自己心里那一关,那一切就OK了!
作者|李星霖(小李)
posted @ 2023-02-16 09:49  古道轻风  阅读(451)  评论(0编辑  收藏  举报