ثبت‌نام

چرا 0.1 + 0.2 در پایتون و جاوااسکریپت 0.3 نمی‌شود؟ | علت خطای ممیز شناور

✍️ مهدیار غفوریان 📅 1405/04/29 👁️ 29 بازدید
چرا 0.1 + 0.2 در پایتون و جاوااسکریپت 0.3 نمی‌شود؟ | علت خطای ممیز شناور

یک جمع ساده که همه را گیج کرده

اگر تا به حال توی کنسول مرورگر یا ترمینال پایتون این عبارت را تایپ کرده باشید، احتمالاً از جوابش تعجب کرده‌اید:

print(0.1 + 0.2)
# خروجی: 0.30000000000000004

مگر قرار نبود جواب 0.3 باشد؟ این 0.00000000000000004 اضافه از کجا آمد؟ آیا پایتون یا جاوااسکریپت اشتباه می‌کنند؟ جواب کوتاه: نه، این یک باگ نیست. این دقیقاً همان چیزی است که باید اتفاق بیفتد.

ماجرا از کجا شروع شد؟

کامپیوترها همه چیز را با صفر و یک (سیستم دودویی یا Binary) می‌فهمند. وقتی ما یک عدد اعشاری مثل 0.1 را می‌نویسیم، کامپیوتر باید آن را به باینری تبدیل کند. مشکل دقیقاً از همین تبدیل شروع می‌شود.

چرا 0.1 در باینری دقیق نیست؟

در سیستم ده‌دهی (همان چیزی که ما انسان‌ها استفاده می‌کنیم)، کسری مثل 1/3 را نمی‌توانیم دقیقاً بنویسیم. مجبوریم آن را به صورت 0.333333... با بی‌نهایت رقم ۳ نشان دهیم. در سیستم باینری هم داستان همین است. عدد 0.1 در مبنای ۲ یک کسر متناوب است:

0.1 (ده‌دهی) = 0.00011001100110011... (باینری)

کامپیوتر نمی‌تواند بی‌نهایت رقم را ذخیره کند. پس مجبور است بعد از ۵۳ بیت، بقیه را گرد کند (Round). این گرد شدن همان چیزی است که باعث به وجود آمدن خطای کوچک در محاسبات می‌شود.

تبدیل دستی 0.1 به باینری

اگر کنجکاوید که دقیقاً چطور 0.1 به باینری تبدیل می‌شود، این الگوریتم ساده را ببینید:

  1. عدد 0.1 را در ۲ ضرب کن: 0.2 ← عدد صحیح ۰، باقی‌مانده 0.2
  2. عدد 0.2 را در ۲ ضرب کن: 0.4 ← عدد صحیح ۰، باقی‌مانده 0.4
  3. عدد 0.4 را در ۲ ضرب کن: 0.8 ← عدد صحیح ۰، باقی‌مانده 0.8
  4. عدد 0.8 را در ۲ ضرب کن: 1.6 ← عدد صحیح ۱، باقی‌مانده 0.6
  5. عدد 0.6 را در ۲ ضرب کن: 1.2 ← عدد صحیح ۱، باقی‌مانده 0.2
  6. عدد 0.2 را در ۲ ضرب کن: 0.4 ← عدد صحیح ۰، باقی‌مانده 0.4
  7. ... و این چرخه تا ابد تکرار می‌شود!

همانطور که می‌بینید، الگوی ۰۰۱۱ تا بی‌نهایت تکرار می‌شود. کامپیوتر فقط ۵۳ بیت از این الگو را ذخیره می‌کند و بقیه را دور می‌ریزد. این یعنی از همان ابتدا یک خطای کوچک در ذخیره‌سازی وجود دارد.

استاندارد IEEE 754 چیست؟

تقریباً تمام زبان‌های برنامه‌نویسی (پایتون، جاوااسکریپت، C++، جاوا و...) از استانداردی به نام IEEE 754 برای ذخیره‌سازی اعداد اعشاری استفاده می‌کنند. به این نوع اعداد، ممیز شناور (Floating Point) گفته می‌شود. در این استاندارد، هر عدد اعشاری با سه بخش ذخیره می‌شود: علامت، توان، و مانتیس. وقتی عددی مثل 0.1 در این قالب ریخته می‌شود، به دلیل محدودیت بیت‌ها، دقت از دست می‌رود و همان خطای کوچک ایجاد می‌شود.

تاریخچه IEEE 754

استاندارد IEEE 754 اولین بار در سال ۱۹۸۵ توسط مؤسسه مهندسان برق و الکترونیک (IEEE) منتشر شد. قبل از این استاندارد، هر شرکت کامپیوتری روش خودش را برای ذخیره‌سازی اعداد اعشاری داشت. مثلاً IBM، DEC و HP هرکدام فرمت متفاوتی استفاده می‌کردند. این یعنی یک برنامه که روی کامپیوتر IBM نوشته می‌شد، روی DEC جواب متفاوتی می‌داد!

ویلیام کاهان (William Kahan)، استاد ریاضیات دانشگاه برکلی، معمار اصلی این استاندارد بود. او به خاطر این کار در سال ۱۹۸۹ جایزه تورینگ (معادل نوبل در علوم کامپیوتر) را دریافت کرد. جالبه بدونید کاهان هنوز هم زنده است و لقب «پدر ممیز شناور» را به او داده‌اند.

نسخه فعلی این استاندارد، IEEE 754-2008 و IEEE 754-2019 است که شامل فرمت‌های جدیدتر مثل float16 (نیم‌دقت) برای هوش مصنوعی و یادگیری عمیق هم می‌شود.

همه زبان‌ها این مشکل را دارند؟

بله. این مشکل مخصوص پایتون یا جاوااسکریپت نیست. در C++، Java، Ruby و Go هم همین رفتار را می‌بینیم:

مثال در C++

#include <iostream>
#include <iomanip>
using namespace std;

int main() {
    cout << fixed << setprecision(20);
    cout << 0.1 + 0.2 << endl;
    // خروجی: 0.30000000000000004441
    return 0;
}

مثال در Java

public class Main {
    public static void main(String[] args) {
        System.out.println(0.1 + 0.2);
        // خروجی: 0.30000000000000004
    }
}

مثال در Ruby

puts 0.1 + 0.2
# خروجی: 0.30000000000000004

مثال در PHP

<?php
echo 0.1 + 0.2;
// خروجی: 0.30000000000000004
?>

همانطور که می‌بینید، این رفتار فقط مختص به یک زبان نیست. هر زبانی که از ممیز شناور IEEE 754 استفاده کند، دقیقاً همین جواب را می‌دهد. حتی ماشین‌حساب ویندوز و Google Sheets هم در محاسبات داخلی خود با همین چالش روبرو هستند.

فجایع واقعی ناشی از این خطا

شاید فکر کنید این اختلاف 0.00000000000000004 آنقدر کوچک است که در دنیای واقعی تأثیری ندارد. اما تاریخ پر از داستان‌هایی است که همین خطای کوچک فاجعه آفریده است:

انفجار موشک پاتریوت (۱۹۹۱)

در جنگ خلیج فارس، یک موشک پاتریوت آمریکایی نتوانست موشک اسکاد عراقی را رهگیری کند. نتیجه؟ ۲۸ سرباز کشته و ۱۰۰ نفر زخمی شدند. علت چه بود؟ یک خطای Rounding در محاسبه زمان! سیستم بعد از ۱۰۰ ساعت کار مداوم، فقط ۰.۳۴ ثانیه خطا داشت. همین ۰.۳۴ ثانیه برای از دست دادن هدف کافی بود.

سقوط آریان ۵ (۱۹۹۶)

موشک آریان ۵ آژانس فضایی اروپا، ۳۷ ثانیه پس از پرتاب منفجر شد. هزینه این انفجار؟ ۳۷۰ میلیون دلار. علت؟ یک عدد ۶۴ بیتی Float به یک متغیر ۱۶ بیتی تبدیل شده بود و باعث Overflow شد. باز هم پای ممیز شناور در میان بود.

راه‌حل چیست؟

اگر دقتی که نیاز دارید بیشتر از ۱۵ رقم اعشار نیست (که در ۹۹٪ پروژه‌ها همینطور است)، می‌توانید از روش‌های زیر استفاده کنید:

۱. گرد کردن (Rounding)

در پایتون از round() استفاده کنید:

print(round(0.1 + 0.2, 2))
# خروجی: 0.3

در جاوااسکریپت از toFixed():

console.log((0.1 + 0.2).toFixed(2));
// خروجی: "0.30"

۲. ماژول Decimal (پایتون)

برای محاسبات مالی که دقت بالا نیاز دارید:

from decimal import Decimal
print(Decimal('0.1') + Decimal('0.2'))
# خروجی: 0.3

۳. کار با اعداد صحیح

در جاوااسکریپت، به جای اعشار، با سنت یا ریال(واحد کوچکتر پول) کار کنید:

const result = (10 + 20) / 100;
console.log(result);
// خروجی: 0.3

۴. ماژول Fractions (پایتون)

یک راه دیگر برای محاسبات دقیق در پایتون، استفاده از کسرها (Fractions) است. در این روش، اعداد به صورت کسر (صورت/مخرج) ذخیره می‌شوند و هیچ خطایی رخ نمی‌دهد:

from fractions import Fraction

a = Fraction(1, 10)  # 0.1
b = Fraction(2, 10)  # 0.2
print(a + b)
# خروجی: 3/10

print(float(a + b))
# خروجی: 0.3

البته این روش برای محاسبات سنگین مناسب نیست، چون عملیات کسری به مراتب کندتر از محاسبات ممیز شناور است.

جدول مقایسه راه‌حل‌ها

راه‌حل دقت سرعت مناسب برای
round() خوب سریع نمایش به کاربر
Decimal عالی کند محاسبات مالی
Integer (سنت) عالی سریع فروشگاه اینترنتی
Fraction (کسر) کامل کند ریاضیات دقیق
BigDecimal (Java) عالی متوسط بانکداری

چطور این خطا را در پروژه واقعی پیدا کنیم؟

فرض کنید یک فروشگاه اینترنتی دارید و می‌خواهید قیمت محصولات را با هم جمع بزنید. اگر از Float استفاده کنید، ممکن است فاکتور مشتری چند ریال خطا داشته باشد. این خطا در ۱۰۰ تراکنش شاید ناچیز باشد، اما در ۱ میلیون تراکنش می‌تواند به ارقام قابل توجهی برسد.

نمونه کد مشکل‌دار

prices = [0.1, 0.2, 0.1, 0.2, 0.1]
total = sum(prices)
print(f"مجموع: {total}")
# خروجی: 0.7000000000000001
# انتظار: 0.7

نمونه کد اصلاح‌شده

from decimal import Decimal

prices = [Decimal('0.1'), Decimal('0.2'), Decimal('0.1'), Decimal('0.2'), Decimal('0.1')]
total = sum(prices)
print(f"مجموع: {total}")
# خروجی: 0.7
💡 نکته طلایی: وقتی قرار است دو عدد اعشاری را با هم مقایسه کنید، هرگز از == استفاده نکنید. به جایش اختلافشان را با یک عدد خیلی کوچک (epsilon) چک کنید: abs(a - b) < 1e-10
⚠️ نکته مهم: اگر با پول سروکار دارید، همیشه از Decimal یا Integer استفاده کنید. هرگز برای محاسبات مالی از Float استفاده نکنید.

خودت امتحان کن!

اگر مرورگرت کنسول جاوااسکریپت دارد (F12 را بزن)، همین الان این دستورات را یکی یکی تایپ کن و نتیجه را ببین:

console.log(0.1 + 0.2);
console.log(0.1 + 0.2 === 0.3);
console.log(0.7 + 0.1);
console.log(0.8 - 0.1);
console.log(0.1 * 0.2);
console.log(0.3 / 0.1);

هرکدام از این محاسبات یک نتیجه عجیب و غریب دارند که نشان می‌دهد خطای ممیز شناور فقط در جمع 0.1 و 0.2 نیست، بلکه یک مسئله سیستماتیک است.

جمع‌بندی

0.1 + 0.2 = 0.30000000000000004 نه یک باگ است، نه یک خطای تصادفی. این نتیجه مستقیم محدودیت ذاتی سیستم باینری در نمایش اعداد اعشاری است. تقریباً هر زبان برنامه‌نویسی که از استاندارد IEEE 754 پیروی کند، همین رفتار را نشان می‌دهد.

حالا که دلیلش را فهمیدید، اگر روزی در مصاحبه شغلی پرسیدند «چرا 0.1 + 0.2 برابر 0.3 نیست؟»، می‌توانید مثل یک حرفه‌ای جواب دهید.

دسته‌بندی‌های مرتبط با این مطلب
آزمایشی

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

هنوز پیامی ثبت نشده است.



×
تصویر پروفایل
⏳ در حال بارگذاری...