PDF:

Toán học mới với Java, Phần 2: Các số phảy động
Elliotte Rusty Harold ([email protected])
Giáo sư
Polytechnic University
14 09 2009
Hãy tham gia cùng Elliotte Rusty Harold để xem xét những đặc trưng mới trong lớp
java.lang.Math cổ điển trong bài báo gồm 2 phần này. Phần 1 tập trung vào các hàm toán
học đơn thuần hơn. Phần 2 khám phá những hàm được thiết kế để hoạt động trên các số phảy
động.
Xem thêm bài trong loạt bài này
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à java.lang.StrictMath, và phiên bản Java 6 đã thêm 10 phương thức mới khác nữa. Phần 1 của
bài báo này đã xem xét những phương thức mới có thể hiểu được trong toán học. Tức là, chúng đã
cung cấp các hàm mà một nhà toán học thời chưa có máy tính có thể cảm thấy quen thuộc. Ở đây
trong Phần 2, tôi chỉ tập trung vào các hàm quan trọng khi bạn nhận ra rằng chúng được thiết kế để
hoạt động trên các số phảy động thay vì trên các số thực trừu tượng.
Như tôi đã chú ý trong Phần 1, sự phân biệt giữa một số thực như là e hay 0.2 và hiển thị của nó
trên máy tính như là một số double trên Java là một điều rất quan trọng. Mô hình lý tưởng Platonic
của số là hoàn toàn chính xác, trong khi thể hiện trên Java chỉ có một số bit nhất định để làm việc
(32 đối với một số float, 64 đối với một số double). Giá trị cực đại của một số float là khoảng
38
3.4*10 , chưa đủ lớn để bạn thể hiện tất cả các con số, như là số electron trong vũ trụ.
308
Một số double có thể thể hiện các con số lên đến khoảng 1.8*10 , tức là nó có thể bao quát được
hầu hết tất cả các đại lượng vật lý mà tôi có thể nghĩ ra. Tuy nhiên, khi bạn làm các phép tính trên
các đại lượng toán học trừu tượng, thì nó có thể sẽ vượt quá những giá trị này. Ví dụ, chỉ phép tính
171! (171 * 170 * 169 * 168 * ... * 1) là đã đủ để vượt quá giới hạn của một số double. Một số float
thì cũng chỉ có giới hạn ở phép tính 35!. Các số nhỏ (đó là các số gần về số 0) cũng có thể là một
vấn đề, và các phép tính liên quan đến cả các số lớn và số nhỏ đều có thể là rất nguy hiểm.
Để giải quyết vấn đề này, tiêu chuẩn IEEE 754 cho toán học dấu phảy động (xem Tài nguyên) đã bổ
sung các giá trị đặc biệt Inf để thể hiện Infinity (vô hạn) và NaN để thể hiện "Not a Number - không
phải là một số." IEEE 754 cũng định nghĩa các số 0 âm và dương. (Trong toán học thông thường,
số 0 không phải là âm cũng không phải là dương. Trong toán học ở máy tính, nó lại có thể được cả
hai.) Các giá trị này làm phá vỡ những quy tắc cổ điển thông thường. Ví dụ, khi NaN xuất hiện, định
© Copyright IBM Corporation 2009
Toán học mới với Java, Phần 2: Các số phảy động
Nhẫn hiệu đăng ký
Trang 1 của 12
developerWorks®
ibm.com/developerWorks/vn/
luật về loại trừ trung gian không còn có tác dụng nữa. Không nhất thiết là cả hai x == y và x != y
đều đúng. Cả hai có thể là sai nếu giá trị x (hoặc y) là NaN.
Ngoài những vấn đề về độ lớn, độ chính xác là một vấn đề thực tế hơn nữa. Chúng ta đã xem xét
vấn đề này khi bạn thêm 0,1 khoảng 100 lần và kết quả nhận được là 9,99999999999998 thay vì
10:
for (double x = 0.0; x <= 10.0; x += 0.1) {
System.err.println(x);
}
Đối với các ứng dụng đơn giản, bạn thường xuyên yêu cầu java.text.DecimalFormat định dạng
lại kết quả cuối cùng thành một số nguyên (integer) gần nhất và gọi nó là một ngày (a day). Tuy
nhiên, trong các ứng dụng khoa học và kỹ nghệ mà bạn không chắc lắm rằng phép tính đó có kết
thúc là một số nguyên hay không, bạn cần rất cẩn thận. Nếu bạn đang trừ các số lớn với nhau để
được một số nhỏ, bạn cần hết sức cẩn thận. Nếu bạn đang chia cho số nhỏ đó, bạn vẫn cần phải cẩn
thận hơn nữa. Các bước tính đó có thể khuếch đại lên rất nhiều ngay cả với những lỗi rất nhỏ thành
các lỗi lớn mà có thể gây ra những hậu quả nghiêm trọng khi những đáp án được áp dụng vào lĩnh
vực vật lý. Các phép tính toán học chính xác bị thổi bay thành những sai lệch rất nghiêm trọng bởi
những lỗi làm tròn gây ra bởi các số phảy động ít chính xác.
Những cách thể hiện các số float và double bằng nhị phân
Một số float theo tiêu chuẩn IEEE 754, được thực thi bởi ngôn ngữa Java, có 32 bit. Bit đầu tiên là
bit dấu, 0 đối với dấu dương và 1 đối với dấu âm. Tám bit tiếp theo là số mũ, chúng có thể nắm giá
trị từ -125 đến +127. 23 bit còn lại để nắm phần định trị (đôi khi còn được gọi là significand), dao
động từ 0 đến 33.554.431. Đặt tất cả chúng lại với nhau, một số float sẽ được hiểu như sau: dấu *
sấ mũ
phấn đấnh trấ * 2
.
Các bạn đọc tinh mắt có thể để ý thấy rằng các số này không lấy tổng. Đầu tiên, tám bit dành cho
số mũ sẽ hiển thị từ -128 đến 127, giống như một byte đã có dấu. Tuy nhiên các số mũ thường
bị chệch một khoảng 126. Tức là, bạn bắt đầu với một giá trị chưa được đánh dấu (0 đến 255) và
sau đó trừ đi một khoảng 126 thì bạn mới nhận được số mũ thật, tức là nó bây giờ sẽ là -126 đến
128. Nhưng, trừ số 128 và -126 vì đây là những giá trị đặc biệt. Khi phần số mũ là 128, thì đó là
dấu hiệu chỉ ra rằng con số đó có thể là Inf, -Inf, hoặc NaN. Để rõ hơn nó thuộc loại nào, bạn phải
xem phần định trị. Khi phần số mũ là các số 0 (tức là -126), thì đó là dấu hiệu chỉ ra rằng con số đó
denormalized (phi chuẩn hóa) (nó ám chỉ nhiều hơn là như vậy) nhưng phần số mũ vẫn là -125.
Phần định trị cơ bản là một số chưa được đánh dấu gồm 23 bit — đủ đơn giản. Hai mươi ba bit có
24
thể nắm một số từ 0 đến 2 -1, tức là 16.777.215. Đợi một chút, tôi đã nói rằng phần định trị dao
25
động từ 0 đến 33.554.431 chưa nhỉ? Đó là 2 -1. Thế thì cái bit thêm đó từ đâu mà ra nhỉ?
Hóa ra là bạn có thể sử dụng số mũ để thể hiện bit đầu tiên là gì. Nếu số mũ là tất cả các các bit số
0, thì bit đầu tiên cũng là số 0. Ngược lại, bit đầu tiên sẽ là số 1. Bởi vì bạn luôn luôn biết rằng bit đầu
tiên là gì nên con số hiển thị sẽ không cần phải bao gồm nó nữa. Bạn sẽ có thêm một bit nữa.
Toán học mới với Java, Phần 2: Các số phảy động
Trang 2 của 12
ibm.com/developerWorks/vn/
developerWorks®
Các số phảy động mà bit đầu của phần định trị là số 1 thì đều là normalized (chuẩn hóa). Tức là,
phần định trị luôn luôn có giá trị từ 1 đến 2. Các số phảy động mà bit đầu tiên của phần định trị là
số 0 thì đều là denormalized (phi chuẩn hóa) và có thể thể hiện được các số nhỏ hơn nhiều, thậm
chí là với số mũ luôn là -125.
Các số double cũng được mã hóa với cách hoàn toàn tương tự chỉ khác ở chỗ chúng dùng một
phần định trị 52 bit và phần số mũ là 11 bit vì thế nó chính xác hơn. Độ chệch của số mũ trong một
số double là 1023.
Phần định trị và số mũ
Hai phương pháp getExponent() được bổ sung vào Java 6 trả lại số mũ không chệch được sử dụng
để thể hiện số float hoặc double. Đây là một số nằm trong khoảng từ -125 đến +127 đối với các số
float và khoảng từ -1022 đến +1023 đối với các số double (+128/+1024 cho Inf và NaN). Thí dụ, Ví
dụ 1 so sánh các kết quả của phương pháp getExponent() với một logarit cơ số 2 cổ điển hơn:
Ví dụ 1. Math.log(x)/Math.log(2) vs. Math.getExponent()
public class ExponentTest {
public static void main(String[] args) {
System.out.println("x\tlg(x)\tMath.getExponent(x)");
for (int i = -255; i < 256; i++) {
double x = Math.pow(2, i);
System.out.println(
x + "\t" +
lg(x) + "\t" +
Math.getExponent(x));
}
}
public static double lg(double x) {
return Math.log(x)/Math.log(2);
}
}
Đối với một vài giá trị mà có thể làm tròn, Math.getExponent() có thể là một bit hoặc hai chính xác
hơn so với phép tính thông thường:
x
...
2.68435456E8
5.36870912E8
1.073741824E9
2.147483648E9
4.294967296E9
lg(x)
Math.getExponent(x)
28.0
29.000000000000004
30.0
31.000000000000004
32.0
28
29
30
31
32
cũng có thể nhanh hơn nếu bạn đang làm rất nhiều trong số các phép tính
này. Tuy nhiên, xin nói trước rằng điều này chỉ có tác dụng với các lũy thừa bậc 2. Ví dụ, đây là kết
quả nếu tôi thay đổi thành lũy thừa bậc 3:
Math.getExponent()
Toán học mới với Java, Phần 2: Các số phảy động
Trang 3 của 12
developerWorks®
x
...
1.0
3.0
9.0
27.0
81.0
lg(x)
ibm.com/developerWorks/vn/
Math.getExponent(x)
0.0
1.584962500721156
3.1699250014423126
4.754887502163469
6.339850002884625
0
1
3
4
6
Phần định trị không được xem xét bởi getExponent() mà bởi Math.log(). Với một chút cố gắng,
bạn có thể độc lập tìm ra phần định trị, lấy logarit của nó, và thêm giá trị đó vào số mũ, nhưng điều
đó cũng không đáng để cố gắng. Math.getExponent() ban đầu rất hữu ích khi bạn muốn một bản
đánh giá nhanh về thứ tự của độ lớn, chứ không phải là giá trị chính xác.
Không giống với Math.log(), Math.getExponent() không bao giờ trả lại giá trị NaN hay Inf. Nếu đối
số là một NaN hay Inf, thì kết quả sẽ là 128 đối với một float và 1024 đối với một double. Nếu đối
số là 0, thì kết quả sẽ là -127 đối với một float và -1023 đối với một double. Nếu đối số là một số
âm, thì số mũ sẽ giống với số mũ của giá trị tuyệt đối của số đó. Ví dụ, số mũ của -8 là 3, cũng giống
như số mũ của 8.
Không có một phương pháp getMantissa() tương ứng, nhưng sẽ dễ dàng suy ra được một phương
pháp chỉ cần với một chút số học:
public static double getMantissa(double x) {
int exponent = Math.getExponent(x);
return x / Math.pow(2, exponent);
}
Phần định trị cũng có thể được tìm ra thông qua mặt nạ bit (bit masking), mặc dù thuật toán
hơi kém rõ ràng. Để trích xuất các bit, bạn chỉ cần tính toán Double.doubleToLongBits(x) &
0x000FFFFFFFFFFFFFL. Tuy nhiên, bạn khi đó cũng cần phải tính đến một bit thêm nữa trong một số
bị chuẩn hóa, và sau đó biến đổi trở lại thành một số phảy động từ 1 đến 2.
Các đơn vị ULP
Các số thực có mật độ rất nhiều. Với bất kì hai số thực khác biệt nào mà bạn có thể đặt ra, tôi đều
có thể tìm ra một số khác nằm giữa hai số đó. Nhưng điều đó lại không đúng với các số phảy động.
Cho một số float hay double, thì có một số float bên cạnh; và có một khoảng cách giới hạn tối thiểu
giữa các số float tiếp theo và các số double. Phương thức nextUp() trả lại số phảy động gần nhất lớn
hơn đối số đầu tiên. Thí dụ, Ví dụ 2 in tất cả các số float từ 1.0 đến 2.0:
Toán học mới với Java, Phần 2: Các số phảy động
Trang 4 của 12
ibm.com/developerWorks/vn/
developerWorks®
Ví dụ 2. Đếm các float
public class FloatCounter {
public static void main(String[] args) {
float x = 1.0F;
int numFloats = 0;
while (x <= 2.0) {
numFloats++;
System.out.println(x);
x = Math.nextUp(x);
}
System.out.println(numFloats);
}
}
Hóa ra là có chính xác 8.388.609 các float trong khoảng từ 1.0 đến 2.0; các số lớn thì có thể nhưng
các số vô cùng không đếm được của các số thực thì khó có thể tồn tại trong dãy này. Các số tiếp
theo là cách nhau khoảng 0,0000001. Khoảng cách này được gọi là một đơn vị ULP viết tắt của unit
of least precision hay unit in the last place.
Nếu bạn cần đi ngược lại — tức là, tìm số phảy động gần nhất nhỏ hơn một số đã định — bạn có thể
sử dụng phương thức nextAfter() thay vào đó. Đối số thứ 2 xác định việc tìm số gần nhất ở trên
hay dưới đối số đầu tiên:
public static double nextAfter(float start, float direction)
public static double nextAfter(double start, double direction)
Nếu direction lớn hơn start, thì nextAfter() trả lại số kế tiếp ở trên start. Nếu direction
nhỏ hơn start, nextAfter() sẽ trả lại một số kế tiếp ở dưới start. Nếu direction bằng start,
nextAfter() trả lại start là chính nó.
Các phương thức này có thể hữu ích trong một số ứng dụng mô hình hóa và vẽ biểu đồ. Về số
lượng, bạn có thể muốn trích mẫu một giá trị tại 10.000 vị trí giữa khoảng a và b, nhưng nếu bạn chỉ
đang lấy đủ độ chính xác để xác định 1.000 điểm duy nhất giữa a và b, thì bạn đang làm 9 phần 10
của công việc là thừa thãi. Bạn có thể chỉ cần làm 1 phần 10 của công việc đó và thu lại những kết
quả tốt tương tự.
Tất nhiên, nếu bạn thực sự cần thêm sự chính xác, thì bạn sẽ cần phải lấy một loại dữ liệu có độ
chính xác hơn, ví dụ như là một double hay là một BigDecimal. Ví dụ, tôi đã thấy điều này ở trong
Mandelbrot set explorers chỗ mà bạn có thể phóng to đến mức mà toàn bộ hình ảnh rơi vào giữa
hai số double gần nhau nhất. Mandelbrot set vô cùng sâu và phức tạp ở mọi mức độ, nhưng một
float hay double có thể chỉ đi quá sâu trước khi mất khả năng phân biệt các điểm gần nhau.
Phương thức Math.ulp() trả lại khoảng cách từ một số đến các số kế bên gần nhất của nó. Ví dụ 3
liệt kê các ULP cho các lũy thừa bậc 2 khác nhau:
Toán học mới với Java, Phần 2: Các số phảy động
Trang 5 của 12
developerWorks®
ibm.com/developerWorks/vn/
Ví dụ 3. Các ULP của các lũy thừa bậc 2 cho một số float
public class UlpPrinter {
public static void main(String[] args) {
for (float x = 1.0f; x <= Float.MAX_VALUE; x *= 2.0f) {
System.out.println(Math.getExponent(x) + "\t" + x + "\t" + Math.ulp(x));
}
}
}
Đây là một số kết quả:
0
1
2
3
4
...
20
21
22
23
24
25
...
125
126
127
1.0
2.0
4.0
8.0
16.0
1.1920929E-7
2.3841858E-7
4.7683716E-7
9.536743E-7
1.9073486E-6
1048576.0
2097152.0
4194304.0
8388608.0
1.6777216E7
3.3554432E7
0.125
0.25
0.5
1.0
2.0
4.0
4.2535296E37
8.507059E37
1.7014118E38
5.0706024E30
1.0141205E31
2.028241E31
Sự hoàn toàn chính xác của các số phảy động có một hệ quả không mong đợi đó là: quá một
điểm nhất định như x+1 == x là đúng. Thí dụ, phép lặp có vẻ như đơn giản này thực ra là vô
hạn:
for (float x = 16777213f; x <
16777218f; x += 1.0f) {
System.out.println(x);
}
Thực tế, phép lặp này sẽ đi đến bế tắc ở một điểm cố định chính xác là ở 16.777.216. Đó là
24
2 , và điểm mà ở đó ULP bây giờ lớn hơn số gia.
Như các bạn có thể thấy, các float khá chính xác cho các số nhỏ có lũy thừa bậc 2. Tuy nhiên, độ
20
chính xác trở thành vấn đề đối với nhiều ứng dụng gần khoảng 2 . Gần giới hạn độ lớn của một
float, các giá trị kế tiếp được tách biệt bởi một triệu lũy thừa 6 (thực ra, hơn một chút, nhưng tôi
không thể tìm ra một từ nào khác có thể lớn hơn nữa).
Như Ví dụ 3 minh họa, kích thước của một ULP không phải là hằng số. Khi các con số lớn hơn
nữa, thì càng có ít các số float giữa chúng. Ví dụ, chỉ có 1.025 số float trong khoảng từ 10.000 đến
10.001; và chúng cách nhau một khoảng là 0,001. Khoảng từ 1.000.000 đến 1.000.001 chỉ có 17 số
float, và chúng cách nhau khoảng 0,05. Độ chính xác đi ngược lại với độ lớn. Đối với một số float ở
10.000.000, thì ULP thực sự đã lớn tới 1,0; và quá điểm đó, có rất nhiều giá trị số nguyên mà ánh
xạ tới cùng một số float tương tự. Đối với một số double điều này không xảy ra cho đến khoảng 45
triệu lũy thừa 4 (4.5E15),nhưng nó vẫn là một mối lo ngại.
Toán học mới với Java, Phần 2: Các số phảy động
Trang 6 của 12
ibm.com/developerWorks/vn/
developerWorks®
Phương thức Math.ulp() có một cách dùng thực tế trong kiểm tra. Như bạn đã biết rõ, bạn không
nên thường xuyên so sánh các số phảy động với nhau để có sự ngang bằng chính xác. Thay vào
đó, bạn kiểm tra thấy rằng chúng bằng nhau trong một giá trị dung sai nhất định. Ví dụ, trong JUnit
bạn có thể so sánh các giá trị phảy động mong đợi với các giá trị phảy động thực tế như vậy:
assertEquals(expectedValue, actualValue, 0.02);
Điều này khẳng định rằng giá trị thực là nằm trong khoảng 0,02 của giá trị mong đợi. Tuy nhiên,
một khoảng 0,02 liệu đã là khoảng dung sai hợp lý chưa? Nếu giá trị mong đợi là 10,5 hay -107,82,
thì 0,02 có thể là tốt. Tuy nhiên, nếu giá trị mong đợi là vài tỉ, thì dung sai có thể hoàn toàn không
thể phân biệt được với số 0. Thường thì cái bạn kiểm tra là lỗi tương ứng đối với các ULP. Dựa vào
độ chính xác mà phép tính yêu cầu, bạn thường sẽ chọn một giá trị dung sai từ 1 đến 10 ULP. Ví
dụ, ở đây tôi xác định rõ rằng giá trị thực tế cần phải nằm trong 5 ULP của giá trị đúng:
assertEquals(expectedValue, actualValue, 5*Math.ulp(expectedValue));
Dựa vào giá trị mong đợi là bao nhiêu, nó có thể là cỡ 1 phần nghìn tỉ hoặc nó có thể là hàng triệu.
scalb
Math.scalb(x, y)
y
nhân x với 2 (scalb là viết tắt của "scale binary").
public static double scalb(float f, int scaleFactor)
public static double scalb(double d, int scaleFactor)
4
Ví dụ, Math.scalb(3, 4) trả lại kết quả 3 * 2 , là 3*16, và là 48.0. Bạn có thể sử dụng Math.scalb()
theo một thực thi thay thế của getMantissa():
public static double getMantissa(double x) {
int exponent = Math.getExponent(x);
return x / Math.scalb(1.0, exponent);
}
Vậy Math.scalb() khác với x*Math.pow(2, scaleFactor) như thế nào? Thực ra, kết quả cuối
cùng là không khác. Tôi đã không thể nghĩ ra được bất kì dữ liệu đầu vào nào mà ở đó kết quả
trả lại là một bit đơn lẻ khác biệt. Tuy nhiên, kết quả thể hiện cũng đáng để bạn nhìn lại lần thứ 2.
Math.pow() là một nhân tố có ảnh hưởng rất lớn đến kết quả thể hiện. Cần phải nắm được thực sự
những trường hợp kỳ lạ như là tăng 3,14 lên lũy thừa -0,078. Thông thường nó chọn hoàn toàn một
thuật toán sai cho các lũy thừa số nguyên như là 2 và 3, hay là đối với các trường hợp đặc biệt như
là một cơ số của 2.
Như với bất cứ khẳng định kết quả thể hiện chung nào khác, tôi phải hết sức lưỡng lự về điều này.
Một số trình biên dịch và VM là thông minh hơn cả so với các loại khác. Một số bộ tối ưu hóa có
thể nhận biết x*Math.pow(2, y) là một trường hợp đặc biệt và biến đổi nó thành Math.scalb(x, y)
hay thành một cái gì đó tương tự. Vì vậy, có thể sẽ không có sự khác biệt nào về kết quả thể hiện
cả. Tuy nhiên, tôi đã xác nhận rằng ít nhất một số VM là không hẳn thông minh cho lắm. Khi kiểm
tra với Java 6 VM của Apple, ví dụ, Math.scalb() là hai thứ tự độ lớn nhanh hơn x*Math.pow(2,
y). Tất nhiên, thông thường điều này sẽ không gây ra vấn đề dù là nhỏ nào cả. Tuy nhiên, trong
Toán học mới với Java, Phần 2: Các số phảy động
Trang 7 của 12
developerWorks®
ibm.com/developerWorks/vn/
những trường hợp hiếm hoi đó mà bạn phải thực hiện hàng triệu phép mũ hóa, bạn có thể muốn
nghĩ về liệu là bạn có thể chuyển đổi chúng để sử dụng Math.scalb() thay thế hay không.
Copysign
Phương pháp Math.copySign() thiết lập dấu cho đối số đầu tiên tới dấu của đối số thứ 2. Một thực
thi ngờ nghệch có thể trông như Ví dụ 4:
Ví dụ 4. Một thuật toán copysign có thể
public static double copySign(double magnitude, double sign) {
if (magnitude == 0.0) return 0.0;
else if (sign < 0) {
if (magnitude < 0) return magnitude;
else return -magnitude;
}
else if (sign > 0) {
if (magnitude < 0) return -magnitude;
else return magnitude;
}
return magnitude;
}
Tuy nhiên, thực thi trong thực tế lại trông như Ví dụ 5:
Ví dụ 5. Thuật toán thực từ sun.misc.FpUtils
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)));
}
Nếu bạn nghĩ về điều này kĩ càng và lấy ra những bit, bạn sẽ thấy rằng các dấu của giá trị NaN được
coi là dương. Về cơ bản, Math.copySign() không hứa hẹn rằng — chỉ StrictMath.copySign() có —
nhưng trong thực tế, chúng cả hai đều dẫn ra mã xoay tròn bit (bit-twiddling code) tương tự.
Ví dụ 5 có lẽ có thể ở một mặt nào đó nhanh hơn Ví dụ 4, nhưng nguyên nhân chính của nó là để
nắm được hoàn toàn số 0 âm. Math.copySign(10, -0.0) trả lại -10, trong khi Math.copySign(10,
0.0) trả lại 10.0. Thuật toán ngờ nghệch trong Ví dụ 4 trả lại 10.0 trong cả hai trường hợp. Số 0 âm
có thể xuất hiện khi bạn thực hiện các thao tác yêu cầu sự chính xác như là chia một số double
âm cực nhỏ với một số double dương cực lớn. Ví dụ, -1.0E-147/2.1E189 trả lại số 0 âm, trong khi
1.0E-147/2.1E189 trả lại một số 0 dương. Tuy nhiên, hai giá trị này có thể so sánh bằng nhau
với == vì thế nếu bạn muốn phân biệt chúng, bạn cần sử dụng Math.copySign(10, -0.0) hay
Math.signum() (được gọi là Math.copySign(10, -0.0)) để so sánh chúng.
Logarit và các hàm mũ
Một hàm mũ được coi là một ví dụ tốt cho bạn phải cẩn thận khi phải xử lý các số phảy động với độ
x
chính các có hạn thay vì các số thực hoàn toàn chính xác. e (Math.exp()) xuất hiện trong rất nhiều
biểu thức. Ví dụ, nó được sử dụng để xác định hàm cosh như được thảo luận trong Phần 1:
Toán học mới với Java, Phần 2: Các số phảy động
Trang 8 của 12
ibm.com/developerWorks/vn/
x
developerWorks®
-x
cosh(x) = (e + e )/2.
Tuy nhiên, đối với các giá trị âm của x, đại thể là -4 và thấp hơn, hàm logarit được dùng để tính
toán Math.exp() có vẻ như không phù hợp và dễ có thể mắc lỗi làm tròn. Sẽ chính xác hơn nếu tính
x
e - 1 với một hàm logarit khác và sau đó cộng 1 vào kết quả cuối cùng. Phương thức Math.expm1()
thi hành hàm logarit khác này. (Chữ m1 viết tắt cho "minus 1.") Thí dụ, Ví dụ 6 minh họa một hàm
cosh mà chuyển đổi giữa hai hàm logarit dựa vào kích thước của x:
Ví dụ 6. Một hàm cosh
public static double cosh(double x) {
if (x < 0) x = -x;
double term1 = Math.exp(x);
double term2 = Math.expm1(-x) + 1;
return (term1 + term2)/2;
}
x
-
Ví dụ này mang một phần nào đó học thuật bởi vì thuật ngữ e sẽ hoàn toàn át hẳn thuật ngữ e
x
trong bất cứ trường hợp nào mà khác biệt giữa Math.exp() và Math.expm1() + 1 mang ý nghĩa.
Tuy nhiên. Math.expm1() lại khá thực tế trong các phép tính tài chính với những lượng lợi tức nhỏ,
như là tỉ lệ hàng ngày của một tờ trái phiếu kho bạc.
Math.log1p() là hàm đảo ngược của Math.expm1(), cũng như Math.log() là hàm đảo ngược của
Math.exp(). Nó tính toán hàm logarit của 1 cộng với đối số của nó. (Chữ 1p viết tắt của "plus 1.")
Sử dụng cái này cho các giá trị cận 1. Thí dụ, thay vì tính toán Math.log(1.0002), bạn nên tính
Math.log1p(0.0002).
Là ví dụ, giả sử bạn muốn biết số của các ngày được yêu cầu cho 1.000 đô la được đầu tư để sinh
lãi thành 1.100 đô la ở mức tỉ lệ lãi suất hàng ngày là 0.03. Ví dụ 7 sẽ thực hiện điều này:
Ví dụ 7. Tìm lượng thời gian cần thiết để đạt được một giá trị tương lai định rõ từ sự
đầu tư hiện tại
public static double calculateNumberOfPeriods(
double presentValue, double futureValue, double rate) {
return (Math.log(futureValue) - Math.log(presentValue))/Math.log1p(rate);
}
Trong trường hợp này, 1p có một cách hiểu rất tự nhiên, bởi vì 1+r xuất hiện trong các công thức
thông thường để tính toán những thứ này. Nói cách khác, các nhà cho vay thường trích các tỉ lệ lãi
suất như là tỉ lệ phần trăm thêm (phần +r ) mặc dù các nhà đầu tư tất nhiên hy vọng sẽ thu lại được
n
(1+r) của vốn đầu tư ban đầu của họ. Thực tế, bất cứ nhà đầu tư nào cho vay tiền ở mức 3% và thu
lại được cũng chỉ có 3% vốn thực ra là họ đang làm việc kém hiệu quả.
Các số double không phải là các số thực
Các số phảy động không phải là các số thực. Có hữu hạn các số đó. Chúng có thể thể hiện được
các giá trị cực đại và cực tiểu. Nhưng quan trọng nhất, chúng có độ chính xác giới hạn mặc dù là độ
Toán học mới với Java, Phần 2: Các số phảy động
Trang 9 của 12
developerWorks®
ibm.com/developerWorks/vn/
chính xác đó lớn và có khuynh hương gặp các lỗi làm tròn. Thực ra, khi làm việc với các số integer
(số nguyên), các số float và các số double có thể có độ chính xác kém hơn so với các số int và
long. Bạn nên xem xét cẩn thận những giới hạn này để tạo ra mã trình đáng tin cậy và mạnh mẽ,
đặc biệt là trong các ứng dụng khoa học và kĩ nghệ. Các ứng dụng tài chính (và đặc biệt là các ứng
dụng kế toán yêu cầu độ chính xác đến số hàng trăm cuối cùng) cũng cần phải hết sức cẩn thận khi
xử lý các số float và các số double.
Các lớp java.lang.Math và java.lang.StrictMath đã được thiết kế cẩn thận để giải quyết các
vấn đề này. Việc sử dụng thích hợp các lớp này và những phương thức của chúng sẽ cải thiện các
chương trình của bạn. Nếu không có gì khác, bài báo này cũng đã chỉ ra cho bạn mức độ toán
học phảy động thực sự phức tạp như thế nào. Tốt hơn hết là giao phó cho các chuyên gia còn
hơn là bạn tự làm các thuật toán của bạn thêm rắc rối. Nếu bạn có thể sử dụng java.lang.Math và
java.lang.StrictMath, thì hãy làm như vậy. Chúng luôn luôn là lựa chọn tốt hơn.
Toán học mới với Java, Phần 2: Các số phảy động
Trang 10 của 12
ibm.com/developerWorks/vn/
developerWorks®
Tài nguyên
Học tập
• "Toán học mới với Java, Phần 1: Các số thực" (Elliotte Rusty Harold, developerWorks,
October 2008): Phần 1 của loạt bài này bao hàm về các phương pháp toán học trừu tượng
hơn trong lớp java.lang.Math.
• IEEE standard for binary floating-point arithmetic: Tiêu chuẩn IEEE 754 xác định số học phảy
động trong hầu hết các bộ xử lý và ngôn ngữ hiện đại, bao gồm ngôn ngữ Java.
• Types, Values, and Variables: Chương 4 của Java Language Specification xác định tập hợp
con của số học IEEE 754 được sử dụng trong ngôn ngữ lập trình Java.
• "Floating-point arithmetic" (Bill Venners, JavaWorld, October 1996): Venners khám phá số
học phảy động trong JVM và bao hàm các mã byte thể hiện các thao tác số học phảy động.
• Unit in the last place: Bài báo trên Wikipedia về chủ đề này rất nhiều thông tin.
• 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.
• Duyệt hiệu sách công nghệ để có những cuốn sách về các chủ đề kỹ thuật trên và các chủ đề
khác.
• Khu vực kỹ thuật Java trên developerWorks: Tìm kiếm các bài báo về mọi vấn đề lập trình
Java.
Lấy sản phẩm và công nghệ
• OpenJDK: Xem xét mã nguồn của các lớp toán bên trong thực thi Java SE mã mở này.
• Tải xuống IBM® các phiên bản đánh giá sản phẩm và bắt tay vào các công cụ phát triển ứng
dụng và các sản phẩm phần mềm trung gian (middleware) từ DB2®, Lotus®, Rational®,
Tivoli®, và WebSphere®.
Thảo luận
• Tham gia vào diễn đàn thảo luận developerWorks.
• Các blog developerWorks: Tham gia vào cộng đồng developerWorks.
• Ghi tên developerWorks blogs và tham gia vào cộng đồng developerWorks.
Toán học mới với Java, Phần 2: Các số phảy động
Trang 11 của 12
developerWorks®
ibm.com/developerWorks/vn/
Đôi nét về tác giả
Elliotte Rusty Harold
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
© Copyright IBM Corporation 2009
(www.ibm.com/legal/copytrade.shtml)
Nhẫn hiệu đăng ký
(www.ibm.com/developerworks/vn/ibm/trademarks/)
Toán học mới với Java, Phần 2: Các số phảy động
Trang 12 của 12