Skip to content
العودة إلى المدوّنة
دروس تعليمية

دقة الفاصلة العائمة: لماذا 0.1 + 0.2 ≠ 0.3

لماذا تكسر دقة الفاصلة العائمة العملية 0.1 + 0.2، وكيف يقرّب معيار IEEE 754، وكيف تقارن الأعداد العشرية بأمان في JS وPython — مع محول مجاني على الإنترنت.

12 دقائق للقراءة

دقة الفاصلة العائمة: لماذا 0.1 + 0.2 ≠ 0.3

تُعيد العملية 0.1 + 0.2 القيمة 0.30000000000000004 لأن لا 0.1 ولا 0.2 موجودة أصلاً داخل عدد ثنائي بفاصلة عائمة. لا يستطيع النوع double أن يحمل إلا قيماً من الشكل m × 2ⁿ، والعُشر كسر دوري لا نهائي في الأساس 2، فيحتفظ العتاد بأقرب جار قابل للتمثيل بدلاً منه. القيمة المخزَّنة خلف 0.1 هي بالضبط 0.1000000000000000055511151231257827021181583404541015625، والقيمة خلف 0.2 هي 0.200000000000000011102230246251565404236316680908203125. اجمع هاتين القيمتين المخزَّنتين، فيقع المجموع الدقيق بين قيمتين متجاورتين قابلتين للتمثيل. يقرّب معيار IEEE 754 إلى الأقرب منهما، وهي قيمة تعلو 0.3 بشعرة.

هذا هو الجواب كله: الشبكة الثنائية محدودة، والكسور العشرية نادراً ما تقع على إحدى نقاطها.

وهذه ليست غرابة في JavaScript ولا عيباً في المعالج. النتيجة نفسها تظهر في Python وJava وC وGo وRust وSwift، وفي أعمدة FLOAT في SQL، لأنها جميعاً تعمل وفق IEEE 754. الصق أي قيمة في محوّل الفاصلة العائمة IEEE 754 لترى البتات الدقيقة والقيمة المخزَّنة الدقيقة، خانةً بخانة.

لكن معرفة السبب وحدها لا تكفي عملياً. يبقى أن تعرف لماذا تخفي لغتك هذا الخطأ أحياناً، وكيف تقارن الأعداد العشرية دون ==، وأي نوع رقمي تختار حين تصبح الدقة مسألة مال.

دقة الفاصلة العائمة في 60 ثانية

ثلاث حقائق تفسّر كل مفاجأة تقريباً:

  1. لا تستطيع الفاصلة العائمة الثنائية تمثيل سوى الأعداد من الشكل m × 2ⁿ، أي مجموع قوى للعدد 2.
  2. الكسور العشرية مثل 0.1 و0.2 و0.3 ليست من هذا الشكل، فتُقرَّب عند دخولها.
  3. وكل عملية حسابية تقرّب نتيجتها من جديد إلى أقرب قيمة قابلة للتمثيل.
العدد العشريقابل للتمثيل بدقة؟السبب
0.5نعم2⁻¹
0.25نعم2⁻²
0.75نعم2⁻¹ + 2⁻²
2.5نعم2 + 2⁻¹
100نعمأي عدد صحيح أقل من 2⁵³
0.1لا1/10 — في المقام عامل هو 5
0.2لا1/5 — المشكلة نفسها
0.3لا3/10 — المشكلة نفسها

قاعدة عملية: اختصر الكسر، فإن كان المقام قوة خالصة للعدد 2 فالقيمة دقيقة. وأي عامل 5 يبقى يعني أن القيمة مخزَّنة تقريبياً.

لماذا لا يمكن تخزين 0.1 بدقة

تحمل البتات التي تلي الفاصلة الثنائية الأوزان 1/2 و1/4 و1/8 و1/16 و1/32 وهكذا. حاول تركيب 0.1 منها فلن تصل إليها أبداً. تحصل على 1/16 = 0.0625، ثم بإضافة 1/32 على 0.09375، ثم بإضافة 1/256 على 0.09765625. تقترب أكثر في كل مرة ولا تصيبها قط. وبالثنائي، العُشر هو 0.0001100110011… مع تكرار 0011 بلا نهاية.

