参考:编写高质量代码:改善Java程序的151个建议
持续更新
、Java
、笔记
1.使用 long
类型时注意使用 大写L
// 错误的
long i = 1l;
// 正确的
long i = 1L;
2.三元表达式类型务必一致
三元操作符是if-else的简化写法,在项目中使用它的地方很多,也非常好用,但是好用 又简单的东西并不表示就可以随便用,我们来看看下面这段代码:
public static void main(String[] args) {
int i = 80;
String s = String.valueOf(i<100 ? 90 : 100);
String si = String.valueOf(i<100 ? 90 : 100.0);
System.out.println("两者是否相等:"+s.equals(si));
}
分析一下这段程序:i是80,那它当然小于100,两者的返回值肯定都是90,再转成 String类型,其值也绝对相等,毋庸置疑的。恩,分析得有点道理,但是变量s中三元操作符的第二个操作数是100,而si的第二个操作数是100.0,难道没有影响吗?不可能有影响吧,三元操作符的条件都为真了,只返回第一个值嘛,与第二个值有一毛钱的关系吗?貌似有道理。
果真如此吗?我们通过结果来验证一下,运行结果是:“两者是否相等:false”,什么? 不相等,Why?
问题就出在了 100和100.0这两个数字上,在变量s中,三元操作符中的第一个操作数 (90)和第二个操作数(100)都是int类型,类型相同,返回的结果也就是int类型的90,而变量si的情况就有点不同了,第一个操作数是90 (int类型),第二个操作数却是100.0,而这是个浮点数,也就是说两个操作数的类型不一致,可三元操作符必须要返回一个数据,而且类型要确定,不可能条件为真时返回int类型,条件为假时返回float类型,编译器是不允许如此的,所以它就会进行类型转换了,int型转换为浮点数90.0,也就是说三元操作符的返回值是浮点数90.0,那这当然与整型的90不相等了。这里可能会有疑惑了:为什么是整型转为浮点,而不是浮点转为整型呢?这就涉及三元操作符类型的转换规则:
若两个操作数不可转换,则不做转换,返回值为Object类型。
若两个操作数是明确类型的表达式(比如变量),則按照正常的二进制数字来转换,int类型转换为long类型,long类型转换为float类型等。
若两个操作数中有一个是数字S,另外一个是表达式,且其类型标示为T,那么,若数字S在T的范围内,则转换为T类型;若S超出了T类型的范围,则T转换为S类型。
若两个操作数都是直接量数字(Literal、字面量),则返回值类型为范围较大者。
知道是什么原因了,相应的解决办法也就有了 :保证三元操作符中的两个操作数类型一致,即可减少可能错误的发生。
3.避免 instanceof 非预期结果
instanceof是一个简单的二元操作符,它是用来判断一个对象是否是一个类实例的,其 操作类似于 >=、==,非常简单,我们来看段程序,代码如下:
public static void main(String[] args) {
// String对象是否是Object的实例
boolean b1 = "Sting" instanceof Object;
// String对象是否是String的实例
boolean b2 = new String() instanceof String;
// Object对象是否是String的实例
boolean b3 = new Object() instanceof String;
// 拆箱类型是否是装箱矣型的实例
boolean b4 = 'A' instanceof Character;
// 空对象是否是String的实例
boolean b5 = null instanceof String;
// 类负转換后的空对象是否是String的实例
boolean b6 = (String) null instanceof String;
//Date对象是否是String的实例
boolean b7 = new Date() instanceof String;
boolean b8 = new GenericClass<String>().isDateInstance("");
}
class GenericClass<T> {
// 判断是否是Date类型
public boolean isDateInstance(T t) {
return t instanceof Date;
}
}
就这么一段程序,instanceof的所有应用场景都出现了,同时问题也产生了:这段程序中哪些语句会编译通不过?我们一个一个地来解说。
"Sting" instanceof Object
返回值是true,这很正常,"String"是一个字符串,字符串又继承了 Object,那当然是返回true了。
new String() instanceof String
返回值是true,没有任何问题,一个类的对象当然是它的实例了。
new Object() instanceof String
返回值是false,Object是父类,其对象当然不是String类的实例了。要注意的是,这句话其实完全可以编译通过,只要instanceof关键字的左右两个操作数有继承或实现关系,就可以编译通过。
'A' instanceof Character
这句话可能有读者会猜错,事实上它编译不通过,为什么呢?因为是一个char类型,也就是一个基本类型,不是一个对象,instanceof只能用于对象的判断,不能用于基本类型的判断。
null instanceof String
返回值是false,这是instanceof特有的规则:若左操作数是null,结果就直接返回 false,不再运算右操作数是什么类。这对我们的程序非常有利,在使用instanceof操作符时, 不用关心被判断的类(也就是左操作数)是否为null,这与我们经常用到的equals、toString方法不同。
(String)null instanceof String
返回值是false,不要看这里有个强制类型转换就认为结果是true,不是的,null是一个万用类型,也可以说它没类型,即使做类型转换还是个null。
new Date() instanceof String
编译通不过,因为Date类和String没有继承或实现关系,所以在编译时直接就报错了,instanceof操作符的左右操作数必须有继承或实现关系,否则编译会失败。
new GenericClass
编译通不过?非也,编译通过了,返回值是false, T是个String类型,与Date之间没有继承或实现关系,为什么"t instanceof Date"会编译通过呢?那是因为Java的泛型是为编码服务的,在编译成字节码时,T已经是Object类型了,传递的实参是String类型,也就是说T的表面类型是Object,实际类型是String,那t instanceof Date
这句话就等价于 Object instance of Date
了,所以返回 false 就很正常了。
4.奇偶判断,用偶判断,不要用奇判断
判断一个数是奇数还是偶数是小学里学的基本知识,能够被2整除的整数是偶数,不能被2整除的是奇数,这规则简单又明了,还有什么好考虑的?好,我们来看一个例f,代码 如下:
public static void main(String[] args) {
//接收键盘输入参数
Scanner input = new Scanner(System.in);
System.out.print("请输入多个数字判断奇偶:");
while(input.hasNextlnt()) {
int i = input.nextInt();
String str = i + "->" + (i % 2 == 1 ? "奇数" : "偶数");
System.out.println(str);
}
}
输入多个数字,然后判断每个数字的奇偶性,不能被2整除就是奇数,其他的都是偶数,完全是根据奇偶数的定义编写的程序,我们来看看打印的结果:
请输入多个数字利断奇偶:1 2 0 -1 -2
1 -> 奇数
2 -> 偶数
0 -> 偶数
-1 -> 偶数
-2 -> 偶数
前三个还很靠谱,第四个参数怎么可能会是偶数呢,这Java也太差劲了,如此简单的计算也会错!別忙着下结论,我们先来了解一下Java中的取余(%标示符)算法,模拟代码如下:
// 橫拟取余计算,dividend被除数,divisor除数
public static int remainder(int dividend, int divisor) {
return dividend - dividend / divisor * divisor;
}
看到这段程序,相信大家都会心地笑了,原来Java是这么处理取余计算的呀。根据上面的模拟取余可知,当输入-1
的时候,运算结果是-1
,当然不等于1 了,所以它就被判定为偶数了,也就是说是我们的判断失误了。问题明白了,修正也很简单,改为判断是否是偶数即可,代码如下:
i % 2 == 0 ? "偶数" : "奇数";
5.不要让类型默默转换
看以下代码
public static final int LIGHT_SPEED = 30 * 10000 * 1000;
public static void main(String[] args) {
long dis = LIGHT_SPEED * 60 * 8;
System.out.println(dis);
}
结果:
-2028888064
这是应为Java是先运算然后再进行类型转换的,具体地说就是因为disc的三个运算参数都是int类型,三者相乘的结果虽然也是int类型,但是已经超过了int的最大值,所以其值就是负值了(为什么是负值?因为过界了就会从头开始),再转换成long型,结果还是负值。
- 问题知道了,解决问题也很简单,只需一个小小的
L
long dis = LIGHT_SPEED * 60L * 8;
在实际开发中,更通用的做法,是主动声明类型转化
long dis = 1L * LIGHT_SPEED * 60 * 8;
意思是:嗨,我已经是长整型了,你们都跟着我一起转为长整型吧
6.边界,边界,还是边界
某商家生产的电子产品非常畅销,需要提前30天预订才能抢到手,同时它还规定了一 个会员可拥有的最多产品数量,目的是防止囤积压货肆意加价。会员的预定过程是这样的: 先登录官方网站,选择产品型号,然后设置需要预订的数量,提交,符合规则即提示下单成功,不符合规则提示下单失败。后台的处理逻辑模拟如下:
// 一个会员拥有产品的最多数量
public final static int LIMIT = 2000;
public static void main(String[] args) {
// 会员当前拥有的产品数量
int cur = 1000;
Scanner input = new Scanner(System.in);
System.out.print("请输入需要预定的数量:");
while (input.hasNextInt()) {
int order = input.nextInt();
// 当前拥有的与准备订购的产品数量之和
if (order > 0 && order + cur <= LIMIT) {
System.out .println ("你已经成功定的" + order + "个产品!");
} else {
System.out.print("超出限额,预定失败!");
}
}
}
这是一个简易的订单处理程序,其中cur代表的是会员已经拥有的产品数LIMIT是一个会员最多拥有的产品数置(现实中这两个参数当然是从数据库中获得的,不过这里是一 个模拟程序),如果当前预订数置与拥有数之和超过了最大数量,则预订失败,否则下单成功。业务逻辑很简单,同时在Web界面上对订单数量做了严格的校验,比如不能是负值、 不能超过最大数量等,但是人算不如天算,运行不到两小时数据库中就出现了异常数据:某会员拥有产品的数址与预订数量之和远远大于限额。怎么会这样?程序逻辑上不可能有问题呀,这是如何产生的呢?我们来模拟一下,第一次输入:
请褕入需要预定的致量:800
你己经成功预定的800
个产品!
这完全满足条件,没有任何问题,继续输入:
请输入需要预定的数量:2147483647
你已径成功預定的2147483647
个产品!
看到没,这个数字远远超过了 2000的限额,但是竞然预订成功了,真是神奇!
看着2147483647
这个数字很眼熟?那就对了,它是int类型的最大值,没错,有人输入 了一个最大值,使校验条件失效了,Why?我们来看程序,order的值是2147483647
,那再加上1000就超出int的范围了,其结果是-21W482649
,那当然是小于正数2000
了!一句话可归结其原因:数字越界使检验条件失效
。
在单元测试中,有一项测试叫做边界测试(也有叫做临界测试),如果一个方法接收的是int类型的参数,那以下三个值是必测的:0、正最大、负最小
,其中正最大和负最小是边界值,如果这三个值都没有问题,方法才是比较安全可靠的。我们的例子就是因为缺少边界测试,致使生产系统产生了严重的偏差。
也许你要疑惑了,Web界面既然已经做了严格的校验,为什么还能输入2147483647
这么大的数字呢?是否说明Web校验不严格?错了,不是这样的,Web校验都是在页面上通过JavaScript实现的,只能限制普通用户(这里的普通用户是指不懂HTML、不懂HTTP、不懂Java的简单使用者),而对干高手,这些校验基本上就是摆设,HTTP是明文传输的,将其拦截几次,分析一下数据结构,然后再写一个模拟器,一切前端校验就都成了浮云!想往后台提交个什么数据那还不是信手拈来?
7.不要让四舍五入亏了一方
本建议还是来重温一个小学数学问题:四舍五入。四舍五入是一种近似精确的计算方法,在Java 5之前,我们一般是通过使用Math.round来获得指定精度的整数或小数的,这种方法使用非常广泛,代码如下:
public static void main(String[] args) {
System.out.println("10.5近似值:" + Math.round(10.5));
System.out.println("-10.5近似值:"+ Math.round(-10.5));
}
输出结果为:
10.5近似值:11
-10.5近似值:-10
这是四舍五入的经典案例,也是初级面试官很乐意选择的考题,绝对值相同的两个数字,近似值为什么就不同了呢?这是由Math.round
采用的舍入规则所决定的(采用的是正无穷方向舍入规则,后面会讲解)。我们知道四舍五入是有误差的:其误差值是舍入位的一半。我们以舍入运用最频繁的银行利息计算为例来阐述该问题。
我们知道银行的盈利渠道主要是利息差,从储户手里收拢资金,然后放贷出去,其间的利息差额便是所获得的利润。对一个银行来说,对付给储户的利息的计算非常频繁,人民银行规定每个季度末月的20日为银行结息日,一年有4次的结息日。
场景介绍完毕,我们回过头来看四舍五入,小于5的数字被舍去,大于等于5的数字进位后舍去,由于所有位上的数字都是自然计算出来的,按照概率计算可知,被舍入的数字均匀分布在0到9之间,下面以10笔存款利息计算作为模型,以银行家的身份来思考这个算法。
四舍。舍弃的数值:0.000、0.001、0.002、0.003、0.004,因为是舍弃的,对银行家来说,就不用付款给储户了,那每舍弃一个数字就会赚取相应的金额:0.000、0.001、0.002、0.003、0.004。
五入。进位的数值:0.005、0.006、0.007、0.008、0.009,因为是进位,对银行家来说,每进一位就会多付款给储户,也就是亏损了,那亏损部分就是其对应的10进制补数:0.005、0.004、0.003、0.002、0.001。
因为舍弃和进位的数字是在0到9之间均匀分布的,所以对于银行家来说,每10笔存款的利息因采用四舍五入而获得的盈利是:
0.000+0.001+0.002+0.003+0.004-0.005-0.004-0.003-0.002-0.001 = -0.005
也就是说,每10笔的利息计算中就损失0.005元,即每笔利息计算损失0.0005元,这对一家有5千万储户的银行来说(对国内的银行来说,5千万是个很小的数字),每年仅仅因为四舍五入的误差而损失的金额是:
public static void main(String[] args) {
// 银行账户数量,5千万
int accountNum = 5000 * 10000;
//按照人行的规定,每个季度末月的20日为银行结息日
double cost = 0.0005 * accountNum * 4 ;
System.out.println("银行每年损失的金额:" + cost);
}
输出的结果是:“银行每年损失的金额:100000.0”。即,每年因为一个算法误差就损失了10万元,事实上以上的假设条件都是非常保守的,实际情况可能损失得更多。那各位可能要说了,银行还要放贷呀,放出去这笔计算误差不就抵消掉了吗?不会抵销,银行的贷款数量是非常有限的,其数量级根本没有办法和存款相比。
这个算法误差是由美国银行家发现的(那可是私人银行,钱是自己的,白白损失了可不行),并且对此提出了一个修正算法,叫做银行家舍入(Banker's Round)的近似算法,其规则如下:
舍去位的数值小于5时,直接舍去;
舍去位的数值大于等于6时,进位后舍去;
当舍去位的数值等于5时,分两种情况:5后面还有其他数字(非0),则进位后舍去;若5后面是0(即5是最后一个数字),则根据5前一位数的奇偶性来判断是否需要进位,奇数进位,偶数舍去。
以上规则汇总成一句话:四舍六入五考虑,五后非零就进一,五后为零看奇偶,五前为偶应舍去,五前为奇要进一。
- 我们举例说明,取2位精度:
round(10.5551) = 10.56
round(10.555) = 10.56
round(10.545) = 10.54
要在Java 5以上的版本中使用银行家的舍入法则非常简单,直接使用RoundingMode类提供的Round模式即可,示例代码如下:
public static void main(String[] args) {
// 存款
BigDecimal d = new BigDecimal(888888);
// 月利率,乘3计算季利率
BigDecimal r = new BigDecimal(0.001875 * 3);
//计算利息
BigDecimal i = d.multiply(r).setScale(2, RoundingMode.HALF_EVEN);
System.out.println("季利息是:" + i);
}
在上面的例子中,我们使用了BigDecimal类,并且采用setScale方法设置了精度,同时传递了一个RoundingMode.HALF_EVEN
参数表示使用银行家舍入法则进行近似计算,BigDecimal和RoundingMode
是一个绝配,想要采用什么舍入模式使用RoundingMode设置即可。目前Java支持以下七种舍入方式:
ROUND_UP:远离零方向舍入。 向远离0的方向舍入,也就是说,向绝对值最大的方向舍入,只要舍弃位非0即进位。
ROUND_DOWN:趋向零方向舍入。 向0方向靠拢,也就是说,向绝对值最小的方向输入,注意:所有的位都舍弃,不存在进位情况。
ROUND_CEILING:向正无穷方向舍入。 向正最大方向靠拢,如果是正数,舍入行为类似于ROUND_UP;如果为负数,则舍入行为类似于ROUND_DOWN。注意:Math.round方法使用的即为此模式。
ROUND_FLOOR:向负无穷方向舍入。 向负无穷方向靠拢,如果是正数,则舍入行为类似于ROUND_DOWN;如果是负数,则舍入行为类似于ROUND_UP。
HALF_UP:最近数字舍入(5进)。这就是我们最最经典的四舍五入模式。
HALF_DOWN:最近数字舍入(5舍)。 在四舍五入中,5是进位的,而在HALF_DOWN中却是舍弃不进位。
HALF_EVEN:银行家算法。
在普通的项目中舍入模式不会有太多影响,可以直接使用Math.round方法,但在大量与货币数字交互的项目中,一定要选择好近似的计算模式,尽量减少因算法不同而造成的损失。
注意 根据不同的场景,慎重选择不同的舍入模式,以提高项目的精准度,减少算法损失。
8.在接口中不要存在实现代码
看到这样的标题读者可能会纳闷:接口中有实现代码?这怎么可能呢?确实,接口中可以声明常量,声明抽象方法,也可以继承父接口,但就是不能有具体实现,因为接口是一种契约(Contract
),是一种框架性协议,这表明它的实现类都是同一种类型,或者是具备相似特征的一个集合体。对于一般程序,接口确实没有任何实现,但是在那些特殊的程序中就例外了,阅读如下代码:
public static void main(String[] args) {
// 调用接口的实现
B.s.doSomething();
}
// 在接口中存在实现代码
interface B {
public static final S s = new S() {
public void doSomething() {
System.out.println("我在接口中实现了");
}
};
}
// 被实现的接口
interface S {
public void doSomething();
}
仔细看main方法,注意那个B接口。它调用了接口常量,在没有任何显式实现类的情况下,它竟然打印出了结果,那B接口中的s常量(接口是S)是在什么地方被实现的呢?答案是在B接口中。
在B接口中声明了一个静态常量s,其值是一个匿名内部类(Anonymous Inner Class
)的实例对象,就是该匿名内部类(当然,可以不用匿名,直接在接口中实现内部类也是允许的)实现了S接口。你看,在接口中存在着实现代码吧!
这确实很好,很强大,但是在一般的项目中,此类代码是严禁出现的,原因很简单:这是一种不好的编码习惯,接口是用来干什么的?接口是一个契约,不仅仅约束着实现者,同时也是一个保证,保证提供的服务(常量、方法)是稳定、可靠的,如果把实现代码写到接口中,那接口就绑定了可能变化的因素,这就会导致实现不再稳定和可靠,是随时都可能被抛弃、被更改、被重构的。所以,接口中虽然可以有实现,但应避免使用。
注意 接口中不能存在实现代码
。
9.避免在构造函数中初始化其他类
构造函数是一个类初始化必须执行的代码,它决定着类的初始化效率,如果构造函数比较复杂,而且还关联了其他类,则可能产生意想不到的问题,我们来看如下代码:
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方法
),再来看看该问题,这种场景不可能出现吗?
那有人可能要说了,这种问题只要系统一运行就会发现,不可能对项目产生影响。
那是因为我们在这里展示的代码比较简单,很容易一眼洞穿,一个项目中的构造函数可不止一两个,类之间的关系也不会这么简单的,要想瞥一眼就能明白是否有缺陷这对所有人员来说都是不可能完成的任务,解决此类问题的最好办法就是:不要在构造函数中声明初始化其他类,养成良好的习惯。
10.让工具类不可实例化
Java项目中使用的工具类非常多,比如JDK自己的工具类java.lang.Math、java.util.Collections
等都是我们经常用到的。工具类的方法和属性都是静态的,不需要生成实例即可访问,而且JDK也做了很好的处理,由于不希望被初始化,于是就设置构造函数为private访问权限,表示除了类本身外,谁都不能产生一个实例,我们来看一下java.lang.Math代码:
public final class Math {
/**
* Don't let anyone instantiate this class.
*/
private Math() {}
}
之所以要将"Don't let anyone instantiate this class.
"留下来,是因为Math的构造函数设置为private了:我就是一个工具类,我只想要其他类通过类名来访问,我不想你通过实例对象访问。这在平台型或框架型项目中已经足够了。但是如果已经告诉你不能这么做了,你还要生成一个Math实例来访问静态方法和属性(Java的反射是如此的发达,修改个构造函数的访问权限易如反掌),那我就不保证正确性了,隐藏问题随时都有可能爆发!那我们在项目开发中有没有更好的限制办法呢?有,即不仅仅设置成private访问权限,还抛异常,代码如下:
public class UtilsClass {
private UtilsClass() {
throw new Error("不要实例化我!");
}
}
如此做才能保证一个工具类不会实例化,并且保证所有的访问都是通过类名来进行的。需要注意一点的是,此工具类最好不要做继承的打算,因为如果子类可以实例化的话,那就要调用父类的构造函数,可是父类没有可以被访问的构造函数,于是问题就会出现。
注意 如果一个类不允许实例化,就要保证“平常”渠道都不能实例化它。
文章评论