Java开发中通用的方法和准则——读《编写高质量代码:改善Java程序的151个建议》(一)

读书,收获,分享
建议后面的五角星仅代表笔者个人需要注意的程度。
Talk is cheap.Show me the code

建议1:不要在常量和变量中出现易混淆的字母★★★☆☆

必须严格遵守下划线分隔,变量采用驼峰命名法(CamelCase)命名等最基本的Java编码规范。
为了程序易读,不要将容易混淆的数字和字母混用,例如字母“l”和大写字母“O”,避免造成程序阅读者的理解偏差。如果字母和数字必须混合使用,字母“l”务必大写,字母“O”则增加注释。

注意:字母“l”作为长整型标志时务必大写。

建议2:莫让常量蜕变成变量★★☆☆☆

错误示例:

interface Const {
    //这还是常量吗?
    public static final int RAND_Const = new Random().nextInt();
}

注意:务必让常量的值在运行期保持不变。

建议3:三元操作符的类型务必一致★★★☆☆

示例代码:

public class Client {

    public static void main(String[] args) {
        int i = 80;
        String s = String.valueOf(i < 100 ? 90 : 100);
        String s1 = String.valueOf(i < 100 ? 90 : 100.0);
        System.out.println("两者是否相等:" + s.equals(s1));
        //运行结果:"两者是否相等:false
    }

}

三元操作符类型的转换规则:

  • 若两个操作数不可转换,则不做转换,返回值为Object类型。
  • 若两个操作数是明确类型的表达式(比如变量),则按照正常的二进制数字来转换,int类型转换为long类型,long类型转换为float类型等。
  • 若两个操作数中有一个是数字S,另外一个是表达式,且其类型标示为T,那么,若数字S在T的范围内,则转换为T类型;若S超出了T类型的范围,则T转换为S类型。
  • 若两个操作数都是直接量数字(Literal),则返回值类型为范围较大者。

建议4:避免带有变长参数的方法重载★★☆☆☆

编译器无法“猜到”你传入的是可变长参数
示例代码:

public class Client {

    //简单折扣计算
    public void calPrice(int price, int discount) {
        System.out.println("调用了简单折扣计算");
    }

    //复杂多折扣计算
    public void calPrice(int price, int... discount) {
        System.out.println("调用了复杂多折扣计算");
    }

    public static void main(String[] args) {
        Client client = new Client();
        client.calPrice(49900, 75);
        //对于这样的计算,到底该调用哪个方法来处理呢?
        //运行结果是:调用了简单折扣计算
    }

}
  • 因为Java在编译时,首先会根据实参的数量和类型(这里是2个实参,都为int类型,注意没有转成int数组)来进行处理,也就是查找到calPrice(int price,int discount)方法,而且确认它是否符合方法签名条件。
  • 编译器为什么会首先根据2个int类型的实参而不是1个int类型、1个int数组类型的实参来查找方法呢?因为int是一个原生数据类型,而数组本身是一个对象,编译器想要“偷懒”,于是它会从最简单的开始“猜想”,只要符合编译条件的即可通过,于是就出现了此问题。

建议5:别让null值和空值威胁到变长方法★★☆☆☆

示例代码:

public class Client {

    public void methodA(String str, Integer... is) {
    }

    public void methodA(String str, String... strs) {
    }

    public static void main(String[] args) {
        Client client = new Client();
        client.methodA("China", 0);
        client.methodA("China", "People");

        //以下两个方法都是编译通不过,编译器提示方法模糊不清,编译器不知道调用哪一个方法
        //根据实参“China”(String类型),两个方法都符合形参格式,编译器不知道该调用哪个方法,于是报错。
        client.methodA("China");
        //直接量null是没有类型的,虽然两个methodA方法都符合调用请求,但不知道调用哪一个,于是报错了
        client.methodA("China", null);
    }

}

建议6:覆写变长方法也循规蹈矩★★☆☆☆

覆写必须满足的条件:

  1. 重写方法不能缩小访问权限。
  2. 参数列表必须与被重写方法相同。
  3. 返回类型必须与被重写方法的相同或是其子类。
  4. 重写方法不能抛出新的异常,或者超出父类范围的异常,但是可以抛出更少、更有限的异常,或者不抛出异常。

错误示例:特例,覆写的方法参数列表竟然与父类不相同

public class Client {

    //基类
    class Base {
        void fun(int price, int... discounts) {
            //...
        }
    }
    
