Đôi khi bạn rất
quen thuộc với một lớp đến mức mà bạn không để ý đến nó nữa. Nếu bạn có
thể viết dẫn chứng tài liệu cho java.lang.Foo,
và Eclipse sẽ tự động hoàn thành những hàm cho bạn, tại sao bạn lại phải
cần đọc Javadoc của nó? Đó là kinh nghiệm mà tôi đã có với java.lang.Math, một lớp mà tôi nghĩ là tôi đã
biết thực sự rõ. Hãy tưởng tượng sự ngạc nhiên của tôi, lúc đó khi tôi gần
đây tình cờ được đọc Javadoc của nó sau gần suốt nửa thập kỷ và tôi nhận
ra rằng lớp này đã gấp đôi về kích cỡ với 20 phương thức mới mà tôi chưa
bao giờ nghe tới. Rõ ràng đó là lúc phải có một cái nhìn khác.
Phiên bản 5 của Java™ Language Specification đã thêm 10
phương thức mới vào java.lang.Math (và người
anh em của nó java.lang.StrictMath), và Java 6
đã thêm 10 phương thức khác nữa. Trong bài báo này, tôi tập trung vào
những hàm toán học đơn giản hơn đã được cung cấp, ví dụ như là log10 và cosh. Trong
phần 2, tôi sẽ khám phá những hàm khác nữa được thiết kế để hoạt động trên
các số dấu phảy động đối lập với các số thực trừu tượng.
Phân biệt
giữa một số thực trừu tượng như là π hay 0.2 và một số double trong Java là điều rất quan trọng. Trước
hết, mô hình lý tưởng Platonic của số là hoàn toàn chính xác, trong khi
Java giới hạn một số lượng các bit cố định. Điều này rất quan trọng khi
bạn xử lý các con số lẻ và lớn. Ví dụ, số 2.000.000.001 (hai tỉ lẻ 1) có
thể được trình bày chính xác như một int, nhưng
không phải như là một float. Điểm gần nhất mà
bạn có thể đạt được trong một float là 2.0E9 — tức là 2 tỉ.
Các double sẽ làm tốt hơn bởi vì chúng có nhiều
số bit hơn (đó là lí do mà bạn nên luôn luôn sử dụng các double thay vì các float); nhưng vẫn có các giới hạn thực tế về độ chính xác của
chúng.
Giới hạn thứ 2 của số học máy tính (của ngôn ngữ Java và các ngôn ngữ khác) là ở chỗ nó được dựa trên hệ nhị phân hơn là hệ thập phân. Các phân số như là 1/5 và 7/50 mà có thể được trình bày chính xác trong hệ thập phân (lần lượt 0.2 và 0.14) trở thành các phân số lặp đi lặp lại khi được trình bày trong chú giải nhị phân. Điều này hoàn toàn giống với cách 1/3 trở thành 0.3333333... khi nó được trình bày dưới dạng thập phân. Trong cơ số 10, bất cứ phân số nào mà mẫu số có thừa số nguyên tố là 5 và 2 (chứ không phải là số khác) đều có thể trình bày được một cách chính xác. Trong cơ số 2, chỉ những phân số mà các phân số là lũy thừa của 2 thì có thể được trình bày chính xác là 1/2, 1/4, 1/8, 1/16 và tương tự.
Những sự không chính xác là một trong những nguyên nhân lớn dẫn tới một lớp toán học được cần đến lúc ban đầu. Chắc chắn bạn có thể xác định lượng giác và các hàm khác với những mở rộng chuỗi Taylor không sử dụng bất cứ gì ngoài toán tử tiêu chuẩn + và * và một phép lặp đơn giản, như trong Ví dụ 1:
Ví dụ 1. Tính các hàm sine với chuỗi Taylor
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;
}
} |
Ở đây, nó được bắt đầu khá tốt, có chăng cũng chỉ là một sự khác biệt nhỏ ở cuối dãy số thập phân:
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 |
Tuy nhiên, khi các góc bắt đầu tăng lên, các lỗi cũng sẽ bắt đầu nhiều và phương pháp tiếp cận này sẽ không còn hiệu quả nữa:
630.0000000000003 -1.0000001371557132 -1.0 652.5000000000005 -0.9238801080153761 -0.9238795325112841 675.0000000000005 -0.7071090807463408 -0.7071067811865422 697.5000000000006 -0.3826922100671368 -0.3826834323650824 |
Chuỗi
Taylor ở đây thực sự đã chứng minh được sự chính xác hơn tôi mong đợi. Tuy
nhiên, khi góc tăng tới 360 độ, 720 độ (4 pi radian) hoặc cao hơn nữa, thì
chuỗi Taylor yêu cầu càng nhiều số hạng để tính toán chính xác. Ngày càng
nhiều thuật toán phức tạp được dùng bởi java.lang.Math để tránh được điều này.
Chuỗi Taylor cũng không đạt hiệu quả so với hàm sine có sẵn của chip máy tính để bàn hiện đại ngày nay. Các phép tính riêng biệt của hàm sine và của các hàm khác nhanh và chính xác yêu cầu các thuật toán phải được thiết kế rất cẩn thận để tránh việc biến các lỗi nhỏ thành to. Thông thường, các thuật toán này được cài sẵn trong phần cứng để cải thiện tốc độ nhanh hơn.Ví dụ, hầu hết các chip X86 được bán ra trong vòng 10 năm qua đều có những bổ sung về phần cứng hàm sine và cosine mà thế hệ chip X86 VM chỉ việc gọi ra hơn là phải tính toán chậm chạp dựa trên các thao tác thô sơ ban đầu nữa. HotSpot lợi dụng những chỉ dẫn này để tăng đáng kể tốc độ các thao tác tính lượng giác.
Các tam giác vuông và những tiên đề Ơclit
Mọi học sinh phổ thông học hình học đều đã học về định lý Pytago: Bình phương chiều dài của cạnh huyền của một tam giác vuông bằng tổng bình phương chiều dài của hai cạnh góc vuông. Tức là, c2 = a2 + b2
Những ai trong chúng ta đã từng áp dụng định lý đó vào vật lý trong đại học hoặc toán cao cấp đều biết rằng biểu thức này thể hiện được hơn rất nhiều điều chứ không chỉ dừng lại ở các tam giác vuông. Thí dụ, cũng là bình phương trong tiên đề Ơclit về R2, chiều dài của một vector hai chiều, một phần của bất đẳng thức tam giác, và hơn nữa. (Thực ra, đây là những cách nhìn khác nhau về cùng một vấn đề. Điểm quan trọng ở đây chính là tiên đề của Ơclit quan trọng hơn rất nhiều so với lúc xem xét nó ban đầu.)
Java 5 đã thêm một hàm Math.hypot để thực hiện chính xác phép tính này, và đây là một
ví dụ điển hình giải thích vì sao một thư viện là rất hữu ích. Cách tiếp
cận này sẽ xem xét vấn đề như sau:
public static double hypot(double x, double y){
return Math.sqrt (x*x + y*y);
} |
Mã trình thực tế là một cái gì đó phức tạp hơn, như trong Ví dụ 2. Điều
đầu tiên mà bạn sẽ chú ý đến đó là nó được viết bằng mã trình C gốc để đạt
hiệu quả tối đa. Điều thứ 2 mà bạn cũng nên chú ý đó là nó đang đạt tới
những độ dài lớn để cố gắng giảm tối thiểu bất cứ lỗi nào có thể trong
phép tính này.Thực ra, các thuật toán khác nhau đang được chọn phụ thuộc
vào kích thước tương đối của x và y.
Ví dụ 2. Mã trình thực được thi hành
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;
} |
Thực ra, việc bạn kết thúc bằng một hàm riêng biệt hoặc một trong số một vài hàm khác tương tự như vậy phụ thuộc vào những chi tiết của JVM trên nền tảng của bạn. Tuy nhiên có nhiều khả năng rằng đây là mã trình được tạo ra trong tiêu chuẩn JDK của Sun.(Các thực thi khác của JDK nếu có thể sẽ được tự do cải tiến dựa trên tiêu chuẩn này.)
Mã trình này (và hầu hết
mã trình toán học gốc khác trong thư viện Java Development Library) đều có
nguồn gốc từ thư viện mã nguồn mở fdlibm mà đã
được viết ở Sun cách đây khoảng 15 năm. Thư viện này được thiết kế để thực
thi dấu phảy động IEE754 một cách chính xác và có những phép tính chính
xác, thậm chí phải hy sinh một tính hiệu quả nào đó.
Một
logarit cho bạn biết lũy thừa nào một cơ số phải tăng lên để cho ra một
giá trị đã định. Nghĩa là, nó là đảo ngược của hàm Math.pow(). Logarit cơ số 10 thường xuất hiện trong các ứng
dụng kỹ nghệ. Logarit cơ số e (hay còn gọi là logarit tự nhiên)
xuất hiện trong phép tính lợi ích chung, và nhiều ứng dụng toán học và
khoa học khác. Logarit cơ số 2 thường xuất hiện trong phân tích thuật
toán.
Lớp Math đã có một hàm logarit tự
nhiên từ phiên bản Java 1.0. Tức là, với một đối số x, hàm logarit tự
nhiên sẽ trả lại lũy thừa mà cơ số e phải được tăng lên để cho giá
trị x. Đáng buồn thay, hàm logarit tự nhiên của ngôn ngữ Java (và ngôn ngữ
C, Fortran, và Basic) bị đặt tên sai là log().
Trong mọi sách giáo khoa toán mà tôi đã từng đọc, log là một hàm logarit
cơ số 10, trong khi ln là một hàm logarit cơ số e và lg là một hàm
logarit cơ số 2. Bây giờ đã quá muộn để sửa điều này, nhưng Java 5 đã thêm
một hàm log10() mà lấy hàm logarit cơ số 10
thay vì cơ số e.
Ví dụ 3 là một chương trình đơn giản để in hàm logarit cơ số 2, 10 và e của dãy số nguyên từ 1 đến 100:
Ví dụ 3. Các hàm logarit với nhiều cơ số khác nhau từ 1 đến 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);
}
} |
Đât là 10 dòng đầu của kết quả:
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() thông thường có những thông báo về
các hàm logarit: lấy logarit của 0 hoặc bất kì một số âm nào sẽ trả lại
giá trị NaN.
Tôi không thể nói rằng tôi đã từng cần lấy căn bậc 3
trong đời tôi, và tôi là một trong số ít người sử dụng đại số học và hình
học hàng ngày để đề cập đến những bước đột phá vào toán tích phân, vi
phân, các phương trình vi phân, và thậm chí hư số học. Kết quả là, tính
hữu dụng của hàm này đã không còn đối với tôi. Tuy nhiên, nếu bạn tìm ra
một nhu cầu bất chợt nào để lấy căn bậc ba ở đâu đó, bạn bây giờ có thể
dùng đến nó — như Java 5 — với phương pháp
Math.cbrt() . Ví dụ 4 minh họa bằng cách
lấy căn bậc 3 của dãy số nguyên từ -5 đến 5:
Ví dụ 4. Căn bậc 3 từ -5 đến 5
public class CubeRoots {
public static void main(String[] args) {
for (int i = -5; i <= 5; i++) {
System.out.println(Math.cbrt(i));
}
}
} |
Đây là kết quả:
-1.709975946676697 -1.5874010519681996 -1.4422495703074083 -1.2599210498948732 -1.0 0.0 1.0 1.2599210498948732 1.4422495703074083 1.5874010519681996 1.709975946676697 |
Như kết quả trên đã minh họa, một đặc trưng thú vị của căn bậc 3 so với căn bậc 2 là: Mỗi số thực có chính xác một căn bậc ba thực. Hàm này chỉ trả lại NaN khi đối số của nó là NaN.
Các hàm lượng giác hypebol cho ra tương ứng các hypebol giống như các hàm lượng giác cho ra các hình tròn. Tức là, hãy tưởng tượng bạn vẽ những điểm này trên một mặt phẳng Đề-Các cho tất cả các giá trị có thể của t:
x = r cos(t) y = r sin(t) |
Bạn sẽ vẽ ra được một vòng tròng với bán kính r. Ngược lại, giả sử bạn sử dụng thay thế bằng sinh và cosh, như sau:
x = r cosh(t) y = r sinh(t) |
Bạn sẽ vẽ ra một hình hypebol chữ nhật có một điểm tiếp xúc gần nhất với gốc là r.
Một cách khác: Chỗ sin(x) có thể được viết là (eix - e-ix)/2i và cos(x) có thể được viết là (eix + e-ix)/2 , sinh và cosh là cái mà bạn nhận được khi bạn gỡ bỏ đơn vị ảo từ các công thức đó. Tức là, sinh(x) = (ex - e-x)/2 và cosh(x) = (ex + e-x)/2.
Java 5 thêm
tất cả ba: Math.cosh(), Math.sinh(), và Math.tanh(). Các
hàm lượng giác hypebol đảo ngược — acosh, asinh, và atanh
— chưa được gộp vào.
Về bản chất, cosh(z) là
phương trình cho hình một chiếc dây treo được nối hai đầu, gọi là một
dây xích (Catenary). Ví dụ 5 là một chương trình đơn giản vẽ
hình một dây xích (Catenary) sử dụng hàm Math.cosh :
Ví dụ 5. Vẽ một dây xích với
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);
}
}
|
Hình 1 thể hiện đường cong được vẽ:
Hình 1. Một đường cong trong mặt phẳng Đề-Các
Các hàm sinh, cosh, và tanh cũng xuất hiện trong các phép tính khác nhau trong tính tương đối riêng biệt và tổng quát.
Hàm Math.signum chuyển đổi các số dương thành 1.0,
các số âm thành -1.0, và các số 0 thành 0. Thực chất, nó trích dấu từ một
số. Điều này có thể sẽ hữu ích khi bạn đang thi hành giao diện Comparable.
Có một phiên bản float và một phiên bản double để duy trì loại đó. Lí do cho hàm khá rõ ràng này là để
nắm các trường hợp đặc biệt của toán học dấu phảy động, NaN, và số 0 dương
và âm. NaN được coi như là số 0, số 0 dương và âm sẽ trả lại số 0 dương và
âm. Ví dụ, giả sử bạn phải đơn thuần thi hành hàm này như trong Ví dụ 6:
Ví dụ 6. Lỗi thi hành của
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;
} |
Đầu tiên, phương pháp này sẽ trả lại tất cả các số 0 âm thành các số 0 dương. (Đúng, các số 0 âm hơi kì lạ một chút, nhưng chúng là một phần cần thiết của thông số IEEE754.) Thứ hai, nó sẽ khẳng định rằng NaN là dương. Sự thi hành thực tế được thể hiện trong Ví dụ 7 phức tạp và cẩn thận hơn để nắm được các trường hợp góc kì lạ này:
Ví dụ 7. Thi hành đúng thực tế của
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)));
} |
Mã trình
hiệu quả nhất là mã trình mà bạn chưa bao giờ viết. Đừng làm theo những gì
mà các chuyên gia đã làm. Mã trình sử dụng các hàm java.lang.Math , mới và cũ, sẽ nhanh hơn, hiệu quả hơn, và
chính xác hơn bất cứ cái gì mà bạn tự viết. Hãy sử dụng nó.
Học tập
- "Java's new math, Part 2: Floating-point numbers" (Elliotte Rusty
Harold, developerWorks, January 2008): Đừng quên phần đăng thứ 2 của loạt
bài này, khám phá những hàm được thiết kế cho việc hoạt động trên các số
dấu phảy động.
- Types, Values, and Variables: Chương 4 của Java Language
Specification bao quát số học dấu phảy động.
- IEEE standard for binary floating-point arithmetic: Tiêu chuẩn
IEEE 754 định nghĩa toán học dấu phảy động trong hầu hết các bộ xử lý và
các ngôn ngũ, bao gồm ngôn ngữ Java.
java.lang.Math: Javadoc cho lớp cung cấp các hàm được thảo luận trong bài báo này.- Bug
5005861: Một người dùng thất vọng yêu cầu các hàm lượng giác nhanh
hơn trong JDK.
- Catenary: Wikipedia
giải thích lịch sử và toán học đằng sau dây xích.
- Duyệt technology bookstore cho các sách về những chủ đề kĩ thuật này và
những chủ đề khác.
-
developerWorks Java technology zone: Tìm hàng trăm bài báo về mọi
chủ đề lập trình Java.
Lấy sản phẩm và công nghệ
fdlibm: Một thư viện toán học C cho máy hỗ trợ dấu phảy động IEEE754, có sẵn từ kho phần mềm toán học Netlib.- OpenJDK: Nhìn vào mã nguồn của các
lớp toán bên trong thi hành Java SE mã nguồn mở.
Thảo luận
- Ghi tên developerWorks blogs và tham gia vào developerWorks community.
Elliotte Rusty Harold xuất thân từ bang New Orleans nơi mà ông vẫn thỉnh thoảng về thăm những lúc thảnh thơi. Tuy nhiên, ông đang cư trú gần Trung tâm University Town Center, Irvine cùng với vợ ông là Beth và những chú mèo Charm (được đặt tên theo hạt "charm quark" trong vật lý) và Marjorie (đặt tên theo tên của mẹ vợ ông). Trang Web Cafe au Lait của ông đã trở thành một trong những trang Java độc lập nổi tiếng nhất trên Internet, và trang Web phụ của ông, Cafe con Leche, đã trở thành một trong những trang XML phổ biến nhất. Cuốn sách của ông gần đây nhất là Refactoring HTML