Bài viết kỹ thuật

Implicit intersection của defined name trong HotXLS Delphi

Một defined name trỏ tới cả một cột được Excel đọc như một ô đơn khi nó xuất hiện ở vị trí scalar: =Vertical+1 ở row 7 nghĩa là “ô row 7 của Vertical”, chứ không phải cả vùng. HotXLS Delphi Component áp implicit intersection đó trong v2.382.4 ở hai tầng, lúc tính giá trị và lúc trích xuất dependency, vì một template cho vay với 4805 công thức cho thấy tính đúng giá trị vẫn chưa đủ. Khi dependency walker mở rộng tên ra toàn vùng, một công thức hạ nguồn ghi vào bất kỳ ô nào của vùng đó sẽ đóng một vòng lặp không hề tồn tại, và TXLSXWorkbook.Recalculate từ chối cả workbook

Template đang nói tới là một workbook khấu hao khoản vay dựng sẵn. Với mọi giá trị cache bị đầu độc thành 777 và một lần chạy Recalculate đầy đủ, cả hai kiến trúc engine đều trả về 23, tức lxErrorRef, mã lỗi tham chiếu vòng. 3842 trong 4805 công thức không khớp với kỳ vọng độc lập, B18 giữ #VALUE!, E18 vẫn là 777, và số kỳ thanh toán ở J7 đã đọc các placeholder trong một cột balance chưa tính xong. Ba lỗi riêng biệt ẩn sau một mã trả về, và bài này đi qua từng cái kèm phần source đã vá nó

Vì sao một tham chiếu scalar tới tên cột tạo ra vòng lặp giả?

Vì đồ thị dependency chỉ biết các cạnh, và một cạnh từ công thức tới vùng 480 dòng là 480 cạnh, một trong số đó trỏ ngược qua một ô phụ thuộc vào chính công thức đó. Thử =IF(TRUE,Vertical+1,0) ở B1 với Vertical định nghĩa là Inputs!$A$1:$A$2, và =B1+1 ở A2. Excel tính B1 thành A1+1 và A2 thành B1+1, một chuỗi thẳng. Một walker ghi B1 phụ thuộc vào A1:A2 sẽ biến A2 thành precedent của B1, trong khi A2 vốn đã liệt B1 là precedent, và hàng đợi Kahn điều khiển tính lại tăng dần trong HotXLS không bao giờ thấy node nào đạt in-degree bằng 0. Đây chính là kiểu cấu trúc mà template cho vay được dựng từ đó: mỗi dòng kỳ hạn tham chiếu các cột có tên cho balance, lãi suất và số kỳ thanh toán, mỗi tên trải khắp lịch trả nợ, và mỗi dòng cũng ghi vào chính các cột đó. Mở rộng các tên ra thì đồ thị là một strongly connected component khổng lồ. Tính chúng bằng implicit intersection thì đồ thị là một tập chuỗi ngắn, mỗi dòng một chuỗi, đúng như ECMA-376 Phần 1 §18.17.2 mô tả cho một operand tham chiếu được dùng ở nơi cần một giá trị đơn

Vì sao một tên cột đóng một vòng lặp giả trong HotXLS: với Vertical định nghĩa là Inputs!$A$1:$A$2, walker ghi B1 phụ thuộc vào A1:A2 trong khi A2 đã liệt B1 là precedent, nên hàng đợi Kahn không bao giờ rút cạn, còn intersection thu hẹp B1 về ô cùng dòng A1 và giữ chuỗi theo dòng A2, B1, A1 mà Recalculate sắp thứ tự
Mở rộng tên ra biến đồ thị thành một strongly connected component khổng lồ, còn tính cùng các công thức đó bằng implicit intersection thì nó thành những chuỗi ngắn, mỗi dòng lịch trả nợ một chuỗi
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;
    // Vị trí scalar: Vertical thu về A1 vì công thức nằm ở row 1
    Sheet.Cells[1, 2].Formula := '=IF(TRUE,Vertical+1,0)';
    Sheet.Cells[2, 1].Formula := '=B1+1';
    // Tên có định nghĩa là một tên khác vẫn intersect, nên đây là A2
    Sheet.Cells[2, 2].Formula := '=Alias';
    // Đối số lớp reference: cả vùng được cộng, không intersect
    Sheet.Cells[3, 2].Formula := '=SUM(Vertical)';
    // Row 6 nằm ngoài A1:A2, giao rỗng và IFERROR bắt được nó
    Sheet.Cells[6, 2].Formula := '=IFERROR(Vertical,42)';

    if Book.Recalculate = lxOk then
    begin
      // B1 = 2, A2 = 3, B2 = 3, B3 = 4, B6 = 42
      // Trước v2.382.4 nhánh này không thể tới được: B1 -> A2 -> B1 là một vòng lặp
    end;
  finally
    Book.Free;
  end;