والنظام العشري يعاني المشكلة نفسها مع كسور أخرى. كتابة 1/3 في الأساس 10 تعطي 0.333… ولا توجد سلسلة منتهية من الخانات تصيبها تماماً. ولا أحد يسمّي ذلك عيباً في النظام العشري. الأساس 2 يرسم الخط في موضع مختلف فحسب، وقد وقع 1/10 على الجانب الخطأ منه. وتوثيق Python في صفحة Floating Point Arithmetic: Issues and Limitations يستعرض التوسّع نفسه إن أردت الصيغة المطوّلة.

والثنائي ليس إلا الأساس 2، والحساب الموضعي نفسه هو ما يشغّل الست عشري والثماني. يوضّح محوّل الأنظمة العددية كيف تنتقل القيمة بين الأنظمة، ويغطّي دليل تحويل الأنظمة العددية لدينا جانب الأعداد الصحيحة بعمق. أما مع الكسور فيبدأ الأساس 2 في إيلامك.

ما الذي يخزّنه النوع double فعلياً

ينقسم النوع double بحجم 64 بت إلى ثلاثة حقول: بت إشارة واحد، و11 بت للأُس، و52 بت للمانتيسا. وتحمل المانتيسا واحداً ضمنياً في المقدمة لا يُخزَّن أبداً، فتحصل على 53 بت من الجزء الدال، أي ما يقارب 15.95 خانة عشرية من دقة الفاصلة العائمة. ويُخزَّن الأُس بانحياز مقداره 1023.

اطلب من أي لغة خانات أكثر مما تطبعه عادة، فيظهر التقريب:

>>> 0.1
0.1
>>> f"{0.1:.20f}"
'0.10000000000000000555'
>>> from decimal import Decimal
>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
(0.1).toFixed(20);      // '0.10000000000000000555'
(0.1).toPrecision(20);  // '0.10000000000000000555'

الاستدعاء Decimal(0.1) هو العرض الأمين: يحوّل قيمة double التي بين يديك أصلاً دون إعادة تقريب، ويطبع الخانات الخمس والخمسين كاملة. وتطبع لوحة الدقة في محوّل الفاصلة العائمة IEEE 754 التوسّع نفسه لأي رقم تكتبه، إلى جانب خطأ التقريب بإشارته مقارنةً بما أدخلته.

الحساب الدقيق وراء 0.30000000000000004

الرقم نفسه قابل للاشتقاق يدوياً، ولا يحتاج الأمر أكثر من جمع طويل ومقارنة مسافتين.

اجمع القيمتين المخزَّنتين جمعاً دقيقاً بلا أي تقريب، فتحصل على:

0.1 →  0.1000000000000000055511151231257827021181583404541015625
0.2 →  0.200000000000000011102230246251565404236316680908203125
sum →  0.3000000000000000166533453693773481063544750213623046875

وهذا المجموع نفسه ليس قيمة double قابلة للتمثيل. إنه يقع بين جارين على الشبكة الثنائية:

below:  0.299999999999999988897769753748434595763683319091796875   (prints as 0.3)
above:  0.3000000000000000444089209850062616169452667236328125     (prints as 0.30000000000000004)

وقياس الفجوتين يكشف حالة غير معتادة. المجموع الدقيق يعلو الجار الأدنى بمقدار 2⁻⁵⁵2.776e-17، ويقلّ عن الجار الأعلى بالمقدار 2⁻⁵⁵ ذاته. فهو لا يقترب من أحدهما أكثر، بل يقع في منتصف المسافة بينهما بالضبط.

ولا تجد قاعدة التقريب إلى الأقرب مسافةً تعمل بها هنا، فيطبّق IEEE 754 قاعدته لفضّ التعادل: التقريب إلى الزوجي عند المنتصف، أي اختيار المرشّح الذي يكون آخر بت في مانتيساه صفراً. الجزء الدال للجار الأدنى هو 5404319552844595 وهو فردي، وللجار الأعلى 5404319552844596 وهو زوجي. فتفوز القيمة الزوجية، وينتج نمط البتات 0x3FD3333333333334، أي وحدة ULP واحدة فوق قيمة double التي يقابلها الثابت الحرفي 0.3، ويُطبع 0.30000000000000004.

ولماذا تفضيل الزوجي؟ لأن تقريب الأنصاف إلى الأعلى دائماً كان سيحيّز المجاميع الطويلة في اتجاه واحد. أما التناوب نحو الجار الزوجي فيبقي هذا الانحراف قريباً من الصفر عبر عمليات كثيرة. إنه تقريب المصرفيين نفسه الذي يستخدمه المحاسبون، مقنَّناً داخل السيليكون، وهو السبب المباشر لانتهاء أشهر خطأ تقريب فاصلة عائمة على الإنترنت بالخانتين ...04.

