Artykuł techniczny

Łańcuchy porównań, puste komórki i SUMIF w HotXLS Delphi

HotXLS Delphi Component wylicza =1<2<3 jako FALSE, tę samą odpowiedź, którą daje Excel 16, bo od v2.384.3 jego parser formuł zwija operatory porównań od lewej do prawej: 1<2 staje się TRUE, a TRUE<3 jest FALSE, bo boolean plasuje się nad każdą liczbą. To samo wydanie czyni pusty operand równym i 0, i "", oraz pozwala SUMIF rozciągnąć jedno-komórkowy zakres sumy do kształtu zakresu kryteriów. Każde z tych wygląda jak ciekawostka, dopóki skoroszyt wyliczony w Delphi nie zacznie się różnić od tego samego skoroszytu otwartego w Excelu

Rozjazd zaczyna się zwykle od formuły napisanej z intuicji. Ktoś wpisuje =0<B2<100, żeby sprawdzić, czy ilość mieści się w zakresie, Excel po cichu odpowiada FALSE w każdym wierszu, a arkusz wychodzi na produkcję z tym błędem w środku. Silnik obliczeń nie ma prawa poprawiać intencji użytkownika; jego robotą jest wyprodukowanie wartości, którą wyprodukowałby Excel, tak żeby wynik z pamięci podręcznej, który HotXLS zapisuje do pliku, zgadzał się z tym, co Excel pokazuje po przeliczeniu. Przed v2.384.3 HotXLS odpowiadał TRUE na tę kontrolę zakresu w każdym wierszu, źle w drugą stronę, i raport wygenerowany na serwerze przeczył temu samemu raportowi otwartemu na biurku

Dlaczego =1<2<3 zwraca FALSE w Excelu?

Excel zwraca FALSE, bo czyta łańcuch porównań jako (1<2)<3, a wewnętrzne TRUE przegrywa potem pojedynek rankingowy typów z liczbą 3. Stary parser HotXLS czytał ten sam tekst jako 1<(2<3): TXLSSyntax.Parse_expr w lxFormula.pas parsował jeden operand, widział token porównania i wchodził w rekurencję do Parse_expr po prawą stronę, co czyni operator prawostronnie łącznym. To daje 1<TRUE, a liczba jest poniżej booleana, więc wynikiem było TRUE. Pomyłka jest symetryczna: =3>2>1 to TRUE w Excelu i było FALSE w HotXLS, a =1=1=TRUE to TRUE w Excelu i było FALSE przed poprawką. Test regresji CalculateFormula_ComparisonChainsFoldLeftToRight przypina siedem takich formuł do wartości zwracanych przez Excel 16 i przepuszcza każdą przez obie architektury silnika, klasyczny TXLSWorkbook i natywny dla XLSX TXLSXWorkbook, używając metody Calculate opisanej w przeglądzie silnika formuł HotXLS

const
  Formulas: array [0..6] of string = ('=1<2<3', '=3>2>1', '=(1<2)<3',
    '=1<(2<3)', '=1=1=TRUE', '=3>2>1=TRUE', '=1<2<3=FALSE');
  // Co zwraca Excel 16:  FALSE, TRUE, FALSE, TRUE, TRUE, TRUE, TRUE
var
  Classic: IXLSWorkbook;
  Xlsx: TXLSXWorkbook;
  i: Integer;
begin
  Classic := TXLSWorkbook.Create;
  Xlsx := TXLSXWorkbook.Create;
  try
    // TXLSXWorkbook.Calculate wylicza względem aktywnego arkusza i
    // zwraca Null, gdy skoroszyt w ogóle nie ma arkusza
    Xlsx.Sheets.Add('Data');
    for i := 0 to High(Formulas) do
      Writeln(Formulas[i], '  classic=', VarToStr(Classic.Calculate(Formulas[i])),
        '  xlsx=', VarToStr(Xlsx.Calculate(Formulas[i])));
  finally
    Xlsx.Free;
  end;
