编写高质量Java代码的151个建议
圈主 [Rocky编程日记] 学习编写高质量Java代码的151个建议记录。
希望我写得笔记你能够喜欢, 希望我写的笔记能够给你提供帮助。
同时若笔记中存在不对的地方,那一定是圈主当时的理解还不够, 希望你能够及时指出嗷~
前人述备矣, 我只是知识的搬运工~
代码仓库地址:
code-improve-suggest
第一章 Java开发中通用的方法和准则
建议1:不要在常量和变量中出现易混淆的字母
i、l、1混用;o、0混用等
public static void main(String[] args) { long i = 1l; System.out.println("i的两倍是: " + (i + i)); }
此情此景就有可能会出现。所以,为了让您的程序更容易理解,字母“I”(还包括大写字母“O")尽量不要和数字混用,以免使阅读者的理解与程序意图产生偏差。如果字母和数字必须混合使用,字母“1”务必大写,字母“O”则增加注释。
建议2:莫让常量蜕变成变量
代码在运行过程中不要去改变常量值
public class Suggest2 { public static void main(String[] args) { System.out.println("常量会变噢: " + Const.RAND_CONST); } } public interface Const { public static final int RAND_CONST = new Random().nextInt(); }
这种常量的定义方式是极不可取的,常量就是常量,在编译期就必须确定其值,不应该在运行期更改,否则程序的可读性会非常差,甚至连作者自己都不能确定在运行期发生了何种神奇的事情。
建议3:三元操作符的类型务必一致
不一致会导致自动类型转换,类型提升int->float->double等。需要保证三元操作符的两个操作数类型一致, 即可减少可能错误的发生。
public class Suggest3 { public static void main(String[] args) { int i = 80; String s1 = String.valueOf(i < 100 ? 90 : 100); String s2 = String.valueOf(i < 100 ? 90 : 100.0); System.out.println("两者是否相等: " + s1.equals(s2)); } }
三元操作符是 if - else 的简化写法, 在项目中使用它的地方很多, 但是好用又简单的东西并不表示就可以随便用。
三元操作符类型的转换规则:
若两个操作数不可转换,则不做转换,返回值为Object类型。
若两个操作数是明确类型的表达式(比如变量),则按照正常的二进制数字来转换,int类型转换为long类型,long 类型转换为float类型等。
若两个操作数中有一个是数字S,另外一个是表达式,且其类型标示为T,那么,若数字S在T的范围内,则转换为T类型﹔若S超出了T类型的范围,则T转换为S类型(可以参考“建议22”,会对该问题进行展开描述)。
若两个操作数不可转换,则不做转换,返回值为Object类型。 若两个操作数是明确类型的表达式(比如变量),则按照正常的二进制数字来转换,int类型转换为long类型,long 类型转换为float类型等。
若两个操作数中有一个是数字S,另外一个是表达式,且其类型标示为T,那么,若数字S在T的范围内,则转换为T类型﹔若S超出了T类型的范围,则T转换为S类型(可以参考“建议22”,会对该问题进行展开描述)。 若两个操作数都是直接量数字(Literal) 9,则返回值类型为范围较大者。 保证三元操作符中的两个操作数类型一致,即可减少可能错误的发生。
建议4:避免带有变长参数的方法重载
变长参数的方法重载之后可能会包含原方法
public class Suggest4 { // 简单折扣计算 public void calPrice(int price, int discount) { float knockdownPrice = price * discount / 100.0F; System.out.println("简单折扣后的价格是: " + formatCurrency(knockdownPrice)); } // 复杂多折扣计算 public void calPrice(int price, int... discounts) { float knockdownPrice = price; for (int discount : discounts) { knockdownPrice = price * discount / 100; } System.out.println("复杂折扣后的价格是: " + formatCurrency(knockdownPrice)); } private String formatCurrency(float price) { return NumberFormat.getCurrencyInstance().format(price / 100); } public static void main(String[] args) { Suggest4 suggest4 = new Suggest4(); suggest4.calPrice(49900, 75); } }
Java 5引入变长参数( varags)就是为了更好垃提高方法的复用性,让方法的调用者可以“随心所欲”地传递实参数量,当然变长参数也是要遵循一定规则的,
比如变长参数必须是方法中的最后一个参数: 一个方法不能定义多个型长参数等,这些基本规则需要牢记,但是即使记住了这些规则,仍然有可能出现错误。
因为Java在编译时,首先会根据实参的数量和类型(这里是2个实参,都为int类型,注意没有转成int数组〉来进行处理,也就是查找到calPrice(int price,int discount)方法,而且确认它是否符合方法签名条件。
现在的问题是编译器为什么会首先根据2个int类型的实参而不是1个int类型、1个int数组类型的实参来查找方法呢﹖这是个好问题,也非常好回答:
因为int是一个原生数据类型,而数组本身是一个对象,编译器想要“偷懒”,于是它会从最简单的开始“猜想”,只要符合编译条件的即可通过,于是就出现了此问题。
建议5:别让null值和空值威胁到变长方法
两个都包含变长参数的重载方法,当变长参数部分空值,或者为null值时,重载方法不清楚会调用哪一个方法
public class Suggest5 { public void methodA(String str, Integer... is){ } public void methodA(String str, String... strs){ } public static void main(String[] args) { Suggest5 suggest5 = new Suggest5(); suggest5.methodA("Rocky编程日记",0); suggest5.methodA("Rocky编程日记","Rocky"); // suggest5.methodA("Rocky编程日记"); // suggest5.methodA("Rocky编程日记",null); } }
有两处编译通不过:
client.Method A(“中国”)和client.Method A(中国,null),估计你已经猜到了,两处的提示是相同的:方法模糊不清,编译器不知道调用哪一个方法,但这两处代码反映的代码味道可是不同的。
第一个不符合懒人原则外,第二个是调用者隐藏了实参类型,这是非常危险的,不仅仅调用者需要“猜测”该调用哪个方法,而且被调用者也可能产生内部逻辑混乱的情况。对于本例来说应该做如下修改:
public static void main(String[] args) { Suggest5 suggest5 = new Suggest5(); suggest5.methodA("Rocky编程日记",0); suggest5.methodA("Rocky编程日记","Rocky"); // suggest5.methodA("Rocky编程日记"); // suggest5.methodA("Rocky编程日记",null); String[] s = null; suggest5.methodA("Rocky编程日记", s); }
建议6:覆写变长方法也循规蹈矩
变长参数与数组,覆写的方法参数与父类相同,不仅仅是类型、数量,还包括显示形式
public class Suggest6 { public static void main(String[] args) { // 向上转型 Base base = new Sub(); base.fun(100, 50); // 不转型 Sub sub = new Sub(); // sub.fun(100, 50); } // 基类 static class Base { void fun(int price, int... discounts) { System.out.println("Base.....fun"); } } // 子类, 覆写父类方法 static class Sub extends Base { @Override void fun(int price, int[] discounts) { System.out.println("Sub....fun"); } } }
注意看注释的内容是有问题的。
事实上,base对象是把子类对象Sub做了向上转型,形参列表是由父类决定的,由于是变长参数,在编译时,“base.fun(100,50)”中的“50”这个实参会被编译器“猜测”而编译成“{50}”数组,再由子类Sub执行。
我们再来看看直接调用子类的情况,这时编译器并不会把“50”做类型转换,因为数组本身也是一个对象,编译器还没有聪明到要在两个没有继承关系的类之间做转换,要知道Java是要求严格的类型匹配的,类型不匹配编译器自然就会拒绝执行,并给予错误提示。
建议7:警惕自增的陷阱
count=count++;操作时JVM首先将count原值拷贝到临时变量区,再执行count加1,之后再将临时变量区的值赋给count,所以count一直为0或者某个初始值。C++中count=count++;与count++等效,而PHP与Java类似
public class Suggest7 { public static void main(String[] args) { int count = 0; for (int i = 0; i < 10; i++) { count = count++; } System.out.println("count== " + count); } }
建议8:不要让旧语法困扰你
Java中抛弃了C语言中的 goto 语法,但是还保留了该关键字,只是不进行语义处理,const 关键字同样类似。
public class Suggest8 { public static void main(String[] args) { // 数据定义及初始化 int fee = 200; // 其他业务处理 saveDefault:save(fee); } static void saveDefault() {} static void save(int fee) {} }
Java中虽然没有了goto关键字,但是扩展了break和continue关键字,它们的后面都可以加上标号做跳转,完全实现了goto功能,同时也把goto的诟病带了进来,所以我们在阅读大牛的开源程序时,根本就看不到 break或continue后跟标号的情况,甚至是break和continue都很少看到,这是提高代码可读性的一剂良药,旧语法就让它随风而去吧!
建议9:少用静态导入
Java5引入的静态导入语法import static,使用静态导入可以减少程序字符输入量,但是会带来很多代码歧义,省略的类约束太少,显得程序晦涩难懂。
import java.text.NumberFormat; import static java.lang.Math.PI; import static java.lang.Double.*; import static java.lang.Integer.*; import static java.text.NumberFormat.*; public class Suggest9 { // 计算圆面积 public static double calCircleArea(double r) { return Math.PI * r * r; } // 计算圆面积(静态导入的作用是把Math类中的PI常量引入到本地中) public static double calCircleArea1(double r) { return PI * r * r; } // 计算球面积 public static double calBallArea(double r) { return 4 * PI * r * r; } // 计算球面积 public static double calBallArea1(double r) { return 4 * Math.PI * r * r; } public static void main(String[] args) { double s = PI * parseDouble("1"); NumberFormat numberFormat = getInstance(); numberFormat.setMaximumFractionDigits(parseInt("2")); formatMessage(numberFormat.format(s)); } // 格式化消息输出 private static void formatMessage(String ans) { System.out.println("圆面积是: " + ans); } }
对于静态导入, 一定要遵循两个规则:
不使用 *(星号) 通配符, 除非是导入静态常量类(只包含常量的类或接口)。
方法名是具有明确、清晰表象意义的工具类。
建议10:不要在本类中覆盖静态导入的变量和方法
例如静态导入Math包下的PI常量,类属性中又定义了一个同样名字PI的常量。编译器的“最短路径原则”将会选择使用本类中的PI常量。本类中的属性,方法优先。如果要变更一个被静态导入的方法,最好的办法是在原始类中重构,而不是在本类中覆盖
import static java.lang.Math.PI; import static java.lang.Math.abs; public class Suggest10 { public static void main(String[] args) { demo1(); demo2(); } private static void demo1() { System.out.println("PI= " + PI); System.out.println("abs(100)=" + abs(100)); } public static void demo2() { final String PI = "祖冲之"; System.out.println("PI= " + PI); System.out.println("abs(100)=" + abs1(100)); } // 可改成abs 调试 public static int abs1(int abs) { return 0; } }
建议11:养成良好的习惯,显式声明UID
显式声明serialVersionUID可以避免序列化和反序列化中对象不一致,JVM根据serialVersionUID来判断类是否发生改变。隐式声明由编译器在编译的时候根据包名、类名、继承关系等诸多因子计算得出,极其复杂,算出的值基本唯一。
建议12:避免用序列化类在构造函数中为不变量赋值
- 在序列化类中,不适用构造函数为final变量赋值)(**序列化规则1:**如果final属性是一个直接量,在反序列化时就会重新计算;**序列化规则2:**反序列化时构造函数不会执行;**反序列化执行过程:**JVM从数据流中获取一个Object对象,然后根据数据流中的类文件描述信息(在序列化时,保存到磁盘的对象文件中包含了类描述信息,不是类)查看,发现是final变量,需要重新计算,于是引用Person类中的name值,而此时JVM又发现name没有赋值(因为反序列化时构造函数不会执行),不能引用,于是它不再初始化,保持原始值状态。整个过程中需要保持serialVersionUID相同
建议13:避免为final变量复杂赋值
- 类序列化保存到磁盘上(或网络传输)的对象文件包括两部分:1、类描述信息:包括包路径、继承关系等。注意,它并不是class文件的翻版,不记录方法、构造函数、static变量等的具体实现。2、非瞬态(transient关键字)和非静态(static关键字)的实例变量值。总结:反序列化时final变量在以下情况下不会被重新赋值:1、通过构造函数为final变量赋值;2、通过方法返回值为final变量赋值;3、final修饰的属性不是基本类型
建议14:使用序列化类的私有方法巧妙解决“部分属性持久化问题”
- 部分属性持久化问题解决方案1:把不需要持久化的属性加上瞬态关键字(transient关键字)即可,但是会使该类失去了分布式部署的功能。方案2:新增业务对象。方案3:请求端过滤。方案4:变更传输契约,即覆写writeObject和readObject私有方法,在两个私有方法体内完成部分属性持久化
建议15:break万万不可忘
switch语句中,每一个case匹配完都需要使用break关键字跳出,否则会依次执行完所有的case内容。
public class Suggest15 { public static void main(String[] args) { System.out.println("2 = " + toChineseNumberCase(2)); } private static String toChineseNumberCase(int value) { String chineseNumberCase =""; switch (value) { case 0: chineseNumberCase = "零"; case 1: chineseNumberCase = "壹"; case 2: chineseNumberCase = "贰"; case 3: chineseNumberCase = "叁"; case 4: chineseNumberCase = "肆"; case 5: chineseNumberCase = "伍"; case 6: chineseNumberCase = "陆"; case 7: chineseNumberCase = "柒"; case 8: chineseNumberCase = "捌"; case 9: chineseNumberCase = "玖"; } return chineseNumberCase; } }
对于此类问题最简单的解决办法: 修改IDE的警告级别
建议16:易变业务使用脚本语言编写
脚本语言:都是在运行期解释执行。脚本语言三大特性:
1、灵活:动态类型;
2、便捷:解释型语言,不需要编译成二进制,不需要像Java一样生成字节码,依靠解释执行,做到不停止应用变更代码;
3、简单:部分简单。Java使用ScriptEngine执行引擎来执行JavaScript脚本代码
建议17:慎用动态编译
好处:更加自如地控制编译过程。很少使用,原因:静态编译能够完成大部分工作甚至全部,即使需要使用,也有很好的替代方案,如JRuby、Groovy等无缝的脚本语言。动态编译注意以下4点:
1、在框架中谨慎使用:debug困难,成本大;
2、不要在要求高性能的项目中使用:需要一个编译过程,比静态编译多了一个执行环节;
3、动态编译要考虑安全问题:防止恶意代码;
4、记录动态编译过程
建议18:避免instanceof非预期结果
instanceof用来判断一个对象是否是一个类的实例,只能用于对象的判断,不能用于基本类型的判断(编译不通过),instanceof操作符的左右操作数必须有继承或实现关系,否则编译会失败。
例:null instanceof String返回值是false,instanceof特有规则,若左操作数是null,结果就直接返回false,不再运算右操作数是什么类
public class Suggest18 { public static void main(String[] args) { // “String”是一个字符串,字符串又继承了对象,那当然是返回true了。 boolean b1 = "String" instanceof Object; // 类对象就是它的实例 boolean b2 = new String() instanceof String; // Object 是父类, 其对象当然不是String类的实例 boolean b3 = new Object() instanceof String; // 编译失败 instance 只能用于对象的判断,不能用于基本类型的判断 // boolean b4 = 'A' instanceof Character; // 左边是false ,直接返回 false boolean b5 = null instanceof String; // 即使做类型转换也还是 null boolean b6 = (String) null instanceof String; // Date 和 String 没有继承或者实现关系, 编译通不过 // boolean b7 = new Date() instanceof String; // 编译通过, T 是 Object, "t instanceof Date" -> "Object instanceof Date" boolean b8 = new GenericClass<String>().isDateInstance(""); System.out.println("b1 " + b1); System.out.println("b2 " + b2); System.out.println("b3 " + b3); System.out.println("b5 " + b5); System.out.println("b6 " + b6); System.out.println("b8 " + b8); } static class GenericClass<T> { // 判断是否是Date类型 public boolean isDateInstance(T t) { return t instanceof Date; } } }
b1 true b2 true b3 false b5 false b6 false b8 false
建议19:断言绝对不是鸡肋
防御式编程中经常使用断言(Assertion)对参数和环境做出判断。断言是为调试程序服务的。
两个特性:
默认assert不启用;
assert抛出的异常AssertionError是继承自Error的
assert虽然是做断言的,但不能将其等价于if…else…这样的条件判断,它在以下两种情况不可使用 :
在对外公开的方法中
我们知道防御式编程最核心的一点就是:所有的外部因素(输入参数、环境变量、上下文)都是“邪恶”的,都存在着企图摧毁程序的罪恶本源,为了抵制它,我们要在程序中处处检验,满地设卡,不满足条件就不再执行后续程序,以保护主程序的正确性,处处设卡没问题,但就是不能用断言做输入校验,特别是公开方法。我们来看一个例子:
public class Suggest19 { public static void main(String[] args) { StringUtils.encode(null); } } class StringUtils { public static String encode(String str) { assert str != null : "加密的字符串为null"; return str; } }
encode方法对输人参数做了不为空的假设,如果为空,则抛出AssertionError错误,但这段程序存在一个严重的问题,
encode是一个public方法,这标志着是它对外公开的,任何一个类只要能够传递一个String类型的参数(遵守契约)就可以调用,
但是Client类按照规范和契约调用enocde方法,却获得了一个AssertionError错误信息,是谁破坏了契约协定?—一是encode方法自己。
在执行逻辑代码的情况下
assert 的支持是可选的,在开发时可以让它运行, 但在生产系统中则不需要其运行了(以便提高性能), 因此在 assert的布尔表达式中不能执行逻辑代码, 否则会因为环境不同而产生不同的逻辑。
建议20:不要只替换一个类
发布应用系统时禁止使用类文件替换方式,整体WAR包发布才是完全之策)(Client类中调用了Constant类中的属性值,如果更改了Constant常量类属性的值,重新编译替换。
而不改变或者替换Client类,则Client中调用的Constant常量类的属性值并不会改变。
**原因:**对于final修饰的基本类型和String类型,编译器会认为它是稳定态(Immutable Status),所以在编译时就直接把值编译到字节码中了,避免了再运行期引用,以提高代码的执行效率。而对于final修饰的类(即非基本类型),编译器认为它是不稳定态(Mutable Status),在编译时建立的则是引用关系(该类型也叫作Soft Final),如果Client类引入的常量是一个类或实例,即使不重新编译也会输出最新值
class Constant { public final static int MAX_AGE = 150; } public class Suggest20 { public static void main(String[] args) { System.out.println("人类寿命权限是: " + Constant.MAX_AGE); } }