HotXLS читает источники .xls, .xlsx, .xlsm, .ods, CSV и TSV через единственный pull-курсор строк TXLSRowCursor, чьи FindFirst и FindNext продвигаются на одну логическую строку за раз, пока в памяти остаётся только она. Машина состояний из шести значений разделяет before-first от EOF, отмены и сбоя, а старый callback-читатель теперь адаптер над тем же курсором
Сценарий знаком всякому, кто выпускал функцию импорта. Приходит .xlsx на 200 МБ, вы подключаете обработчик OnCell, и первое требование после «прочитай это» — «остановись после первой сотни сторнированных проводок». Теперь форма вашего кода борется с вами: цикл живёт внутри библиотеки, обработчику приходится взводить флаг, каждый последующий callback всё ещё срабатывает, пока парсер не заметит, а накопленное состояние — сколько попаданий до сих пор, какой столбец совпал, что делать дальше — должно жить в полях класса, существующего только чтобы дать callback'у где-то сидеть. Ничто из этого не проблема парсинга. Это проблема потока управления, и именно её убирает pull-курсор
Сколько push-callback на самом деле стоит при 200 МБ
Push инвертирует управление, а инверсия — ровно то, чего фильтрующий или соединяющий вызывающий не может себе позволить. С callback-API библиотека владеет циклом, поэтому вызывающий не может использовать Break, не может чередовать два источника, не может передать читателя рутине, которая ожидает, что ею будут управлять, и не может выразить «подгляди следующую строку перед решением» без буферизации. Цена не в пропускной способности — хорошо написанный SAX callback-путь стримится нормально — она в том, что каждый нетривиальный потребитель выращивает собственную маленькую машину состояний, чтобы имитировать цикл, который ему не разрешили написать. Умножьте это на четыре файловых формата, исторически каждый со своей точкой входа сканирования, и семантика фильтрации, формул и ошибок начинает расходиться между ними — ровно тот дрейф, который HotXLS взялся закрыть
Как pull-курсор меняет ваш вызывающий код?
Он возвращает цикл вам, а вместе с ним обычный поток управления Pascal. TXLSRowCursor.Open принимает имя файла или TStream, определяет формат, один раз загружает shared strings и метаданные стилей дат и выбирает лист 1. SelectSheet (с единицы) или SelectSheetByName перенацеливает на другой лист и сбрасывает курсор в before-first. FindFirst и FindNext затем позиционируют на следующей заполненной строке — строки без декодируемых ячеек пропускаются, поэтому RowIndex может прыгать — а текущая строка выставлена как CellCount, Cells[] и ValueByCol[], всё с единицы по оси столбцов. Выход из цикла — это Break
var
Cursor: TXLSRowCursor;
Hits: Integer;
begin
Cursor := TXLSRowCursor.Create;
try
Cursor.FirstRow := 2; // пропустить шапку
Cursor.IncludeColumn(1); // декодировать только эти два столбца
Cursor.IncludeColumn(7);
if not Cursor.Open('postings-200mb.xlsx') then
Exit;
if not Cursor.SelectSheetByName('Ledger') then
Exit;
Hits := 0;
if Cursor.FindFirst then
repeat
if VarToStr(Cursor.ValueByCol[7]) = 'REVERSED' then
begin
Inc(Hits);
if Hits = 100 then
Break; // обычный Break; ни флага прерывания, ни сентинела
end;
until not Cursor.FindNext;
finally
Cursor.Free; // деструктор завершает проход
end;
end;
Проекция и диапазон задаются до прохода, а не фильтруются после. FirstRow, LastRow, IncludeColumn, ClearColumnProjection, IncludeFormulaText, DetectDates и DetectTextTypes все уважаются внутри бэкендов, поэтому невыбранный столбец вообще никогда не аллоцирует своё значение, строку формулы или rich-text нагрузку — регрессионный набор доказывает это формулами 16 КиБ и кэшированными строками, которые никогда не материализуются, когда их столбец не спроецирован. Эти опции намеренно заморожены, пока проход активен, и снова становятся записываемыми на EOF, при SelectSheet или после Close, так что одно сканирование никогда не смешает два декодирующих контракта. Если вам нужна только опись листов, а не строки, загрузка только метаданных и выборочных листов — более дешёвая точка входа
Один бэкенд на формат, по одному циклу сканирования у каждого
У каждого формата ровно один forward-сканер внутри HotXLS, и и pull-курсор, и callback-читатель гоняют тот же сканер. TXLSXForwardRowBackend — единственная SAX машина состояний листа для частей листов ECMA-376 Part 1 §18.3, держащая XML-ридер, таблицу shared-формул и парсер rich-text, и она продвигается ровно к одной физической границе <row> за вызов. TXLSBiffForwardParser владеет глобалами, выбором листа и продвижением строк для потока записей [MS-XLS]; сделать его паузируемым породило самое острое ограничение во всём дизайне, потому что кэшированная строковая формула — это запись Formula, за которой немедленно следует запись String, поэтому точка приостановки на строку никогда не должна попадать между ними. TXLSForwardTextBackend держит BOM-осведомлённый ридер, активный разделитель и одну логическую запись — CSV принюхивается к запятой, точке с запятой, табуляции или пайпу по первой записи, игнорируя символы в кавычках, а многострочные кавыченные поля склеиваются #10, так что номер строки отслеживает логические записи, а не физические переводы строк. TXLSForwardOdsBackend держит один физический шаблон строки для таблиц OpenDocument §9, трактует table:number-rows-repeated как оставшийся счётчик, а не развёртывание, и продвигается мимо покрытых ячеек, не выдавая значений. Потоковый прямой читатель разделяет тот же загрузчик shared-string и стилей дат
Почему шесть состояний вместо одного флага Eof?
Потому что один булев делает четыре разные ситуации неразличимыми, и вызывающие угадывают неправильно обо всех. TXLSRowCursorState называет их явно
xrcsClosed— источник не открытxrcsBeforeFirst— открыт или перенацелен, строк ещё не читалиxrcsActive— стоит на валидной строкеxrcsEof— лист потреблён до концаxrcsCancelled— вызывающий остановил проход намеренноxrcsFaulted— проход провалился и исходное исключение поднято
Последнее различие — то, что важно в продакшене. Отсутствующая часть листа или провалившийся старт прохода сохраняет свой EReadError и переводит курсор в xrcsFaulted; оно никогда не понижается до простого False, который вызывающий прочитал бы как «этот лист был пуст». Cancel намеренно более узок, чем Close: он закрывает бэкенд текущего листа и его inflate-подпоток и инвалидирует текущую строку, но не освобождает ZIP-архив или исходный поток, а вызов его дважды — no-op. После отмены вы возобновляете явным вызовом SelectSheet — курсор не будет тихо перезапускать проход за вас. Владение потоком следует тому же защитному правилу: xsoBorrowed — умолчание и восстанавливает позицию потока при закрытии, xsoOwned передаёт владение только после того, как Open уже успехся, так что провалившееся открытие никогда не освободит поток, который вызывающий всё ещё держит
var
Cursor: TXLSRowCursor;
Src: TFileStream;
begin
Src := TFileStream.Create('quarter.ods', fmOpenRead or fmShareDenyWrite);
try
Cursor := TXLSRowCursor.Create;
try
// xsoBorrowed: курсор никогда не освобождает Src, а Close восстанавливает
// позицию, которую поток имел при вызове Open
if not Cursor.Open(Src, xffAuto, xsoBorrowed) then
Exit;
if Cursor.FindFirst then
repeat
if UserPressedStop then
begin
Cursor.Cancel; // закрывает только бэкенд листа и его
Break; // inflate-подпоток; идемпотентно
end;
until not Cursor.FindNext;
case Cursor.State of
xrcsEof: Log('sheet consumed to the end');
xrcsCancelled: Log('stopped by the operator');
xrcsFaulted: Log('pass failed; the EReadError was already raised');
end;
finally
Cursor.Free;
end;
finally
Src.Free; // всё ещё наш, всё ещё валиден, позиция восстановлена
end;
end;
Заимствование текущей строки без её копирования
IXLSRowCursorView передаёт строку другой рутине, не дублируя массив ячеек. Представление хранит общий guard, держащий указатель курсора плюс счётчик поколений UInt64; продвижение, выбор листа, отмена, закрытие и уничтожение курсора все инкрементируют это поколение, а уничтожение дополнительно очищает владельца guard'а. Поэтому протухшее представление не может читать освобождённую память: Valid — зонд без исключений, который можно звать в любой момент, тогда как каждый другой член сначала валидируется и поднимает EXLSRowCursorViewInvalidated. Будьте честны насчёт того, что это за контракт — это lifetime fail-fast, а не гарантия потокобезопасности, и он не лицензирует чтение строки из второго потока, пока первый продвигает курсор
var
View: IXLSRowCursorView;
Cell: TXLSRowCursorCell;
I: Integer;
begin
if Cursor.FindFirst then
repeat
View := Cursor.CurrentRowView; // заимствует; массив ячеек не копируется
for I := 0 to View.CellCount - 1 do
begin
Cell := View.Cells[I];
if Cell.HasFormula and not Cell.FormulaTextAvailable then
UseCachedResult(Cell.Value) // BIFF forward-чтение держит
else if Cell.Kind = xdkEmpty then // кэшированный результат, а не токены
UseStyleOnly(Cell.StyleIndex) // Blank / MulBlank — реальные ячейки
else
UseValue(Cell.Col, Cell.Value);
end;
until not Cursor.FindNext;
// Интерфейс переживает цикл, но строка за ним — нет
if not View.Valid then // Valid никогда не бросает; Cells[] теперь бросил бы
View := nil; // EXLSRowCursorViewInvalidated
end;
PeakRowBufferedBytes и что ему позволено доказывать
PeakRowBufferedBytes существует, чтобы продемонстрировать, что память отслеживает ширину строки, а не счёт строк. Он накапливает записи ячеек, Variants, строки формул и rich-text нагрузки текущей выходной строки и подмешивает специфичный для формата рабочий набор — логическую запись CSV, физический шаблон строки ODS, пик записей BIFF или сырую ячейку XLSX, декодируемую сейчас. Читайте его вместе с SheetPassesStarted, который считает, сколько проходов листов реально начались. Две оговорки сохраняют честность: цифра — оценка, а не точный учёт кучи, и она монотонна с момента последнего Open, так что это инструмент отладки и регрессии, а не живой датчик. О более широкой картине, куда уходят время и байты на очень больших книгах, смотрите производительность больших книг в Delphi
Push-читатель стал адаптером, и чего курсор делать не будет
TXLSForwardReader больше не несёт отдельных точек входа сканирования XLSX, BIFF и текста. Он настраивает курсор, проходит его и переводит текущую строку в события OnSheet и OnCell, поэтому два фасада больше не могут разойтись в фильтрации, состоянии формул или обработке ошибок. Два следствия стоит знать до обновления: callback SheetIndex теперь единообразно с единицы на TXLSForwardReader (TXLSDirectReader сохраняет свой существующий контракт событий с нуля), а OnSheet срабатывает до SelectSheet, поэтому установка SkipSheet значит, что часть листа вообще никогда не открывается и не распаковывается. Границы столь же явны: книга не должна модифицироваться, пока проход активен, отмена требует явного перезапуска, а BIFF forward-путь никогда не декомпилирует токены формул, поэтому классические формульные ячейки сообщают HasFormula true с FormulaTextAvailable false и выдают вам кэшированный результат вместо выдумывания пустой строки формулы. Курсор строк и его адаптер прошли 1,298 проверок на Delphi Win32 и Win64 плюс статический пакет C++Builder 37.0 Win64
Если вы взвешиваете pull-курсор против загрузчика, который у вас сейчас, вопрос не в том, какой парсит быстрее, а в том, какой позволяет написать условие выхода, которое вам реально нужно. Полные детали компонента, поддерживаемые версии IDE и лицензирование — на странице HotXLS Delphi spreadsheet component