قارن ذلك بمجموع لا يحتاج تقريباً على الإطلاق:

0.5 + 0.25 === 0.75;   // true
0.1 + 0.2 === 0.3;     // false

القيم 0.5 و0.25 و0.75 هي 2⁻¹ و2⁻² و2⁻¹ + 2⁻². وكلها دقيقة، ومجموعها دقيق، فتتصرف المساواة كما يوحي حساب المدرسة. وتعرض لوحة الجيران في المحوّل القيمتين السابقة والتالية القابلتين للتمثيل حول أي قيمة تدخلها، مع فجوة ULP، فتستطيع أن تفحص الشبكة حول 0.3 بنفسك.

لماذا تخفي لغتك الخطأ أحياناً

والمُربك في الأمر أن 0.1 تُطبع 0.1، بينما تُطبع 0.1 + 0.2 بسبع عشرة خانة. صيغة التخزين واحدة، والمخرجات مختلفة تماماً.

تستخدم بيئات التشغيل الحديثة أقصر تنسيق ذهاباً وإياباً: تُخرج أقصر سلسلة نصية عشرية تُحلَّل رجوعاً إلى قيمة double ذاتها. والسلسلة "0.1" تعود بالفعل بشكل وحيد إلى القيمة المخزَّنة لـ 0.1، فهذا ما تراه. أما المجموع 0.1 + 0.2 فهو قيمة double مختلفة عن التي يقابلها "0.3"، فيضطر المنسّق إلى إضافة خانات حتى تصبح السلسلة غير ملتبسة، وهذا يستهلك الخانات السبع عشرة كلها. وقد بدأ هذا النسب من عمل ديفيد غاي (David Gay) على التقريب الصحيح سنة 1990، ثم جاءت Grisu فـRyu لتجعله سريعاً بما يكفي لكل مكتبة قياسية. لغتك لا تكذب عليك؛ إنها تعرض أقصر لافتة تُعرّف القيمة تعريفاً وحيداً.

سلوك كل لغة على حدة

اللغةنوع الفاصلة العائمة الافتراضيما تطبعه 0.1 + 0.2خيار العشري الدقيق
JavaScriptnumber (النوع double فقط)0.30000000000000004لا شيء مدمج — سنتات صحيحة أو مكتبة
Pythonfloat (النوع double)0.30000000000000004decimal.Decimal وfractions.Fraction
Javadouble0.30000000000000004BigDecimal (ابنِه من سلسلة نصية)
C#double0.30000000000000004decimal — 128 بت، بالأساس 10
Gofloat640.30000000000000004 (عبر متغيرات)math/big.Rat
Rustf640.30000000000000004صندوق rust_decimal
SQLFLOAT / DOUBLE PRECISION0.30000000000000004NUMERIC / DECIMAL

وصفّان من هذه الصفوف يخفيان فخاً.

تطوي Go الثوابت بدقة غير محدودة. فالتعبير الحرفي يُقيَّم تقييماً دقيقاً وقت الترجمة ولا يُحوَّل إلا بعد ذلك، فيصير الثابت 0.1 + 0.2 مساوياً لـ 0.3 بالضبط قبل أن يصبح float64 أصلاً:

package main

import "fmt"

func main() {
	fmt.Println(0.1 + 0.2) // 0.3   — constant folded exactly, then converted
	a, b := 0.1, 0.2
	fmt.Println(a + b)      // 0.30000000000000004
	fmt.Println(a+b == 0.3) // false
}

ويرث BigDecimal في Java الخطأ إن أطعمته قيمة double. فالبانِي يحوّل بأمانة أي بتات تُسلَّم إليه:

System.out.println(new BigDecimal(0.1));
// 0.1000000000000000055511151231257827021181583404541015625

System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
// 0.3

أما SQL فينقسم بحسب نوع العمود لا بحسب اللهجة:

-- PostgreSQL: a bare decimal literal is NUMERIC, which is exact
SELECT 0.1 + 0.2 = 0.3;                          -- t

-- Cast to binary floating point and equality fails
SELECT 0.1::float8 + 0.2::float8 = 0.3::float8;  -- f

-- SQLite: REAL is a double, and the printed value hides it
SELECT 0.1 + 0.2;        -- 0.3
SELECT 0.1 + 0.2 = 0.3;  -- 0  (false)

وسطرا SQLite الأخيران يستحقان وقفة. النتيجة المطبوعة تقول 0.3، والمقارنة تقول غير ذلك، وكلاهما صحيح.

