기술 문서

Delphi HotXLS 정의된 이름 암시적 교차와 인수 클래스

전체 열을 가리키는 정의된 이름은 스칼라 위치에 나타날 때 Excel이 단일 셀로 읽습니다. 행 7의 =Vertical+1은 영역 전체가 아니라 "행 7에 해당하는 Vertical의 셀"을 뜻합니다. HotXLS Delphi Component는 v2.382.4에서 이 암시적 교차를 값 계산과 의존성 추출, 두 계층에 적용합니다. 수식이 4805개인 대출 서식이 값만 제대로 구하는 것으로는 부족하다는 것을 보여 주었기 때문입니다. 의존성 워커가 이름을 전체 영역으로 확장하면 그 영역의 임의 셀에 값을 공급하는 하위 수식이 존재하지도 않는 순환을 만들고, TXLSXWorkbook.Recalculate가 통합 문서 전체를 거부합니다

문제의 서식은 흔한 대출 상환 스케줄 통합 문서입니다. 캐시된 값을 전부 777로 오염시키고 전체 Recalculate를 실행하자 두 엔진 아키텍처가 모두 23을 반환했는데, 이는 순환 참조 코드인 lxErrorRef입니다. 4805개 수식 중 3842개가 독립 기대값과 일치하지 않았고, B18은 #VALUE!를 담고 있었으며, E18은 여전히 777이었고, J7의 납입 횟수는 미완성 잔액 열의 자리표시자 값을 읽고 있었습니다. 하나의 반환 코드 뒤에 세 개의 별개 결함이 숨어 있었고, 이 글은 각각을 고친 소스와 함께 짚어 갑니다

열 이름에 대한 스칼라 참조가 왜 가짜 순환을 만들까

의존성 그래프는 에지밖에 모르는데, 수식에서 480행 영역으로 가는 에지는 480개이고 그중 하나는 그 수식에 의존하는 셀을 거쳐 되돌아오기 때문입니다. Vertical이 Inputs!$A$1:$A$2로 정의되고 B1에 =IF(TRUE,Vertical+1,0), A2에 =B1+1이 있다고 해 보겠습니다. Excel은 B1을 A1+1로, A2를 B1+1로 계산하는 곧은 사슬입니다. B1이 A1:A2에 의존한다고 기록하는 워커는 A2를 B1의 선행 노드로 만들고, A2는 이미 B1을 선행 노드로 나열하고 있으므로 HotXLS의 증분 재계산을 구동하는 Kahn 큐는 두 노드 모두 진입 차수가 0이 되는 순간을 보지 못합니다. 대출 서식이 바로 이 패턴으로 만들어집니다. 모든 기간 행이 잔액과 금리, 납입 횟수에 대해 이름 붙은 열을 참조하고, 각 이름이 스케줄 전체에 걸쳐 있으며, 각 행이 그 열에 값을 쓰기도 합니다. 이름을 확장하면 그래프는 하나의 거대한 강결합 컴포넌트가 됩니다. 암시적 교차로 계산하면 그래프는 행마다 하나씩 있는 짧은 사슬의 집합이 되는데, 이는 단일 값이 요구되는 자리에서 참조 피연산자를 소비할 때의 동작으로 ECMA-376 Part 1 §18.17.2가 규정한 것입니다

HotXLS에서 열 이름이 가짜 순환을 닫은 이유: Vertical이 Inputs!$A$1:$A$2로 정의되면 워커는 B1이 A1:A2에 의존한다고 기록하는데 A2는 이미 B1을 선행 노드로 나열하므로 Kahn 큐가 비워지지 않으며, 교차를 적용하면 B1이 해당 행의 셀 A1로 좁혀져 Recalculate가 정렬하는 행별 사슬 A2, B1, A1이 유지됩니다
이름을 확장하면 그래프가 하나의 거대한 강결합 컴포넌트가 되지만, 같은 수식을 암시적 교차로 계산하면 스케줄 행마다 하나씩 있는 짧은 사슬로 바뀝니다
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;
    // 스칼라 위치: 수식이 1행에 있으므로 Vertical은 A1로 축소됨
    Sheet.Cells[1, 2].Formula := '=IF(TRUE,Vertical+1,0)';
    Sheet.Cells[2, 1].Formula := '=B1+1';
    // 정의가 다른 이름인 이름도 교차하므로 이 값은 A2
    Sheet.Cells[2, 2].Formula := '=Alias';
    // 참조 클래스 인수: 영역 전체를 합산하며 교차하지 않음
    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는 인수가 스칼라인지 어떻게 판단할까

