在这篇由两部分组成的文章中,Elliotte Rusty Harold 与您一起探讨经典
java.lang.Math
类中的“新”功能。第 1 部分主要讨论比较单调的数学函数。第 2 部分将探讨专为操作浮点数而设计的函数。
有时候您会对一个类熟悉到忘记了它的存在。如果您能够写出 java.lang.Foo
的文档,那么 Eclipse 将帮助您自动完成所需的函数,您无需阅读它的 Javadoc。例如,我使用 java.lang.Math
(一个我自认为非常了解的类)时就是这样,但令我吃惊的是,我最近偶然读到它的 Javadoc —— 这可能是我近五年来第一次读到,我发现这个类的大小几乎翻了一倍,包含 20 种我从来没听说过的新方法。看来我要对它另眼相看了。
Java™ 语言规范第 5 版向 java.lang.Math
(以及它的姊妹版 java.lang.StrictMath
)添加了 10 种新方法,Java 6 又添加了 10 种。在本文中,我重点讨论其中的比较单调的数学函数,如 log10
和 cosh
。在第 2 部分,我将探讨专为操作浮点数(与抽象实数相反)而设计的函数。
抽象实数(如 π 或 0.2)与 Java double
之间的区别很明显。首先,数的理想状态是具有无限的精度,而 Java 表示法把数限制为固定位数。在处理非常大和非常小的数时,这点很重要。例如,2,000,000,001(二十亿零一)可以精确表示为一个 int
,而不是一个 float
。最接近的浮点数表示形式是 2.0E9 — 即两亿。使用 double
数会更好,因为它们的位数更多(这是应该总是使用 double
数而不是 float
数的理由之一);但它们的精度仍然受到一定限制。
计算机算法(Java 语言和其他语言的算法)的第二个限制是它基于二进制而不是十进制。1/5 和 7/50 之类的分数可用十进制精确表示(分别是 0.2 和 0.14),但用二进制表示时,就会出现重复的分数。如同 1/3 在用十进制表示时,就会变为 0.3333333……以 10 为基数,任何分母仅包含质数因子 5 和 2 的分数都可以精确表示。以 2 为基数,则只有分母是 2 的乘方的分数才可以精确表示:1/2、1/4、1/8、1/16 等。
这种不精确性是迫切需要一个 math 类的最主要的原因之一。当然,您可以只使用标准的 + 和 * 运算符以及一个简单的循环来定义三角函数和其他使用泰勒级数展开式的函数,如清单 1 所示:
清单 1. 使用泰勒级数计算正弦
public class SineTaylor {
public static void main(String[] args) { for (double angle = 0; angle <= 4*Math.PI; angle += Math.PI/8) { System.out.println(degrees(angle) + "/t" + taylorSeriesSine(angle) + "/t" + Math.sin(angle)); } } public static double degrees(double radians) { return 180 * radians/ Math.PI; } public static double taylorSeriesSine(double radians) { double sine = 0; int sign = 1; for (int i = 1; i < 40; i+=2) { sine += Math.pow(radians, i) * sign / factorial(i); sign *= -1; } return sine; }
private static double factorial(int i) { double result = 1; for (int j = 2; j <= i; j++) { result *= j; } return result; } } |
开始运行得不错,只有一点小的误差,如果存在误差的话,也只是最后一位小数不同:
0.0 0.0 0.0 22.5 0.3826834323650897 0.3826834323650898 45.0 0.7071067811865475 0.7071067811865475 67.5 0.923879532511287 0.9238795325112867 90.0 1.0000000000000002 1.0 |
但是,随着角度的增加,误差开始变大,这种简单的方法就不是很适用了:
630.0000000000003 -1.0000001371557132 -1.0 652.5000000000005 -0.9238801080153761 -0.9238795325112841 675.0000000000005 -0.7071090807463408 -0.7071067811865422 697.5000000000006 -0.3826922100671368 -0.3826834323650824 |
这里使用泰勒级数得到的结果实际上比我想像的要精确。但是,随着角度增加到 360 度、720 度(4 pi 弧度)以及更大时,泰勒级数就逐渐需要更多条件来进行准确计算。java.lang.Math
使用的更加完善的算法就避免了这一点。
泰勒级数的效率也无法与现代桌面芯片的内置正弦函数相比。要准确快速地计算正弦函数和其他函数,需要非常仔细的算法,专门用于避 免无意地将小的误差变成大的错误。这些算法一般内置在硬件中以更快地执行。例如,几乎每个在最近 10 年内组装的 X86 芯片都具有正弦和余弦函的硬件实现,X86 VM 只需调用即可,不用基于较原始的运算缓慢地计算它们。HotSpot 利用这些指令显著加速了三角函数的运算。
直角三角形和欧几里德范数
每个高中学生都学过勾股定理:在直角三角形中,斜边边长的平方等于两条直角边边长平方之和。即 c 2 = a 2 + b 2
学习过大学物理和高等数学的同学会发现,这个等式会在很多地方出现,不只是在直角三角形中。例如,R 2 的平方、二维向量的长度、三角不等式等都存在勾股定理。(事实上,这些只是看待同一件事情的不同方式。重点在于勾股定理比看上去要重要得多)。
Java 5 添加了 Math.hypot
函数来精确执行这种计算,这也是库很有用的一个出色的实例证明。原始的简单方法如下:
public static double hypot(double x, double y){ return x*x + y*y; } |
实际代码更复杂一些,如清单 2 所示。首先应注意的一点是,这是以本机 C 代码编写的,以使性能最大化。要注意的第二点是,它尽力使本计算中出现的错误最少。事实上,应根据 x
和 y
的相对大小选择不同的算法。
清单 2. 实现 Math.hypot
的实际代码/* * ==================================================== * Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved. * * Developed at SunSoft, a Sun Microsystems, Inc. business. * Permission to use, copy, modify, and distribute this * software is freely granted, provided that this notice * is preserved. * ==================================================== */
#include "fdlibm.h"
#ifdef __STDC__ double __ieee754_hypot(double x, double y) #else double __ieee754_hypot(x,y) double x, y; #endif { double a=x,b=y,t1,t2,y1,y2,w; int j,k,ha,hb;
ha = __HI(x)&0x7fffffff; /* high word of x */ hb = __HI(y)&0x7fffffff; /* high word of y */ if(hb > ha) {a=y;b=x;j=ha; ha=hb;hb=j;} else {a=x;b=y;} __HI(a) = ha; /* a <- |a| */ __HI(b) = hb; /* b <- |b| */ if((ha-hb)>0x3c00000) {return a+b;} /* x/y > 2**60 */ k=0; if(ha > 0x5f300000) { /* a>2**500 */ if(ha >= 0x7ff00000) { /* Inf or NaN */ w = a+b; /* for sNaN */ if(((ha&0xfffff)|__LO(a))==0) w = a; if(((hb^0x7ff00000)|__LO(b))==0) w = b; return w; } /* scale a and b by 2**-600 */ ha -= 0x25800000; hb -= 0x25800000; k += 600; __HI(a) = ha; __HI(b) = hb; } if(hb < 0x20b00000) { /* b < 2**-500 */ if(hb <= 0x000fffff) { /* subnormal b or 0 */ if((hb|(__LO(b)))==0) return a; t1=0; __HI(t1) = 0x7fd00000; /* t1=2^1022 */ b *= t1; a *= t1; k -= 1022; } else { /* scale a and b by 2^600 */ ha += 0x25800000; /* a *= 2^600 */ hb += 0x25800000; /* b *= 2^600 */ k -= 600; __HI(a) = ha; __HI(b) = hb; } } /* medium size a and b */ w = a-b; if (w>b) { t1 = 0; __HI(t1) = ha; t2 = a-t1; w = sqrt(t1*t1-(b*(-b)-t2*(a+t1))); } else { a = a+a; y1 = 0; __HI(y1) = hb; y2 = b - y1; t1 = 0; __HI(t1) = ha+0x00100000; t2 = a - t1; w = sqrt(t1*y1-(w*(-w)-(t1*y2+t2*b))); } if(k!=0) { t1 = 1.0; __HI(t1) += (k<<20); return t1*w; } else return w; } |
实际上,是使用这种特定函数,还是几个其他类似函数中的一个取决于平台上的 JVM 细节。不过,这种代码很有可能在 Sun 的标准 JDK 中调用。(其他 JDK 实现可以在必要时改进它。)
这段代码(以及 Sun Java 开发库中的大多数其他本机数学代码)来自 Sun 约 15 年前编写的开源 fdlibm
库。该库用于精确实现 IEE754 浮点数,能进行非常准确的计算,不过会牺牲一些性能。
以 10 为底的对数
对数说明一个底数的几次幂等于一个给定的值。也就是说,它是 Math.pow()
函数的反函数。以 10 为底的对数一般出现在工程应用程序中。以 e为底的对数(自然对数)出现在复合计算以及大量科学和数学应用程序中。以 2 为底的对数一般出现在算法分析中。
从 Java 1.0 开始,Math
类有了一个自然对数。也就是给定一个参数 x,该自然对数返回 e 的几次幂等于给定的值 x。遗憾的是,Java 语言的(以及 C 、Fortran 和 Basic 的)自然对数函数错误命名为 log()
。在我读的每本数学教材中,log 都是以 10 为底的对数,而 ln 是以 e 为底的对数,lg 是以 2 为底的对数。现在已经来不及修复这个问题了,不过 Java 5 添加了一个 log10()
函数,它是以 10 为底而不是以 e 为底的对数。
清单 3 是一个简单程序,它输出整数 1 到 100 的以 2、10 和 e 为底的对数:
清单 3. 1 到 100 的各种底数的对数
public class Logarithms { public static void main(String[] args) { for (int i = 1; i <= 100; i++) { System.out.println(i + "/t" + Math.log10(i) + "/t" + Math.log(i) + "/t" + lg(i)); } } public static double lg(double x) { return Math.log(x)/Math.log(2.0); } } |
下面是前 10 行结果:
1 0.0 0.0 0.0 2 0.3010299956639812 0.6931471805599453 1.0 3 0.47712125471966244 1.0986122886681096 1.584962500721156 4 0.6020599913279624 1.3862943611198906 2.0 5 0.6989700043360189 1.6094379124341003 2.321928094887362 6 0.7781512503836436 1.791759469228055 2.584962500721156 7 0.8450980400142568 1.9459101490553132 2.807354922057604 8 0.9030899869919435 2.0794415416798357 3.0 9 0.9542425094393249 2.1972245773362196 3.1699250014423126 10 1.0 2.302585092994046 3.3219280948873626 |
Math.log10()
能正常终止对数函数执行:0 或任何负数的对数返回 NaN。
立方根
我不敢说我的生活中曾经需要过立方根,我也不是每天都要使用代数和几何的少数人士之一,更别提偶然涉足微积分、微分方程,甚至抽象代数。因此,下面这个函数对我毫无用处。尽管如此,如果意外需要计算立方根,现在就可以了 — 使用自 Java 5 开始引入的 Math.cbrt()
方法。清单 4 通过计算 -5 到 5 之间的整数的立方根进行了演示:
清单 4. -5 到 5 的立方根
public class CubeRoots { public static void main(String[] args) { for (int i = -5; i <= 5; i++) { System.out.println(Math.cbrt(i)); } } } |
下面是结果:
-1.709975946676697 -1.5874010519681996 -1.4422495703074083 -1.2599210498948732 -1.0 0.0 1.0 1.2599210498948732 1.4422495703074083 1.5874010519681996 1.709975946676697 |
结果显示,与平方根相比,立方根拥有一个不错的特性:每个实数只有一个实立方根。这个函数只在其参数为 NaN 时才返回 NaN。
双曲三角函数
双曲三角函数就是对曲线应用三角函数,也就是说,想象将这些点放在笛卡尔平面上来得到 t 的所有可能值:
您会得到以 r 为半径的曲线。相反,假设改用双曲正弦和双曲余弦,如下所示:
x = r cosh(t) y = r sinh(t)
|
则会得到一个正交双曲线,原点与它最接近的点之间的距离是 r。
还可以这样思考:其中 sin(x) 可以写成 (ei x - e-i x)/2,cos(x) 可以写成 (ei x + e-i x)/2,从这些公式中删除虚数单位后即可得到双曲正弦和双曲余弦,即 sinh(x) = (e x - e -x)/2,cosh(x) = (e x + e -x)/2。
Java 5 添加了所有这三个函数:Math.cosh()
、Math.sinh()
和 Math.tanh()
。还没有包含反双曲三角函数 — 反双曲余弦、反双曲正弦和反双曲正切。
实际上,cosh(z) 的结果相当于一根吊绳两端相连后得到的形状,即悬链线。清单 5 是一个简单的程序,它使用 Math.cosh
函数绘制一条悬链线:
清单 5. 使用 Math.cosh()
绘制悬链线
import java.awt.*;
public class Catenary extends Frame {
private static final int WIDTH = 200; private static final int HEIGHT = 200; private static final double MIN_X = -3.0; private static final double MAX_X = 3.0; private static final double MAX_Y = 8.0;
private Polygon catenary = new Polygon();
public Catenary(String title) { super(title); setSize(WIDTH, HEIGHT); for (double x = MIN_X; x <= MAX_X; x += 0.1) { double y = Math.cosh(x); int scaledX = (int) (x * WIDTH/(MAX_X - MIN_X) + WIDTH/2.0); int scaledY = (int) (y * HEIGHT/MAX_Y); // in computer graphics, y extends down rather than up as in // Caretesian coordinates' so we have to flip scaledY = HEIGHT - scaledY; catenary.addPoint(scaledX, scaledY); } }
public static void main(String[] args) { Frame f = new Catenary("Catenary"); f.setVisible(true); }
public void paint(Graphics g) { g.drawPolygon(catenary); }
}
|
图 1 为绘制的曲线:
图 1. 笛卡尔平面中的一条悬链曲线
双曲正弦、双曲余弦和双曲正切函数也会以常见或特殊形式出现在各种计算中。
符号
Math.signum
函数将正数转换为 1.0,将负数转换为 -1.0,0 仍然是 0。 实际上,它只是提取一个数的符号。在实现 Comparable
接口时,这很有用。
一个 float
和一个 double
版本可用来维护这种类型 。这个函数的用途很明显,即处理浮点运算、NaN 以及正 0 和负 0 的特殊情况。NaN 也被当作 0,正 0 和负 0 应该返回正 0 和 负 0。例如,假设如清单 6 那样用简单的原始方法实现这个函数:
清单 6. 存在问题的 Math.signum
实现
public static double signum(double x) { if (x == 0.0) return 0; else if (x < 0.0) return -1.0; else return 1.0; } |
首先,这个方法会将所有负 0 转换为正 0。(负 0 可能不好理解,但它确实是 IEEE 754 规范的必要组成部分)。其次,它会认为 NaN 是正的。实际实现如清单 7 所示,它更加复杂,而且会仔细处理这些特殊情况:
清单 7. 实际的、正确的 Math.signum
实现
public static double signum(double d) { return (d == 0.0 || isNaN(d))?d:copySign(1.0, d); }
public static double copySign(double magnitude, double sign) { return rawCopySign(magnitude, (isNaN(sign)?1.0d:sign)); }
public static double rawCopySign(double magnitude, double sign) { return Double.longBitsToDouble((Double.doubleToRawLongBits(sign) & (DoubleConsts.SIGN_BIT_MASK)) | (Double.doubleToRawLongBits(magnitude) & (DoubleConsts.EXP_BIT_MASK | DoubleConsts.SIGNIF_BIT_MASK))); } |
事半功倍
最有效的代码是从您未编写过的代码。不要做专家们已经做过的事情。使用 java.lang.Math
函数(新的和旧的)的代码将更快、更有效,而且比您自己编写的任何代码都准确。所以请使用这些函数。
参考资料
学习
- 类型、值和变量:Java 语言规范的第 4 章讨论了浮点运算。
- 二进制浮点运算的 IEEE 标准:IEEE 754 标准定义了大多数现代处理器和语言(包括 Java 语言)中的浮点运算。
-
java.lang.Math
:提供本文所讨论函数的类的 Javadoc。
- Bug 5005861:不满足的用户要求 JDK 中包含更快的三角函数。
- 悬链线:Wikipedia 说明了悬链线的历史及其背后的数学理论。
获得产品和技术
-
fdlibm
:一个适用于支持 IEEE 754 浮点数的机器的 C math 库,可在 Netlib 数学软件库中找到。
- OpenJDK:查看此开源 Java SE 实现中 math 类的源代码。
关于作者
|
|
|
Elliotte Rusty Harold 出生在新奥尔良,现在他还定期回老家喝一碗秋葵汤。他与他的妻子 Beth、宠物猫 Charm(以 quark 命名)和 Marjorie(以他岳母的名字命名)住在 Irvine 附近的大学城中心。他的 Cafe au Lait Web 站点已成为 Internet 上最流行的独立 Java 站点之一,而且其姊妹站点 Cafe con Leche 已经是最流行的 XML 站点之一。他最近的著作是 Refactoring HTML 。 |