مقارنة الأعداد العشرية: لماذا يفشل == ولماذا ليس Number.EPSILON هامش تسامح

العبارة 0.1 + 0.2 === 0.3 خاطئة لأن الطرف الأيسر قيمة double مختلفة. والنصيحة المعتادة هي المقارنة بهامش تسامح بدلاً من ذلك، وأكثر صيغ هذه النصيحة تكراراً خاطئة:

Math.abs(a - b) < Number.EPSILON;   // ⚠️ not a general-purpose comparison

القيمة Number.EPSILON هي 2.220446049250313e-16، أي 2⁻⁵². ويعرّفها موقع MDN بأنها الفرق بين 1 وأصغر قيمة double أكبر من 1. والتعريف مقيَّد بموضعه: إنها تباعد الشبكة عند 1.0، لا ميزانية خطأ عامة. وتباعد الأعداد العشرية يتضاعف عند كل قوة من قوى 2، فالعتبة الثابتة خاطئة في كل مكان عدا الجوار الذي قيست فيه.

قرب 1.0 تصادف أن تنجح:

Math.abs((0.1 + 0.2) - 0.3);                  // 5.551115123125783e-17
Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON; // true

كبّر الحساب نفسه مليار مرة فينهار:

const a = (0.1 + 0.2) * 1e9;
const b = 0.3 * 1e9;

a === b;                          // false
Math.abs(a - b);                  // 5.960464477539063e-8
Math.abs(a - b) < Number.EPSILON; // false — yet a and b are adjacent doubles

حول 1e9 يبلغ التباعد بين قيمتي double متجاورتين نحو 1.19e-7. وبما أن 1e9 تقع في المجال [2²⁹, 2³⁰)، فإن وحدة ULP هناك تساوي 2²⁹⁻⁵² = 2⁻²³ ≈ 1.19e-7، أي أكبر بنحو 500 مليون مرة من Number.EPSILON. وأي خطأ تقريب حقيقي عند هذا المقدار يقزّم العتبة، فيعود الفحص خاطئاً في كل مرة. أما عند 1e-20 فالثابت نفسه سخيّ أكثر من اللازم، فيعلن تساوي قيم مختلفة اختلافاً بيّناً.

التسامح المطلق والنسبي وبوحدات ULP

أمامك ثلاث أدوات، ولكل منها موضع تصلح فيه:

  • التسامح المطلق، |a − b| <= atol، صحيح حين تعرف المقدار سلفاً، وهو الوحيد الذي يعمل مقابل الصفر، لأن التسامح النسبي حول 0 يساوي 0 دائماً.
  • التسامح النسبي، |a − b| <= rtol × max(|a|, |b|)، يتوسّع مع البيانات عبر المقادير المختلفة، لكنه ينهار قرب الصفر.
  • مسافة ULP تعدّ كم قيمة قابلة للتمثيل تقع بين نمطي البتات، وهي الأدقّ والأقلّ قابلية للقراءة.

اجمع الأولين فتحصل على الشكل الذي تستخدمه كل مكتبة عددية جادّة:

function nearlyEqual(a, b, rtol = 1e-9, atol = 1e-12) {
  if (a === b) return true;            // handles Infinity === Infinity
  const diff = Math.abs(a - b);
  return diff <= Math.max(rtol * Math.max(Math.abs(a), Math.abs(b)), atol);
}

nearlyEqual(0.1 + 0.2, 0.3);                  // true
nearlyEqual((0.1 + 0.2) * 1e9, 0.3 * 1e9);    // true
nearlyEqual(0, 1e-15);                        // true
nearlyEqual(1, 1.0001);                       // false

وتوفّر Python هذا في مكتبتها القياسية، وتستخدم allclose في NumPy الصيغة نفسها:

import math
math.isclose(0.1 + 0.2, 0.3, rel_tol=1e-9, abs_tol=1e-12)  # True
math.isclose(1e9, 1e9 + 1e-7, rel_tol=1e-9)                # True

وللنسخة الصارمة، حوّل البتات إلى ترتيب صحيح رتيب ثم اطرح:

const view = new DataView(new ArrayBuffer(8));

function toOrdinal(x) {
  view.setFloat64(0, x);
  const i = view.getBigInt64(0);
  return i >= 0n ? i : -(1n << 63n) - i;   // make negatives order correctly
}