end;

HotXLS dựa vào đâu để biết một đối số là scalar?

HotXLS đọc câu trả lời từ bảng hàm chứ không từ hình dạng của đối số. Mọi entry trong TXLSFormula.InitFuncHash được đăng ký qua THashFunc.SetValue kèm một chuỗi class tùy chọn cho từng đối số: 'IF' mang '100', 'SUMIF' mang '010', 'VLOOKUP' mang '1011', còn 'SUM' không mang gì, nên mọi đối số của nó rơi về class 0 cấp hàm. Hàm mới TXLSFormula.FunctionArgumentClass(APtg, AArgument) phơi byte đó qua THashFuncEntry.ArgClass, và kết quả bằng 1 nghĩa là lớp value. Đây đúng là ba lớp mà [MS-XLS] §2.2.2 gán cho các token operand, và encoder vốn đã dựa vào chúng: khi ghi một tham chiếu, nó tính ptg là $24 + $20 * aClass, cho ra PtgRef với class 0, PtgRefV với class 1 và PtgRefA với class 2. Một tệp BIFF do Excel ghi lưu class đó trong mọi token tham chiếu, nên một engine có bảng khớp spec có thể trả lời “đối số này có phải scalar không” mà không cần nhìn vào dữ liệu. Đối số giữa của SUMIF là tiêu chí, một value; còn đối số thứ nhất và thứ ba là vùng, tức reference. SUMPRODUCT được đăng ký với class cấp hàm là 2, array, nên =SUMPRODUCT(Vertical,Vertical) vẫn nhân cả vùng

Ba hàm không tra entry của chính mình cho bất cứ thứ gì ngoài đối số đầu tiên. IF (ptg 1), CHOOSE (ptg 100) và IFERROR (ptg 255) chuyển tiếp bất cứ thứ gì chúng chọn, nên các đối số nhánh của chúng thừa hưởng class của chính vị trí mà hàm đó chiếm. Chỉ một quy tắc đó khiến =CHOOSE(1,Vertical,0) ở G2 phân giải thành A2 trong khi =SUMIF(Vertical,">0",Vertical) ngay bên cạnh vẫn cộng cả hai dòng, và đó là quy tắc mà một lịch khấu hao dùng nhiều nhất, vì các ô kỳ hạn của nó dựa vào IF để kiểm tra khoản vay còn mở hay không

Nơi HotXLS đọc argument class cho implicit intersection: IF đăng ký 100, SUMIF 010, VLOOKUP 1011 còn SUM không đăng ký gì nên các đối số rơi về class 0, encoder ghi token tham chiếu thành ptg $24 cộng $20 nhân class cho ra PtgRef, PtgRefV và PtgRefA, và các hàm chuyển tiếp IF, CHOOSE cùng IFERROR thừa hưởng class của vị trí chúng chiếm
Vì bảng class khớp spec, engine có thể trả lời một đối số có phải scalar hay không mà không cần nhìn dữ liệu, và việc CHOOSE phân giải thành A2 bên cạnh một SUMIF cộng cả hai dòng đều suy ra từ một quy tắc

Mang class đi qua lượt duyệt dependency

Bộ trích xuất dependency trong lxCalc.pas là một Walk đệ quy trên cây cú pháp đã biên dịch, và nó tồn tại hai lần, một trong TXLSCalculator.ExtractDependencies cho đồ thị theo workbook và một trong ExtractWorkspaceDependencies cho đồ thị liên workbook. v2.382.4 cho cả hai walker thêm hai tham số. AScalar khởi đầu là True ở gốc một công thức, được tính lại cho từng đối số con của hàm từ FunctionArgumentClass, và được truyền nguyên trạng cho các đối số nhánh của ptg 1, 100 và 255. ANameRoot chỉ thành True khi walker đi xuống định nghĩa đã biên dịch của một tên, và nó chỉ sống sót qua các node SA_GROUP, tức các dấu ngoặc, nên một tên định nghĩa là =A1:A2+1 không bị nhầm thành một vùng trần. Khi cả hai cờ đều True tại một node SA_RANGE, AddResolvedRange thu hẹp vùng bằng đúng helper mà bộ tính giá trị dùng trước khi ghi dependency. Helper này ngắn đến mức có thể dẫn trọn vẹn