HotXLS는 인수의 모양이 아니라 함수 테이블에서 답을 읽습니다. TXLSFormula.InitFuncHash의 모든 항목은 인수별 클래스 문자열을 옵션으로 받는 THashFunc.SetValue로 등록됩니다. 'IF'는 '100'을, 'SUMIF'는 '010'을, 'VLOOKUP'은 '1011'을 지니고, 'SUM'은 아무것도 지니지 않아 모든 인수가 함수 수준 클래스 0으로 폴백합니다. 새로 추가된 TXLSFormula.FunctionArgumentClass(APtg, AArgument)가 그 바이트를 THashFuncEntry.ArgClass로 노출하며, 결과가 1이면 값 클래스입니다. [MS-XLS] §2.2.2가 피연산자 토큰에 부여하는 세 클래스와 같은 것이고, 인코더는 이미 이것에 의존하고 있었습니다. 참조를 쓸 때 ptg를 $24 + $20 * aClass로 계산해 클래스 0이면 PtgRef, 클래스 1이면 PtgRefV, 클래스 2면 PtgRefA가 됩니다. Excel이 쓴 BIFF 파일은 모든 참조 토큰에 그 클래스를 저장하므로, 테이블이 스펙과 일치하는 엔진은 데이터를 보지 않고도 "이 인수가 스칼라인가"에 답할 수 있습니다. SUMIF의 가운데 인수는 조건값, 즉 값이고 첫 번째와 세 번째는 영역, 즉 참조입니다. SUMPRODUCT는 함수 수준 클래스 2(배열)로 등록되어 있어 =SUMPRODUCT(Vertical,Vertical)가 여전히 영역 전체를 곱합니다

세 함수는 첫 인수 이후에 대해 자기 테이블 항목을 참조하지 않습니다. IF(ptg 1), CHOOSE(ptg 100), IFERROR(ptg 255)는 선택한 것을 그대로 전달하므로, 분기 인수는 함수 자체가 차지한 위치의 클래스를 물려받습니다. 이 한 가지 규칙 덕분에 G2의 =CHOOSE(1,Vertical,0)는 A2로 해석되면서 옆의 =SUMIF(Vertical,">0",Vertical)는 여전히 두 행을 합산하며, 상환 스케줄이 가장 많이 행사하는 규칙이기도 합니다. 기간 셀들이 대출이 아직 열려 있는지 검사할 때 IF에 기대기 때문입니다

HotXLS가 암시적 교차를 위해 인수 클래스를 읽는 위치: IF는 100, SUMIF는 010, VLOOKUP은 1011을 등록하고 SUM은 아무것도 등록하지 않아 인수가 클래스 0으로 폴백하며, 인코더는 참조 토큰을 ptg $24 더하기 $20 곱하기 클래스로 기록해 PtgRef, PtgRefV, PtgRefA를 만들고, 전달 함수인 IF, CHOOSE, IFERROR는 자신이 차지한 위치의 클래스를 물려받습니다
클래스 테이블이 스펙과 일치하므로 엔진은 데이터를 보지 않고도 인수가 스칼라인지 답할 수 있고, 두 행을 합산하는 SUMIF 옆에서 CHOOSE가 A2로 해석되는 것도 하나의 규칙에서 나옵니다

의존성 순회 전체로 클래스를 전달하기

lxCalc.pas의 의존성 추출기는 컴파일된 구문 트리를 재귀적으로 Walk하는 코드이며, 통합 문서별 그래프를 위한 TXLSCalculator.ExtractDependencies와 통합 문서 간 그래프를 위한 ExtractWorkspaceDependencies에 두 번 존재합니다. v2.382.4는 두 워커에 매개변수 두 개를 더했습니다. AScalar는 수식 루트에서 True로 시작해 각 함수 자식마다 FunctionArgumentClass로 다시 계산되고, ptg 1, 100, 255의 분기 인수에는 그대로 전달됩니다. ANameRoot는 워커가 이름의 컴파일된 정의로 내려갈 때만 True가 되고, 괄호인 SA_GROUP 노드를 통해서만 살아남으므로 =A1:A2+1로 정의된 이름이 단순 영역으로 오인되지 않습니다. SA_RANGE 노드에서 두 플래그가 모두 True이면 AddResolvedRange가 의존성을 기록하기 전에 평가기가 쓰는 것과 같은 헬퍼로 영역을 좁힙니다. 그 헬퍼는 전문을 인용할 만큼 짧습니다

