چرا 0.1 + 0.2 برابر 0.3 نیست؟ خروجی 0.30000000000000004 در پایتون - اعداد ممیز شناور - آموزشگاه کادوس رشت

چرا 0.1 + 0.2 برابر 0.3 نیست؟ راز اعداد ممیز شناور در برنامه‌نویسی

پایتون را باز کنید و بنویسید 0.1 + 0.2. انتظار دارید جواب 0.3 باشد، اما کامپیوتر با خونسردی می‌نویسد 0.30000000000000004! اولین بار که این را می‌بینید، شک می‌کنید که پایتون باگ دارد یا ریاضی دبستان را اشتباه یاد گرفته‌اید. خبر خوب این است که هیچ‌کدام. این رفتار به نحوهٔ ذخیرهٔ اعداد ممیز شناور (Floating Point) در حافظه برمی‌گردد و تقریبا در همهٔ زبان‌های برنامه‌نویسی تکرار می‌شود. در این مقاله از آموزشگاه کادوس رشت، ساده و قدم‌به‌قدم می‌بینیم چرا این اتفاق می‌افتد و چطور در کدمان از دردسرهایش در امان بمانیم.

آزمایش: 0.1 + 0.2 در زبان‌های مختلف

اول خود مشکل را ببینیم. این دو خط پایتون را اجرا کنید:

print(0.1 + 0.2)
print(0.1 + 0.2 == 0.3)

خروجی 0.30000000000000004 و False است. در جاوااسکریپت (JavaScript) هم دقیقا همین را می‌گیرید:

console.log(0.1 + 0.2);
console.log(0.1 + 0.2 === 0.3);

خروجی: 0.30000000000000004 و false. جاوا (Java)، سی‌شارپ در نسخه‌های جدید دات‌نت و Rust هم همین عدد را چاپ می‌کنند. بعضی زبان‌ها مثل PHP با echo، یا ‎C++‎ با cout، موقع نمایش عدد را گرد می‌کنند و 0.3 نشان می‌دهند؛ اما اگر مقایسه کنید، باز هم جواب «نابرابر» است. حتی اکسل هم پشت پرده همین عدد را نگه می‌دارد و فقط ۱۵ رقمش را نشان می‌دهد. پس مشکل مال یک زبان خاص نیست؛ ریشه‌اش جای دیگری است.

ریشهٔ ماجرا: مبنای ۱۰ در برابر مبنای ۲

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

مقایسه کسر یک سوم در مبنای ده و عدد 0.1 در مبنای دو که هر دو بی‌پایان تکرار می‌شوند
0.1 برای کامپیوتر همان حکمی را دارد که یک‌سوم برای ما دارد: بی‌پایان.

عدد 0.1 برای کامپیوتر دقیقا همین وضعیت را دارد. یک‌دهم در مبنای ۲ می‌شود 0.0001100110011… و الگوی 0011 تا بی‌نهایت تکرار می‌شود. کامپیوتر حافظهٔ بی‌نهایت ندارد، پس جایی عدد را قطع و گرد می‌کند. نتیجه این است که 0.1 و 0.2 و 0.3 هیچ‌کدام دقیق ذخیره نمی‌شوند. در عوض، عددهایی مثل 0.5 و 0.25 و 0.125 که از توان‌های منفی ۲ ساخته شده‌اند، کاملا دقیق ذخیره می‌شوند و 0.5 + 0.25 == 0.75 همیشه درست است.

اعداد ممیز شناور و استاندارد IEEE 754

تقریبا همهٔ پردازنده‌ها و زبان‌ها اعداد اعشاری را طبق استاندارد IEEE 754 ذخیره می‌کنند. نوع float پایتون، Number جاوااسکریپت و double در جاوا و سی‌شارپ همگی «دقت مضاعف» (Double Precision) هستند و ۶۴ بیت جا می‌گیرند. به این اعداد «ممیز شناور» می‌گویند چون جای ممیز ثابت نیست و با بخش نما جابه‌جا می‌شود؛ درست مثل نماد علمی که 1500 را به‌صورت ‎1.5 × 10³‎ می‌نویسیم.

  • ۱ بیت علامت (Sign): مثبت یا منفی بودن عدد.
  • ۱۱ بیت نما (Exponent): مشخص می‌کند ممیز چند خانه جابه‌جا شود.
  • ۵۲ بیت مانتیس (Mantissa): ارقام معنادار عدد؛ چیزی حدود ۱۵ تا ۱۷ رقم دهدهی.