end;
Drzewa parsowania HotXLS dla =1<2<3, gdzie stary prawostronnie łączny Parse_expr wyliczał 1<(2<3) jako TRUE, a zwijacz od lewej do prawej od v2.384.3 wylicza (1<2)<3 jako FALSE, rozstrzygane przez ranking CompareVariants, który stawia każdą liczbę pod tekstem, a tekst pod booleanem — reguła w lxCalc.pas
Oba silniki zwijają teraz łańcuchy porównań od lewej do prawej i przypinają siedem formuł do Excela 16 — boolean bije każdą liczbę, więc to, że TRUE przegrywa z 3, to dokładnie to, co czyni łańcuchową kontrolę zakresu FALSE

Poprawka zamienia Parse_expr w pętlę o tym samym kształcie, jakiego Parse_expr1 już używa dla +, - i &. Parsuje pierwszy operand przez Parse_expr1, a póki następny token to =, <>, <, >, <= albo >=, tworzy węzeł porównania, przyczepia zakumulowany lewy wynik jako pierwsze dziecko, parsuje następny operand przez Parse_expr1 zamiast Parse_expr i czyni nowy węzeł lewym wynikiem kolejnej rundy. Dwa detale łatwo było zepsuć przy zamianie rekurencji na iterację i oba są w notatkach opiekunów: zakumulowany węzeł trzeba przekazać (lChild := Item; Item := nil) właśnie w tej kolejności, a ścieżka błędu musi Exit po zwolnieniu półzbudowanego węzła, zamiast wypaść z pętli i zwrócić wiszące drzewo

Jak HotXLS uszeregowuje liczby, tekst i booleany w porównaniu?

HotXLS porządkuje typy mieszane tak jak Excel: każda liczba jest mniejsza od każdej wartości tekstowej, a każda wartość tekstowa jest mniejsza od każdego booleana. TXLSCalculator.CompareVariants w lxCalc.pas klasyfikuje oba operandy przez GetRetValueType do wyliczenia TXLSRetValueType = (xlVariantValue, xlNumberValue, xlStringValue, xlBooleanValue), a gdy klasy się różnią, po prostu porównuje ich ordynały, więc kolejność deklaracji tego enuma jest regułą międzytypową. W obrębie jednej klasy porównanie jest naturalne, z jednym excelowym akcentem dla tekstu: oba łańcuchy są przepuszczane najpierw przez lxUpperCase, więc ="abc"="ABC" to TRUE. Tego rankingu nie da się obejść przy rozumowaniu wyniku łańcucha. TRUE<3 nie jest koercją TRUE do 1, to boolean porównany z liczbą i boolean wygrywa. Daty są dla silnika liczbami seryjnymi (varDate klasyfikuje się jako xlNumberValue), więc data jest zawsze poniżej jakiegokolwiek tekstu, łącznie z tekstem, który akurat wygląda jak data

Czemu równa się pusta komórka w porównaniu?

Pusta komórka użyta jako operand porównania równa się 0, gdy druga strona to liczba, równa się "", gdy druga strona to tekst, a od v2.384.53 równa się FALSE, gdy druga strona to wartość logiczna, więc przy pustym A1 i =A1=0, i =A1="", i =A1=FALSE to TRUE. TXLSCalculator.CompareVarValues, która obsługuje wszystkie sześć operatorów porównania, podstawia pustkę przed wywołaniem CompareVariants: jeśli dokładnie jeden operand to Null, staje się WideString(''), gdy jego partner to łańcuch, False, gdy partner to boolean, i 0 w pozostałych przypadkach. Dwie pustki nadal porównują się jako równe bez podstawiania. Ścieżka arytmetyczna od zawsze zamieniała pustkę na 0, dlatego =A1+1 dawało 1, ale CompareVariants trzyma Null jako własny najniższy szczebel, poniżej każdej liczby, i operatory porównania używały tego szczebla wprost

var
  Wb: IXLSWorkbook;
  Sh: TXLSWorksheet;
