مقاله فنی

تقاطع ضمنی نام‌های تعریف‌شده در HotXLS برای Delphi

یک نام تعریف‌شده که به یک ستون کامل ارجاع می‌دهد، وقتی در موقعیت scalar بیاید از نظر اکسل یک سلول تک است: =Vertical+1 در سطر 7 یعنی «سلول سطر 7 از Vertical»، نه کل ناحیه. کامپوننت HotXLS Delphi این تقاطع ضمنی را در v2.382.4 در دو لایه اعمال می‌کند، هم هنگام ارزیابی و هم هنگام استخراج وابستگی، چون یک قالب وام با 4805 فرمول نشان داد که درست درآوردن مقدار کافی نیست. وقتی dependency walker نام را به کل ناحیه‌اش باز می‌کند، فرمولی در پایین‌دست که هر سلولی از آن ناحیه را تغذیه می‌کند یک حلقهٔ دوری می‌بندد که وجود ندارد، و TXLSXWorkbook.Recalculate کل workbook را رد می‌کند

قالب مورد بحث یک workbook استاندارد استهلاک وام است. با مسموم کردن همهٔ مقدارهای cache‌شده به 777 و اجرای یک Recalculate کامل، هر دو معماری موتور مقدار 23 را برگرداندند که همان lxErrorRef است، یعنی کد مرجع دوری. 3842 فرمول از آن 4805 فرمول با انتظار مستقل مطابقت نداشت، B18 مقدار #VALUE! داشت، E18 هنوز 777 بود و تعداد پرداخت‌ها در J7 همان placeholderهای داخل ستون balance را که هنوز کامل نشده بود خوانده بود. سه نقص جدا پشت یک کد بازگشتی پنهان شده بودند و این مقاله یکی‌یکی با سورسی که درستشان کرد از آن‌ها می‌گذرد

چرا یک ارجاع scalar به نام یک ستون حلقهٔ دوری کاذب می‌سازد؟

چون یک گراف وابستگی فقط یال می‌شناسد، و یک یال از یک فرمول به ناحیه‌ای با 480 سطر یعنی 480 یال که یکی از آن‌ها از طریق سلولی که به خود فرمول وابسته است برمی‌گردد. =IF(TRUE,Vertical+1,0) را در B1 در نظر بگیر با Vertical تعریف‌شده به‌صورت Inputs!$A$1:$A$2، و =B1+1 در A2. اکسل B1 را A1+1 و A2 را B1+1 ارزیابی می‌کند، یک زنجیرهٔ مستقیم. walkerی که B1 را وابسته به A1:A2 ثبت کند A2 را پیشین B1 می‌کند، در حالی که A2 از قبل B1 را به‌عنوان پیشین فهرست کرده، و صف Kahn که محاسبهٔ دوبارهٔ افزایشی در HotXLS را می‌چرخاند هرگز نمی‌بیند هیچ‌کدام از دو node به in-degree صفر برسند. قالب‌های وام از همین الگو ساخته شده‌اند: هر سطر دوره به نام‌های ستون برای balance و نرخ و تعداد پرداخت‌ها ارجاع می‌دهد، هر نام کل برنامه را پوشش می‌دهد و هر سطر هم در همان ستون‌ها می‌نویسد. نام‌ها را باز کن و گراف به یک مؤلفهٔ قویاً همبند غول تبدیل می‌شود. با تقاطع ضمنی ارزیابی‌شان کن و گراف به مجموعه‌ای از زنجیره‌های کوتاه تبدیل می‌شود، یکی به ازای هر سطر، که همان چیزی است که ECMA-376 Part 1 §18.17.2 برای یک عملوند reference که جایی مصرف می‌شود که یک مقدار تکی لازم است توصیف می‌کند