function ulpDistance(a, b) {
  const d = toOrdinal(a) - toOrdinal(b);
  return d < 0n ? -d : d;
}

ulpDistance(0.1 + 0.2, 0.3);                // 1n
ulpDistance((0.1 + 0.2) * 1e9, 0.3 * 1e9);  // 1n
ulpDistance(-0, 0);                         // 0n

وبالمناسبة، == الدقيق ليس خاطئاً دائماً. فلا بأس به مع قيم double ذات قيمة صحيحة أقل من 2⁵³، ومع ثوابت أسندتها ولم تُجرِ عليها أي حساب، وللتحقق من أن قيمة تساوي صفراً بالضبط.

حين تكلّف الدقة مالاً: اختيار النوع الرقمي المناسب

العملات والأعداد العشرية الثنائية زواج فاشل. الخطأ الواحد ضئيل، لكنه يتكرر بالطريقة نفسها في كل تشغيل، ويبقى خفياً حتى يعثر عليه مدقّق الحسابات:

19.99 * 100;              // 1998.9999999999998
Math.round(19.99 * 100);  // 1999

(1.005).toFixed(2);       // '1.00'  — 1.005 is stored as 1.00499999999999989...

ويوقع المثال الثاني فرقاً برمجية في الفخ كل عام. لم يخطئ toFixed في التقريب؛ فالقيمة التي سُلّمت إليه كانت أصلاً دون 1.005.

الوحدات النقدية الفرعية الصحيحة

خزّن السنتات لا الدولارات. القيمة 1999 تعني 19.99 دولاراً، ويجري الحساب على أعداد صحيحة، ولا تقسم إلا لحظة العرض.

const priceCents = 1999;             // $19.99
const subtotal = priceCents * 3;     // 5997 — exact

const totalCents = 1000;             // $10.00 split three ways
const share = Math.floor(totalCents / 3);   // 333
const remainder = totalCents - share * 3;   // 1 cent to allocate

وهنا تحذيران. فوق Number.MAX_SAFE_INTEGER (9007199254740991، أي 2⁵³ − 1) تتوقف الأعداد الصحيحة في JavaScript عن كونها دقيقة، فاستخدم BigInt. والأعداد الصحيحة لا تقرّر سياسة التقريب نيابة عنك: القسمة والنسب المئوية وتقسيم الضرائب ما زالت تحتاج قاعدة صريحة تحدّد أين يذهب السنت المتبقّي.

الأنواع العشرية

يخزّن النوع العشري خانات بالأساس 10، فتحطّ الكسور العشرية بدقة. ابنِه من سلسلة نصية، لا من عدد بفاصلة عائمة أبداً، وإلا ورثت الخطأ الثنائي قبل أن تبدأ:

from decimal import Decimal

Decimal("0.1") + Decimal("0.2") == Decimal("0.3")   # True
Decimal(0.1)                                        # 0.1000000000000000055511151231257827021181583404541015625
Console.WriteLine(0.1m + 0.2m);          // 0.3
Console.WriteLine(0.1m + 0.2m == 0.3m);  // True

لدى Java النوع BigDecimal، ولدى C# نوع decimal أصيل بحجم 128 بت، ولدى SQL النوع NUMERIC(12,2). وكلها تصلح الكسور العشرية. ولا واحد منها يصلح 1/3، لأن النوع العشري ما زال صيغة فاصلة عائمة؛ كل ما فعله أنه نقل الأساس من 2 إلى 10.

مصفوفة القرار

حالة الاستخدامالنوع الموصى بهالسبب
المال والفوترة والضرائبوحدات نقدية فرعية صحيحة أو نوع عشريحساب عشري دقيق وتقريب قابل للتدقيق
العلوم والفيزياءdouble (FP64)15–16 خانة تكفي وتزيد؛ وأفضل دعم من المكتبات
الهندسة والرسومياتfloat أو double مع هامش تسامحتقريبية أصلاً؛ قارِن بإبسلون
تدريب نماذج التعلّم الآليbfloat16مدى FP32 بنصف الذاكرة
استدلال النماذج والتخزينFP16بتات مانتيسا أكثر حين تكون القيم مُطبَّعة
العدّادات والمعرّفات والمفاتيحعدد صحيح 64 بت أو BigIntلم تكن يوماً من نصيب الفاصلة العائمة

تراكم الخطأ والإلغاء الكارثي

خطأ تقريب واحد مقداره 1e-17 لا يضرّ. وعشرة آلاف منها تذكرة دعم فني:

let total = 0;
for (let i = 0; i < 10000; i++) total += 0.1;

total;         // 1000.0000000001588
total - 1000;  // 1.588205122970976e-10

وينمو الانحراف تقريباً بعدد العمليات. في حلقة بهذا الحجم يبقى غير مرئي؛ وفي تجميع ليلي على ملايين الصفوف لا يبقى كذلك.

الإلغاء الكارثي

الفشل الأشرس هو الطرح. اطرح عددين شبه متساويين فتتلاشى خاناتهما الأولى المتطابقة، وتبقى نتيجة يهيمن عليها ما كان قد تراكم من خطأ. الخطأ المطلق لا يكبر، لكن الخطأ النسبي ينفجر، لأن النتيجة صارت ضئيلة بينما بقي الخطأ بحجمه نفسه.

وصيغة التباين ذات المرور الواحد المعروفة في الكتب، E[x²] − (E[x])²، تمشي إليه مباشرة:

const data = [1e9, 1e9 + 1, 1e9 + 2];
const mean = data.reduce((s, x) => s + x, 0) / data.length;

// One-pass: E[x²] − (E[x])²
data.reduce((s, x) => s + x * x, 0) / data.length - mean * mean;  // 0

// Two-pass: centre the data first
data.reduce((s, x) => s + (x - mean) ** 2, 0) / data.length;      // 0.6666666666666666

التباين السكاني الحقيقي هو 2/3. وتعيد صيغة المرور الواحد صفراً بالضبط. الفارق ليس في الخانات الأخيرة؛ الجواب مختلف كلياً، لأن كلا المعاملين كان حول 1e18 والفرق بينهما يقبع تحت آخر بت مخزَّن. وتتجنّب خوارزمية ويلفورد (Welford) الفورية ذلك بتحديث المتوسط ومجموع مربّعات الانحرافات تدريجياً.

وللقانون العام للمعادلة التربيعية الضعف نفسه حين b² ≫ 4ac، والعلاج من النمط ذاته:

const a = 1, b = 1e8, c = 1;
const disc = Math.sqrt(b * b - 4 * a * c);

(-b + disc) / (2 * a);   // -7.450580596923828e-9  — about 25% wrong
(2 * c) / (-b - disc);   // -1e-8                  — correct

يحسب السطران الجذر نفسه. الأول يطرح عددين شبه متساويين، والثاني يعيد ترتيب الجبر حتى لا يضطر إلى ذلك. وما زالت ورقة غولدبرغ What Every Computer Scientist Should Know About Floating-Point Arithmetic المرجع المعتمد في الإلغاء وحدود الخطأ.

جمع كاهان

يحتفظ جمع كاهان التعويضي بسجل جارٍ للبتات الدنيا التي ترميها كل عملية جمع، ثم يعيدها إلى الحساب في التكرار التالي:

function kahanSum(values) {
  let sum = 0;
  let compensation = 0;
  for (const value of values) {
    const y = value - compensation;
    const t = sum + y;
    compensation = (t - sum) - y;   // the bits that fell off
    sum = t;
  }
  return sum;
}

kahanSum(new Array(10000).fill(0.1));   // 1000  — exactly

وتعطيك Python هذا مجاناً. الاستدعاء math.fsum([0.1] * 10_000) يعيد 1000.0، ومنذ CPython 3.12 تستخدم الدالة المدمجة sum() تعويض نويماير (Neumaier) أيضاً، فيكون sum([0.1] * 10_000) دقيقاً بينما تظل حلقة += الصريحة تنحرف إلى 1000.0000000001588.

استعن بالتعويض في المجاميع الطويلة والتجميعات المالية والتكامل العددي. وتجاوزه مع حفنة قيم أو حين تكون قد انتقلت أصلاً إلى نوع دقيق.

القيم الخاصة التي تكسر الافتراضات

يحجز IEEE 754 أنماط بتات لقيم لا تطيع القواعد التي تتوقعها:

NaN === NaN;          // false — mandated by the standard
Number.isNaN(NaN);    // true
[NaN].indexOf(NaN);   // -1   (uses ===)
[NaN].includes(NaN);  // true (uses SameValueZero)

-0 === 0;             // true
Object.is(-0, 0);     // false
1 / -0;               // -Infinity

1e308 * 10;           // Infinity — overflow never throws
Infinity - Infinity;  // NaN
Number.MIN_VALUE;     // 5e-324 — the smallest subnormal double