begin
  Wb := TXLSWorkbook.Create;
  Sh := Wb.Sheets.Add;
  Sh.Range['B1', 'B1'].Value := 5;      // A1 zostaje puste celowo

  Writeln(VarToStr(Wb.Calculate('=A1=0')));    // True
  Writeln(VarToStr(Wb.Calculate('=A1=""')));   // True
  Writeln(VarToStr(Wb.Calculate('=A1<B1')));   // True: pustka porównuje się jak 0
  Writeln(VarToStr(Wb.Calculate('=A1<0')));    // False; True przed v2.384.3
end;
Podstawianie pustego operandu w CompareVarValues w HotXLS, gdzie puste A1 porównuje się jako równe 0 i pustemu tekstowi, podczas gdy stary ranking Nulla czynił =A1<0 TRUE dla każdego pustego salda, a od v2.384.53 pustka przy booleanie porównuje się jako FALSE, więc =A1=FALSE jest TRUE jak w Excelu
Podstawienie dopasowuje się do typu drugiego operandu: 0, pusty łańcuch albo, od v2.384.53, FALSE — to IF oznaczający każde puste saldo jako przekroczone robił stary ranking Nulla, nie twoje dane

Ostatni wiersz to ten, który bolał w praktyce. Pod starym rankingiem pustka była mniejsza od każdej liczby, także ujemnej, więc =IF(A1<0,"overdrawn","ok") oznaczało każdą pustą komórkę salda jako przekroczony debet, a =A1=0 było FALSE dla komórki, którą każdy użytkownik opisałby jako zero. Jedna granica została po v2.384.3: podstawienie wybierało tylko między 0 a pustym łańcuchem, więc pustka porównana z booleanem stawała się 0, co plasuje się pod TRUE i pod FALSE, i =A1=FALSE na pustym A1 wyliczało się jako FALSE. Od HotXLS 2.384.53 pustka porównana z wartością logiczną jest traktowana jako FALSE w obu silnikach, XLS i XLSX, tak jak w Excelu: przy pustym A1 =A1=FALSE i =A1<TRUE zwracają TRUE, a =A1=TRUE zwraca FALSE. To znaczy też, że porównanie nie odróżni pustki od FALSE, ani w Excelu, ani w HotXLS; gdy arkusz potrzebuje tego rozróżnienia, testuj przez ISBLANK albo =A1=""

Dlaczego SUMIF z jedno-komórkowym zakresem sumy zwracał 0?

SUMIF zwracał 0, bo HotXLS docinał iterację do mniejszego z dwóch zakresów, podczas gdy Excel zachowuje kształt zakresu kryteriów i używa zakresu sumy tylko dla jego lewego górnego rogu. =SUMIF(A1:A10,">5",B1) znaczy więc w Excelu B1:B10, wygoda, na której polega wiele ręcznie budowanych szablonów. Wspólny worker TXLSCalculator.GetValueItemRange2 zmniejszał dotąd liczbę wierszy i kolumn do tych zakresu wartości, co redukowało przykład do pojedynczego testu A1 wobec B1. v2.384.3 zdejmuje docinkę: pętla chodzi teraz po zakresie kryteriów i czyta każdą wartość w tym samym przesunięciu od lewego górnego rogu zakresu sumy. Ponieważ CalcSumIF i CalcAverageIF wołają oba tego workera, AVERAGEIF dostaje to samo rozciąganie, a zakres sumy większy niż zakres kryteriów jest z tego samego powodu przycinany do kształtu kryteriów. Argument kryteriów w środku to argument klasy wartości, a dwa zewnętrzne to klasy referencji — rozróżnienie opisuje artykuł o implicit intersection i klasach argumentów

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  Row: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Sales');
    for Row := 1 to 10 do
    begin
      Sheet.Cells[Row, 1].Value := Row;          // kolumna kryteriów: 1..10
      Sheet.Cells[Row, 2].Value := Row * 100;    // kwoty: 100..1000
    end;
    Sheet.Cells[1, 4].Formula := '=SUMIF(A1:A10,">5",B1)';     // jedno-komórkowy zakres sumy
    Sheet.Cells[2, 4].Formula := '=SUMIF(A1:A10,">5",B1:B10)'; // jawny zakres sumy
    if Book.Recalculate = lxOk then
      // D1 i D2 to 4000 (600+700+800+900+1000); D1 było 0 przed v2.384.3
      Writeln(VarToStr(Sheet.Cells[1, 4].Value), ' ', VarToStr(Sheet.Cells[2, 4].Value));
  finally
    Book.Free;
  end;
