Kliknutie na Cancel počas tlačovej úlohy v prehliadači postavenom na PDFium Component niekedy neurobilo vôbec nič: cyklus pokračoval vo vykresľovaní každej zostávajúcej strany a kópie a úloha sa aj tak dostala na tlačiareň. Príčinou bol cyklus for vo Free Pascale, ktorý si svoju hornú hranicu fixuje jedenkrát, pri vstupe do cyklu, takže vynulovanie premennej za CopyCount alebo ToPage uprostred cyklu nezmenilo nič na tom, čo sa už vykonávalo. Chyba s hranicami cyklu for je piate úskalie vzišlé z toho istého auditu kompatibility Delphi/FPC v knižnici PDFiumPas, ktorý priniesol štyri ďalšie úskalia medzi kompilátormi, a na rozdiel od tamtých štyroch sa táto skrýva celá vnútri tlačovej rutiny prehliadača – trvalo, kým tester počas dlhej úlohy podržal Cancel a naozaj si všimol, že tlačiareň sa nikdy nezastavila
Prečo tlačová úloha pokračuje aj po kliknutí na Cancel?
Tlačová úloha pokračovala preto, lebo kontrola zrušenia resetovala len premenné napájajúce hranice cyklov, nie samotné cykly, ktoré si Free Pascal už uzamkol v okamihu, keď každý z nich začal. Obslužná rutina SpeedButtonPrintClick za tlačidlom Print v ukážkach PDFViewer a MultiPageViewer zostavuje každú úlohu z troch vnorených cyklov: vonkajší cyklus cez kolacionované sady kópií, stredný cyklus cez strany a vnútorný cyklus cez nekolacionované kópie tej istej strany. PrintDialog.Collate rozhoduje, ktorý počítadlo, CollateCopyCount alebo CopyCount, skutočne drží požadovaný počet kópií, zatiaľ čo to druhé zostáva na jednej. Zrušenie musí zasiahnuť všetky tri úrovne naraz a prvá verzia tohto kódu sa o to pokúšala tak, že v okamihu, keď sa Cancel stalo pravdivým, resetovala samotné premenné hraníc
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
Zámer je celkom čitateľný: ak počítadlá, ktoré určujú počet zostávajúcich kolácií, strán a kópií, klesnú na nulu, cyklom by mala dôjsť práca a mali by sa samy ukončiť. Printer.EndDoc sa potom spúšťal bezpodmienečne po cykle bez ohľadu na to, ako sa skončil, takže aj úloha, o ktorej sa používateľ domnieval, že ju zastavil, sa aj tak odoslala do frontu tlačiarne so všetkými stranami vykreslenými skôr, než si niekto všimol kliknutie
Čo si Free Pascal uzamkne pri štarte cyklu for
Free Pascal vyhodnotí konečnú hodnotu cyklu for presne raz, v okamihu, keď sa cyklus začína, a už nikdy viac počas jeho životnosti. for Page := FromPage to ToPage do prečíta ToPage jedenkrát, aby si vypočítal počet iterácií, a potom sa cyklus o premennú s menom ToPage už ďalej vôbec nezaujíma – iba o počet iterácií, ktorý si už zachytil. Nastavenie ToPage := 0 zvnútra tela cyklu mení premennú, ktorú bežiaci cyklus už nekonzultuje, a to je presne dôvod, prečo mohlo byť Cancel pravdivé, kým tlačiareň dostávala ďalšie strany ešte niekoľko ďalších iterácií, niekedy všetky
Tento vzor je úplne rozumný zvyk prenesený z C alebo C++ a demo prehliadača v C++Builder pre PDFium Component je pascalovej verzii dostatočne podobné na priamu porovnateľnosť. Jeho for (Copy = 1; Copy <= CopyCount; Copy++) pri každom priechode znovu testuje Copy <= CopyCount voči aktuálnej hodnote CopyCount, takže vynulovanie počítadla tam cyklus pri ďalšej kontrole naozaj ukončí. Demo v C++Builder obsahovalo identický kód nulujúci počítadlá a fungoval – čo presne spôsobilo, že sa rovnaká myšlienka javila ako bezpečná na opätovné použitie v pascalovskej zostave sediacej hneď vedľa v tom istom repozitári
Prečo sa rovnaká chyba neprejavila v zostave Delphi?
Demo PDFViewer v Delphi sa nikdy nespoliehalo na to, že cyklus zaznamená zmenenú hranicu, pretože jeho cesta pri zrušení sa odvíja cez výnimku, nie cez porovnanie. Jeho najvnútornejší cyklus zavolá procedúru Abort z RTL v okamihu, keď sa Cancel stane pravdivým, čo vyvolá tichú výnimku EAbort, ktorá sa priamo prenesie cez všetky tri vnorené cykly for až po obsluhu obalenú okolo celého bloku tlače
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;
Výnimke je jedno, koľko cyklov for oddeľuje miesto, kde bola vyvolaná, od obsluhy, ktorá ju zachytí, a práve to je vlastnosť, ktorú tento problém potrebuje. Táto odolnosť nebola zámernou obranou proti vyššie opísanému uzamykaniu hraníc – autor dema v Delphi jednoducho siahol po inom nástroji. Únik založený na výnimke stojí za to spomenúť ako robustnejší vzor: prežije aj neskoršie pridanie štvrtej úrovne zanorenia, zatiaľ čo reťaz ručne umiestnených príkazov Break si treba pri každej zmene zanorenia cyklu znovu pamätať a znovu dopĺňať
Oprava: Break na každej úrovni zanorenia, riadené príznakom PrintSucceeded
Oprava, ktorá vyšla vo verzii PDFiumPas v2.27.0, zachováva trojúrovňovú štruktúru cyklov v ukážkach prehliadača pre Lazarus a C++Builder, ale robí zrušenie explicitné na každej úrovni a oddeľuje zastavenie cyklu od odoslania úlohy do príznaku, ktorý sa číta až po úplnom skončení cyklu
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 sa zámerne vypočíta jedenkrát, hneď po skončení trojnásobne vnoreného cyklu, jednoducho ako not Cancel. Nič vnútri tela cyklu samo nerozhoduje o tom, či bola úloha úspešná – cyklus môže skončiť len dvoma spôsobmi: dôjdením kolácií, strán a kópií, alebo narazením na reťaz Break, ktorú spúšťa Cancel, a PrintSucceeded číta výsledok až spätne, namiesto toho, aby ho sledoval počas behu cyklu. Práve takýto spôsob výpočtu robí voľbu v bloku finally medzi Printer.EndDoc a Printer.Abort dôveryhodnou: nikdy sa nespustí skôr, než sa cyklus skutočne ustáli
Audit vlastných tlačových cyklov riadených zrušením
Ďaleko za hranice tejto jednej tlačovej rutiny siahajú tri kontroly. Nikdy nepredpokladajte, že si pascalovský cyklus for všimne zmenu premennej hranice po tom, čo už začal; ak má cyklus skončiť predčasne, povedzte to priamo pomocou Break, na každej úrovni zanorenia, ktorú zrušenie musí prekročiť, nielen na tej najvnútornejšej. Uprednostnite mechanizmus úniku, ktorý sa odvíja konštrukčne, ako je výnimka, akonáhle je zanorenie dostatočne hlboké na to, aby bol vynechaný Break reálnou možnosťou – dvojica Abort/EAbort v deme pre Delphi to dostala zadarmo. Akýkoľvek krok potvrdenia, ako EndDoc, podmieňte príznakom vypočítaným striktne až po cykle, nikdy vnútri neho, aby sa úloha, ktorá skončila predčasne, nikdy nedala zameniť s tou, ktorá skutočne doběhla
Rovnaká revízia, ktorá opravila tento cyklus, tiež sprísnila spôsob, akým deväť ukážkových prehliadačov spracúva klávesnicu, kým je viditeľné tlačidlo zrušenia: teraz pohltia každý kláves okrem Esc, takže náhodný Ctrl+P alebo Ctrl+F počas prebiehajúcej tlače alebo vyhľadávania už nemôže naštartovať ďalšiu operáciu navrch tej prvej. Táto oprava reentrancie je iná chyba s iným mechanizmom, no vzišla z tej istej revízie toho, čo Cancel vlastne robí uprostred operácie. Zvyk, ktorý si z oboch opráv oplatí odniesť, je skôr testovací než programátorský: overujte zrušenie na každom kompilátore, na ktorom zdieľaná tlačová rutina skutočne beží, pretože myšlienka vynulovať počítadlá a spoľahnúť sa, že si to cyklus všimne, môže prežiť testovanie pod C++Builderom, dostať sa neoverená do zostavy pre Free Pascal, čítať sa v zdrojovom kóde identicky a zlyhať spôsobom, ktorý si nikto nevšimne, kým niekto nepodrží Cancel dostatočne dlho na to, aby videl, že úloha sa aj tak dokončila. Pre tlačové nastavenie, na ktorom táto logika zrušenia stojí, pokrýva zvyšok návod na tlač PDF dokumentov s VCL komponentom PDFium
Táto oprava zrušenia tlače vyšla ako súčasť PDFium Component pre Delphi, C++Builder a FPC/Lazarus, ktorý pri každom vydaní spúšťa rovnakú sadu ukážok naprieč všetkými tromi kompilátormi, takže takéto medzery zachytí zostavovacia matica namiesto ticketu z podpory