چرا یک نام ستون در HotXLS یک حلقهٔ دوری کاذب بست: با Vertical تعریف‌شده به‌صورت Inputs!$A$1:$A$2، walker مقدار B1 را وابسته به A1:A2 ثبت می‌کند در حالی که A2 از قبل B1 را به‌عنوان پیشین فهرست کرده، پس صف Kahn هرگز تخلیه نمی‌شود، در حالی که تقاطع B1 را به سلول همان سطر یعنی A1 محدود می‌کند و زنجیرهٔ هر-سطر A2 و B1 و A1 را که Recalculate مرتب می‌کند حفظ می‌کند
باز کردن نام، گراف را به یک مؤلفهٔ قویاً همبند غول تبدیل می‌کرد، و ارزیابی همان فرمول‌ها با تقاطع ضمنی آن را به زنجیره‌های کوتاه تبدیل می‌کند، یکی به ازای هر سطر برنامه
var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Inputs');
    Book.DefinedNames.Add('Vertical', 'Inputs!$A$1:$A$2');
    Book.DefinedNames.Add('Alias', '=Vertical');
    Sheet.Cells[1, 1].Value := 1;
    // موقعیت scalar: Vertical به A1 فرومی‌ریزد چون فرمول در سطر 1 است
    Sheet.Cells[1, 2].Formula := '=IF(TRUE,Vertical+1,0)';
    Sheet.Cells[2, 1].Formula := '=B1+1';
    // نامی که تعریفش نام دیگری است هم تقاطع می‌خورد، پس این همان A2 است
    Sheet.Cells[2, 2].Formula := '=Alias';
    // آرگومان از کلاس reference: کل ناحیه جمع می‌شود، بدون تقاطع
    Sheet.Cells[3, 2].Formula := '=SUM(Vertical)';
    // سطر 6 بیرون A1:A2 است، تقاطع خالی می‌شود و IFERROR می‌گیردش
    Sheet.Cells[6, 2].Formula := '=IFERROR(Vertical,42)';

    if Book.Recalculate = lxOk then
    begin
      // B1 = 2, A2 = 3, B2 = 3, B3 = 4, B6 = 42
      // پیش از v2.382.4 این شاخه دست‌نیافتنی بود: B1 -> A2 -> B1 یک حلقهٔ دوری بود
    end;
  finally
    Book.Free;
  end;
end;

HotXLS چطور تشخیص می‌دهد یک آرگومان scalar است؟

HotXLS جواب را از جدول توابع می‌خواند، نه از شکل آرگومان. هر entry در TXLSFormula.InitFuncHash از طریق THashFunc.SetValue با یک رشتهٔ کلاس اختیاری برای هر آرگومان ثبت می‌شود: 'IF' رشتهٔ '100' را حمل می‌کند، 'SUMIF' رشتهٔ '010'، 'VLOOKUP' رشتهٔ '1011'، و 'SUM' هیچ، پس همهٔ آرگومان‌هایش به کلاس 0 در سطح تابع برمی‌گردند. متد جدید TXLSFormula.FunctionArgumentClass(APtg, AArgument) آن بایت را از طریق THashFuncEntry.ArgClass در دسترس می‌گذارد، و نتیجهٔ 1 یعنی کلاس value. این‌ها همان سه کلاسی هستند که [MS-XLS] §2.2.2 به operand tokenها نسبت می‌دهد، و encoder از قبل به آن‌ها وابسته بود: وقتی یک reference می‌نویسد ptg را به‌صورت $24 + $20 * aClass حساب می‌کند که برای کلاس 0 PtgRef و برای کلاس 1 PtgRefV و برای کلاس 2 PtgRefA می‌دهد. فایل BIFFی که اکسل می‌نویسد آن کلاس را در هر reference token ذخیره می‌کند، پس موتوری که جدولش با spec بخواند می‌تواند بدون نگاه کردن به داده جواب بدهد که آیا این آرگومان scalar است. آرگومان میانی SUMIF همان criterion است، یک مقدار؛ اولی و سومی ناحیه‌اند، reference. SUMPRODUCT با کلاس 2 در سطح تابع ثبت شده، یعنی آرایه، و همین دلیل این است که =SUMPRODUCT(Vertical,Vertical) باز هم کل ناحیه را ضرب می‌کند

سه تابع برای هر چیزی بعد از آرگومان اول به entry جدول خودشان مراجعه نمی‌کنند. IF (ptg 1) و CHOOSE (ptg 100) و IFERROR (ptg 255) هر چه انتخاب کنند را عبور می‌دهند، پس آرگومان‌های شاخه‌شان کلاس همان موقعیتی را به ارث می‌برند که خود تابع در آن نشسته. همین یک قاعده است که باعث می‌شود =CHOOSE(1,Vertical,0) در G2 به A2 برسد در حالی که =SUMIF(Vertical,">0",Vertical) کنارش باز هم هر دو سطر را جمع می‌کند، و همان قاعده‌ای است که یک برنامهٔ استهلاک بیشتر از همه تمرینش می‌کند، چون سلول‌های دوره‌اش به IF تکیه می‌کنند تا بفهمند وام هنوز باز است یا نه

