Технічна стаття

Помилка циклу for у FPC: клацання «Скасувати» не зупиняє друк

Клацання «Скасувати» під час завдання друку в переглядачі компонента PDFium іноді нічого не робило: цикл продовжував рендерити кожну сторінку та копію, що залишилися, і завдання все одно доходило до принтера. Причиною був цикл for у Free Pascal, що фіксує свою верхню межу один раз при вході в цикл, тож обнулення змінної за CopyCount чи ToPage посеред циклу нічого не змінювало в тому, що вже виконувалося. Помилка меж циклу for — п'ятий підводний камінь, що вийшов із того самого аудиту сумісності Delphi/FPC для PDFiumPas, який дав чотири інші пастки крос-компілятора, і на відміну від тих чотирьох, вона живе повністю всередині процедури друку переглядача — знадобився тестувальник, що тримав «Скасувати» протягом довгого завдання, щоб справді помітити, що принтер ніколи не зупинявся

Чому завдання друку продовжується після клацання «Скасувати»?

Завдання друку продовжувалося, бо перевірка скасування скидала лише змінні, що живили межі циклу, не самі цикли, які Free Pascal уже зафіксував, коли кожен цикл почався. Обробник SpeedButtonPrintClick за кнопкою Print у демонстраціях PDFViewer та MultiPageViewer будує кожне завдання з трьох вкладених циклів: зовнішній цикл по зібраних наборах копій, середній цикл по сторінках і внутрішній цикл по незібраних копіях тієї самої сторінки. PrintDialog.Collate вирішує, який лічильник, CollateCopyCount чи CopyCount, насправді тримає запитану кількість копій, тоді як інший залишається на одиниці. Скасування має досягти всіх трьох рівнів одночасно, і перша версія цього коду намагалася зробити це, скидаючи самі змінні межі тієї миті, коли Cancel ставав true

for CollateCopy:= 1 to CollateCopyCount do
  for Page:= FromPage to ToPage do
    for Copy:= 1 to CopyCount do
    begin
      // ... render the page and send it to the printer ...
      Application.ProcessMessages;
      if Cancel then
      begin
        CollateCopyCount:= 0;
        ToPage:= 0;
        CopyCount:= 0;
      end;
    end;
Printer.EndDoc;  // runs whether or not Cancel fired

Задум читається достатньо ясно: якщо лічильники, що визначають, скільки зшивань, сторінок і копій залишилося, усі падають до нуля, циклам мала б скінчитися робота, і вони мали б завершитися самі. Printer.EndDoc потім виконувався безумовно після циклу незалежно від того, як він завершився, тож навіть завдання, яке користувач вважав зупиненим, усе одно подавалося в спулер із кожною сторінкою, відрендереною до того, як клацання було помічено

Що Free Pascal фіксує, коли цикл for починається

Free Pascal обчислює кінцеве значення циклу for рівно один раз, у момент початку циклу, і ніколи знову протягом життя цього циклу. for Page := FromPage to ToPage do читає ToPage один-єдиний раз, щоб обчислити, скільки ітерацій виконати, і після цього цикл більше не цікавиться змінною на ім'я ToPage, лише кількістю ітерацій, яку вже захопив. Встановлення ToPage := 0 зсередини тіла циклу змінює змінну, з якою запущений цикл уже не консультується, і саме тому Cancel міг бути true, поки принтер продовжував отримувати сторінки ще кілька ітерацій, іноді всі з них

Цей шаблон — цілком розумна звичка, перенесена з C чи C++, і демонстрація переглядача C++Builder для компонента PDFium віддзеркалює версію Pascal достатньо близько, щоб зробити порівняння прямим. Його for (Copy = 1; Copy <= CopyCount; Copy++) заново перевіряє Copy <= CopyCount проти живого значення CopyCount на кожному проході, тож обнулення лічильника там справді закінчує цикл на наступній перевірці. Демонстрація C++Builder несла ідентичний код «обнули лічильники», і він працював, і саме це змусило ту саму ідею виглядати безпечною для повторного використання в збірці Pascal, що сиділа поряд у тому самому репозиторії

Чому збірка Delphi не натрапила на ту саму помилку?

Демонстрація PDFViewer для Delphi ніколи не залежала від того, чи цикл помітить змінену межу, бо її шлях скасування розгортається через виняток замість порівняння. Її наймутріший цикл викликає процедуру RTL Abort тієї миті, коли Cancel стає true, що піднімає тихий EAbort, який поширюється прямо через усі три вкладені цикли for до обробника, огорнутого навколо всього блоку друку

Printer.BeginDoc;
try
  for CollateCopy:= 1 to CollateCopyCount do
    for Page:= FromPage to ToPage do
      for Copy:= 1 to CopyCount do
      begin
        // ... render the page and send it to the printer ...
        Application.ProcessMessages;
        if Cancel then
          Abort;  // raises EAbort, unwinds all three loops at once
      end;
  Printer.EndDoc;