    //子类,覆写父类方法
    class Sub extends Base {
        //参数类型变了,IDE也没提示语法错误,但是编译通不过
        @Override
        void fun(int price, int[] discounts) {
                //...
        }
    }

}

注意:覆写的方法参数与父类相同,不仅仅是类型、数量,还包括显示形式。

建议7:警惕自增的陷阱★★☆☆☆

public class Client {

    public static void main(String[] args) {
        int count = 0;
        for (int i = 0; i < 10; i++) {
            count = count++;
        }
        System.out.println("运行结果count=" + count);
        //运行结果count=0
        
    }

}

为什么呢?

  • count++是一个表达式,是有返回值的,它的返回值就是count自加前的值.
  • Java对自加是这样处理的:
    首先把count的值(注意是值,不是引用)拷贝到一个临时变量区,然后对count变量加1,最后返回临时变量区的值。

程序第一次循环时的详细处理步骤如下:

  1. JVM把count值(其值是0)拷贝到临时变量区。
  2. count值加1,这时候count的值是1。
  3. 返回临时变量区的值,注意这个值是0,没修改过。
  4. 返回值赋值给count,此时count值被重置成0。

总结:
java i++与++i的区别:i++是先赋值,然后再自+1;++i是先自+1,后赋值。
若 a = i++; 则等价于 a=i;i=i+1;
而 b = ++i; 则等价于 i=i+1;b=i;
a=i++:表示先将i的值赋值给a,然后i自身再加1
b=++i:表示i+1后,将结果赋值给自己,然后再赋值给b

建议8:不要让旧语法困扰你★★★☆☆

摒弃旧的语法,避免接手的人无法理解。

public class Client {

    public static void main(String[] args) {
       //数据定义及初始化
        int fee =200;
        //其他业务处理
        saveDefault:save(fee);
        //其他业务处理
    }

    static void saveDefault(){

    }
    
    static void save(int fee){

    }

}

下面是作者在维护saveDefault:save(fee);这行代码的一个有趣的场景:
隔壁做C项目的同事过来串门,看我们在讨论这个问题,很惊奇地说“耶,Java中还有标号呀,我以为Java这么高级的语言已经抛弃goto语句了……”,一语点醒梦中人:项目的原创者是C语言转过来的开发人员,所以他把C语言的goto习惯也带到项目中了,后来由于经过N手交接,重构了多次,到我们这里goto语句已经被重构掉了,但是跳转标号还保留着,估计上一届的重构者也是稀里糊涂的,不敢贸然修改,所以把这个重任留给了我们。

建议9:少用静态导入★★★☆☆

import java.text.NumberFormat;
import static java.lang.Double.*;
import static java.lang.Integer.*;
import static java.lang.Math.*;
import static java.text.NumberFormat.*;

public class Client {

    //输入半径和精度要求,计算面积
    public static void main(String[] args) {
        //这么一段程序,有没有看着就让人火大,能一眼看出谁是谁吗?
        double s = PI * parseDouble(args[0]);
        NumberFormat nf = getInstance();
        nf.setMaximumFractionDigits(parseInt(args[1]));
        formatMessage(nf.format(s));
    }

    //消息输出
    public static void formatMessage(String s) {
        System.out.println("圆面积是:" + s);
    }

}

滥用静态导入会使程序更难阅读,更难维护
IDE的导入包整理可以自动帮你整理,去掉‘*’变成本来想导入的具体类,
所以随手Ctrl+Shift+O不会错的。

对于静态导入,一定要遵循两个规则:

  1. 不使用*(星号)通配符,除非是导入静态常量类(只包含常量的类或接口)。
  2. 方法名是具有明确、清晰表象意义的工具类。

建议10:不要在本类中覆盖静态导入的变量和方法★★☆☆☆

import static java.lang.Math.PI;
import static java.lang.Math.abs;

public class Client {

    //常量名与静态导入的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));
        //一句话:编译器的最短路径原则,
        // “如果能够在本类中查找到的变量、常量、方法,就不会到其他包或父类、接口中查找,以确保本类中的属性、方法优先”
    }

}

建议11:养成良好习惯,显式声明UID★★★☆☆

类实现Serializable接口的目的是为了可持久化,比如网络传输或本地存储,为系统的分布和异构部署提供先决支持条件。

JVM是根据什么来判断一个类版本的呢?
通过SerialVersionUID,也叫做流标识符(Stream Unique Identifier),即类的版本定义的,它可以显式声明也可以隐式声明。

  • 显式声明:
private static final long serialVersionUID = XXXXXXXL;
  • 隐式声明:
    编译器在编译的时候自动生成。生成的依据是通过包名、类名、继承关系、非私有的方法和属性,以及参数、返回值等诸多因子计算得出的,极度复杂,基本上计算出来的这个值是唯一的。
  • serialVersionUID的作用:
    JVM在反序列化时,会比较数据流中的serialVersionUID与类的serialVersionUID是否相同,来校验类的一致性。如果不同,会抛个异常InvalidClassException。

向JVM“撒谎”, 如:
我的某个类的当前版本相比上个版本只做了一点小的改动,但是显式声明的serialVersionUID相同,反序列化也是可以运行的,如此,我们编写的类就实现了向上兼容,无疑提高了代码的健壮性。

  1. 在某些场合,希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有相同的serialVersionUID;
  2. 在某些场合,不希望类的不同版本对序列化兼容,因此需要确保类的不同版本具有不同的serialVersionUID。

注意:显式声明serialVersionUID可以避免对象不一致,但尽量不要以这种方式向JVM“撒谎”。

建议12:避免用序列化类在构造函数中为不变量赋值★★☆☆☆

final变量另外一种赋值方式:通过构造函数赋值。

代码如下:

public class Person implements Serializable {

    private static final long serialVersionUID = 1231312L;

    //不变量初始不赋值
    public final String name;

    //构造函数为不变量赋值
    public Person() {
        name = "谨以书为马";
    }  
}

将上面的类定义为版本V1.0,然后进行序列化,实例对象保存到了磁盘上

public class Serialize {

    public static void main(String[] args) {
        //序列化以持久保存
        MySerializationUtils.writeObject(new Client());
    }
}

接下来修改一下name值代表变更,要注意的是serialVersionUID保持不变,修改后的代码如下:

public class Person implements Serializable {

    private static final long serialVersionUID = 1231312L;

    //不变量初始不赋值
    public final String name;

    //构造函数为不变量赋值
    public Person() {
        name = "知无涯,书为马";
    }
}

此时类的版本是V2.0,但serialVersionUID没有改变,仍然可以反序列化,其代码如下:

public class Deserialize {

    public static void main(String[] args) {
        //反序列化
        Person p = (Person) MySerializationUtils.readObject();
        System.out.println(p.name);
        //打印的结果是: 谨以书为马
    }
}

Why? final类型的变量不是会重新计算吗?
这是因为这里触及了反序列化的另一个规则:反序列化时构造函数不会执行。
反序列化的执行过程是这样的:

  • JVM从数据流中获取一个Object对象,然后根据数据流中的类文件描述信息(在序列化时,保存到磁盘的对象文件中包含了类描述信息,注意是类描述信息,不是类)查看,发现是final变量,需要重新计算,于是引用Person类中的name值,而此时JVM又发现name竟然没有赋值,不能引用,于是它很“聪明”地不再初始化,保持原值状态,所以结果就是“谨以书为马”了。

上面引入了下面的工具类:

public class MySerializationUtils {

    private static String FILE_NAME = "c://test.bin";

    //序列化
    public static void writeObject(Serializable s) {
        try {
            ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(FILE_NAME));
            oos.writeObject(s);
            oos.close();

        } catch (Exception e) {
            e.printStackTrace();
        }

    }

    //反序列化
    public static Object readObject() {
        Object obj = null;
        try {
            ObjectInput input = new ObjectInputStream(new FileInputStream(FILE_NAME));
            obj = input.readObject();
            input.close();

        } catch (Exception e) {
            e.printStackTrace();
        }
        return obj;
    }

}

注意:在序列化类中,不使用构造函数为final变量赋值。

建议13:避免为final变量复杂赋值★★☆☆☆

为final变量赋值还有一种方式:通过方法赋值,即直接在声明时通过方法返回值赋值。
在反序列化时方法赋值不会生效。

上个建议所说final会被重新赋值,其中的“值”指的是简单对象。简单对象包括:8个基本类型,以及数组、字符串(字符串情况很复杂,不通过new关键字生成String对象的情况下,final变量的赋值与基本类型相同),但是不能方法赋值。