HotXLS کلاس‌های آرگومان را برای تقاطع ضمنی از کجا می‌خواند: IF مقدار 100 را ثبت می‌کند، SUMIF مقدار 010، VLOOKUP مقدار 1011 و SUM هیچ، پس آرگومان‌هایش به کلاس 0 برمی‌گردند، encoder توکن‌های reference را به‌صورت ptg با $24 به‌علاوهٔ $20 ضربدر کلاس می‌نویسد که PtgRef و PtgRefV و PtgRefA را تولید می‌کند، و توابع عبوردهندهٔ IF و CHOOSE و IFERROR کلاس موقعیتی که اشغال کرده‌اند را به ارث می‌برند
چون جدول کلاس با spec می‌خواند، موتور می‌تواند بدون نگاه کردن به داده جواب بدهد که آیا یک آرگومان scalar است، و رسیدن CHOOSE به A2 در کنار یک SUMIF که هر دو سطر را جمع می‌کند از همان یک قاعده می‌آید

حمل کلاس در پیمایش وابستگی

استخراج‌کنندهٔ وابستگی در lxCalc.pas یک Walk بازگشتی روی درخت نحو کامپایل‌شده است و دو بار وجود دارد، یک بار در TXLSCalculator.ExtractDependencies برای گراف درون-workbook و یک بار در ExtractWorkspaceDependencies برای گراف بین-workbook. نسخهٔ v2.382.4 به هر دو walker دو پارامتر اضافه می‌کند. AScalar در ریشهٔ فرمول با True شروع می‌شود، برای هر فرزند تابع از FunctionArgumentClass از نو حساب می‌شود، و برای آرگومان‌های شاخهٔ ptg 1 و 100 و 255 بدون تغییر عبور داده می‌شود. ANameRoot فقط وقتی True می‌شود که walker به تعریف کامپایل‌شدهٔ یک نام پایین می‌رود، و فقط از طریق nodeهای SA_GROUP یعنی پرانتزها زنده می‌ماند، پس نامی که به‌صورت =A1:A2+1 تعریف شده با یک ناحیهٔ ساده اشتباه گرفته نمی‌شود. وقتی هر دو پرچم در یک node از نوع SA_RANGE مقدار True داشته باشند، AddResolvedRange ناحیه را با همان helperی که evaluator استفاده می‌کند محدود می‌کند، پیش از آنکه وابستگی را ثبت کند. helper آن‌قدر کوتاه است که کامل نقل شود

تصمیم IntersectNamedScalarRange که از وابستگی‌های نام در HotXLS محافظت می‌کند: ناحیه‌ای که از قبل یک سلول است عبور می‌کند، یک ستون تکی وقتی CurRow داخلش بیفتد به سطر فرمول محدود می‌شود، یک سطر تکی به ستون فرمول محدود می‌شود، و هر چیز دیگر یعنی یک ناحیهٔ دوبعدی یا سطری بیرون از محدوده هنگام ارزیابی #VALUE! می‌دهد و اصلاً هیچ وابستگی‌ای ثبت نمی‌کند
هم هر دو walker وابستگی و هم evaluator همان helper را صدا می‌زنند، پس مقداری که یک فرمول می‌خواند و یالی که گراف ثبت می‌کند هرگز نمی‌توانند دربارهٔ یک نام تقاطع‌خورده با هم اختلاف داشته باشند
function IntersectNamedScalarRange(CurRow, CurCol: Integer;
  var Row1, Row2, Col1, Col2: Integer): Boolean;
begin
  Result := False;
  if (Row1 = Row2) and (Col1 = Col2) then Exit(True);   // از قبل یک سلول است
  if (Col1 = Col2) and (CurRow >= Row1) and (CurRow <= Row2) then
  begin
    Row1 := CurRow; Row2 := CurRow;                     // ستون تکی: همین سطر را بردار
    Exit(True);
  end;
  if (Row1 = Row2) and (CurCol >= Col1) and (CurCol <= Col2) then
  begin
    Col1 := CurCol; Col2 := CurCol;                     // سطر تکی: همین ستون را بردار
    Result := True;
  end;
end;