end;
Rozciąganie SUMIF i AVERAGEIF w HotXLS, gdzie =SUMIF(A1:A10,">5",B1) obchodzi dziesięcio-wierszowy zakres kryteriów, czytając B1 do B10 w pasujących przesunięciach przez workera CalcSumIF, z wynikiem 4000, zamiast docinać do jedno-komórkowego zakresu sumy, który zwracał 0 przed v2.384.3
Excel pożycza tylko lewy górny róg zakresu sumy i trzyma kształt kryteriów, więc ręcznie budowany szablon podający B1 znaczy B1:B10 — wspólny worker chodzi teraz po wszystkich dziesięciu przesunięciach i nadmiarowy zakres przycina tak samo

INDIRECT i YEARFRAC: dwie cichsze korekty

INDIRECT respektuje teraz swój drugi argument, a tekst po poprawnej referencji jest błędem zamiast być ignorowanym. Przy a1 FALSE tekst jest parsowany jako absolutne R1C1, więc =INDIRECT("R2C3",FALSE) czyta C2; stary kod ignorował flagę, czytał "R2" jako kolumnę R, wiersz 2, i po cichu zwracał złą komórkę. Flaga jest rozdzielana po typie wariantu (boolean, liczba albo tekst), bo konwersja wariantu łańcuchowego wprost na Double podnosi wyjątek. Względny tekst R1C1, jak R[1]C[1], zwraca #REF!, bo INDIRECT nie ma początku komórki formuły, względem którego mógłby go rozwiązać, a tekst A1 ze znakami na ogonie, "B2 junk", też zwraca #REF!. YEARFRAC z podstawą 0 stosuje teraz reguły NASD ostatniego dnia lutego, które DAYS360 już implementowało: gdy obie daty to ostatni dzień lutego, dzień końca staje się 30, a potem początek w ostatnim dniu lutego staje się 30. Od 2024-02-29 do 2025-02-28 liczba to teraz 360 dni, ułamek dokładnie 1, gdzie poprzednie Days360US liczyło 359

Co gwarantują te poprawki i jaka była lekcja?

Zachowanie łańcuchów porównań jest gwarantowane testem, który porównuje oba silniki z wartościami zmierzonymi w Excelu 16, a ten test istnieje, bo pierwszy opis poprawki był zły. Nota wydania v2.384.3 mówiła pierwotnie, że zwijanie od lewej do prawej czyni =1<2<3 TRUE, co jest dokładnie tym, co produkował stary prawostronnie łączny parser, i przeciwieństwem tego, co zwracają i Excel, i nowy kod. Nikt nie wyliczył przykładu; został napisany z intuicji, że "1 jest mniejsze od 2, a 2 od 3". Notę poprawiono, a test z siedmioma formułami dodano w commitcie następczym, i wypływająca z tego reguła obowiązuje każdego, kto dokumentuje semantykę arkuszy: odpal przykład w Excelu, zanim zapiszesz oczekiwaną wartość. Podstawianie pustego operandu i rozciąganie SUMIF idą za tym samym zachowaniem Excela, łącznie z przypadkiem pustka-versus-boolean od v2.384.53, a agregaty warunkowe, które muszą też pomijać wiersze przefiltrowane albo ukryte, idą za osobnymi regułami z artykułu o ukrytych wierszach SUBTOTAL i AGGREGATE

HotXLS to natywny komponent arkuszowy dla Delphi i C++Buildera, który czyta, przelicza i zapisuje XLS, XLSX, ODS oraz CSV bez zainstalowanego Excela, a reguły porównań, pustki i SUMIF opisane tutaj mieszkają w silniku obliczeń wspólnym dla obu architektur skoroszytów. Pełna lista funkcji i opcje licencjonowania są na stronie produktu HotXLS Delphi spreadsheet component