《编写高质量代码:改善Java程序的151个建议》_秦小波
摘录
1. 不要在常量和变量中出现容易混淆的字母
- 包名全小写,类名首字母全大写,常量全部大写并用下划线分隔,变量采用驼峰命名法(Camel Case)命名等,这些都是最基本的Java编码规范
- 字母“l”作为长整型标志时务必大写。
2. 莫让常量蜕变成变量
- 务必让常量的值在运行期保持不变。
3. 三元操作符的类型务必一致
- 三元操作符类型的转换规则:
- 若两个操作数不可转换,则不做转换,返回值为Object类型。
- 若两个操作数是明确类型的表达式(比如变量),则按照正常的二进制数字来转换,int类型转换为long类型,long类型转换为float类型等。
- 若两个操作数中有一个是数字S,另外一个是表达式,且其类型标示为T,那么,若数字S在T的范围内,则转换为T类型;若S超出了T类型的范围,则T转换为S类型(可以参考“建议22”,会对该问题进行展开描述)。
- 若两个操作数都是直接量数字(Literal[插图]),则返回值类型为范围较大者。
4. 避免带有变长参数的方法重载
- 变长参数必须是方法中的最后一个参数;一个方法不能定义多个变长参数等
5. 别让null值或空值威胁到变长方法
6. 覆写变长方法也循规蹈矩
- 覆写必须满足的条件:
- 重写方法不能缩小访问权限。
- 参数列表必须与被重写方法相同。
- 返回类型必须与被重写方法的相同或是其子类。
- 重写方法不能抛出新的异常,或者超出父类范围的异常,但是可以抛出更少、更有限的异常,或者不抛出异常。
- 覆写的方法参数与父类相同,不仅仅是类型、数量,还包括显示形式。
7. 警惕自增的陷阱
- Java对自加是这样处理的:首先把count的值(注意是值,不是引用)拷贝到一个临时变量区,然后对count变量加1,最后返回临时变量区的值。程序第一次循环时的详细处理步骤如下:
- JVM把count值(其值是0)拷贝到临时变量区。
- count值加1,这时候count的值是1。
- 返回临时变量区的值,注意这个值是0,没修改过。
- 返回值赋值给count,此时count值被重置成0。
8. 不要让旧语法困扰你
9. 少用静态导入
- 对于静态导入,一定要遵循两个规则:
- 不使用*(星号)通配符,除非是导入静态常量类(只包含常量的类或接口)。
- 方法名是具有明确、清晰表象意义的工具类。
import static java.lang.Math.PI;
10. 不要在本类中覆盖静态导入的变量和方法
- 编译器有一个“最短路径”原则:如果能够在本类中查找到的变量、常量、方法,就不会到其他包或父类、接口中查找,以确保本类中的属性、方法优先。
- 因此,如果要变更一个被静态导入的方法,最好的办法是在原始类中重构,而不是在本类中覆盖。
- 类实现Serializable接口的目的是为了可持久化,比如网络传输或本地存储,为系统的分布和异构部署提供先决支持条件。
11. 养成良好习惯,显式声明UID
- 类实现Serializable接口的目的是为了可持久化,比如网络传输或本地存储,为系统的分布和异构部署提供先决支持条件。
- 显式声明serialVersionUID可以避免对象不一致,但尽量不要以这种方式向JVM“撒谎”。
12. 避免用序列化类在构造函数中为不变量赋值
- 反序列化时构造函数不会执行。
- 在序列化类中,不使用构造函数为final变量赋值。
13. 避免为final变量复杂赋值
- 反序列化时final变量在以下情况下不会被重新赋值:
- 通过构造函数为final变量赋值。
- 通过方法返回值为final变量赋值。
- final修饰的属性不是基本类型。
- 保存到磁盘上(或网络传输)的对象文件包括两部分:
- 类描述信息:包括包路径、继承关系、访问权限、变量描述、变量访问权限、方法签名、返回值,以及变量的关联类信息。要注意的一点是,它并不是class文件的翻版,它不记录方法、构造函数、static变量等的具体实现。之所以类描述会被保存,很简单,是因为能去也能回嘛,这保证反序列化的健壮运行。
- 非瞬态(transient关键字)和非静态(static关键字)的实例变量值
14. 使用序列化类的私有方法巧妙解决部分属性持久化问题
- 部分属性持久化问题看似很简单,只要把不需要持久化的属性加上瞬态关键字(transient关键字)即可。这是一种解决方案,但有时候行不通。
15. break万万不可忘
16. 易变业务使用脚本语言编写
脚本语言的三大特征,如下所示:
- 灵活。脚本语言一般都是动态类型,可以不用声明变量类型而直接使用,也可以在运行期改变类型。
- 便捷。脚本语言是一种解释型语言,不需要编译成二进制代码,也不需要像Java一样生成字节码。它的执行是依靠解释器解释的,因此在运行期变更代码非常容易,而且不用停止应用。
- 简单。只能说部分脚本语言简单,比如Groovy,Java程序员若转到Groovy程序语言上,只需要两个小时,看完语法说明,看完Demo即可使用了,没有太多的技术门槛。
17. 慎用动态编译
18. 避免instanceof非预期结果
- ‘A’是一个char类型,也就是一个基本类型,不是一个对象,instanceof只能用于对象的判断,不能用于基本类型的判断。
- 这是instanceof特有的规则:若左操作数是null,结果就直接返回false,不再运算右操作数是什么类。这对我们的程序非常有利,在使用instanceof操作符时,不用关心被判断的类(也就是左操作数)是否为null,这与我们经常用到的equals、toString方法不同。
19. 断言绝对不是鸡肋
- assert抛出的异常AssertionError是继承自Error的
- 断言失败后,JVM会抛出一个AssertionError错误,它继承自Error,注意,这是一个错误,是不可恢复的,也就表示这是一个严重问题,开发者必须予以关注并解决之。
- 按照正常执行逻辑不可能到达的代码区域可以放置assert。具体分为三种情况:
- (1)在私有方法中放置assert作为输入参数的校验
在私有方法中可以放置assert校验输入参数,因为私有方法的使用者是作者自己,私有方法的调用者和被调用者之间是一种弱契约关系,或者说没有契约关系,其间的约束是依靠作者自己控制的,因此加上assert可以更好地预防自己犯错,或者无意的程序犯错。 - (2)流程控制中不可能达到的区域
这类似于JUnit的fail方法,其标志性的意义就是:程序执行到这里就是错误的。 - (3)建立程序探针
我们可能会在一段程序中定义两个变量,分别代表两个不同的业务含义,但是两者有固定的关系,例如var1=var2*2,那我们就可以在程序中到处设“桩”,断言这两者的关系,如果不满足即表明程序已经出现了异常,业务也就没有必要运行下去了。
- (1)在私有方法中放置assert作为输入参数的校验
20. 不要只替换一个类
- 发布应用系统时禁止使用类文件替换方式,整体WAR包发布才是万全之策。
21. 用偶判断,不用奇判断
22. 用整数类型处理货币
23. 不要让类型默默转换
- 是因为Java是先运算然后再进行类型转换的,具体地说就是因为disc2的三个运算参数都是int类型,三者相乘的结果虽然也是int类型,但是已经超过了int的最大值,所以其值就是负值了(为什么是负值?因为过界了就会从头开始),再转换成long型,结果还是负值。
- 在实际开发中,更通用的做法是主动声明式类型转化(注意不是强制类型转换),代码如下:
long dis2 = 1L * LIGHT_SPEED * 60 * 8;
- 基本类型转换时,使用主动声明方式减少不必要的Bug。
24. 边界,边界,还是边界
- 看着2147483647这个数字很眼熟?那就对了,它是int类型的最大值,没错,有人输入了一个最大值,使校验条件失效了,Why?我们来看程序,order的值是2147483647,那再加上1000就超出int的范围了,其结果是-2147482649,那当然是小于正数2000了!一句话可归结其原因:数字越界使检验条件失效。
25. 不要让四舍五入亏了一方
- 根据不同的场景,慎重选择不同的舍入模式,以提高项目的精准度,减少算法损失。
26. 提防包装类型的null值
- 在程序的for循环中,隐含了一个拆箱过程,在此过程中包装类型转换为了基本类型。我们知道拆箱过程是通过调用包装对象的intValue方法来实现的,由于包装对象是null值,访问其intValue方法报空指针异常也就在所难免了。问题清楚了,修改也很简单,加入null值检查即可
- 包装对象和拆箱对象可以自由转换,这不假,但是要剔除null值,null值并不能转化为基本类型。对于此类问题,我们谨记一点:包装类型参与运算时,要做null值校验。
27. 谨慎包装类型的大小比较
28. 优先使用整型池
- new声明的就是要生成一个新的对象,没二话,这是两个对象,地址肯定不等
- 如果是-128到127之间的int类型转换为Integer对象,则直接从cache数组中获得
- cache是IntegerCache内部类的一个静态数组,容纳的是-128到127之间的Integer对象。通过valueOf产生包装对象时,如果int参数在-128和127之间,则直接从整型池中获得对象,不在该范围的int类型则通过new生成包装对象。
- 通过包装类的valueOf生成包装实例可以显著提高空间和时间性能。
29. 优先选择基本类型
- 包装类型是一个类,它提供了诸如构造方法、类型转换、比较等非常实用的功能,而且在Java 5之后又实现了与基本类型之间的自动转换,这使包装类型如虎添翼,更是应用广泛了,在开发中包装类型已经随处可见,但无论是从安全性、性能方面来说,还是从稳定性方面来说,基本类型都是首选方案。
- 定义的两个f()方法实现了重载,一个形参是基本类型,一个形参是包装类型,这类重载很正常。虽然基本类型和包装类型有自动装箱、自动拆箱的功能,但并不影响它们的重载,自动拆箱(装箱)只有在赋值时才会发生,和重载没有关系。
- 基本类型可以先加宽,再转变成宽类型的包装类型,但不能直接转变成宽类型的包装类型。这句话比较拗口,简单地说就是,int可以加宽转变成long,然后再转变成Long对象,但不能直接转变成包装类型,注意这里指的都是自动转换,不是通过构造函数生成。
30. 不要随便设置随机种子
31. 在接口中不要存在实现代码
- 接口是一个契约,不仅仅约束着实现者,同时也是一个保证,保证提供的服务(常量、方法)是稳定、可靠的,如果把实现代码写到接口中,那接口就绑定了可能变化的因素,这就会导致实现不再稳定和可靠,是随时都可能被抛弃、被更改、被重构的。所以,接口中虽然可以有实现,但应避免使用。
32. 静态变量一定要先声明后赋值
- 静态变量是类加载时被分配到数据区(Data Area)的,它在内存中只有一个拷贝,不会被分配多次,其后的所有赋值操作都是值改变,地址则保持不变。
- 我们知道JVM初始化变量是先声明空间,然后再赋值的。静态变量是在类初始化时首先被加载的,JVM会去查找类中所有的静态声明,然后分配空间,注意这时候只是完成了地址空间的分配,还没有赋值,之后JVM会根据类中静态赋值(包括静态类赋值和静态块赋值)的先后顺序来执行。对于程序来说,就是先声明了int类型的地址空间,并把地址传递给了i,然后按照类中的先后顺序执行赋值动作,首先执行静态块中i=100,接着执行i=1,那最后的结果就是i=1了。
33. 不要覆写静态方法
- 我们知道一个实例对象有两个类型:表面类型(Apparent Type)和实际类型(Actual Type),表面类型是声明时的类型,实际类型是对象产生时的类型,比如我们例子,变量base的表面类型是Base,实际类型是Sub。对于非静态方法,它是根据对象的实际类型来执行的,也就是执行了Sub类中的doAnything方法。而对于静态方法来说就比较特殊了,首先静态方法不依赖实例对象,它是通过类名访问的;其次,可以通过对象访问静态方法,如果是通过对象调用静态方法,JVM则会通过对象的表面类型查找到静态方法的入口,继而执行之。
- 在子类中构建与父类相同的方法名、输入参数、输出参数、访问权限(权限可以扩大),并且父类、子类都是静态方法,此种行为叫做隐藏(Hide),它与覆写有两点不同:
- 表现形式不同。隐藏用于静态方法,覆写用于非静态方法。在代码上的表现是:@Override注解可以用于覆写,不能用于隐藏。
- 职责不同。隐藏的目的是为了抛弃父类静态方法,重现子类方法,例如我们的例子,Sub.doSomething的出现是为了遮盖父类的Base.doSomething方法,也就是期望父类的静态方法不要破坏子类的业务行为;而覆写则是将父类的行为增强或减弱,延续父类的职责。
34. 构造函数尽量简化
35. 避免在构造函数中初始化其他类
- 构造函数是一个类初始化必须执行的代码,它决定着类的初始化效率,如果构造函数比较复杂,而且还关联了其他类,则可能产生意想不到的问题,我们来看如下代码:
public class Client {
public static void main(String[] args) {
Son s = new Son();
s.doSomething();
}
}
//父类
class Father{
Father(){
new Other();
}
}//子类
class Son extends Father{
public void doSomething(){
System.out.println("Hi,show me something");
}
}
//相关类
class Other{
public Other(){
new Son();
}
}
- 这段代码并不复杂,只是在构造函数中初始化了其他类,想想看这段代码的运行结果是什么?是打印“Hi,show me something”吗?
- 答案是这段代码不能运行,报StackOverflowError异常,栈(Stack)内存溢出。这是因为声明s变量时,调用了Son的无参构造函数,JVM又默认调用了父类Father的无参构造函数,接着Father类又初始化了Other类,而Other类又调用了Son类,于是一个死循环就诞生了,直到栈内存被消耗完毕为止。
- 可能有读者会觉得这样的场景不可能在开发中出现,那我们来思考这样的场景:Father是由框架提供的,Son类是我们自己编写的扩展代码,而Other类则是框架要求的拦截类(Interceptor类或者Handle类或者Hook方法),再来看看该问题,这种场景不可能出现吗?
- 那有读者可能要说了,这种问题只要系统一运行就会发现,不可能对项目产生影响。
- 那是因为我们在这里展示的代码比较简单,很容易一眼洞穿,一个项目中的构造函数可不止一两个,类之间的关系也不会这么简单的,要想瞥一眼就能明白是否有缺陷这对所有人员来说都是不可能完成的任务,解决此类问题的最好办法就是:不要在构造函数中声明初始化其他类,养成良好的习惯。
36. 使用构造代码块精炼程序
- 在Java中一共有四种类型的代码块:
- 普通代码块:就是在方法后面使用“{}”括起来的代码片段,它不能单独执行,必须通过方法名调用执行。
- 静态代码块:在类中使用static修饰,并使用“{}”括起来的代码片段,用于静态变量的初始化或对象创建前的环境初始化。
- 同步代码块:使用synchronized关键字修饰,并使用“{}”括起来的代码片段,它表示同一时间只能有一个线程进入到该方法块中,是一种多线程保护机制。
- 构造代码块:在类中没有任何的前缀或后缀,并使用“{}”括起来的代码片段。
- 构造代码块会在每个构造函数内首先执行(需要注意的是:构造代码块不是在构造函数之前运行的,它依托于构造函数的执行),明白了这一点,我们就可以把构造代码块应用到如下场景中:
- 初始化实例变量(Instance Variable)
如果每个构造函数都要初始化变量,可以通过构造代码块来实现。当然也可以通过定义一个方法,然后在每个构造函数中调用该方法来实现,没错,可以解决,但是要在每个构造函数中都调用该方法,而这就是其缺点,若采用构造代码块的方式则不用定义和调用,会直接由编译器写入到每个构造函数中,这才是解决此类问题的绝佳方式。 - 初始化实例环境
一个对象必须在适当的场景下才能存在,如果没有适当的场景,则就需要在创建对象时创建此场景,例如在JEE开发中,要产生HTTP Request必须首先建立HTTP Session,在创建HTTP Request时就可以通过构造代码块来检查HTTP Session是否已经存在,不存在则创建之。
- 初始化实例变量(Instance Variable)
37. 构造代码块会想你所想
- 如果遇到this关键字(也就是构造函数调用自身其他的构造函数时)则不插入构造代码块,对于我们的例子来说,编译器在编译时发现String形参的构造函数调用了无参构造,于是放弃插入构造代码块,所以只执行了一次构造代码块
38. 使用静态内部类提高封装性
- Java中的嵌套类(Nested Class)分为两种:静态内部类(也叫静态嵌套类,Static Nested Class)和内部类(Inner Class)。内部类我们介绍过很多了,现在来看看静态内部类。什么是静态内部类呢?是内部类,并且是静态(static修饰)的即为静态内部类。只有在是静态内部类的情况下才能把static修复符放在类前,其他任何时候static都是不能修饰类的。
- 那静态内部类与普通内部类有什么区别呢?问得好,区别如下:
- (1)静态内部类不持有外部类的引用
在普通内部类中,我们可以直接访问外部类的属性、方法,即使是private类型也可以访问,这是因为内部类持有一个外部类的引用,可以自由访问。而静态内部类,则只可以访问外部类的静态方法和静态属性(如果是private权限也能访问,这是由其代码位置所决定的),其他则不能访问。 - (2)静态内部类不依赖外部类
普通内部类与外部类之间是相互依赖的关系,内部类实例不能脱离外部类实例,也就是说它们会同生同死,一起声明,一起被垃圾回收器回收。而静态内部类是可以独立存在的,即使外部类消亡了,静态内部类还是可以存在的。 - (3)普通内部类不能声明static的方法和变量
普通内部类不能声明static的方法和变量,注意这里说的是变量,常量(也就是final static修饰的属性)还是可以的,而静态内部类形似外部类,没有任何限制。
- (1)静态内部类不持有外部类的引用
39. 使用匿名类的构造函数
40. 匿名类的构造函数很特殊
41. 让多重继承成为现实
- 在Java中一个类可以多重实现,但不能多重继承,也就是说一个类能够同时实现多个接口,但不能同时继承多个类。
- 内部类可以继承一个与外部类无关的类,保证了内部类的独立性,正是基于这一点,多重继承才会成为可能。
42. 让工具类不可实例化
- 如果一个类不允许实例化,就要保证“平常”渠道都不能实例化它。