기술 문서

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

열 전체를 가리키는 정의된 이름은 스칼라 위치에 나타날 때 Excel이 하나의 셀로 읽는다. 7행의 =Vertical+1은 영역 전체가 아니라 "Vertical의 7행 셀"을 뜻한다. HotXLS Delphi Component는 그 암시적 교차를 두 수준에서 적용한다. 평가할 때와 의존성 추출할 때다. 값을 올바르게 얻는 것만으로는 부족하기 때문이다. 이름을 전체 영역으로 펼쳐 버리는 의존성 워커 아래에서는, 그 영역의 셀을 먹는 어떤 하류 수식이라도 존재하지 않는 순환을 닫아 버리고, TXLSXWorkbook.Recalculate는 이어서 워크북 전체를 순환으로 거부해야만 한다

4805개 수식을 가진 주식 담보 대출 상환 워크북이 이 모든 것을 작동시키는 스트레스 사례다. 낡은 캐시에서 올바르게 재계산하려면 세 가지 별개의 장치가 필요하다. 이름의 암시적 교차, 탐색 스캔의 순서, 중첩 산술에 쓰이는 수치 타입. 하나라도 틀리면 증상은 순환 참조 코드인 lxErrorRef이거나, 독립적인 행별 계산에서 표류하는 합계다. 이 문서는 각 장치를 관련 소스와 함께 짚어 간다

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

의존성 그래프는 에지밖에 모르는데, 수식에서 480행 영역으로 가는 에지는 480개이고 그중 하나는 그 수식에 의존하는 셀을 거쳐 되돌아오기 때문입니다. B1에 =IF(TRUE,Vertical+1,0)을 두고, Vertical이 Inputs!$A$1:$A$2로 정의되어 있고 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가 규정한 것입니다

열 이름을 펼치는 것이 가짜 순환을 만드는 이유: Vertical이 Inputs!$A$1:$A$2로 정의된 상태에서 B1이 A1:A2에 의존한다고 기록하는 워커는 A2를 B1의 선행 노드로 만들고 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
      // Vertical을 A1:A2로 펼치면 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다. 두 워커는 매개변수 두 개를 더 들고 다닌다. 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가 왜 기다려야 하는가?

MATCH의 조회 배열 인수가 스캔 참조이고, 스캔 참조는 의도적으로 엄격한 평가 순서에서 배제되어 있기 때문이다. 탐색 스캔 문서가 TXLSDepRange.LookupScan을 소개하며 순서에서 스캔 에지를 빼는 대가로 포기하는 것을 적어 둔다. 탐색 수식이 자기 범위의 모든 셀이 재계산되기 전에 실행되어 낡은 값을 읽을 수 있다는 것. 대화형 세션에서는 다음 패스에 수렴한다. 캐시가 낡은 템플릿의 단일 배치 재계산에서는 그렇지 않다. PaymentCount가 =MATCH(0.01,Balances,-1)+1로 정의돼 있다면 잔액 열에 아직 앉아 있는 낡은 값을 읽고 절대 맞을 수 없는 기간 수를 반환할 것이다

그래서 TXLSDepGraph.TopoOrder는 스캔 에지를 부드러운 순서 에지로 취급한다. 엄격한 in-degree 옆에 ScanInDeg 배열을 유지하며 노드별로 낡은 스캔 선행 노드를 세고 그 선행 노드들이 배출될 때마다 깎는다. 그래프가 이미 저장하는 ScanPrecedents, ScanDependents, ScanPrecedentCount 목록을 쓴다. 매 반복마다 Kahn 큐는 준비 창을 훑어 ScanInDeg가 0인 첫 노드를 찾아 머리로 스왑하고, 준비된 노드 전부가 여전히 스캔 선행 노드를 기다리는 중이면 머리를 안정 순서 그대로 팝한다. 스캔 에지는 엄격한 in-degree에 들어가지 않으므로 자기 열 위의 자기 참조 VLOOKUP도 여전히 합법이지만, 끝맺을 수 있는 선행 노드를 기다릴 수 있는 탐색은 기다린다. 잔액 셋이 낡은 값을 담고 있으면 PaymentCount는 3으로 돌아온다. 입력을 0으로 뒤집으면 =IFERROR(PaymentCount,99)가 #N/A를 보고 99를 반환한다

중첩 산술이 Double로 강제되는 이유

Delphi Variant 산술에는 중첩 위치에서만 드러나는 함정이 있기 때문이다. 최상위 +나 -를 Double 지역 변수 둘에 옮겨 담으면 =B1-A1은 정확하다. 그러나 =IF(TRUE,B1-A1,0) 안에서 자연스러운 구현은 같은 뺄셈을 두 Variant에 대해 Value := Value - SubValue로 돌리고, 한 피연산자가 Int64 셀 값이고 다른 하나가 Double일 때 우리가 관찰한 결과는 Currency, 곧 소수 네 자리의 고정소수점 타입이었다. 1066.1854641400994에서 120을 빼면 네 자리로 잘려 나온다. 매 상환이 이전 행에서 복리로 쌓이는 상환 일정에서 그 오류는 수백 기간을 걸어서 합계에 도착한다. TXLSCalculator.GetValueItem이 연산자가 돌기 전에 두 피연산자를 변환하는 이유다

// 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 모두 앞에서 돈다. A1에 Int64(120)이 있고 B1에 1066.1854641400994가 있으면 중첩된 차, 합, 곱, 몫이 모두 배정밀도를 유지한다. HotXLS는 RTL이 컴파일러 버전마다 혼합 Variant 타입에 적용하는 모든 승격 규칙을 안다고 주장하지 않는다. 스프레드시트 산술을 IEEE double로 취급하고 연산자가 보기 전에 두 피연산자를 double로 만들어 질문 자체를 없앤다

엔진이 이름 붙은 열에 대해 보장하는 것

세 장치가 자리 잡으면 두 엔진 아키텍처 모두 상환 템플릿을 낡은 캐시에서 lxOk까지 재계산하고 4805개 값 전부가 독립적인 행별 기대치와 1E-7 안에서 일치한다. 그 자리에 도달하기 위해 반복 계산을 켜거나 오류 코드를 눌러 둔 것은 없다. 이름을 통과하는 진짜 순환, 곧 A1의 =B1과 여전히 Vertical을 읽는 B1의 조합은 여전히 오류를 반환한다. 마땅히 그래야 하듯이

규칙은 Excel을 따른다. 암시적 교차는 괄호를 벗겨 낸 컴파일된 정의가 한 시트 위의 단일 열 또는 단일 행 영역인 이름에 적용된다. 스칼라 위치의 2차원 이름은 Excel처럼 #VALUE!이고, 테이블이 모르는 함수는 FunctionArgumentClass에서 클래스 0을 받아 이름 인수를 통째로 펼친다. 부드러운 순서는 보장이 아니라 선호다. 스캔 전용 순환은 안정 순서로 평가하며 캐시에 있는 무엇이든 읽는데, 탐색 스캔 문서가 일부러 감수한 trade-off가 그것이다. HotXLS는 Excel이 설치되지 않은 환경에서 XLS, XLSX, ODS, CSV를 읽고 재계산하고 쓰는 네이티브 Delphi·C++Builder 스프레드시트 컴포넌트다. 계산 엔진이 공유되므로 이름 교차와 인수 클래스 테이블, 부드러운 스캔 순서는 모든 형식에 적용되며, 현재 함수 커버리지는 HotXLS Delphi 스프레드시트 컴포넌트 제품 페이지에 정리되어 있다