Клацання «Скасувати» під час завдання друку в переглядачі компонента 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, який виконує той самий набір демонстрацій на всіх трьох компіляторах при кожному релізі, тож прогалини на кшталт цієї ловляться матрицею збірки, а не заявкою в підтримку