Home Tags Posts tagged with "防御式编程"

防御式编程

有人说,防止NullPointerException,是程序员的基本修养。

此篇博文,将介绍空指针的相关知识,一起往下看看吧~

 

小杨是一名初入职场的程序员,他很喜欢写代码。每次产品需要他实现的功能,他都能做出来,可当他信心满满的把代码交给组里前辈CR的时候,前辈总会语重心长的告诉他:

小杨, 你这代码用不了啊,可能会报空指针异常,诺,就是这个

此时,小杨还没意识到事情的严重性,他想:这太正常了,谁写个代码还不会出错呢,修复就行了

public class InterToInt{
    public static void main(String[] args) {
        Person person = new Person();
        person.setName("张三");
        Integer count = person.getCount();
        if (count == 0) {
            System.out.println("该变量未填写count");
        }
        // 装箱操作 int -> Integer
        int i = 5;
        Integer integer = new Integer(i);

        // 拆箱操作:Integer -> int
        int num = integer.intValue();
    }
}

上述代码,究竟为什么会出现空指针异常呢?
经过调试,同时结合报错信息可以判断,问题出在if(count == 0)这条语句上,仔细看,我们并没有对person对象的count属性进行赋值,难道是因为count默认值为null,才报的空指针异常吗?

由上图可知,Integer类型的默认值确实为Null,好,那我们给count赋值,这样的话就不会报空指针了吧

果然,没有报空指针错误了,难道问题到这里就解决了吗?未必
在开发中,有时候对于一个属性,我们不需要给其赋值,它也不需要默认值,它就需要用Null来表示。阿里巴巴开发手册规定所有的属性必须用Integer来定义而非int
借助上例,如果我们从库表里面查询一个person对象xiaohuang,xiaohuang的count属性正好是Null,难道我们能给它随便赋一个值吗?显然不能
那我们怎么解决空指针的问题呢?
public class InterToInt{
    public static void main(String[] args) {
        Person person = new Person();
        person.setName("张三");
        Integer count = person.getCount();
        /**拆箱的时候实际上是调用了intValue()方法。
         * Integer类型和int作比较会自动拆箱,由于Interger类型的对象是null,
         * 这时候自动拆箱调用intValue()方法就会报NullPointerException。
         * 所以基本类型和对应包装类作比较时要判断包装类是否有可能为null
         * 不然就会出现这种错误
         */
        if (count != null && 1 == count) { //改写这一句即可
            System.out.println("count == 0");
        }
        // 装箱操作 int -> Integer
        int i = 5;
        Integer integer = new Integer(i);

        // 拆箱操作:Integer -> int
        int num = integer.intValue();
    }
}

到这里,是不是似懂非懂,若有若无?如果你以为你每次在使用count这样的属性前进行非空判断就能避免所有的空指针的话,那就太天真了,空指针要比这狡猾的多,接下来我们一起去揭开空指针的神奇面纱吧~

一、Java中,什么是指针?

Java中究竟有没有指针?你可以就这个问题好好探索下,此处不作深究,本文中的指针可以理解为指针对象

String str = new String(“str是指针对象”);

如上,str就是一个指针,它被存放在Jvm的栈区;留几个问题给你思考吧~ 1.new String(“str是指针对象”)的空间是什么时候分配的? 2.new String(“str是指针对象”)存储在Jvm的哪一块区域?

 

二、什么是空指针?

当指针不指向任何内存地址时,就叫做空指针,例如:int[] array = null

 

三、什么叫空指针异常?

就是一个指针不指向任何内存地址,但是你还调用他了,例如:

int[] array = null;
System.out.println(array[0]);
这个时候原本array数组是个空指针,没有创建新的对象,在调用这个数组的时候就会产生空指针异常的错误!
程序运行会显示Exception in thread “main” java.lang.NullPointerException的异常提示

四、为什么会产生空指针异常呢?

这里我们用上面举的例子进行说明,int[] array = null在内存中的栈内存中创建了一个叫做array的变量,而堆内存中并没有开辟int类型的数组空间,所以在栈内存中的这个array变量没有存放任何内存地址,由此我们可以理解为什么会产生空指针异常,调用没有的东西显然时不可以的。(栈区和堆区之间存放的东西有什么关系呢?详情可在本博文搜索Jvm、栈、堆等关键词)

 

四、NullPointerException以及其产生的场景

1.产生NullPointerException的场景

Java中定义:在应用程序中尝试使用null时会抛出异常。

其中以下的情况会产生NullPointerException

  1. 调用 null 对象的实例方法。
  2. 访问或修改 null 对象的字段。
  3. 将 null 作为一个数组,获得其长度。
  4. 将 null 作为一个数组,访问或修改其时间片。
  5. 将 null 作为 Throwable 值抛出。

 

