整洁代码——提高代码可读性方法简述

  程序代码有双重目的,一是供机器执行,二是供程序员阅读。而代码的质量,往往体现在第二点,可读性是优秀代码的重要指标。在写代码时注意形成和保持代码的可读性,不仅有助于别人阅读,更有助于自己进一步的编写和完善。 《代码整洁之道》(Clean Code)一书提出了这样一种观念:“代码质量与其整洁度成正比。干净的代码,既在质量上较为可靠,也为后期维护、升级奠定了良好基础”(Robert C. Martin,Object Mentor公司的创始人和总裁)。在编写代码时,实现既定的功能只是最基本的要求,不仅要写“对的代码”,更要写“整洁的代码”。

  对于“整洁代码”,C++语言的创始人Bjarne Stroustrup说道:“我喜欢优雅和高效的代码。代码逻辑直截了当,叫缺陷难以隐藏,使之便于维护;依据某种分层战略完善错误处理代码;性能调至最优,省得引诱别人做没规矩的优化,搞出一堆混乱来。整洁的代码只做好一件事。”整洁代码的特点是形式上的优雅和功能上的高效:遵循“只做好一件事”的原则,使用简洁的逻辑解决这件事,以获得代码的“优雅”;周密地考虑并设计代码的层级结构,处理各种可能出现的异常,单一的功能和简洁的逻辑便于代码“性能最优”的调整,代码的高效水到渠成。

  “整洁代码”包含诸多的涵义,编写“整洁代码”需要遵循大量的技巧和规则,这里只从提高可读性的角度,从命名方法、函数设计、格式和注释四个方面,对提高代码可读性的方法做简单的探讨。

1. 命名方法

1.1. 一个例子

  为了说明命名方法对代码可读性的影响,先看下面一段代码:

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

从字面上看,函数名称叫getThem,最后返回了list1,说明这段代码是为了获取某样东西;程序遍历了theList,如果其中某项零下标值为4,则把它加到list1中。阅读这段代码,我们可以获知程序的工作流程,但无法知道它究竟实现了什么功能。想要真正弄懂这段程序的意义,我们至少要知道list1和theList代表什么?theList零下标有什么意义?值4有什么意义?返回值是做什么的?而这些内容在代码中都没有体现。假设这段代码是一个扫雷游戏程序中的一段,它用来获取当前雷区中标记了旗子的单元格,这里theList为整个雷区,int型数组表示单元格,零下标项是一种状态值,该状态值为4时表示“标记了旗子”;把零下标值为4的项,也就是标记了旗子的单元格存储到list1中,最后返回list1也就得到了雷区中标记了旗子的单元格。这样才算真正了解了这段代码的意义。

  把上面的代码作如下改写:

public List<int[]> getFlaggedCells() {
    List<int[]> flaggedCells = new ArrayList<int[]>();
    for(int[] cell : gameBoard)
        if(cell[STATUS_VALUE] == FLAGGED])
            flaggedCells.add(cell);
    return flaggedCells;
}

函数名getThem改名为getFlaggedCells,函数的目的就可以从函数名看出来,就是获取标记的单元格;原程序里面的集合指示随意取了一个名字叫list1,如果不往下读就无法知道里面到底要存什么东西,把它改写为flaggeCells,顾名思义就可以知道这个数组里存储的是被标记的单元格;原程序循环里变量的名称也是随意取的,可以看出来是要遍历整个theList,无法得知theList是什么,改写后就可以知道这里是遍历整个板面的单元格;从原程序的if语句里只可以看出这里要判断theList中整形数组的首元素是否为4,完全不知道为什么要这么做,而从改写后的代码中就可以获知这里是对单元格的状态值进行判断,判断单元格的状态是否是“已标记”,如果是则把这个单元格添加仅flagedCells中,进行记录;源程序返回值list1依旧不知所谓,改写flaggedCells表达返回值的意义,同时与函数名getFlaggedCells照应,任务完成。

  这里只修改了变量的名字,用常量代替了数值,把抽象的结构封装为类,代码的简洁性仍保留了下来,运算符、常量和嵌套的数量保持不变,但代码变得明确的多。更进一步,不用int数组表示单元格,而是另写一个类,包含一个函数isFlagged来掩盖4这个魔术数,代码就更加接近于自然语言。最终得到的代码如下:

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