HotXLS에서 이름 의존성을 지키는 IntersectNamedScalarRange 판정: 이미 한 셀인 영역은 그대로 통과하고, 단일 열은 CurRow가 범위 안에 있으면 수식 행으로 좁혀지며, 단일 행은 수식 열로 좁혀지고, 그 밖의 경우인 2차원 영역이나 범위를 벗어난 행은 계산 시 #VALUE!가 되고 의존성을 전혀 기록하지 않습니다
두 의존성 워커와 평가기가 같은 헬퍼를 호출하므로, 수식이 읽는 값과 그래프가 기록하는 에지가 교차된 이름에 대해 어긋날 수 없습니다
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;

헬퍼가 거부하는 것 — 2차원 영역, 여러 시트 참조, 수식의 행이 이름 붙은 열 밖에 있는 경우 — 은 계산 쪽에서는 #VALUE!를 만들고 그래프 쪽에서는 의존성을 전혀 만들지 않으며, 이는 교차 결과가 비었을 때 Excel이 하는 동작입니다. 계산 쪽은 TXLSCalculator.GetValueItemName에 있습니다. 컴파일된 정의에서 SA_GROUP 래퍼를 벗겨 내고, 루트가 SA_RANGE이면 GetRangeInfo를 호출해 교차한 다음 정의 전체를 계산하는 대신 FGetValue로 그 한 셀을 가져옵니다. 외부 참조는 교차할 로컬 행이 없으므로 예전 경로에 남습니다. 이름의 저장 위치와 스코프가 어디서 오는지는 정의된 이름과 시트 간 수식 글에서 다루며, 여기서 중요한 것은 이름이 해석된 뒤 엔진이 하는 일뿐입니다

절반만 계산된 열에 대한 MATCH가 왜 777을 읽었을까

MATCH의 조회 배열 인수가 스캔 참조이고, 스캔 참조가 계산 순서에서 의도적으로 제외되었기 때문입니다. 조회 스캔 글에서 TXLSDepRange.LookupScan을 소개하며 "스캔 에지를 순서에서 제외하면 무엇을 포기하는가"라는 절로 끝맺었습니다. 조회 수식이 자기 범위의 모든 셀이 재계산되기 전에 실행되어 오래된 값을 읽을 수 있다는 것입니다. 대화형 세션이라면 다음 패스에서 수렴합니다. 오염된 서식을 일괄 재계산할 때는 그렇지 않고, =MATCH(0.01,Balances,-1)+1로 정의된 PaymentCount가 잔액 열에 그대로 남아 있는 777 자리표시자를 읽어 맞을 리 없는 기간 수를 반환했습니다

TXLSDepGraph.TopoOrder는 이제 스캔 에지를 소프트 순서 에지로 취급합니다. 하드 진입 차수와 나란히 ScanInDeg 배열을 유지하며 노드별로 더러운 스캔 선행 노드 수를 세고, 그 선행 노드가 방출될 때마다 감소시킵니다. 이전 변경이 이미 저장해 둔 ScanPrecedents, ScanDependents, ScanPrecedentCount 목록을 사용합니다. 각 반복에서 Kahn 큐는 준비 윈도우를 훑어 ScanInDeg가 0인 첫 노드를 찾아 맨 앞으로 바꿉니다. 준비된 모든 노드가 아직 스캔 선행 노드를 기다리고 있으면 맨 앞 노드를 안정적인 순서대로 꺼냅니다. 스캔 에지는 하드 진입 차수에 들어가지 않으므로 자기 열에 대한 자기 참조 VLOOKUP은 여전히 합법이지만, 완료 가능한 선행 노드를 기다릴 수 있는 조회는 이제 기다립니다. 이것을 고정하는 회귀 테스트 LookupScan_WaitsForDirtyFormulaValues는 잔액 셀 세 개를 777로 오염시키고 PaymentCount가 3으로 돌아오기를 기대한 뒤, 입력을 0으로 뒤집어 =IFERROR(PaymentCount,99)가 #N/A를 보고 99를 반환하기를 기대합니다

