کد Delphi در Win64 میتواند جایی شکست بخورد که همان سورس روی Win32 تمیز اجرا میشود، و کامپوننت HotPDF Delphi PDF در یک گذر سختسازی اخیر پنج چنین موردی را خورد: Power(10, N) که به سربارگذاری Single bind میشد، یک حلقهٔ while که یک TList.Count کهنه میخواند، یک کران High(Int64) که به 2^63 گرد میشود، متن float پانزدهرقمی روی FPC، و assertهای تست که کامپایل را متوقف میکردند
اگر فقط Win32 را بیلد و تست کنی هیچکدام پیدایش نمیشود، و دقیقاً همینطور داخل شدند. موارد پایین از importerهای SVG و XPS یعنی HotPDF و renderer صفحهاش و خوانندهٔ JSON کارهایش میآیند و نتایج عددی نقلشده با برنامههای اکتشافی کوچک بیلدشده برای Win32 و Win64 بازتولید شدند. اگر در حال بردن یک کدبیس Delphi به 64 بیتی هستی، هر کدام یک grep لایق است
چرا Power(10, 100) فقط روی Win64 سرریز میکند؟
روی Win64، System.Math.Power(10, N) با آرگومانهای عدد صحیح به سربارگذاری Single حل میشود، پس نتیجه در دقت تکی محاسبه و برگردانده میشود و هر چیز بالای حدود 3.4E38 سرریز میکند. روی Win32 همان فراخوانی به سربارگذاری Extended bind میشود و روی FPU یعنی x87 با دقت 80 بیتی اجرا میشود، پس Power(10, 100) بهسادگی 1E100 است
System.Math Power را برای Extended و Double و Single declare میکند بهعلاوهٔ یک خانوادهٔ منطبق IntPower که Power وقتی توان یک عدد صحیح کامل است صدا میزند. روی Win64 Extended فقط یک نام مستعار برای Double است (SizeOf(Extended) = 8) و برای دو آرگومان عدد صحیح کامپایلر نسخهٔ Single را انتخاب میکند. نشانه دقت است نه فقط سرریز: روی Win64 Power(10, 20) مقدار 1.0000000200408773E20 برمیگرداند که دقیقاً Single(1E20) است. یک نتیجهٔ Double بهشکل 1E20 چاپ میشد. همان bind را با هر کامپایلر Win64 ای که امتحان کردیم دیدیم، از Delphi 10.3 تا نسخهٔ کامپایلر 37.0
بعدش چه میشود به ماسک استثناهای ممیز شناور وابسته است. Delphi 12 و بعدتر بهطور پیشفرض همهٔ استثناهای ممیز شناور را ماسک میکنند، پس سرریز بیصداست: Power(10, 100) مقدار +Inf و Power(10, -100) مقدار 0 برمیگرداند. Delphi 11 و قبلتر exOverflow را ماسکنشده میگذارند و همان فراخوانی EOverflow بالا میدهد. اپهایی که خودشان ماسک را ست میکنند و DLLهایی که داخل چنین میزبانهایی load میشوند هر رفتاری را که میزبان انتخاب کرده میگیرند، و برای همین یک کتابخانه نمیتواند هیچکدام از دو نتیجه را فرض کند
uses
System.SysUtils, System.Math;
procedure ShowPowerOverload;
var
N: Integer;
OldMask: TArithmeticExceptionMask;
begin
N := 20;
// Win32 مقدار 1E20 را چاپ میکند؛ Win64 مقدار 1.0000000200408773E20 (سربارگذاری Single)
Writeln(FloatToStrF(Power(10, N), ffGeneral, 17, 0));
// بازتولید رفتار Delphi 11 یا میزبانی با تنظیمات FP سختگیرانه
OldMask := GetExceptionMask;
SetExceptionMask(OldMask - [exOverflow, exInvalidOp]);
try
N := 100;
Writeln(Power(10, N)); // Win64: EOverflow؛ Win32: 1E100
finally
SetExceptionMask(OldMask);
end;
end;
ماسکبرداشتن از exOverflow و exInvalidOp برای مدت یک تست ارزانترین راه دیدن چیزی است که یک کامپایلر قدیمیتر یا میزبان سختگیر میبیند. روی کامپایلر مدرن با تنظیمات پیشفرض باگ crash نمیکند، بینهایتها و صفرها تولید میکند و آنها در یک لاگ تست بهمراتب سختتر دیده میشوند. ماسک قبلی را در finally برگردان: ماسک state بهازای هر thread است و بقیهٔ اجرای تست هرچه را که جا گذاشتهای به ارث میبرد
سربارگذاری چطور به import یعنی SVG و XPS در HotPDF رسید
خوانندههای مسیر یعنی SVG و XPS در HotPDF یک اسکنر عدد مشترک دارند و آن اسکنر وقتی توانی خوانده بود مانتیس را با Power(10, Exponent) مقیاس میکرد. پس هر SVG ای که به THotPDF.ImportSVGFormXObject داده میشد (نقطهٔ ورود پشت import کردن SVG به PDF بهشکل form XObjectهای قابلاستفادهٔ دوباره) و هر هندسهٔ مسیری که حین تبدیل XPS و OpenXPS به PDF هندل میشد میتوانست مختصاتی مثل 1e100 یا 5e99 به آن فراخوانی بدهد
v2.770.91 از قبل توان را روی 100 کپ کرده بود و مقادیری را که از 1E300 رد میشدند رد میکرد، که به اندازهٔ کافی به نظر میرسید: 1E100 به هیچجای نزدیک کران Double یعنی حدود 1.8E308 نیست. روی Win64 همچنان سرریز میشد، چون محاسبه اصلاً در Double اتفاق نمیافتاد. از v2.770.155 اسکنر توان ده را خودش میسازد و عددهایی مثل 1e-100 یا مانتیسی بلند با توانی منفی بزرگ بهشکل مقدار واقعیشان خوانده میشوند بهجای فروپاشی به 0
یک توان ده امن برای توانهای کپشده
وقتی توان کپ شده، امنترین توان ده آنی است که خودت با ضرب در Double بسازی. یک حلقهٔ حداکثر 100 ضربی کنار اسکن کردن متن اطرافش هیچ هزینهای ندارد، هرگز واسطی بزرگتر از مقیاس نهایی تولید نمیکند و روی Win32 و Win64 و Free Pascal یکسان رفتار میکند
const
MaxDecimalExponent = 100;
function TryScaleByPowerOf10(const Value: Double; Exponent: Integer;
out Scaled: Double): Boolean;
var
Scale: Double;
I: Integer;
begin
Scaled := 0;
Result := False;
if (Exponent < -MaxDecimalExponent) or (Exponent > MaxDecimalExponent) then
Exit;
// نتایجی که از بازهٔ Double بیرون میزنند را رد کن
if (Exponent > 0) and (Value <> 0) and
(Log10(Abs(Value)) + Exponent > 300) then
Exit;
Scale := 1.0;
for I := 1 to Abs(Exponent) do
Scale := Scale * 10.0; // هرگز از 1E100 عبور نمیکند
if Exponent >= 0 then
Scaled := Value * Scale
else
Scaled := Value / Scale; // تقسیم: 1E-100 هیچ Double دقیقی ندارد
Result := True;
end;
سه جزئیات بار را حمل میکنند. چک بازه از دو مقایسه استفاده میکند نه Abs(Exponent) <= 100، چون Abs(Low(Integer)) همچنان منفی است و مستقیم از آن رد میشد. توانهای منفی بر مقیاس تقسیم میکنند بهجای ضرب در یک 1E-100 از پیش محاسبهشده، که هیچ Double دقیقی ندارد و یک گام گرد کردن اضافه میگذاشت. و پیشچک یعنی Log10 نتایج بیرون بازهٔ Double را قبل از اینکه ضرب فرصت سرریز پیدا کند رد میکند
روشن باش که حلقه چه چیزی را قربانی میکند. توانهای ده تا 1E22 در Double دقیقاند؛ بعد از آن هر ضرب گرد میکند و بعد از 100 تای آن مقیاس چند واحد در آخرین خانه با 1E100 گردشدهٔ درست فاصله دارد. برای مختصات رسم نامرئی است. برای یک تبدیل همهمنظورهٔ متن-به-double که باید هر مقدار را بیتبهبیت بازتولید کند کافی نیست و به یک الگوریتم تبدیل درستگرد به جای آن نیاز داری
وقتی dcc64 یک TList.Count کهنه را داخل یک حلقهٔ while میخواند
دیدیم کامپایلر Win64 یعنی (dcc64، نسخهٔ کامپایلر 37.0) برای حلقهٔ while List.Count > Start do کدی تولید کرد که از انتهای فهرست حذف میکرد و بهجای خواندن دوبارهٔ Count با یک موقت روی پشته مقایسه میکرد. بازنویسیای که فیکسش کرد یک حلقهٔ for ... downto بود که کرانهایش طبق تعریف دقیقاً یک بار ارزیابی میشوند
حلقه در v2.769.3 آمد که کد گروه شفافیت renderer را یاد داد ماسکهای نرم ساختهشده داخل یک گروه را در سراسر یک رندر دومرحلهای زنده نگه دارد و بعداً آزادشان کند. پاکسازی داخل یک بلوک finally بعد از یک حلقهٔ for یک یا دومرحلهای، داخل حلقهٔ بهازای هر tile بود. کاهشیافته به شکلش، قبل و بعد چنین به نظر میرسند:
// شکلی که دیدیم dcc64 (نسخهٔ کامپایلر 37.0) اشتباه کامپایل کرد
procedure DropMasksWhile(Masks: TList; Start: Integer);
begin
while Masks.Count > Start do
begin
TObject(Masks[Masks.Count - 1]).Free;
Masks.Delete(Masks.Count - 1);
end;
end;
// جایگزین: کرانها یک بار ارزیابی میشوند، هیچ موقتی برای کهنه شدن نیست
procedure DropMasksFrom(Masks: TList; Start: Integer);
var
Idx: NativeInt; // TList.Count از Delphi 12 یعنی NativeInt است
begin
for Idx := Masks.Count - 1 downto Start do
begin
TObject(Masks[Idx]).Free;
Masks.Delete(Idx);
end;
end;
در کد Win64 تولیدشده، Count داخل شرط حلقه و Count خواندهشده داخل بدنه یک خانهٔ پشته را شریک بودند. شرط در ورود، قبل از اینکه چیزی آن را نوشته باشد، نسبت به آن خانه مقایسه میکرد و هیچ چیز بعد از Delete آن را تازه نمیکرد. وقتی گروهی ماسک نرم خودش را نساخته بود، بدنه به هر حال اجرا میشد و از یک فهرست خالی آیتم -1 را میخواست، پس در بیلدهای 64 بیتی هر صفحهٔ حامل چنین گروه شفافیتی با EListError شکست میخورد. کد Win32 برای همان سورس درست بود و v2.770.1 حلقه را جایگزین کرد
این را به یک بازتولید حداقلی نکاهشاندهایم و یک حلقهٔ مستقل کوچک مثل DropMasksWhile خیلی ممکن است درست کامپایل شود؛ try/finally اطراف و حلقههای تودرتو به نظر میرسد مهماند. با آن بهشکل کدتولیدی که روی یک نسخهٔ کامپایلر دیدیم رفتار کن، نه بهشکل یک عیب شناختهشدهٔ هر کامپایلر Win64. درس عملی ارزانتر از ریشهٔ اصلی است: حلقهای که شرطش شمارش یک مجموعه را در حالی که بدنه آن مجموعه را کوچک میکند دوباره میخواند لایق بازنویسی به یک for ... downto با کران ثابت است، و تغییرات renderer به یک اجرای کامل تست Win64 نیاز دارند نه فقط Win32
مکانیابی crash ای که فقط یک بیلد بهینهشدهٔ Win64 نشان میدهد
خرابی فقط در بیلد بهینهشدهٔ Win64 بازتولید میشد، پس مکان از ابزارهای بیرون IDE آمد. یک برنامهٔ اکتشافی کوچک یک هندلر استثنای برداری با AddVectoredExceptionHandler ثبت میکرد، پشته را روی اولین استثنا با RtlCaptureStackBackTrace میگرفت و آدرسهای بازگشت را با استفاده از فایل map مفصل که لینکر با -GD مینویسد به نام توابع ترجمه میکرد. دیاسمبل کردن آن تابع بعد نشان داد مقایسه یک خانهٔ پشته یعنی [rbp+0x298] را میخواند که فقط داخل بدنهٔ حلقه نوشته میشد. همان سطح شواهد است که قبل از سرزنش یک کامپایلر میخواهی و کمتر از قدم زدن در یک بیلد release وقت گرفت
چرا High(Int64) یک کران بالای امن برای یک Double نیست؟
یک Double نمیتواند High(Int64) را نمایش دهد: تبدیل 9223372036854775807 به Double دقیقاً به 2^63 گرد میشود، یکی بعد از بزرگترین Int64. روی Win64 آن تبدیل داخل خود مقایسه اتفاق میافتد، پس D <= High(Int64) برای D = 2^63 مقدار True است و Round یا Trunc بعدی سرریز میکند
Win32 این را به همان دلیلی پنهان میکند که مشکل Power را پنهان میکرد. مقایسه در دقت Extended یعنی 80 بیتی با مانتیس 64 بیتی اجرا میشود که در آن High(Int64) دقیق است و 2^63 درست بزرگتر مقایسه میشود. Win64 نوع عریضتری برای پناه گرفتن ندارد. تبدیل خارج از بازه هم زیبا نیست: در تستهای Win64 ما Round(2^63) مقدار Low(Int64) را برمیگرداند، یک وارونگی علامت بیصدا، چه exInvalidOp ماسک شده باشد چه نه. Win32 وقتی ماسک باشد همان مقدار را برمیگرداند و وقتی ماسک نباشد EInvalidOp بالا میدهد
| عبارت | Win32 | Win64 |
|---|---|---|
Power(10, N) با N = 20 | 1E20 | 1.0000000200408773E20 |
Power(10, 100)، استثناها ماسکشده (پیشفرض Delphi 12 به بعد) | 1E100 | +Inf |
Power(10, 100)، exOverflow ماسکنشده | 1E100 | EOverflow |
D <= High(Int64) با D = 2^63 | False | True |
Round(2^63)، exInvalidOp ماسکنشده | EInvalidOp | Low(Int64) |
HotPDF این را در خوانندهٔ JSON پشت مقادیر کار سندش دید. JSON هیچ محدودیت بازهای روی اعداد نمیگذارد و سریالایزر قدیمی هر مقداری با Frac(Value) = 0 را با Round به یک عدد صحیح تبدیل میکرد، پس یک 1e19 کاملاً قانونی یا به یک عدد صحیح اشتباه یا به یک exception تبدیل میشد، بسته به ماسک. از v2.770.169 یک عدد کامل فقط وقتی بهشکل عدد صحیح نوشته میشود که در Int64 جا شود، هر چیز دیگری متن ممیز شناورش را نگه میدارد و getterهای عدد صحیح برای مقادیر خارج از بازه پیشفرض فراخواننده را برمیگردانند بهجای یک مقدار پیچیدهشده
const
TwoPow63 = 9223372036854775808.0; // 2^63، دقیق در Double و Extended
function TryDoubleToInt64(const Value: Double; out R: Int64): Boolean;
begin
R := 0;
Result := not IsNan(Value) and not IsInfinite(Value) and
(Frac(Value) = 0) and (Value >= -TwoPow63) and (Value < TwoPow63);
if Result then
R := Trunc(Value);
end;
function JsonNumberText(const Value: Double): string;
var
R: Int64;
begin
// فراخوانندهها اول NaN و بینهایتها را رد میکنند: JSON هیچ املایی برایشان ندارد
if TryDoubleToInt64(Value, R) then
Result := IntToStr(R)
else
begin
{$IFDEF FPC}
Str(Value:24, Result); // ffGeneral در FPC Win64 روی 15 رقم میایستد
Result := Trim(Result);
{$ELSE}
Result := FloatToStrF(Value, ffGeneral, 17, 0, TFormatSettings.Invariant);
{$ENDIF}
end;
end;
کران بالا همان literal یعنی 9223372036854775808.0 با یک < اکید است. آن ثابت 2^63 است، دقیق در هر دو Double و Extended، پس مقایسه روی هر پلتفرمی همان معنا را دارد. کران پایین میتواند از >= استفاده کند چون -2^63 دقیقاً Low(Int64) است. آزمودن اول IsNan و IsInfinite با ارزیابی مبتنی بر اتصال کوتاه، NaN و بینهایتها را از Frac و مقایسهها دور نگه میدارد که وقتی میزبان ماسکش نکرده میتوانند EInvalidOp بالا بدهند
تبدیل float-به-متن روی Win64 واقعاً چند رقم به تو میدهد؟
کمتر از آنچه میخواهی، روی دو کامپایلر از سه کامپایلر. FloatToStrF(Value, ffGeneral, 17, 0) یعنی Free Pascal 3.3.1 روی Win64 روی 15 رقم معنادار میایستد، پس 1/3 بهشکل 0.333333333333333 برمیگردد و دو مقدار Double متفاوت میتوانند به متن یکسان سریالایز شوند. Str(Value:24, Text) بهدنبالشدهٔ Trim، 17 رقم معنادار در نماد علمی تولید میکند، 3.3333333333333331E-001 برای همان مقدار، و فارغ از locale همیشه نقطه را بهعنوان جداکنندهٔ اعشار مینویسد. اگر HotPDF روی FPC بخشی از ماتریس بیلدت است، یادداشتهای پشتیبانی HotPDF برای Free Pascal و Lazarus Win64 بقیهٔ تفاوتهای پلتفرم را پوشش میدهد
Delphi درخواست 17 رقمی را میپذیرد اما دو تارگت Delphi همچنان در خروجی اختلاف دارند: FloatToStrF(0.1, ffGeneral, 17, 0) روی Win32 مقدار 0.10000000000000001 و روی Win64 مقدار 0.1 میدهد. RTL یعنی Win64 میتواند یک خطای گرد کردن رقم آخر هم موقع قالببندی و هم موقع parse تولید کند، پس رقمهای بیشتر شکاف را باریک میکند بدون تضمین اینکه هر الگوی بیت یعنی Double از یک رفتوبرگشت متنی جان سالم به در ببرد. مستندات HotPDF چنین وعدهای نمیدهد و تو هم نباید بدهی مگر اینکه یک قالببند و parser درستگرد خودت عرضه کنی. TFormatSettings.Invariant را بده، یا جداکننده را روی نسخههای قدیمیتر Delphi خودت جایگزین کن، تا یک locale آلمانی یا فرانسوی ویرگول داخل JSON ننویسد
چرا Assert.AreEqual روی Win64 کامپایل را متوقف میکند؟
Assert.AreEqual(3, Length(Arr)) روی یک آرایه پویا برای Win32 کامپایل میشود و برای Win64 با E2532 شکست میخورد، «نمیتوان آرگومان نوع generic را از انواع آرگومان متفاوت استنتاج کرد»، چون Length یک آرایه پویا روی Win64 مقدار NativeInt برمیگرداند. با یک literal یعنی Integer در یک سمت و یک NativeInt یعنی 64 بیتی در سمت دیگر، Assert.AreEqual<T> یعنی DUnitX نمیتواند روی یک T واحد بایستد و بیلد متوقف میشود
TList.Count از Delphi 12 همان خطا را راه میاندازد، جایی که property به NativeInt تبدیل شد؛ Delphi 11 همچنان آن را بهشکل Integer declare میکند. Length یک string در هر دو پلتفرم مقدار Integer برمیگرداند و تحتتأثیر نیست، و برای همین خطا در بعضی یونیتهای تست ظاهر میشود و در بعضی نه. آرگومان نوع را صریح بنویس، Assert.AreEqual<NativeInt>(3, Length(Arr))، و پروژهٔ تست را با dcc64 قبل از commit کامپایل کن. مجموعهای که فقط برای Win32 بیلد میشود تا وقتی یک نفر دیگر امتحانش کند به تو نمیگوید بیلد Win64 اش شکسته است
چکلیست port یعنی Win64 برای کد عددی در Delphi
- فراخوانیهای
Power(وIntPower(با آرگومانهای عدد صحیح را جستوجو کن؛ مقادیر از نوعDoubleبده یا توانهای ده کپشده را خودت بساز - تستهای عددی را دستکم یک بار با حذف
exOverflowوexInvalidOpاز طریقSetExceptionMaskاجرا کن، روی هر دو Win32 و Win64 - کران بالای
Int64را بهشکل< 9223372036854775808.0بنویس، هرگز<= High(Int64)نه، و NaN و بینهایتها را قبل از هر مقایسهای رد کن - یک عدد parseشده را فقط به این دلیل که
Fracصفر است بهInt64تبدیل نکن؛ اعداد JSON میتوانند خیلی بزرگتر باشند - حلقههای
whileای را که موقع حذف آیتمها Countرا دوباره میخوانند به حلقههایfor ... downtoبا کران ثابت بازنویسی کن - روی FPC Win64 وقتی بیشتر از 15 رقم معنادار لازم داری از
Str(Value:24, Text)استفاده کن - برای assertهای
LengthوCountازAssert.AreEqual<NativeInt>استفاده کن و تستها را قبل از commit با dcc64 کامپایل کن - بعد از هر تغییری در یک parser یا renderer مجموعه regression کامل را روی Win32 و Win64 اجرا کن، نه فقط یکی از آنها
فیکسهای سمت کتابخانه که اینجا توضیح داده شدند همه از v2.770.169 در HotPDF هستند، پس import یعنی SVG و تبدیل XPS و رندر شفافیت و هندل کردن کار JSON حالا روی Win64 مثل Win32 رفتار میکنند. اگر فایلهای PDF را از Delphi یا C++Builder برای هر دو پلتفرم تولید یا پردازش میکنی، صفحهٔ کامپوننت HotPDF Delphi PDF دانلودها و فهرست کامل قابلیتها را دارد