بیت‌های علامت، نما و مانتیس عدد 0.1 در استاندارد IEEE 754 و مقدار واقعی ذخیره‌شده
۶۴ بیت واقعی عدد 0.1؛ آخرین بیت‌ها گرد شده‌اند و مقدار ذخیره‌شده کمی بیشتر از 0.1 است.

می‌توانید مقدار دقیقی را که پشت هر عدد ذخیره شده، با ماژول decimal پایتون ببینید:

from decimal import Decimal

print(Decimal(0.1))
print(Decimal(0.2))
print(Decimal(0.1 + 0.2))
print(Decimal(0.3))
print(f"{0.1:.20f}")
0.1000000000000000055511151231257827021181583404541015625
0.200000000000000011102230246251565404236316680908203125
0.3000000000000000444089209850062616169452667236328125
0.299999999999999988897769753748434595763683319091796875
0.10000000000000000555

پس چرا 0.1 + 0.2 برابر 0.3 نمی‌شود؟

به خروجی بالا دقت کنید. 0.1 و 0.2 هر دو کمی بزرگ‌تر از مقدار واقعی‌شان ذخیره شده‌اند. وقتی جمع می‌شوند، این خطاهای کوچک روی هم می‌آیند و حاصل بعد از گرد شدن، روی عددی کمی بالاتر از 0.3 می‌نشیند. از آن طرف، وقتی خودتان در کد می‌نویسید 0.3، کامپیوتر نزدیک‌ترین عدد قابل‌ذخیره را برمی‌دارد که اتفاقا کمی کمتر از 0.3 است.

محور اعداد: مقدار ذخیره‌شده برای 0.3 کمی کمتر و حاصل 0.1 + 0.2 کمی بیشتر از 0.3 است
دو عدد همسایه، با فاصلهٔ فقط یک پله؛ اما عملگر == به همین تفاوت کوچک هم حساس است.

یعنی دو عدد داریم که فقط یک پله (ULP یا Unit in the Last Place) با هم فاصله دارند. برای چشم ما فرقی نمی‌کنند، اما عملگر == بیت‌به‌بیت مقایسه می‌کند و جواب منفی می‌دهد. پایتون هنگام چاپ، کوتاه‌ترین عددی را نشان می‌دهد که دقیقا به همان مقدار ذخیره‌شده برگردد. برای حاصل جمع، این کوتاه‌ترین عدد 0.30000000000000004 است و آن 4 معروف از همین‌جا می‌آید.

این خطای کوچک کجا دردسر درست می‌کند؟

شاید بگویید «فرقش در رقم هفدهم است، چه اهمیتی دارد؟». مشکل وقتی جدی می‌شود که خطاها جمع شوند یا با == مقایسه کنیم. مثلا این حلقه را ببینید که قرار است با ده بار اضافه کردن 0.1 به عدد 1 برسد و بایستد:

x, steps = 0.0, 0
while x != 1.0 and steps < 15:
    x += 0.1
    steps += 1
print(steps, x)

خروجی 15 1.5000000000000002 است؛ متغیر x هیچ‌وقت دقیقا 1 نمی‌شود و اگر شرط steps < 15 را نگذاشته بودیم، حلقه تا ابد ادامه پیدا می‌کرد. نمونهٔ واقعی و تلخ‌ترش، ماجرای سامانهٔ موشکی پاتریوت در جنگ خلیج فارس (۱۹۹۱) است. ساعت این سامانه زمان را بر حسب دهم ثانیه می‌شمرد و چون 0.1 در یک رجیستر ۲۴ بیتی دقیق ذخیره نمی‌شد، بعد از حدود ۱۰۰ ساعت کار مداوم، ۰٫۳۴ ثانیه خطا جمع شده بود. همین خطا باعث شد یک موشک اسکاد رهگیری نشود.

راه‌حل‌ها: با اعداد اعشاری درست کار کنیم

۱. اعداد اعشاری را با == مقایسه نکنید

به‌جای برابری دقیق، بپرسید «آیا این دو عدد به اندازهٔ کافی به هم نزدیک‌اند؟». در پایتون تابع math.isclose همین کار را می‌کند و برای نمایش هم می‌توانید عدد را گرد کنید:

import math