其中(不能方法赋值)的原理是这样的,保存到磁盘上(或网络传输)的对象文件包括两部分:

  1. 类描述信息
    包括包路径、继承关系、访问权限、变量描述、变量访问权限、方法签名、返回值,以及变量的关联类信息。要注意的一点是,它并不是class文件的翻版,它不记录方法、构造函数、static变量等的具体实现。之所以类描述会被保存,很简单,是因为能去也能回嘛,这保证反序列化的健壮运行。
  2. 非瞬态(transient关键字)和非静态(static关键字)的实例变量值
    注意,这里的值如果是一个基本类型,好说,就是一个简单值保存下来;如果是复杂对象,也简单,连该对象和关联类信息一起保存,并且持续递归下去(关联类也必须实现Serializable接口,否则会出现序列化异常),也就是说递归到最后,其实还是基本数据类型的保存。

总结一下,反序列化时final变量在以下情况下不会被重新赋值:

  • 通过构造函数为final变量赋值。
  • 通过方法返回值为final变量赋值。
  • final修饰的属性不是基本类型。

建议14:使用序列化类的私有方法巧妙解决部分属性持久化问题★★★☆☆

部分属性持久化问题看似很简单,只要把不需要持久化的属性加上瞬态关键字(transient关键字)即可。这是一种解决方案,但有时候行不通。

示例,现在有如下需求:
一个计税系统和HR系统通过RMI(Remote Method Invocation,远程方法调用)对接,计税系统需要从HR系统获得人员的姓名和基本工资,HR系统的工资分为两部分:基本工资和绩效工资,绩效工资却是保密的,不能泄露到外系统。
下面是使用序列化类的私有方法巧妙解决部分属性持久化问题的案例
薪水类:

@Data
public class Salary implements Serializable {

   private static final long serialVersionUID = 123434324L;

   //基本工资
   private int basePay;
   //绩效工资
   private int bonus;

   public Salary(int basePay, int bonus) {
       this.basePay = basePay;
       this.bonus = bonus;
   }
}

Person类与Salary类是关联关系,代码如下:

@Data
public class Person implements Serializable {

    private static final long serialVersionUID = 1231312L;

    //姓名
    private String name;
    //薪水
    private transient Salary salary;

    public Person(String name, Salary salary) {
        this.name = name;
        this.salary = salary;
    }

    //序列化委托方法
    private void writeObject(ObjectOutputStream out) throws IOException {
        out.defaultWriteObject();

    }

    //反序列化委托方法
    private void readObject(ObjectInputStream in )throws IOException ,ClassNotFoundException{
        in.defaultReadObject();
        salary = new Salary(in.readInt(),0);
    }

}

首先计税系统请求HR系统对某一个Person对象进行序列化,把人员和工资信息传递到计税系统中,代码如下:

public class Serialize {

    public static void main(String[] args) {
        //基本工资1000,绩效工资2500
        Salary salary = new Salary(1000,2500);
        //人员信息
        Person person = new Person("马良",salary);
        //HR系统持久化,并传入到计税系统
        MySerializationUtils.writeObject(person);
    }

}

在通过网络传送到计税系统后,进行反序列化,代码如下:

public class Deserialize {

    public static void main(String[] args) {
        //反序列化,并输出信息
        Person p = (Person) MySerializationUtils.readObject();
        StringBuffer sb = new StringBuffer();
        sb.append("姓名:"+p.getName());
        sb.append("\t基本工资:"+p.getSalary().getBasePay());
        sb.append("\t绩效工资:"+p.getSalary().getBonus());
        System.out.println(sb);
        //姓名:张三 基本工资:1000 绩效工资:0
        //达到了目的
    }
}

上面主要就是在Person类中增加了writeObject和readObject两个方法,并且访问权限都是私有级别。

这里使用了序列化独有的机制:序列化回调。

Java调用ObjectOutputStream类把一个对象转换成流数据时,会通过反射(Reflection)检查被序列化的类是否有writeObject方法,并且检查其是否符合私有、无返回值的特性。若有,则会委托该方法进行对象序列化,若没有,则由ObjectOutputStream按照默认规则继续序列化。同样,在从流数据恢复成实例对象时,也会检查是否有一个私有的readObject方法,如果有,则会通过该方法读取属性值。

此处有几个关键点要说明:

  1. out.defaultWriteObject()
    告知JVM按照默认的规则写入对象,惯例的写法是写在第一句话里。
  2. in.defaultReadObject()
    告知JVM按照默认规则读入对象,惯例的写法也是写在第一句话里。
  3. out.writeXX和in.readXX
    分别是写入和读出相应的值,类似一个队列,先进先出,如果此处有复杂的数据逻辑,建议按封装Collection对象处理。

建议15:break万万不可忘★☆☆☆☆

switch (n) {
    case 1:
        //...
        break;
}

建议16:易变业务使用脚本语言编写★☆☆☆☆

因为脚本语言的三大特征:

  1. 灵活。脚本语言一般都是动态类型,可以不用声明变量类型而直接使用,也可以在运行期改变类型。
  2. 便捷。脚本语言是一种解释型语言,不需要编译成二进制代码,也不需要像Java一样生成字节码。它的执行是依靠解释器解释的,因此在运行期变更代码非常容易,而且不用停止应用。
  3. 简单。只能说部分脚本语言简单,比如Groovy,Java程序员若转到Groovy程序语言上,只需要两个小时,看完语法说明,看完Demo即可使用了,没有太多的技术门槛。

脚本语言的这些特性是Java所缺少的,引入脚本语言可以使Java更强大

建议17:慎用动态编译★☆☆☆☆

使用动态编译时,需要注意以下几点:

  1. 在框架中谨慎使用
    比如在Spring中,写一个动态类,要让它动态注入到Spring容器中,需要花费很大功夫,鸡肋。
  2. 不要在要求高性能的项目使用
    动态编译毕竟需要一个编译过程,与静态编译相比多了一个执行环节,因此在高性能项目中不要 使用动态编译
  3. 动态编译要考虑安全问题
    如果你在Web界面上提供了一个功能,允许上传一个Java文件然后运行,那就等于说:“我的机器没有密码,大家都来看我的隐私吧”,这是非常典型的注入漏洞,只要上传一个恶意Java程序就可以让你所有的安全工作毁于一旦
  4. 记录动态编译过程
    建议记录源文件、目标文件、编译过程、执行过程等日志,不仅仅是为了诊断,还是为了安全和审计,对Java项目来说,空中编译和运行是很不让人放心的,留下这些依据可以更好地优化程序。

建议18:避免instanceof非预期结果★★☆☆☆

在使用时需要注意的几个点:

  • instanceof只能用于对象的判断,不能用于基本类型的判断
  • 只要instanceof关键字的左右两个操作数有继承或实现关系,就可以编译通过
  • instanceof特有的规则:若左操作数是null,结果就直接返回false,不再运算右操作数是什么类

建议19:断言绝对不是鸡肋★★★☆☆

在防御式编程中经常会用断言(Assertion)对参数和环境做出判断,避免程序因不当的输入或错误的环境而产生逻辑异常

//用法
assert <布尔表达式>
assert <布尔表达式> : <错误信息>

assert的语法较简单,有以下两个特性:

  1. assert默认是不启用的
  2. assert抛出的异常AssertionError是继承自Error的

assert虽然是做断言的,但不能将其等价于if…else…这样的条件判断,它在以下两种情况不可使用:

  1. 在对外公开的方法中
    不能用断言做输入校验
  2. 在执行逻辑代码的情况下
    assert启用的环境下,没有任何问题,一旦在没启用的情况下可能会执行非正常逻辑。

能够使用assert的三种情况:

  1. 在私有方法中放置assert作为输入参数的校验(因为私有方法的使用者是作者自己)
  2. 流程控制中不可能达到的区域(程序执行到这里就是错误的)
  3. 建立程序探针(如果不满足即表明程序已经出现了异常)

建议20:不要只替换一个类★☆☆☆☆

以常量类为例:

public class Constant {

    public final static int MAX_AGE = 150;
}

public class Client {

    public static void main(String[] args) {
        System.out.println("人类寿命极限是" + Constant.MAX_AGE);
    }
}   
  • 对于final修饰的基本类型和String类型,编译器会认为它是稳定态(Immutable Status),所以在编译时就直接把值编译到字节码中了,避免了在运行期引用(Run-time Reference),以提高代码的执行效率。
    当Client类在编译时,字节码中就写上了“150”这个常量,而不是一个地址引用,因此无论你后续怎么修改常量类,只要不重新编译Client类,输出还是照旧。
  • 而对于final修饰的类(即非基本类型),编译器认为它是不稳定态(MutableStatus),在编译时建立的则是引用关系(该类型也叫做Soft Final),如果Client类引入的常量是一个类或实例,即使不重新编译也会输出最新值。

注意:发布应用系统时禁止使用类文件替换方式,整体WAR包发布才是万全之策。

你可能感兴趣的:(Java开发中通用的方法和准则——读《编写高质量代码:改善Java程序的151个建议》(一))