تخرج NaN غير مساوية لكل شيء بما في ذلك ذاتها، وهذا مقصود في التصميم، حتى لا يتنكّر حساب فاشل في هيئة نتيجة صالحة. اختبرها بـ Number.isNaN() أو بـ math.isnan() في Python.

أما الفيضان فأهدأ وأخطر: يعيد ±Infinity ويمضي، فينتشر إلى ما بعده حتى يلاحظ أحدهم رسماً بيانياً مليئاً بالفراغات. والصفر السالب نمط بتات متميّز يظل مع ذلك مساوياً لـ +0 تحت ===؛ يظهر من هبوط قيمة سالبة دون الحد أو من -1 * 0، ولا تكشفه إلا القسمة أو Object.is.

وتملأ القيم دون الطبيعية (subnormals) الفجوة بين الصفر وأصغر عدد طبيعي. فحين يكون حقل الأُس أصفاراً كلها يُسقَط الواحد الضمني في المقدمة وتتقلّص المانتيسا تدريجياً نحو الصفر، ما يضمن ألا يُقرَّب الفرق بين عددين عشريين مختلفين إلى صفر تماماً أبداً. والثمن دقة متناقصة، وعلى بعض العتاد هبوط حادّ في الأداء. وتحمّل شرائح القيم الخاصة في محوّل الفاصلة العائمة IEEE 754 القيم ±0 و±Infinity وNaN وأصغر قيمة دون طبيعية، لتفحص كل نمط بتات مباشرة.

float مقابل double مقابل FP16 مقابل bfloat16

المعيار نفسه، لكن بميزانيات مختلفة للمدى ولدقة الفاصلة العائمة:

الصيغةإجمالي البتاتالأُسالمانتيساخانات عشرية تقريبيةأكبر قيمة منتهيةالاستخدام النموذجي
binary16 (FP16)16510~3.365504الاستدلال على GPU، تخزين مضغوط
bfloat161687~2.4~3.39 × 10³⁸تدريب التعلّم الآلي
binary32 (float)32823~7.2~3.40 × 10³⁸الرسوميات والحسّاسات وGPU
binary64 (double)641152~15.9~1.80 × 10³⁰⁸الافتراضي في كل مكان آخر

بتات الأُس تشتري المدى، وبتات المانتيسا تشتري الدقة. تنفق FP16 ميزانيتها على الدقة وتتوقف عند 65504، وهو قريب بما يكفي من قيم التدرّج الحقيقية ليصبح الفيضان إلى Infinity خطراً روتينياً. وتعقد bfloat16 الصفقة المعاكسة، فتحتفظ ببتات الأُس الثماني الخاصة بـFP32 وتتعايش مع سبع بتات مانتيسا، فتمرّ القيم التي كانت ستفيض في FP16 بسلام، ونادراً ما يحتاج التدريب إلى تحجيم الخسارة.

اجعل double هو الافتراضي. ولا تنزل إلى صيغة أضيق إلا بعد أن تقيس اختناقاً في الذاكرة أو في عرض النطاق، وتحقّق مما يكلّفه هذا التضييق بإدخال القيمة نفسها في كل صيغة داخل المحوّل.

قواعد عملية لدقة الفاصلة العائمة

  • لا تستخدم == أبداً على أعداد عشرية محسوبة. استخدم تسامحاً مركّباً، مطلقاً ونسبياً، بمقاس بياناتك.
  • لا تعامل Number.EPSILON كعتبة. فهو يصف الشبكة عند 1.0 ولا شيء غير ذلك.
  • احتفظ بالمال في وحدات نقدية فرعية صحيحة أو في نوع عشري، وابنِ الأعداد العشرية دائماً من سلاسل نصية.
  • عوّض المجاميع الطويلة بكاهان أو نويماير أو math.fsum.
  • أعد ترتيب الصيغ التي تطرح كميات شبه متساوية بدل إحكام هوامش التسامح حولها.
  • نسّق الأرقام صراحةً للبشر بـ toFixed أو بسلسلة f-string أو بـ printf. ولا تسلّم المستخدمين ناتج repr الافتراضي أبداً.
  • أرسل الأرقام عبر حدود الخدمات كسلاسل نصية أو أعداد صحيحة. فرحلة الذهاب والإياب عبر JSON مروراً بنوع double صامتة ومُفقِدة للبيانات.
  • اختر NUMERIC لا FLOAT لأعمدة العملات. فإصلاح هذا بعد الإطلاق يعني ترحيلاً للبيانات وتسوية حسابية.
  • فكّر بالبتات حين تبدو قيمة ما مستحيلة. فالعادة نفسها هي ما يشغّل دليل عمليات البت الكاملة لدينا، وهي تحوّل ألغاز الفاصلة العائمة إلى حساب تستطيع التحقق منه.

