JavaRush /Java 博客 /Random-ZH /FindBugs 帮助您更好地学习 Java
articles
第 15 级

FindBugs 帮助您更好地学习 Java

已在 Random-ZH 群组中发布
静态代码分析器很受欢迎,因为它们可以帮助发现由于粗心而造成的错误。但更有趣的是,它们有助于纠正由于无知而犯下的错误。即使所有内容都写在该语言的官方文档中,但事实并非所有程序员都仔细阅读过它。程序员可以理解:你会厌倦阅读所有文档。在这方面,静态分析器就像一位经验丰富的朋友,坐在您旁边看着您编写代码。他不仅告诉你:“这是你复制粘贴时出错的地方”,而且还说:“不,你不能这样写,你自己看一下文档。” 这样的朋友比文档本身更有用,因为他只建议你在工作中实际遇到的那些东西,而对那些永远对你没有用处的东西保持沉默。在这篇文章中,我将讨论我从使用 FindBugs 静态分析器中学到的一些 Java 复杂性。也许有些事情也会让你意想不到。重要的是,所有示例都不是推测性的,而是基于真实的代码。

三元运算符 ?:

似乎没有什么比三元运算符更简单的了,但它也有其缺陷。我相信这些设计之间没有根本的区别 Type var = condition ? valTrue : valFalse; ,但 Type var; if(condition) var = valTrue; else var = valFalse; 事实证明这里有一个微妙之处。由于三元运算符可以是复杂表达式的一部分,因此其结果必须是在编译时确定的具体类型。因此,假设 if 形式的 true 条件,编译器将 valTrue 直接引向类型 Type,而以三元运算符的形式,它首先引向公共类型 valTrue 和 valFalse(尽管事实上 valFalse 不是评估),然后结果导致类型 Type。如果表达式涉及原始类型及其包装器(整数、双精度型等),则转换规则并非完全微不足道。JLS 15.25 中详细描述了所有规则。让我们看一些例子。 Number n = flag ? new Integer(1) : new Double(2.0); 如果设置了标志,n 会发生什么?值为 1.0 的 Double 对象。编译器发现我们创建对象的笨拙尝试很有趣。由于第二个和第三个参数是不同基元类型的包装器,因此编译器将它们解包并产生更精确的类型(在本例中为 double)。并且执行完三元运算符进行赋值后,再次进行装箱。本质上,代码与此等效: Number n; if( flag ) n = Double.valueOf((double) ( new Integer(1).intValue() )); else n = Double.valueOf(new Double(2.0).doubleValue()); 从编译器的角度来看,代码没有任何问题,可以完美编译。但 FindBugs 给出了警告:
BX_UNBOXED_AND_COERCED_FOR_TERNARY_OPERATOR:在 TestTernary.main(String[]) 中,原始值被拆箱并强制用于三元运算符。包装的原始值被拆箱并转换为另一个原始类型,作为条件三元运算符(b? e1: e2 运算符)评估的一部分)。Java 的语义规定,如果 e1 和 e2 是包装的数值,则这些值将被拆箱并转换/强制为其公共类型(例如,如果 e1 是 Integer 类型,e2 是 Fl​​oat 类型,则 e1 是拆箱的,转换为浮点值,并装箱。参见 JLS 第 15.25 节。当然,FindBugs 还警告 Integer.valueOf(1) 比 new Integer(1) 更高效,但每个人都已经知道这一点。
或者这个例子: Integer n = flag ? 1 : null; 如果没有设置标志,作者希望将 null 放入 n 中。你认为这会起作用吗?是的。但让我们把事情复杂化: Integer n = flag1 ? 1 : flag2 ? 2 : null; 看起来没有太大区别。但是,现在如果两个标志都已清除,则此行将引发 NullPointerException。右侧三元运算符的选项为 int 和 null,因此结果类型为 Integer。左边的选项是int和Integer,所以根据Java规则,结果是int。为此,您需要通过调用 intValue 来执行拆箱,这会引发异常。代码等效于: Integer n; if( flag1 ) n = Integer.valueOf(1); else { if( flag2 ) n = Integer.valueOf(Integer.valueOf(2).intValue()); else n = Integer.valueOf(((Integer)null).intValue()); } 这里 FindBugs 生成两条消息,这足以怀疑有错误:
BX_UNBOXING_IMMEDIATELY_REBOXED:装箱值被取消装箱,然后立即在 TestTernary.main(String[]) 中重新装箱 NP_NULL_ON_SOME_PATH:TestTernary.main(String[]) 中可能对 null 进行空指针取消引用 有一个语句分支,如果执行,则保证null 值将被取消引用,这将在执行代码时生成 NullPointerException。
好吧,关于这个主题的最后一个例子: double[] vals = new double[] {1.0, 2.0, 3.0}; double getVal(int idx) { return (idx < 0 || idx >= vals.length) ? null : vals[idx]; } 这段代码不起作用并不奇怪:返回基本类型的函数如何返回 null?令人惊讶的是,它编译没有问题。好吧,你已经明白为什么它可以编译了。

日期格式

要在 Java 中格式化日期和时间,建议使用实现 DateFormat 接口的类。例如,它看起来像这样: public String getDate() { return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); } 通常一个类会一遍又一遍地使用相同的格式。很多人会产生优化的想法:既然可以使用通用实例,为什么每次都要创建一个格式对象呢? private static final DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); public String getDate() { return format.format(new Date()); } 它是如此美丽和酷,但不幸的是它不起作用。更准确地说,它可以工作,但偶尔会崩溃。事实上,DateFormat 的文档说:
日期格式不同步。建议为每个线程创建单独的格式实例。如果多个线程同时访问某种格式,则必须进行外部同步。
如果您查看 SimpleDateFormat 的内部实现,情况确实如此。在执行 format() 方法期间,对象会写入类字段,因此两个线程同时使用 SimpleDateFormat 将有一定概率导致不正确的结果。FindBugs 对此的描述如下:
STCAL_INVOKE_ON_STATIC_DATE_FORMAT_INSTANCE:调用 TestDate.getDate() 中静态 java.text.DateFormat 的方法 正如 JavaDoc 所述,DateFormat 对于多线程使用本质上是不安全的。检测器发现对通过静态字段获取的 DateFormat 实例的调用。这看起来很可疑。有关这方面的更多信息,请参阅 Sun Bug #6231579 和 Sun Bug #6178997。

