دانستنی‌ها

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

مدتی است که خبرهایی مبنی بر حذف چهار صفر از پول ملی به گوش می‌رسد، این خبر ما را به عنوان برنامه‌نویس دچار چه چالش‌هایی می‌کند؟ چه راه‌کارهایی برای مدیریت مقدار پول‌های اعشاری وجود دارد؟ در این مطلب یاد می‌گیریم که چرا هیچ‌گاه نباید از ممیز شناور (float و double) برای محاسبات مالی استفاده کنیم و در مقابل جاوا چه راه حلی به ما ارائه می‌دهد. بیایید با کمک یک مثال، مشکل را بررسی کنیم. هیچ یک از مقادیر اعشاری که می‌توانند بیانگر مقداری پول باشند، در سیستم ممیز شناور به صورت دقیق نگه‌داری نمی‌شوند. برای مثال اگر بخواهیم ۰.۱ دلار (معادل ۱۰ سنت) را با شیوه ممیز شناور (float یا double) ذخیره کنیم، مقداری که ذخیره می‌شود با همان مقداری که واقعا هست تفاوت دارد. در سیستم ممیز شناور به جای اندازهٔ دقیق، تنها می‌توانیم تخمین نزدیکی از مقدار آن را نگه‌داریم، در مثال نگه‌داری ۰.۱، عددی که واقعا ذخیره می‌شود برابر 0.100000001490116119384765625 است. این مسئله زمانی نمود پیدا می کند که ما تعداد زیادی عملیات (جمع و ضرب) روی اعداد اعشاری که در سیستم ممیز شناور هستند انجام دهیم. در کد پایین نمونه‌ای از اتفاق نامطلوبی که می‌افتد را می‌بینیم. در این کد از double استفاده کردیم و دقت افت کرد. 

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 خروجی باید 20.20 می‌بود (۲۰ دلار و ۲۰ سنت) ولی در محاسبات اعداد اعشاری به 20.19999999999996 تغییر پیدا کرد که این یک نمونه افت دقت است.  

دلیل افت دقت: ممیز شناور

در دنیای کامپیوتر، ممیز شناور (FP) فرمولی برای بازنمایی اعداد حقیقی (real) است که تقریبی از اعداد را ذخیره می‌کند که مصالحه‌ای بین دقت و بازه‌اعداد قابل نمایش به وجود بیاورد.

بر اساس ویکی پیدا:

اینکه نمایش اعشاری یک عدد گویا، مختوم یا متناوب باشد به مبنا ربط دارد. مثلا در مبنای ۱۰، عدد ۱/۲ نمایش اعشاری مختوم دارد (۰.۵) در حالی که کسر ۱/۳ نمایش اعشاری متناوب دارد (۰.۳۳۳۳۳۳). در مبنای ۲، فقط اعداد گویایی که مخرجشان توانی از ۲ است (مثلا ۱/۲ یا ۳/۱۶) نمایش اعشاری باینریِ مختوم دارند. هر عدد گویایی که مخرجش عامل اولی غیر از ۲ داشته باشد، نمایش اعشاریِ باینریِ متناوب (نامختوم) دارد. این بدین معنی‌است که عددی که در نمایش ده‌دهی،  ظاهر کوتاه و دقیقی دارد، ممکن‌است در تبدیل به ممیز شناورِ باینری، دچار تقریب شوند. برای مثال عدد ۰.۱ با هیچ دقتی در سیستم ممیز شناور به صورت دقیق نگه‌داری نمی‌شود. نمایش اعشاریِ باینریِ دقیق این عدد، دارای تناوب 1100 است. عدد ۰.۱ در باینریِ ممیز شناور،

exponent = -4

significand = 11001100110011001100110011001100…

وقتی که به ۲۴ بیت رند شود داریم:

exponent = -4

significand =  110011001100110011001101

که معادل عدد 0.100000001490116119384765625 در مبنای ۱۰ است. توجه: دقت کنید در پاراگراف بالا منظور از عدد اعشاری هر عددی‌ست که ممیز داشته باشد و نه لزوما در مبنای ۱۰.

راه حل: BigDecimal