a = 0.1 + 0.2
print(a == 0.3)
print(math.isclose(a, 0.3))
print(round(a, 2), f"{a:.2f}")

خروجی: False، True و 0.3 0.30. در جاوااسکریپت هم می‌توانید از Number.EPSILON و toFixed کمک بگیرید:

const a = 0.1 + 0.2;
console.log(Math.abs(a - 0.3) < Number.EPSILON);
console.log(a.toFixed(2));

توجه کنید که Number.EPSILON برای اعداد نزدیک به ۱ مناسب است؛ برای اعداد خیلی بزرگ یا خیلی کوچک، تلورانس را نسبت به اندازهٔ خود عدد انتخاب کنید (کاری که isclose به‌طور خودکار انجام می‌دهد).

۲. برای پول و حسابداری از Decimal استفاده کنید

وقتی دقت دهدهی واقعا مهم است، مثل محاسبات مالی، از ماژول decimal استفاده کنید. فقط یادتان باشد عدد را به‌صورت رشته بدهید، وگرنه همان خطای float وارد Decimal می‌شود:

from decimal import Decimal

print(Decimal("0.1") + Decimal("0.2"))
print(Decimal("0.1") + Decimal("0.2") == Decimal("0.3"))
print(Decimal(0.1) + Decimal(0.2))  # wrong: built from floats

دو خط اول 0.3 و True چاپ می‌کنند، اما خط سوم 0.3000000000000000166533453694 می‌دهد. اگر با کسرها سروکار دارید، ماژول fractions هم حساب را کاملا دقیق نگه می‌دارد: Fraction(1, 10) + Fraction(2, 10) دقیقا 3/10 می‌شود.

۳. مبلغ را به‌صورت عدد صحیح نگه دارید

ساده‌ترین راه در بسیاری از برنامه‌ها این است که اصلا اعشار نداشته باشیم. ببینید ده بار جمع کردن 0.1 چه می‌کند:

total = 0.0
for _ in range(10):
    total += 0.1  # add 0.1 ten times
print(total, total == 1.0)

prices = [12500, 37500, 4990]  # in toman, as integers
print(sum(prices))

خط اول 0.9999999999999999 False چاپ می‌کند، اما جمع قیمت‌ها به‌صورت عدد صحیح (Integer) دقیقا 54990 است. برای همین فروشگاه‌های اینترنتی مبلغ را به ریال یا تومان، یا به سنت به‌جای دلار، ذخیره می‌کنند. در پایگاه داده هم برای ستون قیمت به‌جای FLOAT از نوع DECIMAL استفاده کنید.

جمع‌بندی

این‌که 0.1 + 0.2 برابر 0.3 نمی‌شود، باگ پایتون یا جاوااسکریپت نیست؛ نتیجهٔ طبیعی ذخیرهٔ اعداد دهدهی در حافظهٔ دودویی و محدودیت ۶۴ بیتی اعداد ممیز شناور است. اعداد اعشاری را با == مقایسه نکنید، برای نمایش گردشان کنید و برای پول از Decimal یا عدد صحیح کمک بگیرید. اگر دوست دارید بیشتر بدانید پایتون چرا این‌قدر محبوب است، مقالهٔ زبان برنامه‌نویسی پایتون: تاریخچه، کاربردها و دلایل یادگیری و مهم‌ترین تغییرات Python 3.15 را هم بخوانید.

یادگیری اصولی برنامه‌نویسی در آموزشگاه کادوس رشت

برنامه‌نویس خوب فقط کد نمی‌نویسد؛ می‌داند پشت پردهٔ کامپیوتر چه می‌گذرد. دپارتمان کامپیوتر و برنامه‌نویسی مؤسسه فنی و آموزشی کادوس در رشت، همین نگاه عمیق را در کنار پروژه‌های عملی آموزش می‌دهد. اگر هنوز برای شروع مردد هستید، مقاله‌های چرا در سال ۲۰۲۶ هنوز باید برنامه‌نویسی یاد بگیریم؟ و آیا بدون مدرک دانشگاهی می‌توان برنامه‌نویس شد؟ را ببینید.

فهرست کامل دوره‌های برنامه‌نویسی و طراحی وب را ببینید و برای مشاورهٔ رایگان و ثبت‌نام با هر دو شعبهٔ آموزشگاه کادوس در رشت در ارتباط باشید:

یا از صفحهٔ تماس با ما پیام بدهید.

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

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