解决 NumberFormat 的解析问题

防御标准解析潜在的数据丢失问题

早期的程序员们很快发现了数字的文本表示与程序能在上面执行数学运算的数字变量显著不同。例如,“123” 与真正的数值 123 或十六进制的 0x7B 是不一样的。程序必须用一种算法或转换程序来从文本中获取数字,特别是在文本被分组分隔符或小数分隔符(如美国数字格式中的逗号和小数点)格式化的情况下。文本到数字的转换是交互式编程中一个主要要关注的问题,也会在 HTML、XML 以及其他将数据当作文本处理的文件和通信格式中常常遇到。

Java SE API 提供 Integer.parseInt() 和 Double.parseDouble() 方法用于转换,但这两种方法需要语句符合 Java Language Specification(参见 参考资料)定义的文字格式。为本文(主要探讨整数和双精度型)之便,该格式基本上 由下列字符组成:

  • 前置负号(ASCII 值 45 或十六进制值 0x2D)
  • 从 0 至 9 的数字(ASCII 值从 48 至 57 或十六进制值 0x30 至 0x39)
  • 对于浮点值,小数点由圆点或句点代替(ASCII 值 46 或十六进制值 0x2E)

该需求对程序员和代码来说都是合理的,但用户期待以符合其当地文化的通用格式来输入及查看数字。Java SE API 的 java.text.NumberFormat 类包含一个方便的 parse(String source) 方法,大多数程序员都使用它来将针对特定的区域设置的格式化文本解析为数值。不幸的是,该方法能够导致无法预料且不精确的结果。本文解释了 NumberFormat 的基本原理,考察了其功能,揭示了该类的解析陷阱,并提供了可靠的使用指南。

未经验证的解析

本文的样例程序(参见 下载) —— NumberInput,如图 1 所示,是一个 Swing 应用程序,该程序考察几种将文本型输入转换为数值的方法。除了输入域,该程序还显示默认的地区名、初始键入值、初始长度以及解析的位数(可接受的)。开始时,它分别加载双精度值 123456.7 和整数值 1234567 作为输入域。这两种值都按默认地区当地用户所期待的那样进行了格式化。因为我住在美国,按照美国的习惯,程序中双精度值显示为 “123,456.7” 而整数值显示为 “1,234,567”。

图 1. NumberInput 初始显示

单击 NoCheck 按钮时,该程序使用 Double.parseDouble() 和 Integer.parseInt() 不进行验证,直接解析。请注意,在 actionPerformed() 中,调用任何其他方法前,前导和后缀的空格都从输入的字符串中去掉了。图 2 显示了结果:

图 2. Double.parseDouble() 针对格式化的文本抛出 NumberFormatException

错误的原因是在美国地区使用逗号作为分组分隔符。去掉逗号输入就变为 “123456.7”,该程序接受该双精度值。

负值又如何呢?键入一个前置符号(同时去掉逗号)能让程序顺利运行,但键入后置符号的结果是:输入字符串出现 NumberFormatException: “123456.7-”

整数解析与此类似,原因相同。NoCheck 按钮调用的代码在 NumberInput 的 noCheckInput() 方法中。针对 JTextField jtD 中的输入使用 Double.parseDouble(),针对 JTextField jtI 中的输入使用 Integer.parseInt()。根据规则,这些结果都是正常的,且也应该是大多数渡过了新手期的 Java 程序员们所能预料的行为。

拯救 NumberFormat

完全排除了键入和其他的用户输入错误,在显示格式化的数字文本和从同一个字段中接受数字文本输入之间的关系还是不太好处理。我们也许都遇到过这样的程序员,他们认为解决方案不过是显示一个像 “别键入逗号和前置负号” 这样的消息。这个方法对于那些低着头进行数据录入的家伙来说没有问题,但对于通常想要看到格式化的数字然后直接在格式化的显示(保留分隔符和分组符)下键入的用户来说,却是大有问题的。总的来说,经过一些抱怨后,美国的程序员解决这一问题的第一步是编写一个除掉逗号并将后置负号移到输入值前面的程序。许多严格按照该方式编写的程序得以长期生存。在某种程度上,这实际是对程序员们实现国际化(I18N)和本地化(L10N)的第一次打击(参见 参考资料)。其问题就是,该方法仅对一个或有限个区域设置的程序本地化有效。

Java 程序被吹捧为能够在任何可能的平台上运行,许多人认为这点也意味着在任何国家和任何语言(以熟悉的方式)下都适用。Java SE SDK 提供了使这一期待的大部分成为现实的 API。然而,当超过其假设范围时,编写得和我刚描述的相似的程序一会就会宕掉。在许多国家,值 123456.7 可以有这样的格式或这样的键入,如 “123.456,7”、“123456,7” 或 “123'456,7” 等等。任何假设所有地区都使用相同的分组分隔符和小数分隔符(在美国这里分别使用“,”和“.”)的方法都是行不通的。预料到这一问题,该 API 包含 java.text.NumberFormat。该类提供外表简单的 parse() 和 format() 方法,这些方法能够自动识别地区,还包含格式符号的知识。事实上,NumberInput 使用 NumberFormat 对输入中显示的值进行格式化。