Quyết định IntersectNamedScalarRange canh các dependency mang tên trong HotXLS: một vùng đã là một ô thì đi thẳng qua, một cột đơn thu hẹp về dòng của công thức khi CurRow nằm trong dải, một dòng đơn thu hẹp về cột của công thức, còn mọi trường hợp khác, vùng hai chiều hay dòng ngoài dải, cho ra #VALUE! khi tính giá trị và không ghi dependency nào
Cả hai walker dependency lẫn bộ tính giá trị đều gọi cùng một helper, nên giá trị mà công thức đọc và cạnh mà đồ thị ghi không bao giờ mâu thuẫn nhau về một tên đã intersect
function IntersectNamedScalarRange(CurRow, CurCol: Integer;
  var Row1, Row2, Col1, Col2: Integer): Boolean;
begin
  Result := False;
  if (Row1 = Row2) and (Col1 = Col2) then Exit(True);   // đã là một ô
  if (Col1 = Col2) and (CurRow >= Row1) and (CurRow <= Row2) then
  begin
    Row1 := CurRow; Row2 := CurRow;                     // cột đơn: lấy dòng này
    Exit(True);
  end;
  if (Row1 = Row2) and (CurCol >= Col1) and (CurCol <= Col2) then
  begin
    Col1 := CurCol; Col2 := CurCol;                     // dòng đơn: lấy cột này
    Result := True;
  end;
end;

Bất cứ thứ gì helper từ chối, một vùng hai chiều, một tham chiếu nhiều sheet hay một công thức có dòng nằm ngoài cột mang tên, đều sinh ra #VALUE! ở phía tính giá trị và không có dependency nào ở phía đồ thị, đúng như Excel làm với một giao rỗng. Phía tính giá trị nằm trong TXLSCalculator.GetValueItemName: nó bóc các lớp SA_GROUP khỏi định nghĩa đã biên dịch, và nếu gốc là một SA_RANGE thì nó gọi GetRangeInfo, intersect, rồi lấy đúng một ô qua FGetValue thay vì tính cả định nghĩa. Các tham chiếu bên ngoài vẫn đi đường cũ, vì không có dòng cục bộ nào để intersect. Chuyện nơi lưu trữ và phạm vi của một tên đến từ đâu được trình bày trong bài về defined name và công thức liên sheet; ở đây chỉ bàn việc engine làm gì sau khi tên đã phân giải

Vì sao MATCH trên một cột tính dở lại đọc ra 777?

Vì đối số lookup-array của MATCH là một scan reference, và scan reference đã bị cố ý loại khỏi thứ tự tính giá trị. Bài về lookup scan giới thiệu TXLSDepRange.LookupScan và khép lại bằng một mục mang tên “Thứ bạn đánh đổi khi loại cạnh scan khỏi thứ tự”: một công thức lookup có thể chạy trước khi mọi ô trong vùng của nó được tính lại và đọc phải giá trị cũ. Trong một phiên tương tác thì nó hội tụ ở lượt sau. Trong một lần tính lại hàng loạt trên một template đã bị đầu độc thì không, và PaymentCount, định nghĩa là =MATCH(0.01,Balances,-1)+1, đọc các placeholder 777 còn nằm trong cột balance và trả về một số kỳ không thể nào đúng

TXLSDepGraph.TopoOrder giờ coi cạnh scan là cạnh sắp thứ tự mềm. Bên cạnh in-degree cứng, nó giữ một mảng ScanInDeg, đếm số precedent scan bẩn của từng node và giảm dần khi các precedent đó được phát ra, dùng các danh sách ScanPrecedents, ScanDependents và ScanPrecedentCount mà thay đổi trước đó đã lưu sẵn. Ở mỗi vòng lặp, hàng đợi Kahn quét cửa sổ ready của nó để tìm node đầu tiên có ScanInDeg bằng 0 rồi đổi nó lên đầu; nếu mọi node ready vẫn còn chờ một precedent scan, node đầu được pop theo thứ tự ổn định của nó. Cạnh scan không bao giờ đi vào in-degree cứng, nên một VLOOKUP tự tham chiếu trên chính cột của nó vẫn hợp lệ, nhưng một lookup có thể chờ một precedent hoàn tất được thì giờ sẽ chờ. Bài regression ghim hành vi này, LookupScan_WaitsForDirtyFormulaValues, đầu độc ba ô balance thành 777 và kỳ vọng PaymentCount trả về 3, rồi lật đầu vào về 0 và kỳ vọng =IFERROR(PaymentCount,99) nhìn thấy #N/A và trả về 99