کلاس BigDecmial جاوا، نمایانگر یک عدد علامت‌دار با دقت دلخواه (و مقیاس مرتبط) است. بیگ‌دسیمال امکان کنترل کاملی روی دقت و  و رند کردن اعداد به ما می‌دهد. تقریبا این امکان وجود دارد که عدد پی را تا ۲ میلیون بعد از ممیز با استفاده از  BigDecmial محاسبه کرد، در این حالت تنها محدودیت ما حافظه فیزیکی رم خواهد بود. به همین علت است که باید همیشه استفاده از BigDecimal و BigInteger را برای کارهای مالی ترجیح دهیم.  

یادداشت‌های ویژه

  • نوع داده ابتدایی (primitive type): از int و long برای محاسبات مالی‌ای استفاده می‌شود که قسمت اعشاری نداشته باشند.
  • ما باید از ساختن BigDecimal با استفاده از کانستراکتورِ double و float اجتناب کنیم چون مثلا با استفاده از BigDecimal(double) همان بی‌دقتیِ ممیز شناور را به BigDecimal هم منتقل کردیم. یعنی new BigDecimal(0.1) مقدار داخلی BigDecimal را همان مقدار غیر دقیقِ 0.1000000000000000055511151231257827021181583404541015625 را ذخیره می‌کند. در عوض ما باید از String استفاده کنیم مثلا new BigDecimal(“0.1”)

دقت و مقیاس چی هستند؟

دقت (precision) تعداد کل رقم‌های یک عدد حقیقی است. مقیاس (scale) مشخصه‌کنندهٔ تعداد رقم‌های بعد از اعشار است. مثلا در عدد ۱۲.۳۴۵، ۵ رقم precision دارد (تعداد کل رقم‌ها) و مقیاسش برابر ۳ است (تعداد رقم‌های بعد از اعشار).

چگونه طوری از BigDecimal خروجی بگیریم که نماد علمی و صفر‌های بعد از اعشار نداشته باشیم؟

ممکن است اگر در استفاده از BigDecimal برخی best practiceها را رعایت نکنیم، در نتیجهٔ محاسبات، نماد علمی خروجی بگیریم. کد نمونه زیر، مثال خوبی از استفاده از محاسبه نتیجه با استفاده از BigDecimal است.

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:

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 می‌تواند برای ارتش هم مهلک باشد؟

در ۲۵ فبریه ۱۹۹۱، از دست دادن دقت عدد ممیز شناور در شلیک یک موشک باعث به خطا رفتن آن شد و نیرو‌های خودی کشته شدند.

 

مطالعه بیش‌تر:

  . . . . این مقاله، ترجمه با اندک تغییر از این مطلب در سایت dzone است. (برداشت از مطلب در اردیبهشت ۹۹ صورت گرفت.)


با ما همراه باشید آدرس کانال تلگرام: JavaCupIR@ آدرس اکانت توییتر: JavaCupIR@ آدرس صفحه اینستاگرام: javacup.ir آدرس گروه لینکدین: Iranian Java Developers

نوشته های مشابه

‫2 دیدگاه ها

  1. سلام عالی بود
    یک سوالی خب اگر برای کارهای مالی نباید از double استفاده کرد
    فرق بین bigdeciaml و biginteger چیه؟
    مثلا برای مدیریت یک صندوق خانگی از کدوم بادی استفاده کرد؟
    یا مثلا همین بانک ها ما از کدوم استفاده می کنن؟

    1. سلام.
      ممنون از نظرتون.
      به نکته درستی در مورد تشابه این دو کلاس اشاره کردید. هردو می‌توانند اعداد بزرگ که در متغیر های معمولی جا نمی‌شوند را نگهداری کنند.
      فرق این دو کلاس در این است که BigInteger اعداد صحیح را نگهداری می‌کند و برای نگهداری اعداد اعشاری مناسب نیست.
      در مقابل BigDecimal یک کلاس نگه‌دارنده (wrapper) برای BigInteger است پس در باطن یک BigInteger نگه می‌دارد و همچنین مکان ممیز را نیز نگهداری می‌کند تا در محاسبات و چاپ نتیجه به صورت صحیح تاثیر دهد.

      نتیجه اینکه اگر عدد صحیح کوچک دارید، خود داده‌های اولیه جوابگو هستند، برای اعداد صحیح بزرگ، BigInteger و برای اعداد اعشاری ممیز ثابت (برای محاسبات حساس) BigDecimal استفاده می‌شود.
      در صندوق خانگی یا بانک‌ها بسته به اینکه احتیاج به ممیز و اعشار هست یا نه می‌توان انتخاب نمود.

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا