什么样的代码规范才能得到程序员的认可?

最近公司任命我负责 Java 代码规范的编写工作,我有了机会针对行业内几家顶级公司发布的 Java 代码规范进行调研、比较。

提到代码规范,一般都会直接联想到代码应该如何编写,才更加易读。我们可以翻阅 SUN 公司(已被 Oracle 收购)、谷歌、BAT、华为等公司发布的 Java 代码规范,你会发现它们不仅仅是针对代码编写规范,而是覆盖了编写规范、性能优化、新特性解释等等,覆盖面最广的一家公司的代码规范甚至是由编程规约、异常日志、安全规约、单元测试、MySQL 数据库、工程结构等六大部分组成的,为什么 Java 代码规范需要覆盖这么多内容呢?

我在写自己第一本书《大话 Java 性能优化》的时候,也有类似的感觉。如果单纯只写 Java 语言本身的性能优化,那覆盖范围势必很窄,但是如果站在工程的角度来看,那么我们就可以包含很多内容,例如架构设计、算法流程、数据结构等等。我们在面试社招员工时动不动就听到对于大型高并发系统的设计经验,但是真正问到细处,又有几个人能够回答呢。

您对于代码规范有什么自己的理解?可以在本文留言。我先谈谈自己的一些浅薄理解。

代码规范的重要性

谷歌发布的代码规范中指出,80% 的缺失是由 20% 的代码所引起的。每个人写代码的思维方式、思路、方法不同,技术水平也不同,这时候确实需要有较为正式的编码规范作为约束。此时我想起了很多年前看到过的一段代码,没有换行,一行里面写完,数百字的代码,怪不得诸家大公司要纷纷规定每行代码最多 80-120 个英文字符。

代码规范的局限性

听朋友提起过一个事情,一个团队的管理者制定了一套代码规范,或者说是适用于他的代码规范,由于他自身的技术停留在 10 年前,所以代码规范自然也会停留在那时代的思维,最终导致手下能力较强的几个程序员集体出走。这个事情让我想起了《天下粮田》里的一幕,浙江巡抚唐思迅评价做官“没有点个性,是做不好官的”,此评价我认为类同于程序员。

代码规范本身就不是对与错的选择,而是结合很多人在工作中遇到的问题的分析、总结,通过一定的规则约束避免再次出现类似问题。所以,代码规范的制定是严谨的,不是一个不重要的工作,不是一锤子买卖,也不是光有代码规范就够的。

如何制定代码规范

我认为应该遵循以下几点:

  1. 专业的事交给专业的人来做,代码规范的制定应该由在一线摸爬滚打很多年的程序员主导,由多人参与共同制定,牵头人最好是一位全栈工程师,做过很多大项目,有超过 10 年编程经验,为人谦虚,并一直保持学习状态;
  2. 现今还能存活并且被程序员广泛使用的语言,其本质一定不仅仅是一门语言,而是构筑了强大的生态系统,因此,代码规范应该从工程角度入手,客观地分析整个工程建设过程当中需要面对的编码、设计问题,全方位对这些技术进行规范性指导;
  3. 光有指导意见是不够的,应该学习阿里输出代码检测工具或者插件,自动化实现对于代码规范是否执行到位的检测,而不是依靠人工;
  4. 凡事需要以理服人,因此,代码规范应该分为上下两卷,上卷为代码规范,下卷为针对代码规范每一条的详细解释,说清楚为什么要这么制定代码规范,它背后有哪些技术上和工程过程中的故事,说得人心服口服,用技术说服或者碾压程序员;
  5. 需要不断更新。技术更新很快,工程过程中遇到的问题也是层出不穷,因此,代码规范也不会是一招定论的,需要不断地更新、补充、完善,这样才能与时俱进,保持生命力。

具体解读示例

前面谈了一些务虚的理解,接下来说点具体的。

命名规则

命名规则应该算是代码规范中必定需要覆盖的,针对这一点,几家公司的代码规范的倾向性比较类同,多家公司规定代码中的命名均不能以下划线或美元符号开始,也不能以下划线或美元符号结束。我们来看看各厂商的理解。Oracle 官网建议不要使用 $ 或者开始_变量命名,并且建议在命名中完全不要使用“$”字符,原文是“The convention,however,is to always begin your variable names with a letter,not ‘$’ or ‘_’”。对于这一条,腾讯的看法是一样的,百度认为虽然类名可以支持使用“$”符号,但只在系统生成中使用(如匿名类、代理类),编码不能使用。