الأسئلة الشائعة

هل حساب الفاصلة العائمة معطوب؟

حساب الفاصلة العائمة ليس معطوباً؛ إنه يتبع IEEE 754 بحذافيره. يمثّل المعيار الأعداد الحقيقية بعدد منتهٍ من الخانات الثنائية، والكسور العشرية مثل 0.1 لا صورة ثنائية دقيقة لها، فتُخزَّن أقرب قيمة قابلة للتمثيل. المعطوب هو توقّع أن يتّسع النوع لكل عدد عشري.

لماذا تطبع Python القيمة 0.1 كـ 0.1 وتطبع 0.1 + 0.2 كـ 0.30000000000000004؟

تستخدم repr في Python أقصر تنسيق ذهاباً وإياباً: تطبع أقصر سلسلة نصية عشرية تُحلَّل رجوعاً إلى قيمة double ذاتها. والسلسلة "0.1" تعرّف قيمتها تعريفاً وحيداً أصلاً. أما المجموع 0.1 + 0.2 فهو قيمة double مختلفة عن التي يقابلها "0.3"، فيضطر المنسّق إلى إخراج الخانات السبع عشرة كلها ليبقى غير ملتبس.

لماذا لا ينبغي أن أستخدم Number.EPSILON لمقارنة عددين عشريين؟

القيمة Number.EPSILON (نحو 2.22e-16) هي الفجوة بين 1 وقيمة double التالية، لا ميزانية خطأ عامة. تباعد الأعداد العشرية يتضاعف عند كل قوة من قوى 2، فقرب 1e9 تفصل بين قيمتي double متجاورتين نحو 1.19e-7. وأي خطأ تقريب حقيقي هناك يتجاوز EPSILON، فتصبح المقارنة خاطئة دائماً. استخدم تسامحاً نسبياً أو مركّباً.

هل أستطيع استخدام الفاصلة العائمة للمال إن قرّبت في النهاية؟

التقريب في النهاية لا ينقذ المال المحفوظ بفاصلة عائمة. تتراكم الأخطاء عبر عمليات الجمع والضرب والتوزيعات متعددة الخطوات، ولحظة التقريب نفسها تغيّر الإجمالي. فالعملية 19.99 * 100 تعطي أصلاً 1998.9999999999998. والتدقيق المحاسبي يحتاج حساباً دقيقاً قابلاً لإعادة الإنتاج: سنتات صحيحة أو decimal أو BigDecimal أو NUMERIC في SQL.

ما الأعداد العشرية التي يمكن تخزينها بدقة في النوع double؟

فقط القيم القابلة للتعبير بالشكل m / 2ⁿ لعددين صحيحين m وn ضمن دقة الصيغة. يشمل ذلك 0.5 و0.25 و0.125 و0.75 و2.5، إضافة إلى كل عدد صحيح حتى 2⁵³. اختصر الكسر أولاً: أي عامل 5 يبقى في المقام، كما في 0.1 و0.2 و0.3 و0.7، يعني أن القيمة مخزَّنة تقريبياً.

لماذا 0.1 + 0.2 === 0.3 خاطئة بينما 0.5 + 0.25 === 0.75 صحيحة؟

لأن 0.5 و0.25 و0.75 هي 2⁻¹ و2⁻² و2⁻¹ + 2⁻²، وكلها قابلة للتمثيل بدقة، ومجموعها لا يحتاج أي تقريب. أما 0.1 و0.2 و0.3 فكل منها مخزَّن تقريبياً، وتقريب مجموع الأولين يحطّ وحدة ULP واحدة فوق قيمة double التي تقابلها 0.3.

هل يحدث هذا في كل لغة برمجة؟

كل لغة مبنية على الفاصلة العائمة الثنائية وفق IEEE 754 تُظهر السلوك نفسه: JavaScript وPython وJava وC وC++ وGo وRust وSwift، وعمود FLOAT في SQL. وما يختلف هو دقة الطباعة الافتراضية، وما إذا كان نوع عشري دقيق يأتي جاهزاً في الصندوق، مثل decimal في C# أو وحدة decimal في Python.

الوسوم: floating-point ieee-754 javascript python numbers

مقالات ذات صلة

عرض جميع المقالات