Java Locale 对象代表并标识一个由语言和地区(国家)组成的特殊的组合。它自己本身并不提供本地化的行为;类必须自己提供本地化。然而,Java 平台的确支持一些区域设置的一致集合,许多标准类都实现一致的本地化行为。这些类通常有两个版本的方法:一个带 Locale 参数,另一个假设为默认。默认区域设置在程序启动时自动确定,或被传递到 Java 运行时的参数所覆盖。

NumberFormat 是一个抽象的类,但它提供静态工厂 getXXXInstance() 方法,该方法用预先定义的本地化了的格式来获取具体的实现。底层的实现通常是一个 java.text.DecimalFormat 实例。本文中的代码和讨论使用的是默认值,其中用于格式化及解析双精度值的是由 NumberFormat.getNumberInstance()返回的,用于格式化及解析整数值的是由 NumberFormat.getIntegerInstance() 返回的。

值得注意的是,完全实现本地化解析的代码是多么简短。步骤如下:

  1. 获得一个 NumberFormat 实例。
  2. 将 String 解析为一个 Number。
  3. 获取适当的数值。

如此小的努力即换来如此大的成就,每个 Java 程序员都应该使用 NumberFormat 来处理格式化的数字转换问题。要在不同地区实现,请使用下列代码行调用 NumberInput 应用程序,其中 lc 是 ISO-639 语言的代码,而 cc 是 ISO-3166 国家的代码:

java -Duser.language=lc -Duser.region=cc NumberInput

对于 JDK 1.4,可以使用 user.country 系统属性代替 user.region。要确定由 Java 平台支持的区域设置,请参见 JDK 文档中 Internationalization 部分中的 Supported Locales(参见 参考资料)。程序可以在运行时用 java.util.Locale 的静态 getAvailableLocales() 方法确定区域设置支持。

清单 1 显示了 NumberInput 的 NFInput() 方法的相关代码,该方法在单击 NF 按钮时被调用。该方法使用 NumberFormat.parse(String) 来验证和转换。

清单 1. NFInput() 方法使用 NumberFormat.parse(String)

...
NumberFormat nfDLocal =
NumberFormat.getNumberInstance(),
nfILocal =
NumberFormat.getIntegerInstance();
...

public void NFInput( String sDouble, String sInt )
{ // "standard" NumberFormat parsing
double d;
int i;
Number n;

try
{
n = nfDLocal.parse( sDouble );
d = n.doubleValue();
...
n = nfILocal.parse( sInt );
i = n.intValue();
...
}
catch( ParseException pe )
{
...
}
} // end NFInput

NumberFormat 实例的实现

在这时候,很有必要先简单地回顾一下当要求 NumberFormat 来 getXXXInstance() 时发生的情况以及 DecimalFormatSymbols 类的角色。此讨论是基于对实现源代码的引用的回顾,该代码由 J2SE 1.4 自带并会有所改变。

基本的事件流是这样的,NumberFormat 参考内部的 ListResourceBundle,选择一个合适的基于一个关联的区域设置的模式,并返回一个由该模式创建的 DecimalFormat 对象。没有显式的负数模式时,假设正数模式中包含前置的负号。在这一过程中,创建了一个和地区相适应的 DecimalFormatSymbols 对象,且该 DecimalFormat 实例获取了一个对它的引用。由于 NumberFormat.getXXXInstance() 方法是基于工厂模式,所以其他的实现或未来的引用实现也许会返回一个不同的类。为此,任何定制的代码都必须确保,在试图访问关联的 DecimalFormatSymbols 实例前返回一个 DecimalFormat。

DecimalFormatSymbols 对象包含了诸如合适的小数分隔符、分组分隔符及负号标记在内的信息。NumberInput 收集许多这样的信息并在单击 Info 按钮后在一个对话框中显示它。图 3 显示了一个使用 en_US 区域设置的例子。此信息对于在本地化的格式中解析及验证数字来说是至关重要的。

图 3. DecimalFormatSymbols 数据

试着单击 NF 按钮,就会看到该值被正确地接受了,即使其中含有逗号或其他当地的分组分隔符。依照当前模式放置,负号也被接受了。负号放在别处又如何呢?这一问题和许多其他问题一起是下一部分的主题,也是本文的重点所在。

UnexpectedResults. equals (bigTrouble)

大多数关于 Java 国际化的文章都专注于 NumberFormat 的格式化功能,并在与我到目前为止所给出的信息有些不同之处的地方就中止了对解析的讨论。不幸的是,用该类(实际上是由 NumberFormat.getXXXInstance() 返回的具体的 DecimalFormat 子类)进行的测试和实验揭示出的解析缺陷是惊人的:在大量通用的应用环境下,NumberFormat.parse(String) 会自然地截断数据,并在不对程序员作任何提示的情况下 丢失符号。下列情况展示了这种行为(使用 en_US 区域设置,除非有所指定):

  • 在小数分隔符前插入的多个挨着的或不规则的分组分隔符被忽略了。
  • 例如,“123,,,456.7” 和 “123,45,6.7” 都能被接受且都返回 123456.7。
  • 考虑再三,我得出一个结论,尽管此行为在技术上是错误的,但却并未丢失数据,且任何针对性的解决方案都会引起更多的工作而得不偿失。应该注意这个行为,但 NumberInput 应用程序并没有改正它,我不会再在文章中提到这个问题。
  • 在小数分隔符后面的分组分隔符会导致截断。
  • “123,456.7,85” 被接受为 123456.7。
  • 多个小数分隔符会导致截断。
  • “123,456..7” 被接受为 123456.0;“12.3.456.7” 被接受为 12.3。
  • 对于带前置负号(负前缀)的模式,截断发生在非数字字符的点(包括了内嵌的负号)。
  • “123,4r56.7” 被接受为 1234.0;“12-3,456.7” 被接受为 12.0(正值)。
  • 对于带后置负号(负后缀)的模式,截断发生在非数字字符点(不包括内嵌的负号)。接受内嵌负号,但截断额外的数据。
  • 对于沙特阿拉伯的区域设置(ar_SA)来说,“123,4r56.7” 被接受为 1234.0;“12-3,456.7” 被接受为 -12.0(负值)。
  • 如果该模式指定前置负号为负输入,后置负号将被忽略。
  • “123,456.7-” 被接受为 123456.7(正值),且 “-123.456,7-”(荷兰区域设置 nl_NL)被接受为 -123456.7(负值)。

图 4 和图 5 是在单击 NF 按钮后一些行为的一个例子。尽管通过初始输入很难解释其目的,但可以断言 1234.0 的双精度结果也是不可预料的,让这个整数输入的后两位小数丢失也不是用户所希望的。这次也是一样,并未抛出异常,也没有任何关于忽略了输入中的一些部分的提示。

图 4. NumberFormat.parse(String) 的意外结果

图 5. NumberFormat.parse(String) 接受截断的值

这些结果,对于 JDK 1.4 和 5.0 上的许多测试都是一致的,所以花了大量工夫探讨 NumberFormat 和 DecimalFormat 类的实现就很难理解。另一方面,代码再易懂也不如将参数传给一个方法并检查结果更加直白。惟一真实的线索在针对 NumberFormat.parse(String source) 的 JDK 文档中,该文档称“该方法也许不会使用给定字符串的整个文本”,但并未给出进一步的说明。

像这样的异常看起来很麻烦,乍看上去,返回编程的“Key it my way or else” 似乎更好。“垃圾进,垃圾出” 在计算机界是个老生常谈的问题,但那只意味着程序永远不能保证数据是正确的 ;程序员的义务是要尽量确保所有的输入都是有效的 。与其成为一个 bug,不如由 NumberFormat.parse(String) 从输入的字符串的某部分中返回一个数字(如果可能的话)。不幸地是,该行为包含一个未声明的假设,即该数据已经过验证。最后的结果是程序员不能确定输入何时无效,也不能确定是什么打断了和用户及数据本身的隐式的约定。

几年前发现这些问题的时候,我第一个反应就是为 parse(String) 方法编写了一个前期的预处理器。那起了作用,但却产生了额外的代价,即部分多余的代码且需要花更多的时间处理数据。幸运地是,原来小心地使用一个现有的 NumberFormat 方法能够解决这一问题。

用 NumberFormat. parse(String, ParsePosition) 进行验证

parse(String source, ParsePosition parsePosition) 方法很不正常,它并不抛出任何异常。它通常用于从单个字符串中解析多个数字。然而,方法一返回,ParsePosition.getIndex() 中值加上 1 就是输入字符串中最后解析过的位置。如果代码常以设置为 0 的索引开始,处理后,索引值将等于解析过的字符的值。使用该方法验证的关键在于将更新过的索引和初始输入字符串的长度进行比较。

为避免混淆,我要提一下 ParsePosition 也有一个 getErrorIndex() 方法。此方法除了在这里讨论的情况外毫无用处,因为没探测到错误。另外,使用它时,在每次解析操作前必须将错误索引重置为 -1 ;否则结果将可能产生误导。

