建议1:不要在常量和变量中出现易混淆的字母
包名全小写,类名首字母全大写,常量全部大写并用下划线分隔,变量采用驼峰命名法(Camel Case)命名等,这些都是最基本的Java编码规范,字母“l”(还包括大写字母“O”)尽量不要和数字混用。如果字母和数字必须混合使用,字母“l”务必大写,字母“O”则增加,注释。字母“l”作为长整型标志时务必大写。
建议2:莫让常量蜕变成变量
import java.util.Random;
public class test {
public static void main(String[] args) {
test02();
}
public static void test02() {
System.out.println("常量会变哦:" + Constant.RAND_CONST);
}
}
interface Constant {
public static final int RAND_CONST = new Random().nextInt();
}
常量在编译期就必须确定其值,不应该在运行期更改,否则程序的可读性会非常差,不要想着使用常量会变的这个功能来实现序列号算法、随机种子生成,除非这真的是项目中的唯一方案,否则就放弃吧,常量还是当常量使用。注意:务必让常量的值在运行期保持不变。
建议3:三元操作符的类型务必一致
public static void test03() {
int i = 80;
String str = String.valueOf(i < 100 ? 90 : 100);
String str1 = String.valueOf(i < 100 ? 90 : 100.0);
System.out.println("两者是否相等:" + str.equals(str1));
}
三元运算时if-else的缩写,三元操作符必须要返回一个数据,而且类型要确定。这里就涉及到类型的转化。1. 若两个操作数不可转换,则不作转换,返回值是Object类型;
2. 若两个操作数是明确类型的表达式(比如变量),则按照正常的二进制数字转换,int转为long,long转为float等; 3. 若两个操作数中有一个是数字S,另外一个是表达式,且其类型标志位T,那么,若数字S在T的范围内,则转换为T类型;若S超出了T的范围,则T转换为S; 4. 若两个操作数都是直接量数字,则返回值类型范围较大者。保证三元运算符的两个操作数类型一致。
建议4:避免带有变长参数的方法重载
import java.text.NumberFormat;
public class test {
public static void main(String[] args) {
test client = new test();
// 499元的货物 打75折
client.calPrice(499, 75);
}
// 简单折扣计算
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 = knockdownPrice * discount / 100;
}
System.out.println("复杂折扣后的价格是:" + formatCurrency(knockdownPrice));
}
public String formatCurrency(float price) {
return NumberFormat.getCurrencyInstance().format(price);
}
}
不确定数量的参数在方法中的传递,在JAVA5之前常用的设计技巧就是把形参定义成Collection类型或其子类类型,或者数组类型,这种方法的缺点就是需要对空参数进行判断和筛选,比如实参为null值和长度为0的Collection或数组。而Java5引入了变长参数(varags)就是为了更好地挺好方法的复用性,变长参数也是要遵循一定规则的,比如变长参数必须是方法中的最后一个参数;一个方法不能定义多个变长参数等。本例中是一个商店打折的例子,这里的方法重载calPrice(int price ,int... discounts)的参数范畴覆盖了calPrice(int price,int discount)的参数范畴,对于calPrice(499,75)这样的计算到底走的哪个函数,如果参数多余两个肯定走的不定参数,而此处的两个参数走的是calPrice(499,75),int是一个原生数据类型,而数组本身是一个对象,编译器想要"偷懒",于是它会从最简单的开始"猜想",只要符合编译条件的即可通过,于是就出现了此问题。
注:慎重考虑变长参数的方法重载
建议5:别让null值和空值威胁到变长方法
public class test {
public void methodA(String str, Integer... is) {
}
public void methodA(String str, String... strs) {
}
public static void main(String[] args) {
test client5 = new test();
client5.methodA("china", 0);
client5.methodA("china", "people");
client5.methodA("china");
client5.methodA("china", null);
}
}
两个methodA都进行了重载,现在的问题是:上面的client5.methodA("china");client5.methodA("china", null); 编译不通过,提示相同:方法模糊不清,编译器不知道调用哪一个方法,但这两处代码反应是不同的对于methodA("china")方法,根据实参"china"(String类型),两个方法都符合形参格式,编译器不知道调用那个方法,符合规则却不能调用违反了KISS原则(Keep it Smile,Stupid,即懒人原则),对于Client5.methodA("China",null),null是没有类型的,调用者隐藏了自己的实参类型导致符合规则却不知调用哪个。(这里需要修改为明确null为string类型)。
建议6:覆写变长方法也循规蹈矩
子类覆写父类的中的方法既可以修正bug,也可以提供扩展的业务功能支持,同时还符合开闭原则(Open-Closed Principle)。符合开闭原则(Open-Closed Principle)的主要特征:
1)对于扩展是开放的(Open for extension)。这意味着模块的行为是可以扩展的。当应用的需求改变时,我们可以对模块进行扩展,使其具有满足那些改变的新行为。也就是说,我们可以改变模块的功能。
2)对于修改是关闭的(Closed for modification)。对模块行为进行扩展时,不必改动模块的源代码或者二进制代码。模块的二进制可执行版本,无论是可链接的库、DLL或者.EXE文件,都无需改动。
覆写必须满足的条件:
1)覆写方法不能缩小访问权限;
2)参数列表必须与被覆写方法相同;
3)返回类型必须与被重写方法的相同;
4)重写方法不能抛出新的异常,或者超出父类范围的异常,但是可以抛出更少,更有限的异常,或者不抛出异常。
public class test {
public static void main(String[] args) {
// 向上转型
Base base = new Sub();
base.fun(100, 50);
// 不转型
Sub sub = new Sub();
sub.fun(100, 50);
}
}
// 基类
class Base {
void fun(int price, int... discounts) {
System.out.println("Base......fun");
}
}
// 子类,覆写父类方法
class Sub extends Base {
@Override
void fun(int price, int[] discounts) {
System.out.println("Sub......fun");
}
}
该程序中sub.fun(100, 50)报错,提示找不到fun(int,int)方法,表面上看符合了继承规则,但是base对象是把子类对象做了向上转型,形参列表由父类决定,由于是变长参数,在编译时,base.fun(100, 50);中的50这个实参会被编译器"猜测"而编译成"{50}"数组,再由子类Sub执行。直接调用子类编译器并不会把"50"实参类型转换因为数组本身也是一个对象,编译器还没有聪明到要在两个没有继承关系的类之间转换,要知道JAVA是要求严格的类型匹配的,类型不匹配编译器自然就会拒绝执行,并给予错误提示。这里覆写的方法参数列表竟然与父类不相同,这违背了覆写的定义。注:覆写的方法参数与父类相同,不仅仅是类型、数量,还包括显示形式。
建议7:警惕自增的陷阱
public class test {
public static void main(String[] args) {
int count=0;
for(int i=0; i<10;i++){
count=count++;
}
System.out.println("count = "+count);
}
}
结果为0,count++是一个表达式,返回count自加前的值,java中的自加把count的值拷贝至一个临时变量区,然后自加1,最后返回临时变量区的值,此程序处理过程为:1)JVM把count的值(其值是0)拷贝到临时变量区;2)count的值+1,这时候count的值是1;3)返回临时变量区的值,注意这个值是0,没修改过;4)返回值赋给count,此时count的值被重置为0。count=count++可理解为用以中间变量保存初始值,自增操作,返回初始值。所以此处结果为0,在不同的语言环境中有着不同的实现,C++中"count=count++"与"count++"是等效的,而在PHP中保持着与JAVA相同的处理方式。每种语言对自增的实现方式各不相同。
建议8:不要让旧语法困扰你
public class test {
public static void main(String[] args) {
int fee = 200; // 数据定义初始化
saveDefault: save(fee); // 其它业务处理
}
static void saveDefault() {
System.out.println("saveDefault....");
}
static void save(int fee) {
System.out.println("save....");
}
}
这里的奇怪之处在于出现了标号,java中保留关键字go to但抛弃了其语法,类似的还有const关键字,如果是纯java开发者看到这会容易看到,学过C++之类的可能不会感到奇怪。java扩展了break和continue关键字但是仍要尽量少用,要跟随java更新的脚步,抛弃旧的语法。
建议9:少用静态导入
Java5开始引入了静态导入语法(import static),其目的是为了减少字符的输入量,提高代码的可阅读性。但是滥用静态导入会使程序更难阅读更难维护,静态导入后,代码中就不需要再写类名了,类是"一类事物的描述",缺少了类名的修饰,静态属性和静态方法的表象意义可以被无限放大,很难弄清楚其属性或者方法代表何意,甚至哪一类的属性(方法)都要思考一番。
import static java.lang.Math.*;
import static java.lang.Double.*;
import static java.lang.Integer.*;
import static java.text.NumberFormat.*;
import java.text.NumberFormat;
public class Client {
public static void formatMessage(String s) {
System.out.println("圆面积是: " + s);
}
public static void main(String[] args) {
double s = PI * parseDouble(args[0]);
NumberFormat nf = getInstance();
nf.setMaximumFractionDigits(parseInt(args[1]));
formatMessage(nf.format(s));
}
}
常量PI,这知道是圆周率,parseDouble方法可能是Double类的一个转换方法,那getInstance()方法是哪个类的?是Client本地类?本地没有这个方法,原来是NumberFormat类的方法,这个和formatMessage本地方法没有任何区别了---这代码太难阅读了,所以,对于静态导入,一定要追寻两个原则:
1)不使用*(星号)通配符,除非是导入静态常量类(只包含常量的类或接口)。
2)方法名是具有明确、清晰表象意义的工具类。
import static org.junit.Assert.*;
class DaoTest{
@Test
public void testInsert(){
//断言
assertEquals("foo","foo");
assertFalse(Boolean.FALSE);
}
}
从程序中很容易判断出assertEquals方法是用来断言两个值是否相等的,assertFalse方法则是断言表达式为假,这是静态导入用到正确的地方带来的好处。减少代码量且可读性较高。
建议10:不要在本类中覆盖静态导入的变量和方法
import static java.lang.Math.PI;
import static java.lang.Math.abs;
public class test {
// 常量名于静态导入的PI相同
public final static String PI = "祖冲之";
//方法名于静态导入的方法相同
public static int abs(int abs) {
return 0;
}
public static void main(String[] args) {
System.out.println("PI = "+PI);
System.out.println("abs(-100) = "+abs(-100));
}
}
程序的输出结果为PI = "祖冲之",abs(-100) = 0,而不是所期望的正确值,那是因为定义的方法与静态导入相同,很明显是本地的方法被调用了,为何不调用Math类中的那是因为编译器有一个"最短路径"原则:如果能够在本类中查找到相关的变量、常量、方法、就不会去其它包或父类、接口中查找,以确保本类中的属性、方法优先。因此,如果要变更一个被静态导入的方法,最好的办法是在原始类中重构,而不是在本类中覆盖。
建议11:养成良好习惯,显示声明UID
类实现Serializable接口的目的是为了可持久化,比如网络传输或本地存储,为系统的分布和异构部署提供先决条件支持。若没有序列化,远程调用、对象数据库都不可能存在。通过对象序列化过程,把一个内存块转化为可传输的数据流,然后通过网络发送到消息消费者(Customer)哪里,进行反序列化,生成实验对象。序列化和反序列化的类在不一致的情况下,反序列化时会报一个InalidClassException异常,原因是序列化和反序列化所对应的类版本发生了变化,JVM不能把数据流转换为实例对象。JVM是根据SerializableUID,也叫做流标识符(Stream Unique Identifier),即类的版本定义的,它可以显示声明也可以隐式声明。显示声明格式如下:
private static final long serialVersionUID = 1867341609628930239L; 而隐式声明则是我不声明,你编译器在编译的时候帮我生成。生成的依据是通过包名、类名、继承关系、非私有的方法和属性,以及参数、返回值等诸多因子算出来的,极度复杂,基本上计算出来的这个值是唯一的。
serialVersionUID的作用。JVM在反序列化时,会比较数据流中的serialVersionUID与类的serialVersionUID是否相同,如果相同,则认为类没有改变,可以把数据load为实例相同;如果不相同,JVM抛个异常InviladClassException。这是一个非常好的校验机制,完美的实现了类的一致性。但是,有时候我们需要一点特例场景,例如我的类改变不大,JVM是否可以把我以前的对象反序列化回来?就是依据显示声明的serialVersionUID,向JVM撒谎说"我的类版本没有变化",如此我买你编写的类就实现了向上兼容,我们修改Person类,里面添加private static final long serialVersionUID = 1867341609628930239L;
刚开始生产者和消费者持有的Person类一致,都是V1.0,增加了一个“年龄”属性,升级为V2.0,
此时虽然生产这和消费者对应的类版本不同,但是显示声明的serialVersionUID相同,序列化也是可以运行的,所带来的业务问题就是消费端不能读取到新增的业务属性(age属性而已)。
显示声明serialVersionUID可以避免对象的不一致,但尽量不要以这种方式向JVM撒谎。
建议12:避免用序列化类在构造函数中为不变量赋值
带有final标识的属性是不变量,也就是只能赋值一次,不能重复赋值,但是在序列化类中保持新旧对象的final变量相同,有利于代码业务逻辑统一,这是序列化的基本原则之一,也就是说,如果final属性是一个直接量,在反序列化时就会重新计算。但是如果在构造函数中进行变量的赋值的话,反序列化的另一个原则:反序列化时构造函数不会执行,反序列化的执行过程:JVM从数据流中获取一个Object对象,然后根据数据流中的类文件描述信息(在序列化时,保存到磁盘的对象文件中包含了类描述信息,注意是描述信息,不是类)查看,发现是final变量,需要重新计算,于是引用Person类中的perName值,而此时JVM又发现perName竟没有赋值,不能引用,于是它很聪明的不再初始化,保持原值状态。
注意:在序列化类中不使用构造函数为final变量赋值
建议13:避免为final变量复杂赋值
final变量赋值还有另外一种方式:通过方法赋值
import java.io.Serializable;
public class test implements Serializable {
private static final long serialVersionUID = 1867341609628930239L;
//通过方法返回值为final变量赋值
public final String pName = initName();
public String initName() {
return "混世魔王";
}
}
现在序列化后做修改,initName()方法返回值变为“尉迟恭”,那么也就是通过new生成的对象的final变量的值都是"尉迟恭",那么我们把之前存储在磁盘上的实例加载上来,pName的值却是混世魔王,在建议12中final会被重新赋值在此确没有,上个建议说的重新赋值,其中的"值"指的是简单对象。简单对象包括:8个基本类型,以及数组、字符串(字符串情况复杂,不通过new关键字生成的String对象的情况下,final变量的赋值与基本类型相同),但是不能方法赋值。
其中的原理是这样的,保存到磁盘上(或网络传输)的对象文件包括两部分:
(1).类描述信息:包括类路径、继承关系、访问权限、变量描述、变量访问权限、方法签名、返回值、以及变量的关联类信息。要注意一点是,它并不是class文件的翻版,它不记录方法、构造函数、static变量等的具体实现。之所以类描述会被保存,很简单,是因为能去也能回嘛,这保证反序列化的健壮运行。
(2).非瞬态(transient关键字)和非静态(static关键字)的实体变量值
注意,这里的值如果是一个基本类型,就是一个简单值保存下来;如果是复杂对象,连该对象和关联类信息一起保存,并且持续递归下去(关联类也必须实现Serializable接口,否则会出现序列化异常),也就是递归到最后,还是基本数据类型的保存。
正是因为这两个原因,一个持久化的对象文件会比一个class类文件大很多
总结:反序列化时final变量在以下情况下不会被重新赋值:
1)通过构造函数为final变量赋值
2)通过方法返回值为final变量赋值
3)final修饰的属性不是基本类型
建议14:使用序列化类的私有方法巧妙解决部分属性持久化问题
部分属性持久化问题看似很简单,只要把不需要持久化的属性加上瞬态关键字(transient关键字)即可。这是一种解决方案,但有时候行不通。本例中是对工资的计算,基本工资 basePay和绩效工资bonus解决保密数据的方法有:1)在bonus前加上关键字transient:这是一个方法,但不是一个好方法,加上transient关键字就标志着Salary失去了分布式部署的功能,它可能是HR系统核心的类了,一旦遭遇性能瓶颈,再想实现分布式部署就可能了,此方案否定。
2)新增业务对象:增加一个Person4Tax类,完全为计税系统服务,就是说它只有两个属性:姓名和基本工资。符合开闭原则,而且对原系统也没有侵入性,只是增加了工作量而已。但是这个方法不是最优方法。
3)请求端过滤:在计税系统获得Person对象后,过滤掉Salary的bonus属性,方案可行但不符合规矩,因为HR系统中的Salary类安全性竟然然外系统(计税系统来承担),设计严重失职。
4)变更传输契约:例如改用XML传输,或者重建一个WebSerive服务,可以做但成本很高。
下面展示一个优秀的方案,其中实现了Serializable接口的类可以实现两个私有方法:writeObject和readObject,以影响和控制序列化和反序列化的过程。把Person类稍作修改:
public class Person implements Serializable {
private static final long serialVersionUID = 9146176880143026279L;
private String name;
private transient Salary salary;
public Person(String _name, Salary _salary) {
this.name = _name;
this.salary = _salary;
}
//序列化委托方法
private void writeObject(ObjectOutputStream oos) throws IOException {
oos.defaultWriteObject();
oos.writeInt(salary.getBasePay());
}
//反序列化委托方法
private void readObject(ObjectInputStream input)throws ClassNotFoundException, IOException {
input.defaultReadObject();
salary = new Salary(input.readInt(), 0);
}
}
它代码不做任何改动在Person类中增加了writeObject和readObject两个方法,并且访问权限都是私有级别,这里用了序列化的独有机制:序列化回调。Java调用ObjectOutputStream类把一个对象转换成数据流时,会通过反射(Refection)检查被序列化的类是否有writeObject方法,并且检查其是否符合私有,无返回值的特性,若有,则会委托该方法进行对象序列化,若没有,则由ObjectOutputStream按照默认规则继续序列化。同样,在从流数据恢复成实例对象时,也会检查是否有一个私有的readObject方法,如果有,则会通过该方法读取属性值,此处有几个关键点
1)oos.defaultWriteObject():告知JVM按照默认的规则写入对象,惯例的写法是写在第一行。
2)input.defaultReadObject():告知JVM按照默认规则读入对象,惯例的写法是写在第一行。
3)oos.writeXX和input.readXX
分别是写入和读出相应的值,类似一个队列,先进先出,如果此处有复杂的数据逻辑,建议按封装Collection对象处理。大家可能注意到上面的方式也是Person失去了分布式部署的能了,确实是,但是HR系统的难点和重点是薪水的计算,特别是绩效工资,它所依赖的参数很复杂(仅从数量上说就有上百甚至上千种),计算公式也不简单(一般是引入脚本语言,个性化公式定制)而相对来说Person类基本上都是静态属性,计算的可能性不大,所以即使为性能考虑,Person类为分布式部署的意义也不大。
建议15:break万万不可忘
public class Client {
public static void main(String[] args) {
System.out.println(toChineseNuberCase(0));
}
public static String toChineseNuberCase(int n) {
String chineseNumber = "";
switch (n) {
case 0:
chineseNumber = "零";
case 1:
chineseNumber = "壹";
case 2:
chineseNumber = "贰";
case 3:
chineseNumber = "叁";
case 4:
chineseNumber = "肆";
case 5:
chineseNumber = "伍";
case 6:
chineseNumber = "陆";
case 7:
chineseNumber = "柒";
case 8:
chineseNumber = "捌";
case 9:
chineseNumber = "玖";
}
return chineseNumber;
}
}
运行结果玖。case 的穿透力,break的重要性。
建议16:易变业务使用脚本语言编写
脚本语言都是在运行期解释执行的。脚本语言的三大特征
1)灵活:脚本语言一般都是动态类型,可以不用声明变量类型而直接使用,可以再运行期改变类型。
2)便捷:脚本语言是一种解释性语言,不需要编译成二进制代码,也不需要像Java一样生成字节码。它的执行时依靠解释器解释的,因此在运行期间变更代码很容易,而且不用停止应用;
3)简单:只能说部分脚本语言简单,比如Groovy,对于程序员来说,没有多大的门槛。
引入脚本语言可以使Java更强大,于是Java6开始正式支持脚本语言,JSCP(Java Community ProCess)很聪明的提出了JSR233规范,只要符合该规范的语言都可以在Java平台上运行(它对JavaScript是默认支持的)。
建议17:慎用动态编译
从Java6开始支持动态编译了,可以在运行期直接编译.java文件,执行.class,并且获得相关的输入输出,甚至还能监听相关的事件。不过,我们最期望的还是定一段代码,直接编译,然后运行,也就是空中编译执行(on-the-fly),动态编译虽然是很好的工具,让我们可以更加自如的控制编译过程,但是在我们目前所接触的项目中还是使用较少。原因很简单,静态编译已经能够帮我们处理大部分的工作,甚至是全部的工作,即使真的需要动态编译,也有很好的替代方案,比如Jruby、Groovy等无缝的脚本语言。另外,我们在使用动态编译时,需要注意以下几点:
1.在框架中谨慎使用:比如要在struts中使用动态编译,动态实现一个类,它若继承自ActionSupport就希望它成为一个Action。能做到,但是debug很困难;再比如在Spring中,写一个动态类,要让它注入到Spring容器中,这是需要花费老大功夫的。
2.不要在要求性能高的项目中使用:如果你在web界面上提供了一个功能,允许上传一个java文件然后运行,那就等于说:"我的机器没有密码,大家都可以看看",这是非常典型的注入漏洞,只要上传一个恶意Java程序就可以让你所有的安全工作毁于一旦。
3.记录动态编译过程:建议记录源文件,目标文件,编译过程,执行过程等日志,不仅仅是为了诊断,还是为了安全和审计,对Java项目来说,空中编译和运行时很不让人放心的,留下这些依据可以很好地优化程序。
建议18:避免instanceof非预期结果
instanceof是一个简单的二元操作符,它是用来判断一个对象是否是一个类的实现,其操作类似于>=、==,
import java.util.Date;
public class test {
public static void main(String[] args) {
// String对象是否是Object的实例 true
boolean b1 = "String" instanceof Object;
// String对象是否是String的实例 true
boolean b2 = new String() instanceof String;
// Object对象是否是String的实例 false
boolean b3 = new Object() instanceof String;
// 拆箱类型是否是装箱类型的实例 编译不通过
boolean b4 = 'A' instanceof Character;
// 空对象是否是String的实例 false
boolean b5 = null instanceof String;
// 转换后的空对象是否是String的实例 false
boolean b6 = (String) null instanceof String;
// Date是否是String的实例 编译不通过
boolean b7 = new Date() instanceof String;
// 在泛型类型中判断String对象是否是Date的实例 false
boolean b8 = new GenericClass<String>().isDateInstance("");
}
}
class GenericClass<T> {
// 判断是否是Date类型
public boolean isDateInstance(T t) {
return t instanceof Date;
}
}
1. "String" instanceof Object:返回值是true,"String"是一个字符串,字符串又继承了Object,那当然返回true了。
2. new String() instanceof String:返回值是true,没有任何问题,一个类的对象当然是它的实例了。
3. new Object() instanceof String:返回值为false,Object是父类,其对象当然不是String类的实例了。要注意的是,这句话其实完全可以编译通过,只要instanceof关键字的左右两个操作数有继承或实现关系,就可以编译通过。
4. 'A' instanceof Character:这句话编译不通过,为什么呢?因为'A'是一个char类型,也就是一个基本类型,不是一个对象,instanceof只能用于对象的判断,不能用于基本类型的判断。
5. null instanceof String:返回值为false,这是instanceof特有的规则,若做操作数为null,结果就直接返回false,不再运算右操作数是什么类。这对我们的程序非常有利,在使用instanceof操作符时,不用关心被判断的类(也就是左操作数)是否为null,这与我们经常用到的equals、toString方法不同。
6. (String) null instanceof String:返回值为false,不要看这里有个强制类型转换就认为结果是true,不是的,null是一个万用类型,也就是说它可以没类型,即使做类型转换还是个null。
7. new Date() instanceof String:编译不通过,因为Date类和String没有继承或实现关系,所以在编译时就直接报错了,instanceof操作符的左右操作数必须有继承或实现关系,否则编译会失败。
8. new GenericClass<String>().isDateInstance(""):编译不通过,非也,编译通过了,返回值为false,T是个String类型,于Date之间没有继承或实现关系,为什么"t instanceof Date"会编译通过呢?那是因为Java的泛型是为编码服务的,在编译成字节码时,T已经是Object类型了传递的实参是String类型,也就是说T的表面类型是Object,实际类型是String,那么"t instanceof Date"等价于"Object instanceof Date"了,所以返回false就很正常了。
建议19:断言绝对不是鸡肋
在防御式编程中经常会用断言(Assertion)对参数和环境做出判断,避免程序因不当的判断或输入错误而产生逻辑异常
在Java中断言使用的是assert关键字,其基本用法如下:assert<布尔表达式>
assert<布尔表达式> : <错误信息>,在布尔表达式为假时,跑出AssertionError错误,并附带了错误信息。assert的语法比较简单,有以下两个特性:(1)assert默认是不启用的,断言是为调试程序服务的,目的是为了能够迅速、方便地检查到程序异常,但Java在默认条件下是不启用的,要启用就要在编译、运行时加上相关的关键字,这就不多说,有需要的话可以参考一下Java规范。(2)assert跑出的异常AssertionError是继承自Error的,断言失败后,JVM会抛出一个AssertionError的错误,它继承自Error,这是一个错误,不可恢复,也就是表明这是一个严重问题,开发者必须予以关注并解决之。assert虽然是做断言的,但不能将其等价于if...else...这样的条件判断,它在以下两种情况下不可使用:(1)在对外的公开方法中
防御式编程最核心的一点就是:所有的外部因素(输入参数、环境变量、上下文)都是"邪恶"的,都存在着企图摧毁程序的罪恶本源,为了抵制它,我们要在程序处处检验。满地设卡,不满足条件,就不执行后续程序,以保护后续程序的正确性,处处设卡没问题,但就是不能用断言做输入校验,特别是公开方法。看一个例子:
public class test {
public static void main(String[] args) {
System.out.println(StringUtils.encode(null));;
}
}
class StringUtils{
public static String encode(String str){
assert str != null : "加密的字符串为null";
/*加密处理*/
return str;
}
}
encode方法对输入参数做了不为空的假设,如果为空则抛出AssertionError错误,但这段程序存在一个严重的问题,encode是一个public方法,这标志着它是对外公开的任何一个类只要能传递一个String类型的参数就可以调用,但是test类按照规定调用encode方法,却获得了一个AssertionError错误信息,是谁破坏了契约协议?是encode方法自己。(2)在执行逻辑代码的情况下,assert的支持是可选的,在开发时可以让他运行,但在生产环境中系统则不需要其运行了(以便提高性能),因此在assert的布尔表达式中不能执行逻辑代码,否则会因为环境的不同而产生不同的逻辑。
按照正常的执行逻辑不可能到达的代码区域可以放置assert。具体分为三种情况:
1)在私有方法中放置assert作为输入参数的校验:在私有方法中可以放置assert校验输入参数,因为私有方法的使用者是作者自己,私有的方法的调用者和被调用者是一种契约关系,或者说没有契约关系,期间的约束是靠作者自己控制的,因此加上assert可以更好地预防自己犯错,或者无意的程序犯错。
2)流程控制中不可能到达的区域:这类似于Junit的fail方法,其标志性的意义就是,程序执行到这里就是错误的。
3)建立程序探针:我们可能会在一段程序中定义两个变量,分别代两个不同的业务含义但是两者有固定的关系,例如:var1=var2 * 2,那我们就可以在程序中到处设"桩"了,断言这两者的关系,如果不满足即表明程序已经出现了异常,业务也就没有必要运行下去了。
建议20:不要只替换一个类
我们经常在系统中定义一个常量接口(或常量类),以囊括系统中所涉及的常量,从而简化代码,方便开发,在很多的开源项目中已经采用了类似的方法,比如在struts2中,org.apache.struts2.StrutsConstants就是一个常量类,它定义Struts框架中与配置有关的常量,而org.apache.struts2.StrutsConstants则是一个常量接口,其中定义了OGNL访问的关键字。代码是在"智能型"IDE工具中完成的是这些输出结果都不会也逻辑上错误,但是用记事本编写代码,修改常量结果中并没有改变,因为对于final修饰的基本类型和String类型,编译器会认为它是稳定态的(Immutable Status)所以在编译时就直接把值编译到字节码中了,避免了在运行期引用(Run-time Reference),以提高代码的执行效率。对于final修饰的类(即非基本类型),编译器会认为它不是稳定态的(Mutable Status),编译时建立的则是引用关系(该类型也叫作Soft Final)。
建议21:用偶判断,不用奇判断
判断一个数是奇数还是偶数是小学里的基本知识,能够被2整除的整数是偶数,不能被2整除的数是奇数
public class test {
public static void main(String[] args) {
// 接收键盘输入参数
Scanner input = new Scanner(System.in);
System.out.println("输入多个数字判断奇偶:");
while (input.hasNextInt()) {
int i = input.nextInt();
String str = i + "-->" + (i % 2 == 1 ? "奇数" : "偶数");
System.out.println(str);
}
}
//取余计算模拟
public static int remainder(int dividend, int divisor) {
return dividend - dividend / divisor * divisor;
}
}
输入多个数字判断奇偶:1 2 -2 1-->奇数 2-->偶数 -2-->偶数,由取余计算当输入-1的时候,运算结果为-1,修正也很简单,改为判断是否是偶数即可。i % 2 == 0 ? "偶数" : "奇数"。
建议22:用整数类型处理货币
System.out.println(10.00-9.60);简单的输出语句,你期望是0.4,但是结果为0.40000000000000036。因为在计算机中浮点数有可能是不准确的它只能无限接近准确值,而不能完全精确。这是由浮点数的存储规则所决定的,0.4这个十进制小数如何转换成二进制小数,使用"乘2取整顺序排列"法,我们发现0.4不能使用二进制准确的表示,在二进制数世界里它是一个无限循环的小数,也就是说,展示都不能展示,更别说在内存中存储了(浮点数的存储包括三部分:符号位、指数位、尾数),可以这样理解,在十进制的世界里没有办法唯一准确表示1/3,那么在二进制的世界里当然也无法准确表示1/5(如果二进制也有分数的话倒是可以表示),在二进制的世界里1/5是一个无限循环的小数。解决办法有{NumberFormat f = new DecimalFormat("#.##"); System.out.println(f.format(10.00-9.60));}强制转换,但是存在精度隐患。要解决此问题有两种方法:
(1)使用BigDecimal专门为弥补浮点数无法精确计算的缺憾而设计的类,并且它本身也提供了加减乘除的常用数学算法。特别是与数据库Decimal类型的字段映射时,BigDecimal是最优的解决方案。
(2)使用整型把参与运算的值扩大100倍,并转为整型,然后在展现时再缩小100倍,这样处理的好处是计算简单,准确,一般在非金融行业(如零售行业)应用较多。此方法还会用于某些零售POS机,他们输入和输出的全部是整数,那运算就更简单了。
建议23:不要让类型默默转换
public class test {
public static final int LIGHT_SPEED = 30 * 10000 * 1000;
public static void main(String[] args) {
long dis1 = LIGHT_SPEED * 1;
System.out.println( dis1);
long dis2 = LIGHT_SPEED * 60 * 8;
System.out.println( dis2);
}
}
输出结果为300000000和-2028888064。因为Java是先运算然后进行类型转换的,具体的说就是因为dis2的三个运算参数都是int型,三者相乘的结果虽然也是int型,但是已经超过了int的最大值,所以其值就是负值了,再转换为long型,结果还是负值。解决也很简单只要加个小小的L即可:long dis2 = LIGHT_SPEED * 60L * 8;
60L是一个长整型,乘出来的结果也是一个长整型的,在还没有超过int类型的范围时就已经转换为long型了,彻底解决了越界问题。在实际开发中,更通用的做法是主动声明类型转化(注意,不是强制类型转换)代码如下:
long dis2 = 1L * LIGHT_SPEED * 60L * 8。 注:基本类型转换时,使用主动声明方式减少不必要的Bug。
建议24:边界还是边界
数字越界使校验条件失效。
public class test {
public final static int LIMIT = 2000;
public static void main(String[] args) {
int cur = 1000;
Scanner input = new Scanner(System.in);
while (input.hasNextInt()) {
int order = input.nextInt();
if (order > 0 && order + cur <= LIMIT) {
System.out.println("你已经成功预定:" + order + " 个产品");
} else {
System.out.println("超过限额,预定失败!");
}
}
}
}
order的值是2147483647那再加上1000就超出int的范围了,其结果是-2147482649,那当然是小于正数2000了!一句归其原因:数字越界使校验条件失效。在单元测试中,有一项测试叫做边界测试(也叫临界测试),如果一个方法接收的是int类型的参数,那么以下三个值是必须测试的:0、正最大、负最小,其中正最大、负最小是边界值,如果这三个值都没有问题,方法才是比较安全可靠的。我们的例子就是因为缺少边界测试,致使生产系统产生了严重的偏差。
建议25:不要让四舍五入亏了一方
四舍五入。四舍五入是一种近似精确的计算方法,在Java5之前,我们一般是通过Math.round来获得指定精度的整数或小数的10.5近似值: 11 -10.5近似值: -10绝对值相同的两个数字,近似值为什么就不同了呢?这是由Math.round采用的舍入规则决定的(采用的是正无穷方向舍入规则),我们知道四舍五入是有误差的:其误差值是舍入的一半。这种四舍五入对于银行利息的计算会对银行大为不利,有人提出银行家算法:1)舍去位的数值小于5时,直接舍去;2)舍去位的数值大于等于6时,进位后舍去;3)当舍去位的数值等于5时,分两种情况:5后面还有其它数字(非0),则进位后舍去;若5后面是0(即5是最后一个数字),则根据5前一位数的奇偶性来判断是否需要进位,奇数进位,偶数舍去。以上规则汇总成一句话:四舍六入五考虑,五后非零就进一,五后为零看奇偶,五前为偶应舍去,五前为奇要进一。我们举例说明,取2位精度;round(10.5551) = 10.56 round(10.555) = 10.56 round(10.545) = 10.54
public class test {
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支持以下七种舍入方式:
1:ROUND_UP:原理零方向舍入。向远离0的方向舍入,也就是说,向绝对值最大的方向舍入,只要舍弃位非0即进位。
2:ROUND_DOWN:趋向0方向舍入。向0方向靠拢,也就是说,向绝对值最小的方向输入,注意:所有的位都舍弃,不存在进位情况。
3:ROUND_CEILING:向正无穷方向舍入。向正最大方向靠拢,如果是正数,舍入行为类似于ROUND_UP;如果为负数,则舍入行为类似于ROUND_DOWN.注意:Math.round方法使用的即为此模式。
4:ROUND_FLOOR:向负无穷方向舍入。向负无穷方向靠拢,如果是正数,则舍入行为类似ROUND_DOWN,如果是负数,舍入行为类似以ROUND_UP。
5:HALF_UP:最近数字舍入(5舍),这就是我们经典的四舍五入。
6:HALF_DOWN:最近数字舍入(5舍)。在四舍五入中,5是进位的,在HALF_DOWN中却是舍弃不进位。
7:HALF_EVEN:银行家算法
在普通的项目中舍入模式不会有太多影响,可以直接使用Math.round方法,但在大量与货币数字交互的项目中,一定要选择好近似的计算模式,尽量减少因算法不同而造成的损失。
注:根据不同的场景,慎重选择不同的舍入模式,以提高项目的精准度,减少算法损失。
建议26:提防包装类型的null值
Java引入包装类型(Wrapper Types)是为了解决基本类型的实例化问题,以便让一个基本类型也能参与到面向对象的编程世界中。而在Java5中泛型更是对基本类型说了"不",如果把一个整型放入List中,就必须使用Integer包装类型。
public class test {
public static int testMethod(List<Integer> list) {
int count = 0;
for (int i : list) {
count += i;
}
return count;
}
public static void main(String[] args) {
List<Integer> list = new ArrayList<Integer>();
list.add(1);
list.add(null);
System.out.println(testMethod(list));
}
}
基本类型和包装类型都是可以通过自动装箱(Autoboxing)和自动拆箱(AutoUnboxing)自由转换的,null应该可以转换为0吗?运行之后的结果是:Exception in thread "main" java.lang.NullPointerException运行失败,报空指针异常,原因:在程序for循环中,隐含了一个拆箱过程,在此过程中包装类型转换为了基本类型。我们知道拆箱过程是通过调用包装对象的intValue方法来实现的,由于包装类型为null,访问其intValue方法报空指针异常就在所难免了。修改也很简单,加入null值检查即可,代码如下:for (Integer i : list) { count += (i != null) ? i : 0;}。上面以Integer和int为例说明了拆箱问题,其它7个包装对象的拆箱过程也存在着同样的问题。包装对象和拆箱对象可以自由转换,null值并不能转换为基本类型。谨记包装类型参与运算时,要做null值校验。
建议27:谨慎包装类型的大小比较
public class test {
public static void main(String[] args) {
Integer i = new Integer(10);
Integer j = new Integer(10);
compare(i, j);
}
public static void compare(Integer i, Integer j) {
System.out.println(i == j);
System.out.println(i > j);
System.out.println(i < j);
}
}
基本类型是可以比较大小的,其所对应的包装类型都实现了Comparable接口产生了两个Integer对象,然后比较两个的大小关系结果是三个false,1)i==j:在java中"=="是用来判断两个操作数是否有相等关系的,如果是基本类型则判断值是否相等,如果是对象则判断是否是一个对象的两个引用,也就是地址是否相等,这里很明显是两个对象,两个地址不可能相等。
2)i>j 和 i<j:在Java中,">" 和 "<" 用来判断两个数字类型的大小关系,注意只能是数字类型的判断,对于Integer包装类型,是根据其intValue()方法的返回值(也就是其相应的基本类型)进行比较的(其它包装类型是根据相应的value值比较的,如doubleValue,floatValue等),那很显然,两者不肯能有大小关系的。问题清楚了,修改总是比较容易的,直接使用Integer的实例compareTo方法即可,但是这类问题的产生更应该说是习惯问题,只要是两个对象之间的比较就应该采用相应的方法,而不是通过Java的默认机制来处理,除非你确定对此非常了解。
建议28:优先使用整型池
public class test {
public static void main(String[] args) {
Scanner input = new Scanner(System.in);
while (input.hasNextInt()) {
int tempInt = input.nextInt();
System.out.println("\n=====" + tempInt + " 的相等判断=====");
Integer i = new Integer(tempInt);
Integer j = new Integer(tempInt);
System.out.println(" new 产生的对象:" + (i == j));
// 基本类型转换为包装类型后比较
i = tempInt;
j = tempInt;
System.out.println(" 基本类型转换的对象:" + (i == j));
// 通过静态方法生成一个实例
i = Integer.valueOf(tempInt);
j = Integer.valueOf(tempInt);
System.out.println(" valueOf产生的对象:" + (i == j));}}}
}}}
public static Integer valueOf(int i) {
assert IntegerCache.high >= 127;
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
//Integer.valueOf的源码
}
当输入小于等127输出的结果为new 产生的对象:false。基本类型转换的对象:true。valueOf产生的对象:true。输入大于等于128时都是false。(1)new产生的Integer对象要生成一个新的对象,这是两个对象,地址肯定不等,比较结果为false。(2)装箱生成的对象,首先要说明的是装箱动作是通过valueOf方法实现的,也就是说后两个算法相同的,那结果肯定也是一样的,现在问题是:valueOf是如何生成对象从Integer.valueOf的源码看出如果是-128到127之间的int类型转换为Integer对象,则直接从cache数组中获得。cache是IntegerCache内部类的一个静态数组,容纳的是-128到127之间的Integer对象。通过valueOf产生包装对象时,如果int参数在-128到127之间,则直接从整型池中获得对象,不在该范围内的int类型则通过new生成包装对象。127的包装对象是直接从整型池中获得的,不管你输入多少次127这个数字,获得的对象都是同一个,那地址自然是相等的。而128超出了整型池范围,是通过new产生一个新的对象,地址不同当然也就不相等了。整型池的存在不仅仅提高了系统性能,同时也节约了内存空间,这也是我们使用整型池的原因,也就是在声明包装对象的时候使用valueOf生成,而不是通过构造函数来生成的原因。在判断对象是否相等的时候,最好使用equals方法,避免使用"=="产生非预期效果。
注:通过包装类型的valueOf生成的包装实例可以显著提高空间和时间性能。
建议29:优先选择基本类型
包装类型是一个类,它提供了诸如构造方法,类型转换,比较等非常实用的功能,而且在Java5之后又实现了与基本类型的转换但无论是从安全性、性能方面来说,还是从稳定性方面来说,基本类型都是首选
public class test {
public static void main(String[] args) {
test c = new test();
int i = 140;
// 分别传递int类型和Integer类型
c.testMethod(i);
c.testMethod(new Integer(i));
}
public void testMethod(long a) {
System.out.println(" 基本类型的方法被调用");
}
public void testMethod(Long a) {
System.out.println(" 包装类型的方法被调用");
}
}
输出结果都是:基本类型的方法被调用。c.testMethod(i)的输出是正常的,那第二个为什么会调用testMethod(long a)方法呢?这是因为自动装箱有一个重要原则:基本类型可以先加宽,再转变成宽类型的包装类型,但不能直接转变成宽类型的包装类型。这句话比较拗口,简单的说就是,int可以加宽转变成long,然后再转变成Long对象,但不能直接转变成包装类型,注意这里指的都是自动转换,不是通过构造函数生成
public class test {
public static void main(String[] args) {
test c = new test();
int i = 140;
c.testMethod(i);
}
public void testMethod(Long a) {
System.out.println(" 包装类型的方法被调用");
}
}
程序的编译是不通过的,因为i是一个int类型,不能自动转变为Long型,但是修改成以下代码就可以通过了:int i = 140; long a =(long)i; c.testMethod(a);这就是int先加宽转变成为long型,然后自动转换成Long型,testMethod(Integer.valueOf(i))是如何调用的,Integer.valueOf(i)返回的是一个Integer对象,但是Integer和int是可以互相转换的。没有testMethod(Integer i)方法?编译器会尝试转换成int类型的实参调用,这次成功了,与testMethod(i)相同了,于是乎被加宽转变成long型结果也很明显了。整个testMethod(Integer.valueOf(i))的执行过程是:(1)i 通过valueOf方法包装成一个Integer对象。(2)、由于没有testMethod(Integer i)方法,编译器会"聪明"的把Integer对象转换成int。(3)、int自动拓宽为long,编译结束使用包装类型确实有方便的方法,但是也引起一些不必要的困惑,比如我们这个例子,如果testMethod()的两个重载方法使用的是基本类型,而且实参也是基本类型,就不会产生以上问题,而且程序的可读性更强。自动装箱(拆箱)虽然很方便,但引起的问题也非常严重,我们甚至都不知道执行的是哪个方法。
建议30:不要随便设置随机种子
在Java项目中通常是通过Math.random方法和Random类来获得随机数的:
public class test {
public static void main(String[] args) {
Random r = new Random(10000);
for(int i=1; i<=4; i++){
System.out.println("第"+i+"次:"+r.nextInt());
}
}
}
在Java中,随机数的产生取决于种子这里程序中设置了种子1000在同一台机器上,甭管运行多少次,所打印的随机数都是相同的,如果没有种子,每次测试的结果是不同的。随机数和种子之间的关系遵从以下两个原则:
1. 种子不同,产生不同的随机数。
2. 种子相同,即使实例不同也产生相同的随机数。Random类默认种子(无参构造)是System.nonoTime()的返回值(JDK1.5版本以前默认种子是System.currentTimeMillis()的返回值),注意这个值是距离某一个固定时间点的纳秒数,不同的操作系统和硬件有不同的固定时间点,也就是说不同的操作系统其纳秒值是不同的,而同一个操作系统纳秒值也会不同,随机数自然也就不同了(System.nonoTime不能用于计算日期,那是因为"固定"的时间是不确定的,纳秒值甚至可能是负值,这点与System.currentTiemMillis不同)。new Random(1000)显示的设置了随机种子为1000,运行多次,虽然实例不同,但都会获得相同的四个随机数,所以,除非必要,否则不要设置随机种子。在Java中有两种方法可以获得不同的随机数:通过,java.util.Random类获得随机数的原理和Math.random方法相同,Math.random方法也是通过生成一个Random类的实例,然后委托nextDouble()方法的,两者殊途同归,没有差别。
建议31:在接口中不要存在实现代码
接口中可以声明常量,声明抽象方法,可以继承父接口,但就是不能有具体实现。因为接口是一种契约(Contract),是一种框架性协议,这表明它的实现类都是同一种类型,或者具备相似特征的一个集合体。
建议32:静态变量一定要先声明后赋值
public class test {
public static int i = 1;
static {
i = 100;
}
public static void main(String[] args) {
System.out.println(i);
}
}
输出结果为100,如果生命放在静态代码块后面那么结果就是1,静态变量是类加载时被分配到数据区(Data Area)的,它在内存中只有一个拷贝,不会被分配多次,其后的所有赋值操作都是值改变,地址则保持不变。JVM初始化变量是先声明空间,然后再赋值,也就是说:在JVM中是分开执行的,等价于:int i ; //分配空间,i = 100; //赋值。静态变量是在类初始化的时候首先被加载的,JVM会去查找类中所有的静态声明,然后分配空间,注意这时候只是完成了地址空间的分配,还没有赋值,之后JVM会根据类中静态赋值(包括静态类赋值和静态块赋值)的先后顺序来执行,谁的位置最靠后谁有最终的决定权。
建议33:不要覆写静态方法
覆写(Override)用来增强或减弱父类的方法和行为,但覆写是针对非静态方法(也叫做实例方法,只有生成实例才能调用的方法)的,不能针对静态方法(static修饰的方法,也叫做类方法)。
public class test {
public static void main(String[] args) {
Base base = new Sub();
//调用非静态方法
base.doAnything();
//调用静态方法
base.doSomething();
}
}
class Base {
// 我是父类静态方法
public static void doSomething() {
System.out.println("我是父类静态方法");
}
// 父类非静态方法
public void doAnything() {
System.out.println("我是父类非静态方法");
}
}
class Sub extends Base {
// 子类同名、同参数的静态方法
public static void doSomething() {
System.out.println("我是子类静态方法");
}
// 覆写父类非静态方法
@Override
public void doAnything() {
System.out.println("我是子类非静态方法");
}
}
结果:我是子类非静态方法 我是父类静态方法。对象有两个类型:表面类型(Apparent Type)和实际类型(Actual Type),表面类型是声明的类型,实际类型是对象产生时的类型,例子中,变量base的表面类型是Base,实际类型是Sub。对于非静态方法,它是根据对象的实际类型来执行的,也就是执行了Sub类中的doAnything方法。而对于静态方法来说就比较特殊了,首先静态方法不依赖实例对象,它是通过类名来访问的;其次,可以通过对象访问静态方法,如果是通过对象访问静态方法,JVM则会通过对象的表面类型查找静态方法的入口,继而执行之。在子类中构建与父类方法相同的方法名、输入参数、输出参数、访问权限(权限可以扩大),并且父类,子类都是静态方法,此种行为叫做隐藏(Hide),它与覆写有两点不同:
(1)表现形式不同:隐藏用于静态方法,覆写用于非静态方法,在代码上的表现是@Override注解可用于覆写,不可用于隐藏。
(2)职责不同:隐藏的目的是为了抛弃父类的静态方法,重现子类方法,例如我们的例子,Sub.doSomething的出现是为了遮盖父类的Base.doSomething方法,也就是i期望父类的静态方法不要做破坏子类的业务行为,而覆写是将父类的的行为增强或减弱,延续父类的职责,虽然不能覆写,但可以隐藏。