هر چیزی که helper رد کند، یعنی یک ناحیهٔ دوبعدی یا یک reference چندکاربرگی یا فرمولی که سطرش بیرون ستون نام‌گذاری‌شده است، در سمت ارزیابی #VALUE! تولید می‌کند و در سمت گراف اصلاً هیچ وابستگی‌ای، که همان کاری است اکسل برای تقاطع خالی می‌کند. سمت ارزیابی در TXLSCalculator.GetValueItemName زندگی می‌کند: پوشش‌های SA_GROUP را از تعریف کامپایل‌شده برمی‌دارد و اگر ریشه یک SA_RANGE باشد GetRangeInfo را صدا می‌زند، تقاطع می‌دهد و همان یک سلول را از طریق FGetValue می‌گیرد به جای اینکه کل تعریف را ارزیابی کند. referenceهای خارجی روی مسیر قدیمی می‌مانند، چون سطری محلی برای تقاطع دادن وجود ندارد. اینکه محل ذخیره و اسکوپ یک نام اول از کجا می‌آید در مقالهٔ نام‌های تعریف‌شده و فرمول‌های بین-کاربرگی پوشش داده شده؛ نکتهٔ این‌جا فقط این است که موتور وقتی نام resolve شد چه می‌کند

چرا MATCH روی ستونی که نصفه محاسبه شده بود 777 خواند؟

چون آرگومان lookup-array در MATCH یک scan reference است، و scan referenceها عمداً از ترتیب ارزیابی کنار گذاشته شده بودند. مقالهٔ lookup scan ساختار TXLSDepRange.LookupScan را معرفی کرد و با بخشی به نام «آن‌چه با کنار گذاشتن یال‌های scan از ترتیب‌دهی از دست می‌دهید» تمام شد: یک فرمول lookup ممکن است پیش از آنکه همهٔ سلول‌های بازه‌اش دوباره محاسبه شده باشند اجرا شود و مقدارهای کهنه بخواند. در یک نشست تعاملی این در pass بعدی همگرا می‌شود. در محاسبهٔ دوبارهٔ دسته‌ای یک قالب مسموم نمی‌شود، و PaymentCount که به‌صورت =MATCH(0.01,Balances,-1)+1 تعریف شده بود، همان 777های placeholder را که هنوز در ستون balance نشسته بودند خواند و یک تعداد دوره برگرداند که نمی‌توانست درست باشد

حالا TXLSDepGraph.TopoOrder با یال‌های scan مثل یال‌های ترتیب‌دهی نرم رفتار می‌کند. کنار in-degree سخت یک آرایهٔ ScanInDeg نگه می‌دارد که پیشین‌های scan کثیف را به ازای هر node می‌شمارد و هم‌زمان با امیت شدن آن پیشین‌ها کمش می‌کند، با استفاده از فهرست‌های ScanPrecedents و ScanDependents و ScanPrecedentCount که تغییر قبلی از قبل ذخیره‌شان کرده بود. در هر تکرار، صف Kahn پنجرهٔ آماده‌اش را برای اولین nodeی که ScanInDegش صفر است می‌پیماید و آن را به سر صف جابه‌جا می‌کند؛ اگر همهٔ nodeهای آماده هنوز منتظر یک پیشین scan باشند، سر صف به همان ترتیب پایدارش pop می‌شود. یال‌های scan هرگز به in-degree سخت وارد نمی‌شوند، پس یک VLOOKUP خودارجاع روی ستون خودش همچنان مجاز است، ولی lookupی که می‌توانست منتظر یک پیشین قابل‌تمام‌شدن بماند حالا واقعاً منتظر می‌ماند. رگرسیونی که این را تثبیت می‌کند، LookupScan_WaitsForDirtyFormulaValues، سه سلول balance را به 777 مسموم می‌کند و انتظار دارد PaymentCount مقدار 3 برگردد، بعد ورودی را به صفر می‌چرخاند و انتظار دارد =IFERROR(PaymentCount,99) آن #N/A را ببیند و 99 برگرداند

این برش چهار-اعشاری از کجا آمد؟

از حساب Variant در Delphi، و فقط در موقعیت‌های تودرتو. عملگرهای دودویی در TXLSCalculator.GetValueItem از قبل یک + یا - سطح-بالا را در دو متغیر محلی Double کپی می‌کردند، پس =B1-A1 سالم بود. داخل =IF(TRUE,B1-A1,0) همان تفریق به‌صورت Value := Value - SubValue روی دو Variant اجرا می‌شد، و وقتی یک عملوند مقدار یک سلول از نوع Int64 و دیگری یک Double بود، نتیجه‌ای که دیدیم یک Currency بود، یک نوع fixed-point با چهار رقم اعشار، پس 1066.1854641400994 منهای 120 برش‌خورده به چهار اعشار برگشت. در برنامه‌ای که هر پرداختش از سطر قبلی مرکب می‌شود، این خطا پیش از آنکه به مجموع‌ها برسد از صدها دوره می‌گذرد