소수 네 자리 절단은 어디서 왔을까

Delphi Variant 연산에서, 그것도 중첩된 위치에서만 왔습니다. TXLSCalculator.GetValueItem의 이항 연산자는 이미 최상위 +나 -를 Double 지역 변수 두 개로 복사했으므로 =B1-A1은 문제가 없었습니다. =IF(TRUE,B1-A1,0) 안에서는 같은 뺄셈이 두 Variant에 대해 Value := Value - SubValue로 실행되었고, 한 피연산자가 Int64 셀 값이고 다른 하나가 Double일 때 관찰된 결과는 소수 네 자리의 고정 소수점 타입인 Currency였습니다. 그래서 1066.1854641400994에서 120을 뺀 값이 소수 네 자리로 잘려 돌아왔습니다. 모든 납입액이 이전 행에서 복리로 쌓이는 스케줄에서는 그 오차가 합계에 닿기 전에 수백 개 기간을 거쳐 갑니다

// TXLSCalculator.GetValueItem, 이항 산술 분기 (lxCalc.pas)
if VarIsNull(Value) then Value := 0;
if VarIsNull(SubValue) then SubValue := 0;
// Int64와 Double이 섞인 Variant 연산은 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은 A1에 Int64(120)을, B1에 1066.1854641400994를 저장한 뒤 중첩된 차와 합을 1E-10까지, 곱과 몫을 1E-8과 1E-12까지 검사합니다. HotXLS는 컴파일러 버전에 따라 RTL이 혼합 Variant 타입에 적용하는 모든 승격 규칙을 안다고 주장하지 않습니다. 스프레드시트 연산은 IEEE double이라고 주장하고, 이제 연산자가 두 피연산자를 보기 전에 둘 다 double로 만들므로 그 문제 자체가 사라집니다

이 수정이 보장하는 것과 보장하지 않는 것

v2.382.4 이후 두 엔진 아키텍처 모두 오염된 서식에 대해 lxOk를 반환하고, 4805개 캐시 값 전부가 독립적인 행별 기대값과 1E-7 이내로 일치하며, 캐시가 실제로 오염되었는지, 소스 해시가 변경되지 않았는지, 모든 수식이 여전히 존재하는지를 확인하는 단언도 모두 통과합니다. 그렇게 만들기 위해 반복 계산을 켜지도, 오류 코드를 억제하지도 않았습니다. 이름을 통한 진짜 순환, 즉 B1이 여전히 Vertical을 읽는 상태에서 A1에 =B1을 두는 경우는 여전히 오류를 반환하며, 테스트 NamedScalarRanges_IntersectWithoutFalseCycles는 바로 그것을 단언하며 끝납니다

경계는 분명히 밝힐 가치가 있습니다. 암시적 교차는 괄호를 벗긴 컴파일된 정의가 한 시트의 단일 열 또는 단일 행 영역인 이름에만 적용됩니다. 스칼라 위치의 2차원 이름은 Excel에서처럼 #VALUE!이고, 테이블이 모르는 함수는 FunctionArgumentClass에서 클래스 0을 받으므로 그 이름 인수는 여전히 전체가 확장됩니다. 소프트 순서는 보장이 아니라 선호입니다. 스캔만으로 이루어진 순환은 여전히 안정적인 순서로 계산되어 캐시된 값을 그대로 읽으며, 이는 조회 스캔 글이 의도적으로 받아들인 동작입니다. 그리고 서식 전체 결과는 또 다른 스프레드시트 엔진이 아니라 독립적인 기대값 스크립트와 대조해 검증했습니다. 참조 오피스 스위트가 원본 서식을 60초 예산 안에 재계산을 끝내지 못했기 때문입니다. HotXLS는 Excel 설치 없이 XLS, XLSX, ODS, CSV를 읽고 재계산하고 쓰는 네이티브 Delphi 및 C++Builder 스프레드시트 컴포넌트입니다. 이름 교차와 인수 클래스 테이블, 소프트 스캔 순서는 계산 엔진을 공유하므로 모든 형식에 적용되며, 현재 함수 지원 범위는 HotXLS Delphi 스프레드시트 컴포넌트 제품 페이지에 정리되어 있습니다