چرا هیچگاه نباید از float و double برای محاسبات پولی استفاده کرد

مدتی است که خبرهایی مبنی بر حذف چهار صفر از پول ملی به گوش میرسد، این خبر ما را به عنوان برنامهنویس دچار چه چالشهایی میکند؟ چه راهکارهایی برای مدیریت مقدار پولهای اعشاری وجود دارد؟ در این مطلب یاد میگیریم که چرا هیچگاه نباید از ممیز شناور (float و double) برای محاسبات مالی استفاده کنیم و در مقابل جاوا چه راه حلی به ما ارائه میدهد. بیایید با کمک یک مثال، مشکل را بررسی کنیم. هیچ یک از مقادیر اعشاری که میتوانند بیانگر مقداری پول باشند، در سیستم ممیز شناور به صورت دقیق نگهداری نمیشوند. برای مثال اگر بخواهیم ۰.۱ دلار (معادل ۱۰ سنت) را با شیوه ممیز شناور (float یا double) ذخیره کنیم، مقداری که ذخیره میشود با همان مقداری که واقعا هست تفاوت دارد. در سیستم ممیز شناور به جای اندازهٔ دقیق، تنها میتوانیم تخمین نزدیکی از مقدار آن را نگهداریم، در مثال نگهداری ۰.۱، عددی که واقعا ذخیره میشود برابر ۰٫۱۰۰۰۰۰۰۰۱۴۹۰۱۱۶۱۱۹۳۸۴۷۶۵۶۲۵ است. این مسئله زمانی نمود پیدا می کند که ما تعداد زیادی عملیات (جمع و ضرب) روی اعداد اعشاری که در سیستم ممیز شناور هستند انجام دهیم. در کد پایین نمونهای از اتفاق نامطلوبی که میافتد را میبینیم. در این کد از double استفاده کردیم و دقت افت کرد.
1 2 3 4 5 6 7 8 9 | public class DoubleForCurrency { public static void main(String[] args) { double total = 0.2; for (int i = 0; i < 100; i++) { total += 0.2; } System.out.println("total = " + total); } } |
خروجی برنامه: total = 20.19999999999996
خروجی باید ۲۰٫۲۰
میبود (۲۰ دلار و ۲۰ سنت) ولی در محاسبات اعداد اعشاری به ۲۰٫۱۹۹۹۹۹۹۹۹۹۹۹۹۶
تغییر پیدا کرد که این یک نمونه افت دقت است.
دلیل افت دقت: ممیز شناور
در دنیای کامپیوتر، ممیز شناور (FP) فرمولی برای بازنمایی اعداد حقیقی (real) است که تقریبی از اعداد را ذخیره میکند که مصالحهای بین دقت و بازهاعداد قابل نمایش به وجود بیاورد.
بر اساس ویکی پیدا:
اینکه نمایش اعشاری یک عدد گویا، مختوم یا متناوب باشد به مبنا ربط دارد. مثلا در مبنای ۱۰، عدد ۱/۲ نمایش اعشاری مختوم دارد (۰.۵) در حالی که کسر ۱/۳ نمایش اعشاری متناوب دارد (۰.۳۳۳۳۳۳). در مبنای ۲، فقط اعداد گویایی که مخرجشان توانی از ۲ است (مثلا ۱/۲ یا ۳/۱۶) نمایش اعشاری باینریِ مختوم دارند. هر عدد گویایی که مخرجش عامل اولی غیر از ۲ داشته باشد، نمایش اعشاریِ باینریِ متناوب (نامختوم) دارد. این بدین معنیاست که عددی که در نمایش دهدهی، ظاهر کوتاه و دقیقی دارد، ممکناست در تبدیل به ممیز شناورِ باینری، دچار تقریب شوند. برای مثال عدد ۰.۱ با هیچ دقتی در سیستم ممیز شناور به صورت دقیق نگهداری نمیشود. نمایش اعشاریِ باینریِ دقیق این عدد، دارای تناوب ۱۱۰۰ است. عدد ۰.۱ در باینریِ ممیز شناور،
exponent = -4
significand = 11001100110011001100110011001100…
وقتی که به ۲۴ بیت رند شود داریم:
exponent = -4
significand = ۱۱۰۰۱۱۰۰۱۱۰۰۱۱۰۰۱۱۰۰۱۱۰۱
که معادل عدد ۰٫۱۰۰۰۰۰۰۰۱۴۹۰۱۱۶۱۱۹۳۸۴۷۶۵۶۲۵ در مبنای ۱۰ است. توجه: دقت کنید در پاراگراف بالا منظور از عدد اعشاری هر عددیست که ممیز داشته باشد و نه لزوما در مبنای ۱۰.
راه حل: BigDecimal
کلاس BigDecmial جاوا، نمایانگر یک عدد علامتدار با دقت دلخواه (و مقیاس مرتبط) است. بیگدسیمال امکان کنترل کاملی روی دقت و و رند کردن اعداد به ما میدهد. تقریبا این امکان وجود دارد که عدد پی را تا ۲ میلیون بعد از ممیز با استفاده از BigDecmial محاسبه کرد، در این حالت تنها محدودیت ما حافظه فیزیکی رم خواهد بود. به همین علت است که باید همیشه استفاده از BigDecimal و BigInteger را برای کارهای مالی ترجیح دهیم.
یادداشتهای ویژه
- نوع داده ابتدایی (primitive type): از int و long برای محاسبات مالیای استفاده میشود که قسمت اعشاری نداشته باشند.
- ما باید از ساختن BigDecimal با استفاده از کانستراکتورِ double و float اجتناب کنیم چون مثلا با استفاده از BigDecimal(double) همان بیدقتیِ ممیز شناور را به BigDecimal هم منتقل کردیم. یعنی new BigDecimal(0.1) مقدار داخلی BigDecimal را همان مقدار غیر دقیقِ ۰٫۱۰۰۰۰۰۰۰۰۰۰۰۰۰۰۰۰۵۵۵۱۱۱۵۱۲۳۱۲۵۷۸۲۷۰۲۱۱۸۱۵۸۳۴۰۴۵۴۱۰۱۵۶۲۵ را ذخیره میکند. در عوض ما باید از String استفاده کنیم مثلا new BigDecimal(“0.1”)
دقت و مقیاس چی هستند؟
دقت (precision) تعداد کل رقمهای یک عدد حقیقی است. مقیاس (scale) مشخصهکنندهٔ تعداد رقمهای بعد از اعشار است. مثلا در عدد ۱۲.۳۴۵، ۵ رقم precision دارد (تعداد کل رقمها) و مقیاسش برابر ۳ است (تعداد رقمهای بعد از اعشار).
چگونه طوری از BigDecimal خروجی بگیریم که نماد علمی و صفرهای بعد از اعشار نداشته باشیم؟
ممکن است اگر در استفاده از BigDecimal برخی best practiceها را رعایت نکنیم، در نتیجهٔ محاسبات، نماد علمی خروجی بگیریم. کد نمونه زیر، مثال خوبی از استفاده از محاسبه نتیجه با استفاده از BigDecimal است.
1 2 3 4 5 6 7 8 9 10 11 | import java.math.BigDecimal; public class BigDecimalForCurrency { public static void main(String[] args) { int scale = 4; double value = 0.11111; BigDecimal tempBig = new BigDecimal(Double.toString(value)); tempBig = tempBig.setScale(scale, BigDecimal.ROUND_HALF_EVEN); String strValue = tempBig.stripTrailingZeros().toPlainString(); System.out.println("tempBig = " + strValue); } } |
خروجی برنامه:
tempBig = 0.1111
اما برای نمایش با فرمت دلخواه چگونه عمل کنیم؟ مثلا برای واحد پول هند (INR). کلاس NumberFormat مخصوص همین هدف طراحی شدهاست. نماد مخصوص ارز مورد نظر و شیوه رند کردن با کمک NumberFormat به صورت خودکار بر اساس locale مورد نظر تعیین میشوند. مثال استفاده از NumberFormat:
1 2 3 4 5 6 7 8 9 10 11 12 13 | class Scratch { public static String formatRupees(double value) { NumberFormat format = NumberFormat.getCurrencyInstance(new Locale("en", "in")); format.setMinimumFractionDigits(2); format.setMaximumFractionDigits(5); format.setRoundingMode(RoundingMode.HALF_EVEN); return format.format(value); } public static void main(String[] args) { BigDecimal tempBig = new BigDecimal(22.121455); System.out.println("tempBig = " + formatRupees(tempBig.doubleValue())); } } |
خروجی برنامه:
tempBig = Rs.22.12146
همین! همهچیز توسط NumberFormat درست شد.
موارد احتیاط
- استفاده از سازنده با پارامتر String به سازنده با پارامتر Double ترجیح داده شود چون در صورتی که BigDecimal با ورودی ممیز شناور ساخته شود، نتیجه غیرقابلپیشبینی است چون خود ممیز شناور نمیتواند عددی مثل ۰.۱ را درست نگهداری کند.
- اگر ناچارید برای ساخت BigDecimal از Double استفاده کنید از
BigDecimal.valueOf(double)
استفاده کنید که دابل را با کمک toString به رشته تبدیل میکند. - در هنگام تنظیم کردن مقیاس (scale) باید حالت رند کردن (Rounding mode) را هم مشخص کنید.
- متد
StripTrailingZeros
صفرهای انتهایی بعد از اعشار را حذف میکند. - متد
toString()
ممکن است خروجی نماد علمی بدهد ولیtoPlainString()
هیچگاه نماد علمی به عنوان خروجی نمیدهد.
آیا میدانستید استفاده از ممیز شناور (float و double) به جای BigDecimal میتواند برای ارتش هم مهلک باشد؟
در ۲۵ فبریه ۱۹۹۱، از دست دادن دقت عدد ممیز شناور در شلیک یک موشک باعث به خطا رفتن آن شد و نیروهای خودی کشته شدند.
مطالعه بیشتر:
- https://blogs.oracle.com/CoreJavaTechTips/entry/the_need_for_bigdecimal
- Item 60, Effective Java 3rd Edition by Joshua Bloch
. . . . این مقاله، ترجمه با اندک تغییر از این مطلب در سایت dzone است. (برداشت از مطلب در اردیبهشت ۹۹ صورت گرفت.)
با ما همراه باشید آدرس کانال تلگرام: JavaCupIR@ آدرس اکانت توییتر: JavaCupIR@ آدرس صفحه اینستاگرام: javacup.ir آدرس گروه لینکدین: Iranian Java Developers
سلام عالی بود
یک سوالی خب اگر برای کارهای مالی نباید از double استفاده کرد
فرق بین bigdeciaml و biginteger چیه؟
مثلا برای مدیریت یک صندوق خانگی از کدوم بادی استفاده کرد؟
یا مثلا همین بانک ها ما از کدوم استفاده می کنن؟
سلام.
ممنون از نظرتون.
به نکته درستی در مورد تشابه این دو کلاس اشاره کردید. هردو میتوانند اعداد بزرگ که در متغیر های معمولی جا نمیشوند را نگهداری کنند.
فرق این دو کلاس در این است که BigInteger اعداد صحیح را نگهداری میکند و برای نگهداری اعداد اعشاری مناسب نیست.
در مقابل BigDecimal یک کلاس نگهدارنده (wrapper) برای BigInteger است پس در باطن یک BigInteger نگه میدارد و همچنین مکان ممیز را نیز نگهداری میکند تا در محاسبات و چاپ نتیجه به صورت صحیح تاثیر دهد.
نتیجه اینکه اگر عدد صحیح کوچک دارید، خود دادههای اولیه جوابگو هستند، برای اعداد صحیح بزرگ، BigInteger و برای اعداد اعشاری ممیز ثابت (برای محاسبات حساس) BigDecimal استفاده میشود.
در صندوق خانگی یا بانکها بسته به اینکه احتیاج به ممیز و اعشار هست یا نه میتوان انتخاب نمود.