except
  on E: EAbort do
    Printer.Abort;
else
  begin
    Printer.Abort;
    raise;
  end;
end;

Виняток не переймається тим, скільки циклів for відокремлюють точку, де він піднятий, від обробника, що його ловить, і саме ця властивість потрібна цій проблемі. Ця стійкість не була навмисним захистом проти поведінки фіксації межі, описаної вище, — автор демонстрації Delphi просто скористався іншим інструментом. Утечу на основі винятків усе одно варто назвати міцнішим шаблоном: він переживе додавання четвертого рівня вкладеності пізніше, тоді як ланцюжок вручну розміщених операторів Break доводиться пам'ятати й повторно додавати щоразу, коли вкладеність циклу змінюється

Виправлення: Break на кожному рівні вкладеності, з воротами через прапорець PrintSucceeded

Виправлення, що з'явилося в PDFiumPas v2.27.0, зберігає триярусну структуру в демонстраціях переглядача для Lazarus та C++Builder, але робить скасування явним на кожному рівні, і воно відокремлює зупинку циклу від подання завдання в прапорець, що читається лише після того, як цикл повністю завершився

Printer.BeginDoc;
try
  Cancel:= False;
  for CollateCopy:= 1 to CollateCopyCount do
  begin
    for Page:= FromPage to ToPage do
    begin
      for Copy:= 1 to CopyCount do
      begin
        // ... render the page and send it to the printer ...
        Application.ProcessMessages;
        if Cancel then
          Break;
      end;
      if Cancel then
        Break;
    end;
    if Cancel then
      Break;
  end;
  PrintSucceeded:= not Cancel;
finally
  if PrintSucceeded then
    Printer.EndDoc
  else
    Printer.Abort;
end;

PrintSucceeded навмисно обчислюється один раз, одразу після виходу з потрійно вкладеного циклу, лише з not Cancel. Ніщо всередині тіла циклу не отримує права самостійно вирішувати, чи вдалося завдання, — цикл може закінчитися лише двома способами: закінчаться зшивання, сторінки й копії, або спрацює ланцюжок Break, який запускає Cancel, а PrintSucceeded читає результат заднім числом, а не відстежує його в міру виконання циклу. Обчислення саме так робить вибір блоку finally між Printer.EndDoc та Printer.Abort надійним: він ніколи не спрацьовує до того, як цикл справді врегулювався

Аудит власних циклів друку, керованих скасуванням

Три перевірки поширюються далеко за межі цієї однієї процедури друку. Ніколи не припускайте, що цикл for у Pascal помітить зміну змінної межі після того, як він почався; якщо циклу потрібно закінчитися рано, скажіть це прямо через Break, на кожному рівні вкладеності, який скасування має перетнути, а не лише на наймутрішому. Надавайте перевагу механізму втечі, що розгортається за побудовою, такому як виняток, щойно вкладеність достатньо глибока, щоб пропущений Break був правдоподібним — пара Abort/EAbort демонстрації Delphi отримала це безкоштовно. Ставте будь-який крок фіксації на кшталт EndDoc за прапорцем, обчисленим строго після циклу, ніколи всередині нього, тож завдання, що зупинилося рано, ніколи не можна сплутати з тим, що завершилося

Той самий перегляд, що виправив цей цикл, також посилив, як дев'ять демонстрацій переглядача обробляють клавіатуру, поки видима кнопка скасування: тепер вони поглинають кожну клавішу, крім Esc, тож випадковий Ctrl+P чи Ctrl+F під час активного друку чи пошуку більше не може запустити другий поверх нього. Це виправлення повторного входу — інша помилка з іншим механізмом, але воно вийшло з того самого перегляду того, що Cancel насправді робить посеред операції. Звичка, яку варто винести з обох виправлень, — швидше звичка тестування, ніж кодування: перевіряйте скасування на кожному компіляторі, до якого справді постачається спільна процедура друку, бо ідея на кшталт очищення лічильників і довіри циклу помітити це може пережити тестування під C++Builder, дійти до збірки Free Pascal неперевіреною, читатися ідентично в джерелі й провалитися так, що ніхто не побачить, доки хтось не потримає «Скасувати» достатньо довго, щоб побачити, як завдання все одно завершується. Про налаштування друку, на якому сидить ця логіка скасування, дивіться огляд друку документів PDF за допомогою компонента PDFium VCL

Це виправлення скасування друку постачається як частина компонента PDFium для Delphi, C++Builder та FPC/Lazarus, який виконує той самий набір демонстрацій на всіх трьох компіляторах при кожному релізі, тож прогалини на кшталт цієї ловляться матрицею збірки, а не заявкою в підтримку