上面的例子说明命名的重要性,对于函数、变量、常量等内容,好的命名可以显提高代码的可读性。

1.2. 常见命名规则

  在介绍命名需要遵循的原则前,下面首先列出一些常见的命名规则。

1.2.1. 帕斯卡命名法(PascalCase)

  帕斯卡命名法混合使用大小写字母来构成变量和函数的名字,组成命名的每个逻辑断点都有一个大写字母来标记。一般用于命名较大的概念,如类名等。例:Cell ,UsrName 。

1.2.2. 驼峰命名法(camelCase)

  驼峰命名法,是指混合使用大小写字母来构成变量和函数的名字,第一个单词首字母小写,其余首字母大写,这样的命名看上去像驼峰一样,中间高两边低。一般用于命名函数/方法,变量等。例:cell ,gameBoard ,getFlaggedCells 。

1.2.3. 下滑线法

  下滑线法顾名思义,命名中的每一个逻辑断点都用下划线来标记。一般用于命名常量、枚举类型,C语言和Linux内核编码风格中很多采用下划线法来命名。常量的命名常用大写字母加下划线,以示与各种变量的区别,例如STATUS_VALUE 。

1.2.4. 匈牙利命名法(Hungarian)

  匈牙利命名法通过在变量名前面加上相应的小写字母的符号标识作为前缀,标识出变量的作用域,类型等。Windows 编程中用到的变量(还包括宏)的命名遵循匈牙利命名法,早期编译器不做类型检查,程序员需要通过命名来帮助自己记住类型。现代编程语言具有更丰富的类型系统,特别是像C这种强类型的语言,编译器会记住并检查类型,使用匈牙利命名法增加了修改名称或类型的难度,就显得多余了。现在使用匈牙利命名法的一个好处是通过IDE的自动完成功能,进行快速查找。如在进行用户图形界面设计时,使用匈牙利命名法可以方便地对不同控件进行区分和查找。

1.3. 命名的原则

1.3.1. 名副其实

  关于命名,首先应遵循的原则是名副其实。变量、函数或类的名称应该能够解释程序的大部分功能,应该能够告诉读者,它为什么存在,它做什么事,应该怎么用。如果名称需要注释来补充,那就不算名副其实。如前面例子中的命名:public List<int[]> getThem() 就无法解释这个方法的功能是什么,而public List getFlaggedCells() 就是名副其实的命名。

1.3.2. 避免误导

  不使用有歧义的词,不使用区别过小的名称。如这样的两个变量XYZControllerForEfficientHandlingOfString 和XYZControllerForEfficientStorageOfString 它们差异很小,很容易形成误导,导致混淆或错用。

1.3.3. 做有意义的区分

  编译器要求同一作用范围内两样不同的东西不能重名,出现重名的问题时,一般只是随手改掉其中一个的名称,添加一些废话或者数字,虽然足以让编译器满意,但却不易于读者阅读。如果名称必须相异,则其意义也应该不同。

  如下面一段代码:

public static void copyChars(char a1[], char a2[]) {
    for(int i = 0; i < a1.length; i++) {
        a2[i] = a1[i];
    }
}

这是一个用来复制char 型数字的函数,用数字后缀来区分两个数组,函数名没有提供理解函数所需的全部信息,没有提供导向作者意图的线索,我们必须阅读函数的具体代码才能知道这个函数是把a1 复制给a2 。如果更改一下里面的命名,变成:

public static void copyChars(char source[], char destination[]) {
    for(int i = 0; i < source.length; i++) {
        destination[i] = source[i];
    }
}

destination 代替a1 ,用source 代替a2 ,仅从函数名就可以了解函数的运行机制。

1.3.4. 使用易读的名称

  人类通过语言进行交流,如果一个命名无法让人在看到的时候自然地读出来,就会妨碍交流。如这样一个命名genymdhms ,代表生成时的时间,年月日时分秒,十分简练,但几乎完全无法读出来;使用恰当的英文单词比生硬的自造词更容易让人理解,如generationTimestamp 。 可以使用适当的缩写来缩短名称,但不能过于简练以至于让人费解。