这类问题在 StackOverFlow 上有很多人提出,主流意见为人不需要过多关注,只需要关注原先的代码是否存在”_”,如果存在就继续保留,如果不存在则尽量避免使用。也有一位提出尽量不使用”_”的原因是低分辨率的显示器,肉眼很难区分”_”(一个下划线)和”__”(两个下划线)。

我个人觉得可能是由于受 C 语言的编码规范所影响。因为在 C 语言里面,系统头文件里将宏名、变量名、内部函数名用 _ 开头,因为当你 #include 系统头文件时,这些文件里的名字都有了定义,如果与你用的名字冲突,就可能引起各种奇怪的现象。综合各种信息,建议不要使用””、”$”、空格作为命名开始,以免不利于阅读或者产生奇怪的问题。

单行代码字符数

对于单行代码字符数,各家公司都有规定,普遍以 80 或者 120 个字作为分割界限。

SUN 公司 1997 年的规范中指出单行不要超过 80 个字符,对于文档里面的代码行,规定不要超过 70 个字符单行。当表达式不能在一行内显示的时候,genuine 以下原则进行切分:

  1. 在逗号后换行;
  2. 在操作符号前换行;
  3. 倾向于高级别的分割;
  4. 尽量以描述完整作为换行标准;
  5. 如果以下标准造成代码阅读困难,直接采用 8 个空格方式对第二行代码留出空白。

以下是示例代码:

function(longExpression1, longExpression2, longExpression3,
longExpression4, longExpression5);
var = function(longExpression1,
            function2(longExpression2,
                    longExpression3));
longName1 = longName2 * (longName3 + longName4 – longName5)
           + 4 * longName6;// 做法正确