Việc cắt còn bốn chữ số thập phân đến từ đâu?

Từ phép toán Variant của Delphi, và chỉ ở các vị trí lồng nhau. Các toán tử hai ngôi trong TXLSCalculator.GetValueItem vốn đã sao chép một phép + hay - ở mức cao nhất vào hai biến cục bộ Double, nên =B1-A1 không sao. Bên trong =IF(TRUE,B1-A1,0), phép trừ y hệt chạy dưới dạng Value := Value - SubValue trên hai Variant, và khi một toán hạng là giá trị ô Int64 còn toán hạng kia là Double, kết quả chúng tôi quan sát được là một Currency, kiểu fixed-point với bốn chữ số thập phân, nên 1066.1854641400994 trừ 120 quay về bị cắt còn bốn chữ số thập phân. Trên một lịch trả nợ mà mọi khoản thanh toán đều được cộng dồn từ dòng trước, sai số đó đi qua hàng trăm kỳ trước khi chạm tới các tổng

// TXLSCalculator.GetValueItem, nhánh số học hai ngôi (lxCalc.pas)
if VarIsNull(Value) then Value := 0;
if VarIsNull(SubValue) then SubValue := 0;
// Phép toán Variant trộn Int64/Double có thể được promote thành Currency.
// Số học bảng tính phải giữ độ chính xác dấu phẩy động.
if VarIsNumeric(Value) then Value := Double(Value);
if VarIsNumeric(SubValue) then SubValue := Double(SubValue);

Guard chạy trước cả SA_ADD, SA_SUB, SA_MUL lẫn SA_DIV, và bài regression Arithmetic_MixedInt64AndDoubleKeepsPrecision lưu Int64(120) vào A1 cùng 1066.1854641400994 vào B1, rồi kiểm tra hiệu và tổng lồng nhau tới 1E-10 còn tích và thương tới 1E-8 và 1E-12. HotXLS không tuyên bố biết hết mọi quy tắc promotion mà RTL áp cho các kiểu Variant hỗn hợp qua các phiên bản compiler; nó chỉ khẳng định số học bảng tính là double theo IEEE, và giờ nó ép cả hai toán hạng thành double trước khi toán tử nhìn thấy chúng, thứ xóa bỏ câu hỏi đó

Bản sửa bảo đảm điều gì và không bảo đảm điều gì

Sau v2.382.4, cả hai kiến trúc engine đều trả về lxOk cho template đã bị đầu độc, toàn bộ 4805 giá trị cache khớp với kỳ vọng độc lập theo từng dòng trong sai số 1E-7, và các assertion rằng cache thật sự đã bị đầu độc, rằng hash nguồn không đổi và rằng mọi công thức vẫn còn nguyên đều đúng. Không có vòng lặp iteration nào được bật và không có mã lỗi nào bị che đi để đạt được điều đó. Một vòng lặp thật sự đi qua một tên, =B1 ở A1 với B1 vẫn đọc Vertical, vẫn trả về lỗi, và bài test NamedScalarRanges_IntersectWithoutFalseCycles kết thúc bằng đúng assertion đó

Nói thẳng các ranh giới cũng đáng. Implicit intersection chỉ áp cho một tên mà định nghĩa đã biên dịch, sau khi bỏ ngoặc, là một vùng một cột hoặc một dòng trên một sheet; một tên hai chiều ở vị trí scalar là #VALUE!, giống như trong Excel, và một hàm mà bảng không biết sẽ nhận class 0 từ FunctionArgumentClass, nên các đối số tên của nó vẫn được mở rộng đầy đủ. Thứ tự mềm là một ưu tiên chứ không phải một bảo đảm: một vòng lặp chỉ có cạnh scan vẫn được tính theo thứ tự ổn định và đọc bất cứ thứ gì đang được cache, đó là hành vi mà bài về lookup scan cố ý chấp nhận. Và kết quả toàn template được kiểm chứng với một script kỳ vọng độc lập, không phải với một engine bảng tính khác, vì bộ office tham chiếu không tính lại xong template gốc trong ngân sách 60 giây. HotXLS là một component bảng tính Delphi và C++Builder thuần, đọc, tính lại và ghi XLS, XLSX, ODS cùng CSV mà không cần cài Excel; phép giao tên, bảng argument class và thứ tự scan mềm áp cho mọi định dạng vì engine tính toán được chia sẻ, và độ phủ hàm hiện tại được liệt kê trên trang sản phẩm component bảng tính HotXLS cho Delphi