بررسی یک تغییر ساختاری (Breaking Change) در .NET 11: دقت و گرد کردن در تبدیل انواع ممیز شناور به Decimal
نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۷/۰۵ ۰۸:۲۰
آدرس: www.dntips.ir
چکیده: تبدیل میان انواع دادههای عددی با مبنای دودویی (Binary Floating-Point) مانندfloatوdoubleو انواع دهدهی با دقت ثابت (Decimal Floating-Point) نظیرdecimal، همواره یکی از چالشهای محاسباتی در مهندسی نرمافزار به شمار میرود. در نگارشهای پیشین داتنت (از جمله .NET 10 و نسخههای قبل از آن)، متدConvert.ToDecimalبرای تبدیل اعداد اعشاری به نوعdecimalاز یک لایه درونیابی و گرد کردن ضمنی متناسب با ارقام معنیدار نمایش متنی استفاده میکرد. با این حال، در نسخه جدید داتنت (معرفیشده در پیشنویسهای انتشار .NET 11)، رفتار موتور زماناجرا (Runtime) بازطراحی شده و مقدار به نزدیکترین تقریب باینری حقیقیِ ذخیرهشده در حافظه تبدیل میشود. این تغییر ساختاری (Breaking Change) فاقد سوییچ سراسری (Feature Flag) برای بازگشت به رفتار پیشین است و میتواند در صورت عدم بازبینی کدها و آزمونهای واحد/یکپارچهسازی، منطق محاسبات مالی، منطقی و دامنهای نرمافزار را تحت تأثیر قرار دهد. در این مقاله به بررسی چرایی این تغییر، تحلیل رفتار سختافزاری و راهکارهای عملی مهاجرت میپردازیم.
float و double): مطابق استاندارد IEEE 754، اعداد را بر مبنای توانهایی از ۲ نمایش میدهند. این ساختار عملکرد سختافزاری فوقالعاده بالایی دارد، اما توانایی بازنمایی دقیق کسرهای دهدهی (مانند 0.1 یا 3.14) را ندارد و صرفاً یک «تقریب بسیار نزدیک» ذخیره میکند.decimal): اعداد را بر مبنای توانهای ۱۰ نمایش داده و با ارائه ۲۸ تا ۲۹ رقم معنادار، گزینهای استاندارد برای محاسبات دقیق حسابداری و مالی محسوب میشود.Convert در داتنت بهگونهای پیادهسازی شده بود که هنگام فراخوانی Convert.ToDecimal(float)، با فرض اینکه کاربر به دنبال «نمایش متنی قابلانتظار» متغیر است، بخش انتهایی تقریب باینری را حذف یا گرد میکرد. با تغییرات اعمالشده در هسته زماناجرای داتنت (از جمله پولریکوئستهای ارتقای دقت ممیز شناور در ریپازیتوری dotnet/runtime)، این منطق اصلاح شده تا واقعیت بیتهای حافظه عیناً به مبنای دهدهی منتقل شود.float pi = 3.14f; decimal converted = Convert.ToDecimal(pi); Console.WriteLine(converted);
3.14
3.1400001049041748046875
3.14 در قالب ممیز شناور ۳۲ بیتی (Single یا همان float) قابلیت ذخیرهسازی دقیق ندارد. در سطح بیتها و معماری IEEE 754، مقدار دقیق نگهداریشده در ثباتهای پردازنده به شرح زیر است:3.14m میشد.decimal بازتولید میکند.نکته کلیدی: برخلاف بسیاری از رفتارهای ناسازگار دیگر در بهروزرسانیهای داتنت، هیچAppContext Switchیا تنظیمی درruntimeconfig.jsonبرای غیرفعالسازی این رفتار در نظر گرفته نشده است؛ زیرا معماری زیرسیستم محاسباتی و الگوریتمهای پارس/فرمتینگ ممیز شناور مستقیماً به سمت انطباق استاندارد و یکدستی ریاضی حرکت کردهاند.
Assert.Equal): هر تستی که برابری مستقیم مقادیر decimal حاصل از تبدیل float را با مقادیر مورد انتظار بررسی میکند، با خطا مواجه خواهد شد.3.1400001049041748046875 به فیلدی با نوع DECIMAL(18, 2) ممکن است منجر به خطای بُرش (Truncation Error) یا سرریز طول اعشار شود.float استفاده کنید اما نتیجه را در قالب اعشاری تمیز با تعداد مشخصی رقم بعد از اعشار میخواهید، از Math.Round به صورت صریح استفاده کنید:float pi = 3.14f; // تعیین صریح تعداد ارقام اعشار بر اساس نیاز بیزینس decimal converted = Math.Round((decimal)pi, 2, MidpointRounding.AwayFromZero); Console.WriteLine(converted); // خروجی: 3.14
float pi = 3.14f;
// استفاده از فرمتدهنده رشتهای دهدهی
decimal converted = decimal.Parse(pi.ToString("G7", CultureInfo.InvariantCulture), CultureInfo.InvariantCulture);
Console.WriteLine(converted); // خروجی: 3.14"G7" تضمین میکند که بیشینه دقت استاندارد تکدقتی یا Single-Precision یعنی ۷ رقم لحاظ شود).float یا double در ابتدای چرخه استفاده شود. بهترین شیوه مهندسی نرمافزار، تعریف مستقیم متغیرها با نوع decimal است:// به جای: float price = 19.99f; decimal price = 19.99m;
float (تکدقتی) حداکثر ۷ رقم بامعنی تضمینشده دارد؛ بنابراین فرمت "G7" نزدیکترین نمایش دهدهی اولیه را بدون دنباله باینری بازمیگرداند.double (دقتمضاعف) حداکثر ۱۵ تا ۱۷ رقم بامعنی دارد؛ بنابراین فرمت "G15" تا "G17" مناسبترین گزینه برای حفظ دقت اولیه بدون خطای تقریب است.using System.Globalization;
namespace System;
public static class FloatingPointConversionExtensions
{
/// <summary>
/// تبدیل امن float به decimal بر اساس دقت استاندارد ۷ رقم بامعنی
/// </summary>
public static decimal ToSafeDecimal(this float value)
{
// مدیریت مقادیر خاص ممیز شناور که در decimal تعریف نشدهاند
if (float.IsNaN(value) || float.IsInfinity(value))
{
throw new OverflowException("مقادیر NaN یا Infinity قابلیت تبدیل به نوع decimal را ندارند.");
}
// استفاده از Span برای کاهش تخصیص حافظه (Allocation-free parsing)
Span<char> buffer = stackalloc char[16];
if (value.TryFormat(buffer, out int charsWritten, "G7", CultureInfo.InvariantCulture))
{
return decimal.Parse(buffer[..charsWritten], NumberStyles.Float, CultureInfo.InvariantCulture);
}
// مسیر پشتیبان در صورت بروز خطا در اندازه بافر
return decimal.Parse(value.ToString("G7", CultureInfo.InvariantCulture), CultureInfo.InvariantCulture);
}
/// <summary>
/// تبدیل امن double به decimal بر اساس دقت استاندارد ۱۵ رقم بامعنی
/// </summary>
public static decimal ToSafeDecimal(this double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
throw new OverflowException("مقادیر NaN یا Infinity قابلیت تبدیل به نوع decimal را ندارند.");
}
Span<char> buffer = stackalloc char[32];
if (value.TryFormat(buffer, out int charsWritten, "G15", CultureInfo.InvariantCulture))
{
return decimal.Parse(buffer[..charsWritten], NumberStyles.Float, CultureInfo.InvariantCulture);
}
return decimal.Parse(value.ToString("G15", CultureInfo.InvariantCulture), CultureInfo.InvariantCulture);
}
}float pi = 3.14f; // رفتار پیشفرض در داتنت 11: decimal standardConvert = Convert.ToDecimal(pi); Console.WriteLine(standardConvert); // 3.1400001049041748046875 // استفاده از متد اکستنشن: decimal safeConvert = pi.ToSafeDecimal(); Console.WriteLine(safeConvert); // 3.14
stackalloc و Span): به جای فراخوانی مستقیم .ToString() که در هر تبدیل یک شیء string روی Heap ایجاد میکند، از TryFormat همراه با حافظه پشتهای استفاده شده است تا فشار GC در فراخوانیهای مکرر به صفر برسد.CultureInfo.InvariantCulture: تضمین میکند که ممیز اعشاری بدون وابستگی به Culture جاری سیستم همیشه کاراکتر نقطه (.) در نظر گرفته شود.NaN، PositiveInfinity و NegativeInfinity در نوع داده decimal مجاز نیستند و پیش از ورود به چرخه پارس، با پرتاب OverflowException مدیریت شدهاند.Convert.ToDecimal(float)، Convert.ToDecimal(double) و کستهای مستقیم (decimal)myFloat.decimal در مدلهای دادهای با نیاز به دقت اعشاری بالا.