2.怎么产生的?

《阿里巴巴开发手册》中提到,

1)返回类型为基本数据类型,return 包装数据类型的对象时,自动拆箱有可能产生 NPE。反例:public int f() { return Integer 对象}, 如果为 null,自动解箱抛 NPE。

2) 数据库的查询结果可能为 null。

3) 集合里的元素即使 isNotEmpty,取出的数据元素也可能为 null。

4) 远程调用返回对象时,一律要求进行空指针判断,防止 NPE。

5) 对于 Session 中获取的数据,建议 NPE 检查,避免空指针。

6) 级联调用 obj.getA().getB().getC();一连串调用,易产生 NPE。正例:使用 JDK8 的 Optional 类来防止 NPE 问题。

 

3.怎么预防NPE?

1) 对象防止,直接!=null

2)集合类判空:一般采用!=null&&判断size(),或者调用isEmpty()方法,或者用Collection工具类判空,java8种Optional类

3)字符串判空:需要判断是否==null&&””.equals(str)来判断,或者StringUtils工具类判断

4)另外项目中要对所有前台参数,对象判空,数据库查询语句判空,JSON对象,JSON数组判空,get()后的值判空

5)对于级联调用 obj.getA().getB().getC(),可以使用Optional类

6)主动进行参数检查,对方法中传入的参数进行检验

7)在已知字符串上使用equals(),equalsIgnoreCase()等方法

8)尽量避免方法中返回null。一些返回数组或者List的方法,如果没有值,尽量返回空集合,避免返回null


总结

记住一句话:避免空指针异常的最好的方法就是总是检查哪些不是自己创建的对象。


补充

针对第一点,我们进行实验

定义Person类

定义测试类

我们对count进行了非空判断,可它还是报了空指针异常,错误信息帮我们定位到第25行

public int getCount(){
    return this.count;
}

这是因为 
返回类型为基本数据类型,return 包装数据类型的对象时,自动拆箱有可能产生 NPE。反例:public int f() { return Integer 对象}, 如果为 null,自动解箱抛 NPE。
拆箱的时候实际上是调用了intValue()方法。Integer类型和int作比较会自动拆箱,由于Interger类型的对象是null,这时候自动拆箱调用intValue()方法就会报NullPointerException。
所以要判断包装类是否有可能为null,不然就会出现这种错误
class VerdictInter{
    public static void main(String[] args) {
        Person person = new Person();
        person.setName("张三");
        person.setCount(null);
        Integer count = person.getCount();
        int res = (count == null) ? 0:count;
        if (res == 0) {
            System.out.println("该变量未填写count");
        }
    }
}


参考资料:
防止NullPointerException,是程序员的基本修养 - 知乎 (zhihu.com)

空指针异常--java.lang.NullPointerException - 云+社区 - 腾讯云 (tencent.com)

(36条消息) java中什么是空指针异常以及为什么会产生空指针异常_imagpie的博客-CSDN博客_为什么会出现空指针异常

主要思想:子程序应该不因传入错误数据而被破坏,哪怕是由其他子程序产生的错误数据;更一般地说,其核心想法是承认程序都会有问题,都需要被修改,聪明的程序员应根据这一点来编程;

 

保护程序免遭非法输入数据的破坏

好的程序:“垃圾进,什么都不出”,“进来垃圾,出去出错提示”或者“不许垃圾进来”;

通常有三种方法来处理垃圾的情况

检查所有源于外部的数据的值;数值在它可接受的取值范围内;字符串不超长;注意可能攻击系统的数据等;

检查子程序所有输入参数的值;同上,差别在于数据来源于其他子程序;

决定如何处理错误的输入数据;一旦检测到非法数据,按照合理方案进行处理;

 

断言:在开发期间使用的,让程序在运行时进行自检的代码(通常是一个子程序或宏)。断言为真,贼表明程序运行正常,断言为假,则它已经在代码中发现了意料之外的错误;

举例:系统假定一份客户信息文件所含的记录数不可超过50000,那么程序中可以包含一个断定记录数小于等于50000的断言。若记录数小于等于50000,这一断言会默默无语,否则它会大声地“断言”说程序存在一个错误;

作用:对大型的复杂程序或可靠性要求极高的程序来说尤其有用,通过使用断言,程序员能更快速地排查出因修改代码或者别的原因,而弄进程序里的不匹配的接口假定和错误等;

格式:两个参数:一个描述假设为真时的情况的布尔表达式;一个断言为假时需要显示的信息;