2. 函数的设计

  下面通过一个例子说明函数设计所要遵循的规则。

  设想这样一个场景:PC与外部数据终端通过一套特定的指令集进行交互,PC发往外部数据终端的指令格式为“AT+<指令名>=<参数>, <参数>, …, <参数>”,一条指令中可以包含若干个参数,用逗号隔开,末尾的为格式控制字符,如指令“AT+CTSP=1,3,130”,其中指令名为“CTSP”,参数有三个,分别为“1”,“3”和“130”;外部数据终端发往PC的指令格式为“+<指令名>=<参数>, <参数>, …, <参数>”,如指令“+CMGS: 184,2”,其中指令名为“CMGS”,参数有两个,分别为“184”和“2”。

  现有一个类来处理PC与外部数据终端交互的指令,要求设计一个方法(函数),来获取指令中指定的的参数(如获取指令“AT+CTSP=1,3,130”中第2个参数,应为“3”)。

  对于获取指令中指定的参数的方法,初步的设计是需要三个参数:首先需要一个String 型的参数command 来传入指令的内容,还需要一个枚举型参数commandType 来指明指令的类型(PC发送给外部数据终端,还是外部数据终端发送给PC)以判断第一个参数的起始位置(第一个参数前为“=”还是“:”),最后需要一个整型参数parameterNumber 指明所要获取的是指令中的哪一个参数。这样,这个方法就可以定义为:

public String getParameter(String command, CommandType commandType, int parameterNumber);

这个设计看上去理所当然,三个参数直接包含了解决问题所需的全部信息,但仍存在很大的改进空间。

2.1. 尽可能少的参数

  最理想的参数数量是零个,其次是单参数函数,再次是双参数函数,应尽量避免使用三参数函数。参数引入了额外的概念,在理解函数名的同时,还需要理解参数的内容和作用,参数越多,代码就越不容易阅读。尤其是对带有输出参数的函数更难以阅读,我们一般惯于认为函数通过参数传入需要的信息,并通过返回值来输出处理后的信息。如果一个函数不得不需要两三个或更多的参数,那就说明其中的一些参数应该封装为类了。

  现在重新审视刚才的getParameter 方法:

public String getParameter(String command, CommandType commandType, int parameterNumber);

其中的参数commandType 用来指示指令的类型,据此获知第一个参数前为“=”还是“: ”,以获取第一个参数的起始位置。既然已经有了参数command 来传入指令内容,就可以在指令中直接查找“=”和“: ”:如果指令中有“=”,则把“=”之后的位置作为第一个参数的起始位置;如果指令中有“: ”,则把“: ”之后的位置作为第一个参数的起始位置(这里假设已知在指令中“=”和“: ”只会出现一次,且不会同时出现)。参数commandType 的信息已经包含在了参数command 里,不需要额外通过参数传入,可以简化掉。

  这样函数就剩下了两个参数,但还是不够好。观察剩下的两个参数command 和parameterNumber ,对于指令内容command ,可以预见在查找参数结束位置以及修改指令参数时都会用到,而且指令内容显然也是指令本身的属性,应该定义为类的属性,这样就不再需要通过参数传递。 最终设计的函数的参数就只有参数序号parameterNumber 一个,即:

public String getParameter(int parameterNumber);

parameterNumber 是获取指令中指定参数所必须的信息,无法再进行精简。这样就把一个三参数的函数缩短到了单参数的函数。

2.2. 短小

  这样就可以把获取指令参数的方法写出来:

public String getParameter(int parameterNumber) throws CommandException {
    String parameter = new String("None");
    int startOfParameter = -1, endOfParameter = -1;

    if(parameterNumber == 1) {
        startOfParameter = command.indexOf(':');
        if(startOfParameter == -1) {
            startOfParameter = command.indexOf('=');
            startOfParameter = startOfParameter + 1;
        } else {
            startOfParameter = startOfParameter + 2;
        }
    } else {
        for(int i = 0; i < parameterNumber - 1; i++) {
            startOfParameter = command.indexOf(',', startOfParameter);
        }
        }

    endOfParameter = command.indexOf(',', startOfParameter);
    if(endOfParameter == -1) {
        endOfParameter = command.indexOf('r', startOfParameter);
    }

    if((startOfParameter != -1) && (endOfParameter != -1)) {
        parameter = command.substring(startOfParameter, endOfParameter);
    } else {
        throw new CommandException("Cannot find the specific parameter.");
    }

    return parameter;
}