当单击了 NF 或 NFPP 按钮后,NumberInput 应用程序在 Length/PP 栏下显示 ParsePosition 索引。如果初始值的长度比 0 大且和索引值相匹配,这两者都会以绿色显示;否则显示红色。此项操作根据特定的验证方法单独完成。如果再看一次 图 4,就会发现该值显示为红色,这指示了一个错误,尽管和 NF 按钮相关联的 NFInput() 方法接受了该数据。

为了最终验证,会在单击 NFPP 按钮时调用 NFPPInput() 方法。此方法使用 parse(String, ParsePosition) 来验证输入并获取数值。图 6 和图 7 显示了 图 4 中的无效输入被 NFPPInput() 检查了出来。在我的测试中,该方法恰当地处理了所有被 NumberFormat.parse(String) 忽略的情况。

图 6. 检查无效的双精度输入

图 7. 检查无效的整数输入

必须遵循以下几条指导方针来确保 parse(String, ParsePosition) 的正确结果:

  • 记住一点,该方法从不抛出异常。
  • 出于清楚及示范的目的,这里的代码只显示 Acceptable/Unacceptable 对话框。在一般情况下,应该抛出 ParseException,从而更加接近正常的预期。
  • 在调用 parse(String, ParsePosition) 前始终将 ParsePosition 索引重置为 0。
  • 重置很必要,因为使用此方法时解析开始于输入字符串的 ParsePosition 索引。
  • 使用 NumberFormat.getNumberInstance() 来解析双精度值,使用NumberFormat.getIntegerInstance() 来解析整数值。
  • 如果不为整数使用整数实例(而是将 setParseIntegerOnly(true) 应用到数字实例),该方法解析越过小数分隔符直达输入字符串的末端。结果是,长度和索引相匹配,接受了无效输入。
  • 除了要比较长度和索引值是否相等之外,还必须检查是解析后的 null Number 还是一个空的输入字符串(“”或长度为 0)
  • 清除一个输入域会导致空字符串。在本例中,长度和索引值都为 0,所以它们是匹配的。对于空字符串输入,该解析方法返回 null。这个行为和使用 NumberFormat.parse(String source) 的空字符串结果是不同的,后者抛出一个 “unparsable number”ParseException。请记住,parse(String source, ParsePosition parsePosition) 从不抛出异常!在 NumberInput 中,清单 2 中的代码段被用于处理这些可能性:
  • 清单 2. 检查错误条件
  • 1
  • 2
  • 3
  • if( sDouble.length() != pp.getIndex() ||
  • n == null )
  • { /* error */ }

总结一下,合适的输入处理步骤是:

  1. 获取一个适当的 NumberFormat 并定义一个 ParsePosition 变量。
  2. 将 ParsePosition 索引值设为 0。
  3. 用 parse(String source, ParsePosition parsePosition) 解析输入值。
  4. 如果输入长度和 ParsePosition 索引值不匹配或解析过的 Number 是 null,则执行的是错误操作。
  5. 否则,该值通过验证。

清单 3 显示了相关代码:

清单 3. NFPPInput() 方法

...

NumberFormat nfDLocal =

NumberFormat.getNumberInstance(),

nfILocal =

NumberFormat.getIntegerInstance();

ParsePosition pp;

...

public void NFPPInput( String sDouble,

String sInt )

{ // validate NumberFormat with ParsePosition

Number n;

double d;

int i;

pp.setIndex( 0 );

n = nfDLocal.parse( sDouble, pp );

if( sDouble.length() != pp.getIndex() ||

n == null )

{

showErrorMsg(

"Double Input Not Acceptable\n" +

"\"" + sDouble + "\"");

}

else

{

d = n.doubleValue();

jtD.setText( nfDLocal.format( d ) );

showInfoMsg( "Double Accepted \n" + d );

}

pp.setIndex( 0 );

n = nfILocal.parse( sInt, pp );

if( sInt.length() != pp.getIndex() ||

n == null )

{

showErrorMsg(

"Int Input Not Acceptable \n" +

"\"" + sInt + "\"");

}

else

{

i = n.intValue();

jtI.setText( nfILocal.format( i ) );

showInfoMsg( "Int Accepted \n" + i );

}

} // end NFPPInput

结束语

Java SE API 中包含进了大量的工作来实现 “编写一次,随处运行”,这不仅体现在字节码的层面,也同样适用于国际化和本地化的应用程序。NumberFormat 和 DecimalFormat 类是那些想要编写顶级应用程序的 Java 程序员们所不能缺少的。然而,正如这篇文章所展示的那样,就目前的情况来说,开发人员同样不能离开 parse(String source) 方法,除非可以假设进行了无瑕输入 —— 这在现实世界中是极难发生的。在本文中所展示的信息及代码提供了一种可选的技术,即使用 parse(String source, ParsePosition parsePosition) 来确定输入何时无效并获取正确结果。

发表评论
留言与评论(共有 0 条评论)
   
验证码:

相关文章

推荐文章

'); })();