写法:assert denominator !=  0: “denominator is unexpectedly equal to 0.”;

声明denominator不会等于0;参数denominator != 0 是个布尔表达式;第二个参数是当第一个参数为false—断言为假时—所打印的消息;

检查假定类型

输入参数或者输出参数的取值处于预期的范围内;

子程序开始(或结束)执行时文件或流时处理打开(或关闭)的状态;

子程序开始(或结束)执行时,文件或流的读写位置处于开头(或结尾)处;

文件或流已用只读,只写或可读可写方式打开;

仅用于输入的变量的值没有被子程序所修改;

指针非空;

传入子程序的数组或其他容器至少能容纳X个数据元素

表已初始化,存储着真实数值;

子程序开始(或结束)执行时,某个容器是空的(或满的);

一个经过高度优化的复杂子程序的运算结果和相对较慢但代码清晰的子程序的运算结果相一致;

说明正常情况下,不希望用户看到产品代码中的断言信息,即断言主要用于开发和维护阶段。通常,断言只在开发阶段被编译到目标代码中,而在产生产品代码时并不编译进去,在开发阶段,断言可以帮助查清相互矛盾的假定,预料之外的情况以及传给子程序的错误数据等;在生成产品代码时,可以不把断言编译进目标代码里去,以免降低系统的性能;

使用断言的指导建议

用错误处理代码来处理预期会发生的状况,用断言来处理绝不应该发生的状况;

用错误处理代码来处理反常情况,程序就能从容地对错误做出反映,如果发生异常情况时触发了断言,那么要采取的更正的措施就不仅仅是对错误做出恰当的反映了—而是应该修改程序的源代码并重新编译,然后发布软件的新版本;

避免把需要执行的代码放到断言中;

用断言来注解并验证前条件和后条件(契约式设计);

前条件:子程序或类的调用方法代码在调用子程序或实例化对象之前要确保为真的属性,前条件是调用方代码对其所调用的代码要承担的义务;

后条件:是子程序或类在执行结束后要确保为真的属性,后条件是子程序或类对调用方代码所承担的责任;

断言:用来说明前条件和后条件的有力工具,判断前条件和后条件是否为真;

对于高健壮性的代码,应该先使用断言再处理错误;断言可以帮助再开发阶段排查出尽可能多的错误;

 

错误处理技术

断言用于处理代码中不应发生的错误,如何处理预料之中可能发生的错误呢?

可以选择:返回中立值,换用下一个正确数据,返回与前次相同的值,换用最接近的有效值,在日志文件中记录警告信息,返回一个错误码,调用错误处理子程序或对象,显示出错信息或者关闭程序—或把这些技术结合;

返回中立值:继续执行操作并简单的返回一个无害数值;如数值计算返回0,字符串操作返回空字符串,指针操作返回空指针;

换用下一个正确的数据:处理数据流时,有时只需返回下一个正确的数据即可;

返回与前次相同的数据:返回前一次的读取结果,如体温测量,空气检测等,但银行取款不可使用;

换用最接近的合法值:温度计校准在0到100摄氏度之间,检测到一次小于0的结果,则替换为0,即最接近的合法值,如倒车时,车上无法表示负速度,因此简单地显示0,即最接近的合法值;

把警告信息记录到日志文件中:检测到错误数据时,在日志文件log file中记录一条警告信息,然后继续执行,结合其他错误处理技术使用;

返回一个错误码;

调用错误处理子程序或对象:错误处理集中在一个全局的错误处理子程序或对象中,将错误处理的职责集中到一起,从而使调试工作更简单;但安全性:如果代码发生了缓冲区溢出,则不再安全;

当错误发生时显示出错消息;

最稳妥的方式在局部处理错误;

关闭程序:适用人身安全的应用软件,如治疗癌症病人的设备,一旦输入的药剂异常立刻关闭程序才是最佳选择;

正确性与健壮性正确性意味着永不返回不准确的结果,哪怕不返回结果也比返回不准确的结果好,然后,健壮性则意味着要不断尝试采取某些措施,以保证软件可以持续地运行下去,哪怕有时做出一些不够准确的结果;

人身安全相关的软件更倾向于正确性;

消费类应用软件更注重健壮性而非正确性;

高层次设计对错误处理方式的影响

应该在整个程序里采用一致的方式处理非法的参数,确定一种同意的处理错误参数的方法,是架构层次的设计决策;

一旦确定了某种方法,需要确保始终如一地贯彻这一方法;如果你决定让高层的代码来处理错误,而底层的代只需简单地报告错误,那么就要确保高层代码真的处理了错误;

防御式编程全部的重点就在于防御哪些你未曾预料到的错误;