这个方法中,先获取指定参数开始和结束的位置,再根据参数在指令字符串中的起始位置来获取参数;获取第一个参数时,还需要考虑参数前是“=”还是“:”的情况,如果指令中有“=”,则第一个参数紧跟其后,如果指令中有“:”,则第一个参数在“:”后第二个位置(“:”后是空格,然后才是参数)。

  这样写出来的函数很长,过程复杂,如果事先对所处理的指令没有足够的了解,是无法读懂这段程序的。编写函数的一条重要规则是短小。要做到短小,首先要注意的是,函数应该只做一件事。

2.2.1. 只做一件事

  函数应该只做一件事。刚才的那段长代码显然做了好几件事,它先对指令类型进行判断,然后查找参数的起止位置。而实际上这个函数真正应该做的事,只是获取指令这一段。其他内容都不是这个函数应该做的事,都应该拆分出去成为单独的函数。这里拆分并不是随意地把代码剪切出去写成函数,要按函数所实现功能的抽象层级来划分函数的范围。

2.2.2. 每个函数一个抽象层级

  编写函数是为了把大一些的概念拆分成各个抽象层上的一系列步骤,按照划分好抽象层级编写函数,可以让代码具有自顶向下的阅读顺序,让每个函数后面都跟着下一抽象层级的函数,在查看函数列表时,就能遵循抽象层级向下阅读。

  getParameter 这个方法的目的是获取指令中的指定参数,实现这个目标的过程,可以分成三级:

      • 为了获取指令中的参数,需要找到所设置参数在指令中的其实和结束的位置。
        • 为了查找参数的起始位置,需要先判断当前的指令是接收的指令还是发送的指令。
          • 为了判断当前指令是否是接收的指令······
          • 为了判断当前指令是否是发送的指令······
        • 为了查找参数的结束位置······
          • ······

  重构后的代码结构就应该设这样:

public String getParameter(int parameterNumber) {
    //······
    int startOfParameter = getStartIndexOfParameter(parameterNumber);
    int endOfParameter = getEndIndexOfParameter(parameterNumber);
    //······
}

private int getStartIndexOfParameter(int parameterNumber) {
    //······
}

private int getEndIndexOfParameter(int parameterNumber) {
    //······
}

顶层是我们的目标,也就是获取指令中参数,其中需要获知参数起始位置,放在下一层实现;获取参数起始位置又要获知指令类型,再放在下一层实现,让代码拥有自顶向下的阅读顺序。

2.2.3. 重构后的getParameter

  按照上面的思路重构后的getParameter 如下:

public String getParameter(int parameterNumber) throws CommandException {
    String parameter = new String("None");

    int startOfParameter = getStartIndexOfParameter(parameterNumber);
    int endOfParameter = getEndIndexOfParameter(parameterNumber);

    if((startOfParameter != -1) && (endOfParameter != -1)) {
        parameter = command.substring(startOfParameter, endOfParameter);
    } else {
        throw new CommandException("Cannot find the specific parameter.");
    }

    return parameter;
}

2.3. 如何写出简洁的函数

  写代码和写文章很像,首先需要制定一个大纲,列出要写的主要内容,然后在各个部分和段落根据大纲中制定的内容,想到什么就写什么,最后在打磨它。初稿虽然能够满足大纲的要求,但一般都粗陋无序,冗长复杂,具有大量缩进、嵌套循环,过长的参数列表,名称也是随意取的。这就需要在不影响功能的前提下进行修改,分解函数、修改名称、消除重复,缩短和重新安置方法,但无论怎么修改,都必须要保证各部分仍然满足大纲的要求。最后将分解和修改后的函数重新组合,构成完整的、满足既定要求的程序。从最开始就严格按照各个规则和要求直接简洁的函数十分困难,需要自顶向下地逐步求精。

3. 格式

  理想的代码格式应该像报纸一样,在顶部你期望有个头条,告诉你事件的主题,你可以据此判断是否要读下去;第一段是导语,是整个故事的大纲,给出粗线条的概述,但省略了许多故事细节;往下读,获得的细节逐渐增加,直到你了解了所有的细节。源代码也应该像报纸一样,名称应该简单而且一目了然,足够告诉读者他期望获取的信息是否位于这个模块当中。源文件顶部应该给出高层次概念和算法,细节则在下面逐渐展开,直到最底层的函数和细节。报纸由许多篇文章组成,多数短小精悍,有些稍微长点儿,很少会占满整整一页。如果一份报纸只刊登一篇长篇故事,其中充斥毫无组织的事实、日期、名字等等或笼统或详细的概念,就没人愿意去读它。
几乎所有代码都是从上往下、从左往右读的,首先从形式上,每行应展现一个表达式或语句,每组代码展示一条完整的思路,不同的思路用空行区隔开,而相互联系紧密的代码则要靠近。

3.1. 垂直格式

  要在垂直方向上使用空行分隔代码,以区分不同的思路或实现不同功能的代码段。如下面一段代码对界面进行初始化,分别初始化了一个Pannel和两个按钮,用空行分开成三段。

private void initPortConfig() {
    panelSerialPortConfig = new JPanel();
    panelSerialPortConfig.setBounds(10, 10, 234, 149);
    frame.getContentPane().add(panelSerialPortConfig);
    panelSerialPortConfig.setLayout(null);

    btnOpenPort = new JButton("Open");
    btnOpenPort.setBounds(68, 19, 74, 23);
    btnOpenPort.addActionListener(new ButtonListenerForPortManagement());
    panelSerialPortConfig.add(btnOpenPort);

    btnClosePort = new JButton("Close");
    btnClosePort.setBounds(141, 19, 74, 23);
    btnClosePort.setEnabled(false);
    btnClosePort.addActionListener(new ButtonListenerForPortManagement());
    panelSerialPortConfig.add(btnClosePort);
}

相关的内容在垂直方向上应该相互临近:变量声明应尽可能靠近其使用位置,有调用关系的函数放在一起,概念相关性越强,彼此间距离就该越短。如前面的

public String getParameter(int parameterNumber) {
    //······
    int startOfParameter = getStartIndexOfParameter(parameterNumber);
    int endOfParameter = getEndIndexOfParameter(parameterNumber);
    //······
}

private int getStartIndexOfParameter(int parameterNumber) {
    //······
}

private int getEndIndexOfParameter(int parameterNumber) {
    //······
}

 被调用的函数紧挨在调用它的函数之后,遵循这样的规律,阅读的时候就总可以期望在下面了解到更详细的内容。

3.2. 水平格式

   水平方向上,代码行的宽度要以不需要拖动滚动条为标准。水平方向的分隔与靠近通过空格实现。如下面求二次方程根的函数:

public class Quadratic {
    public static double root1(double a, double b, double c) {
        double determinant = determinant(a, b, c);
        return (-b + Math.sqrt(determinant)) / (2*a);
    }

    public static double root2(double a, double b, double c) {
        double determinant = determinant(a, b, c);
        return (-b - Math.sqrt(determinant)) / (2*a);
    }

    public static double determinant(double a, double b, double c) {
        return b*b - 4*a*c;
    }
}

优先级高的计算彼此靠近,不同优先级的计算间用空格分隔。 源代码文件具有一种继承结构,其中的信息涉及整个文件、文件中每个类、类中的方法、方法中的代码块,以至于代码块中的代码块,使用缩进可以表现出代码的结构。实现相对于声明缩进一个层级。

4. 注释

  写注释的常见动机之一就是代码本身写的很糟糕,带有少量注释的整洁而有表达力的代码,要比带有大量注释的零碎而复杂的代码像样的多,与其花时间解释糟糕的代码,不如花时间清洁那堆糟糕的代码。当然,也有一些情况必须使用注释,如法律信息、提供信息的注释、对意图的解释和警示等等。要尽可能使用前面提到的规则和技巧,使代码自身具有良好的可读性,注释应当用来阐述编程的意图和思路,而不是用来解释这段代码为何如此糟糕。

 

注:本文根据我在例会上学术交流的内容整理,对涉及到的一些专业应用(如第2节中PC与数据终端通过AT指令进行交互)做了简化。主要参考了Robert C. Martin所著的《Clean Code》(中译本《代码整洁之道》,韩磊译),部分例子也引自其中。