// TXLSCalculator.GetValueItem، شاخهٔ حساب دودویی (lxCalc.pas)
if VarIsNull(Value) then Value := 0;
if VarIsNull(SubValue) then SubValue := 0;
// حساب Variant مخلوط Int64/Double می‌تواند به Currency ارتقا پیدا کند
// حساب صفحه‌گسترده باید دقت ممیز شناور را حفظ کند
if VarIsNumeric(Value) then Value := Double(Value);
if VarIsNumeric(SubValue) then SubValue := Double(SubValue);

این محافظ پیش از SA_ADD و SA_SUB و SA_MUL و SA_DIV یکسان اجرا می‌شود، و رگرسیون Arithmetic_MixedInt64AndDoubleKeepsPrecision مقدار Int64(120) را در A1 و 1066.1854641400994 را در B1 می‌گذارد، بعد تفریق و جمع تودرتو را تا 1E-10 و ضرب و تقسیم را تا 1E-8 و 1E-12 بررسی می‌کند. HotXLS ادعا نمی‌کند که هر قاعدهٔ ارتقایی را که RTL روی نوع‌های Variant مخلوط در نسخه‌های مختلف کامپایلر اعمال می‌کند می‌شناسد؛ ادعا می‌کند که حساب صفحه‌گسترده یعنی double استاندارد IEEE، و حالا هر دو عملوند را پیش از آنکه عملگر ببیندشان double می‌کند، که خود سؤال را حذف می‌کند

این fix چه چیزی را تضمین می‌کند و چه چیزی را نه

بعد از v2.382.4 هر دو معماری موتور برای قالب مسموم lxOk برمی‌گردانند، هر 4805 مقدار cache‌شده تا 1E-7 با انتظار مستقل سطر-به-سطر مطابقت دارند، و هم آن assertionهایی که می‌گویند cacheها واقعاً مسموم شده بودند و هم آن‌که هش منبع تغییر نکرده و همهٔ فرمول‌ها همچنان حاضرند برقرارند. هیچ iterationی فعال نشد و هیچ کد خطایی برای رسیدن به این‌جا سرکوب نشد. یک حلقهٔ دوری واقعی از طریق یک نام، یعنی =B1 در A1 در حالی که B1 هنوز Vertical را می‌خواند، باز هم خطا برمی‌گرداند، و تست NamedScalarRanges_IntersectWithoutFalseCycles با تأکید بر همین تمام می‌شود

ارزش دارد مرزها را صریح بگوییم. تقاطع ضمنی فقط به نامی اعمال می‌شود که تعریف کامپایل‌شده‌اش بعد از برداشتن پرانتزها، ناحیه‌ای تک‌ستونی یا تک‌سطری روی یک sheet باشد؛ یک نام دوبعدی در موقعیت scalar مثل اکسل #VALUE! است، و تابعی که جدول نمی‌شناسدش از FunctionArgumentClass کلاس 0 می‌گیرد، پس آرگومان‌های نامش همچنان کامل باز می‌شوند. ترتیب‌دهی نرم یک ترجیح است نه یک تضمین: یک حلقهٔ دوری فقط-scan باز هم به ترتیب پایدار ارزیابی می‌شود و هر چه cache شده باشد را می‌خواند، که همان رفتاری است که مقالهٔ lookup scan عمداً پذیرفت. و نتیجهٔ کل-قالب در برابر یک اسکریپت انتظار مستقل تأیید شده، نه در برابر یک موتور صفحه‌گستردهٔ دیگر، چون مجموعهٔ اداری مرجع در بودجهٔ 60 ثانیه‌ای موفق نشد محاسبهٔ دوبارهٔ قالب اصلی را تمام کند. HotXLS یک کامپوننت صفحه‌گستردهٔ بومی Delphi و C++Builder است که XLS و XLSX و ODS و CSV را بدون نصب اکسل می‌خواند و دوباره محاسبه می‌کند و می‌نویسد؛ تقاطع نام و جدول کلاس آرگومان و ترتیب‌دهی نرم scan روی هر فرمتی اعمال می‌شوند چون موتور محاسبه مشترک است، و پوشش فعلی توابع در صفحهٔ محصول کامپوننت صفحه‌گستردهٔ HotXLS برای Delphi فهرست شده