longName1 = longName2 * (longName3 + longName4 
– longName5) + 4 * longName6;// 做法错误
if ((condition1 && condition2)
   || (condition3 && condition4)
   || !(condition5 && condition6) {
   doSomethingAboutIt();
}// 这种做法错误
if ((condition1 && condition2)
|| (condition3 && condition4)
        || !(condition5 && condition6) {
   doSomethingAboutIt();
}// 这种做法正确
if ((condition1 && condition2) || (condition3 && condition4)
|| !(condition5 && condition6) {
   doSomethingAboutIt();
}// 这种做法正确

Switch 的定义

Switch 是各编程语言必有的保留字,谷歌公司的代码规范中提及“每个 case 要么通过 break/return 等来终止,要么注释说明程序将继续执行到哪一个 case 为止”。为什么会有这样的约束?因为这样可以比较清楚地表达程序员的意图,有效防止无故遗漏的 break 语句。

示例:

switch(condition){
case ABC:
    statements;
    /* 程序继续执行直到 DEF 分支 */
case DEF:
    statements;
    break;
case XYZ:
    statements;
    break;
default:
    statements;
    break;
}

上述示例中,每当一个 case 顺着往下执行时(因为没有 break 语句),通常应在 break 语句的位置添加注释。上面的示例代码中就包含了注释“/* 程序继续执行直到 DEF 分支 */”(这一条也是 SUN 公司 1997 年代码规范的要求)。

语法上来说,default 语句中的 break 是多余的,但是如果后续添加额外的 case,可以避免找不到匹配 case 项的错误。

Finally 和 Return 的关系

Finally 和 Return 之间的关系是 Java 程序员面试题里经常会出现的题目,主要是考察程序员对于 Finally 块中如果出现 return 之后的结果预判。

我来谈点自己的理解。我们来看一个示例,代码如下:

public class demo{
      public static void main(String[] args){
            System.out.println(m_1());
     }
     public int m_1(){
         int i = 10;
         try{
             System.out.println(“start”);
             return i += 10;
         }catch(Exception e){
             System.out.println(“error:”+e);
         }finally{
             if(i>10){
                 System.out.println(i);
             }
             System.out.println(“finally”);
             return 50;
         }
     }
}

输出如下:

start
20
finally
50

对此现象可以通过反编译 class 文件很容易找到原因:

public class demo{
public static void main(String[] args){
            System.out.println(m_1());
     }
     public int m_1(){
         int i = 10;
         try{
             System.out.println(“start”);
             return i;
}catch(Exception e){
             System.out.println(“error:”+e);
         }finally{
             if(i>10){
                 System.out.println(i);
             }
             System.out.println(“finally”);
             i = 50;
}
return i;
}
     }
}

首先 i+=10; 被分离为单独的一条语句,其次 return 50; 被加在 try 和 catch 块的结尾,“覆盖”了 try 块中原有的返回值。如果我们在 finally 块中没有 return,则 finally 块中的赋值语句不会改变最后的返回结果,如代码所示:

public class demo{
      public static void main(String[] args){
            System.out.println(m_1());
     }
     public int m_1(){
         int i = 10;
         try{
             System.out.println(“start”);
             return i += 10;
         }catch(Exception e){
             System.out.println(“error:”+e);
         }finally{
             if(i>10){
                 System.out.println(i);
             }
             System.out.println(“finally”);
             i = 50;
         }
         return I;
     }
}

输出结果如下所示:

start
finally
10

关于单元测试原则

提及单元测试原则,大家一定都会指向 BCDE 原则,以保证被测试模块的交付质量,这一条国内外各厂商仅有阿里涉及,实属难得的企业级代码规范。

我聊一聊自己对于 BCDE 原则的浅薄理解:

B(Border):确保参数边界值均被覆盖。

例如:对于数字,测试负数、0、正数、最小值、最大值、NaN(非数字)、无穷大值等。对于字符串,测试空字符串、单字符、非 ASCII 字符串、多字节字符串等。对于集合类型,测试空、第一个元素、最后一个元素等。对于日期,测试 1 月 1 日、2 月 29 日、12 月 31 日等。被测试的类本身也会暗示一些特定情况下的边界值。对于边界情况的测试一定要详尽。

C(Connect):确保输入和输出的正确关联性。

例如,测试某个时间判断的方法 boolean inTimeZone(Long timeStamp),该方法根据输入的时间戳判断该事件是否存在于某个时间段内,返回 boolean 类型。如果测试输入的测试数据为 Long 类型的时间戳,对于输出的判断应该是对于 boolean 类型的处理。如果测试输入的测试数据为非 Long 类型数据,对于输出的判断应该是报错信息是否正确。

D(Design):任务程序的开发包括单元测试都应该遵循设计文档。

E(Error):单元测试包括对各种方法的异常测试,测试程序对异常的响应能力。

《单元测试之道(Java 版)》这本书里面提到了关于边界测试的 CORRECT 原则:

  • 一致性(Conformance):值是否符合预期格式(正常的数据),列出所有可能不一致的数据,进行验证。
  • 有序性(Ordering):传入的参数的顺序不同的结果是否正确,对排序算法会产生影响,或者是对类的属性赋值顺序不同会不会产生错误。
  • 区间性(Range):参数的取值范围是否在某个合理的区间范围内。
  • 引用 / 耦合性(Reference):程序依赖外部的一些条件是否已满足。前置条件:系统必须处于什么状态下,该方法才能运行。后置条件,你的方法将会保证哪些状态发生改变。
  • 存在性(Existence):参数是否真的存在,引用为 Null,String 为空,数值为 0 或者物理介质不存在时,程序是否能正常运行。
  • 基数性(Cardinality):考虑以“0-1-N 原则”,当数值分别为 0、1、N 时,可能出现的结果,其中 N 为最大值。
  • 时间性(Time):相对时间指的是函数执行的依赖顺序,绝对时间指的是超时问题、并发问题。

关于数据类型的精度损失

各家公司的代码规范中都有提及数据类型的使用规范,我认为这是由于数据类型之间存在精度损失情况,我来谈谈自己的看法。

我们先来看看各个精度的范围。

Float:浮点型,4 字节数 32 位,表示数据范围 -3.4E38~3.4E38

Double:双精度型,8 字节数 64 位,表示数据范围 -1.7E308~1.7E308

Decimal:数字型,16 字节数 128 位,不存在精度损失,常用于银行账目计算

在精确计算中使用浮点数是非常危险的,在对精度要求高的情况下,比如银行账目就需要使用 Decimal 存储数据。

实际上,所有涉及到数据存储的类型定义,都会涉及数据精度损失问题。Java 的数据类型也存在 float 和 double 精度损失情况,阿里没有指出这条规约,就全文来说,这是一个比较严重的规约缺失。

Joshua Bloch(著名的 Effective Java 书作者)认为,float 和 double 这两个原生的数据类型本身是为了科学和工程计算设计的,它们本质上都采用单精度算法,也就是说在较宽的范围内快速获得精准数据值。但是,需要注意的是,这两个原生类型都不保证也不会提供很精确的值。单精度和双精度类型特别不适用于货币计算,因为不可能准确地表示 0.1(或者任何其他十的负幂)。

举个例子:

float calnUM1;
double calNum2;
calNum1 = (float)(1.03-.42);
calNum2 = 1.03-.42;
System.out.println(“calNum1=”+ calNum1);
System.out.println(“calNum2=”+ calNum2);
System.out.println(1.03-.42);
calNum1 = (float)(1.00-9*.10);
calNum2 = 1.00-9*.10;
System.out.println(“calNum1=”+ calNum1);
System.out.println(“calNum2=”+ calNum2);
System.out.println(1.00-9*.10);

输出结果如所示:

calNum1=0.61
calNum2=0.6100000000000001
0.6100000000000001
calNum1=0.1
calNum2=0.09999999999999998
0. 09999999999999998

从上面的输出结果来看,如果寄希望于打印时自动进行四舍五入,这是不切实际的。我们再来看一个实际的例子。假设你有 1 块钱,现在每次购买蛋糕的价格都会递增 0.10 元,为我们一共可以买几块蛋糕。口算一下,应该是 4 块(因为 0.1+0.2+0.3+0.4=1.0),我们写个程序验证看看。

// 错误的方式
double funds1 = 1.00;
int itemsBought = 0;
for(double price = .10;funds>=price;price+=.10){
funds1 -=price;
itemsBought++;
}
System.out.println(itemsBought+” items boughts.”);
System.out.println(”Changes:”+funds1);
// 正确的方式
final BigDecimal TEN_CENTS = new BigDecimal(“.10”);

itemsBought = 0;
BigDecimal funds2 = new BigDecimal(“1.00”);
for(BigDecimal price = TEN_CENTS;funds2.compareTo(price)>0;price = price.add(TEN_CENTS)){
     fund2 = fund2.substract(price);
     itemsBought++;
}
System.out.println(itemsBought+” items boughts.”);
System.out.println(”Changes:”+funds2);

运行输出如下所示。

3 items boughts.
Changes:0.3999999999999999
4 items boughts.
Changes:0.00

这里我们可以看到使用了 BigDecimal 解决了问题,实际上 int、long 也可以解决这类问题。采用 BigDecimal 有一个缺点,就是使用过程中没有原始数据这么方便,效率也不高。如果采用 int 方式,最好不要在有小数点的场景下使用,可以在 100、10 这样业务场景下选择使用。

关于分层架构思想

Java9 引入了模块化编程,模块化编程是基于模块之间的依赖关系,模块之间又具有分层思想,代码规范不仅仅是针对代码编写,也是编写代码方式的一种约束。经典的《软件架构模式》中介绍了分层架构思想,我们可以读一读好好理解分层架构概念:

分层架构是一种很常见的架构模式,它也被叫做 N 层架构。这种架构是大多数 Java EE 应用的实际标准。许多传统 IT 公司的组织架构和分层模式十分的相似,所以它很自然地成为大多数应用的架构模式。

分层架构模式里的组件被分成几个平行的层次,每一层都代表了应用的一个功能(展示逻辑或者业务逻辑)。尽管分层架构没有规定自身要分成几层几种,大多数的结构都分成四个层次,即展示层、业务层、持久层和数据库层。业务层和持久层有时候可以合并成单独的一个业务层,尤其是持久层的逻辑绑定在业务层的组件当中。因此,有一些小的应用可能只有三层,一些有着更复杂的业务的大应用可能有五层甚至更多的层。

分层架构中的每一层都有着特定的角色和职能。举个例子,展示层负责所有的界面展示以及交互逻辑,业务层负责处理请求对应的业务。架构里的层次是具体工作的高度抽象,它们都是为了实现某种特定的业务请求。比如说展示层并不关心如何得到用户数据,它只需在屏幕上以特定的格式展示信息。业务层并不关心要展示在屏幕上的用户数据格式,也不关心这些用户数据从哪里来,它只需要从持久层得到数据,执行与数据有关的相应业务逻辑,然后把这些信息传递给展示层。

分层架构的一个突出特性地组件间关注点分离。一个层中的组件只会处理本层的逻辑。比如说,展示层的组件只会处理展示逻辑,业务层中的组件只会去处理业务逻辑。因为有了组件分离设计方式,让我们更容易构造有效的角色和强力的模型,这样应用变得更好开发、测试、管理和维护。

服务器之间的超时时间

假如我们现在正在设计基于私有云的高并发系统,自然我们需要涉及高并发服务器相关的知识,而处理过程自然需要涉及 TCP 的连接,跨服务器的连接都需要建立连接。我们知道,服务器在处理完客户端的连接后,主动关闭,就会有 time_wait 状态。TCP 连接是双向的,所以在关闭连接的时候,两个方向各自都需要关闭。先发 FIN 包的一方执行的是主动关闭,后发 FIN 包的一方执行的是被动关闭。主动关闭的一方会进入 time_wait 状态,并且在此状态停留两倍的 MSL 时长。

主动关闭的一方收到被动关闭的一方发出的 FIN 包后,回应 ACK 包,同时进入 time_wait 状态,但是因为网络原因,主动关闭的一方发送的这个 ACK 包很可能延迟,从而触发被动连接一方重传 FIN 包。极端情况下,这一去一回就是两倍的 MSL 时长。如果主动关闭的一方跳过 time_wait 直接进入 closed,或者在 time_wait 停留的时长不足两倍的 MSL,那么当被动关闭的一方早于先发出的延迟包达到后,就可能出现类似下面的问题:

  1. 旧的 TCP 连接已经不存在了,系统此时只能返回 RST 包
  2. 新的 TCP 连接被建立起来了,延迟包可能干扰新的连接

不管是哪种情况都会让 TCP 不再可靠,所以 time_wait 状态有存在的必要性。

修改 net.ipv4.tcp_fin_timeout 也就是修改了 MSL 参数。

拓展思维

我一直认为,Java 已经不再仅仅是一门语言,它是一个生态环境,既然是生态环境,它必然需要根据外部环境的变化不断调整自己,不断地吸收外部优良的设计加强自身,也必然需要不断地改变扩大自己的范围,不仅仅局限于语言,这也是为什么会有 Java9 的出现。

我们每天在构建的应用程序,也许大家编码是基于某种框架,例如 Spring Cloud,基于它可以很方便地启动微服务应用,但是它背后其实引用了大量的 Java 生态环境里的库。长期来看,应用程序如果缺乏结构化设计终会付出代价。模块化编程为什么会出现?因为它的出现可以然你有效地管理依赖关系,并且减少复杂度。

相较于 Java8 引入的 lambda 表达式,模块化编程关注的是整个应用程序的结构扩展问题,而 lambda 表达式更多的是提供了在一个类里面切换到 lambda 的方式。模块化带来的影响涉及到设计、编译、打包、部署等等,所以我前面讲了,它不仅仅是一个语言级的特性变化。因此,网络上对于 Java9“无作为”的评论是片面的,我不赞同。

写在最后

我之所以总结这些经验,是因为我认为代码规范确实有存在的必要性,如果能够定义正确、准确、范围较广的代码规范,辅助以代码检测工具,并通过输出代码规范解读手册,可以有效地帮助程序员提升技术水平,帮助公司有效地管理代码输出质量。大家要记住,程序员是一群个性独特的人,唯有你定义的东西能够服众,才能被长久传承下去。愿我们能在最好的年华做最美好的事,谨以此文献给在路上的各位程序员们。

作者介绍

周明耀,毕业于浙江大学,工学硕士。13 年软件开发领域工作经验,仅 10 年技术管理经验,4 年分布式软件开发经验,提交发明专利 17 项。著有《大话 Java 性能优化》 、《深入理解 JVM&G1 GC》 、《技术领导力 程序员如何才能带团队》。微信号 michael_tec,微信公众号“麦克叔叔每晚 10 点说”。

余下全文(1/3)
分享这篇文章:

请关注我们:

共有 1 条讨论

  1. 张静茹  这篇文章

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注