BigDecimal 的陷阱

了解 BigDecimal 类允许您存储任意精度的小数,并且看到它有一个 double 的构造函数后,有些人会认为一切都清楚了,您可以这样做: System.out.println(new BigDecimal( 1.1));没有人真正禁止这样做,但结果可能看起来出乎意料:1.100000000000000088817841970012523233890533447265625。发生这种情况是因为原始 double 以 IEEE754 格式存储,在这种格式中不可能完美准确地表示 1.1(在二进制数系统中,获得无限周期分数)。因此,最接近 1.1 的值存储在那里。相反,BigDecimal(double) 构造函数的工作方式完全一样:它完美地将 IEEE754 中的给定数字转换为十进制形式(最终的二进制小数始终可表示为最终的十进制)。如果您想将 1.1 精确地表示为 BigDecimal,则可以编写 new BigDecimal("1.1") 或 BigDecimal.valueOf(1.1)。如果你不立即显示该数字,而是对其进行一些操作,你可能不明白错误来自哪里。FindBugs 发出警告 DMI_BIGDECIMAL_CONSTRUCTED_FROM_DOUBLE,它给出了相同的建议。还有一件事: BigDecimal d1 = new BigDecimal("1.1"); BigDecimal d2 = new BigDecimal("1.10"); System.out.println(d1.equals(d2)); 实际上,d1 和 d2 代表相同的数字,但 equals 返回 false,因为它不仅比较数字的值,还比较当前的顺序(小数位数)。这是文档中写的,但是对于 equals 这么熟悉的方法,很少有人会去读文档。这样的问题可能不会立即出现。不幸的是,FindBugs 本身并没有对此发出警告,但它有一个流行的扩展 - fb-contrib,它考虑了这个错误:
调用 MDM_BIGDECIMAL_EQUALS equals() 来比较两个 java.math.BigDecimal 数字。这通常是一个错误,因为两个 BigDecimal 对象只有在值和小数位数都相等时才相等,因此 2.0 不等于 2.00。要比较 BigDecimal 对象的数学相等性,请改用compareTo()。

换行符和 printf

通常,在 C 之后转向 Java 的程序员很高兴发现PrintStream.printf(以及PrintWriter.printf等)。就像,太好了,我知道,就像 C 语言一样,你不需要学习任何新东西。实际上是有差异的。其中之一在于行翻译。C语言分为文本流和二进制流。通过任何方式将 '\n' 字符输出到文本流都会自动转换为系统相关的换行符(在 Windows 上为“\r\n”)。Java 中没有这样的分离:必须将正确的字符序列传递到输出流。这是自动完成的,例如,通过 PrintStream.println 系列的方法。但是当使用 printf 时,在格式字符串中传递 '\n' 只是 '\n',而不是系统相关的换行符。例如,让我们编写以下代码: System.out.printf("%s\n", "str#1"); System.out.println("str#2"); 将结果重定向到文件后,我们将看到: FindBugs 帮助您更好地学习 Java - 1 因此,您可以在一个线程中得到一种奇怪的换行符组合,这看起来很草率,并且可能会让某些解析器大吃一惊。该错误可能会在很长一段时间内被忽视,特别是如果您主要在 Unix 系统上工作。要使用 printf 插入有效的换行符,需要使用特殊的格式字符“%n”。FindBugs 对此的描述如下:
VA_FORMAT_STRING_USES_NEWLINE:格式字符串应在 TestNewline.main(String[]) 中使用 %n 而不是 \n 此格式字符串包含换行符 (\n)。在格式字符串中,通常最好使用 %n,这将生成特定于平台的行分隔符。
或许,对于一些读者来说,以上这些都是早已知晓的。但我几乎可以肯定,对于他们来说,静态分析器将会发出一个有趣的警告,这将向他们揭示所使用的编程语言的新功能。
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION