Tehnički članak

Greška u FPC for-petlji: klik na Cancel ne zaustavlja štampanje

Klik na Cancel tokom štampanja u pregledaču zasnovanom na PDFium Component-u ponekad nije radio ništa: petlja je nastavila da iscrtava sve preostale stranice i kopije, a zadatak je ipak stizao do štampača. Uzrok je bila Free Pascal-ova for petlja, koja fiksira gornju granicu pri ulasku u petlju, pa promena promenljive iza CopyCount ili ToPage usred petlje nije uticala na već pokrenuto izvršavanje. Greška sa granicama for-petlje peti je problem otkriven u istoj reviziji kompatibilnosti PDFiumPas-a između Delphija i FPC-a koja je pronašla još četiri problema pri prevođenju između kompajlera, ali se za razliku od njih nalazi u celosti u proceduri za štampanje pregledača — tester je morao da drži Cancel tokom dugog zadatka da bi primetio da se štampač nikada nije zaustavio

Zašto se zadatak štampanja nastavlja nakon klika na Cancel?

Zadatak štampanja se nastavlja jer provera otkazivanja resetuje samo promenljive koje određuju granice petlji, a ne i same petlje, čije je granice Free Pascal već zaključao pri njihovom pokretanju. Rukovalac SpeedButtonPrintClick iza dugmeta Print u demonstracijama PDFViewer i MultiPageViewer gradi svaki zadatak pomoću tri ugnježdene petlje: spoljašnja prolazi kroz grupisane setove kopija, srednja kroz stranice, a unutrašnja kroz pojedinačne kopije iste stranice. PrintDialog.Collate određuje koji brojač, CollateCopyCount ili CopyCount, zaista sadrži traženi broj kopija, dok drugi ostaje na jedan. Otkazivanje mora istovremeno da prođe kroz sva tri nivoa, a prva verzija ovog koda pokušala je to da uradi resetovanjem samih promenljivih granica čim Cancel postane 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

Namena deluje dovoljno jasno: ako brojači koji određuju broj grupisanih setova, stranica i kopija padnu na nulu, petlje bi trebalo same da ostanu bez posla i završe se. Printer.EndDoc se zatim bezuslovno izvršava nakon petlje, bez obzira na način završetka, pa se čak i zadatak za koji je korisnik verovao da ga je zaustavio i dalje šalje u red štampanja sa svim stranicama iscrtanim pre nego što je klik primećen

Šta Free Pascal zaključava kada for-petlja počne

Free Pascal izračunava završnu vrednost for petlje tačno jednom, u trenutku njenog pokretanja, i više je ne proverava tokom njenog trajanja. for Page := FromPage to ToPage do čita ToPage samo jednom da bi izračunao broj iteracija, a zatim petlju više ne zanima promenljiva po imenu ToPage, već samo broj iteracija koji je već zapamtila. Postavljanje ToPage := 0 unutar tela petlje menja promenljivu koju pokrenuta petlja više ne koristi, pa je upravo zato Cancel mogao da bude true dok je štampač još primao stranice kroz više narednih iteracija, ponekad kroz sve njih

To je sasvim razumna navika preneta iz C-a ili C++-a, a demonstracija PDFium Component-a za C++Builder dovoljno je slična Pascal verziji da poređenje bude neposredno. Njen for (Copy = 1; Copy <= CopyCount; Copy++) ponovo proverava Copy <= CopyCount protiv trenutne vrednosti CopyCount pri svakom prolazu, pa postavljanje brojača na nulu tamo zaista završava petlju pri sledećoj proveri. Demonstracija za C++Builder sadržala je isti kod za postavljanje brojača na nulu i radila je, zbog čega je ista ideja delovala bezbedno za ponovnu upotrebu u Pascal izgradnji koja se nalazila odmah pored nje u istom repozitorijumu

Zašto Delphi izgradnja nije imala istu grešku?

Delphi demonstracija PDFViewer-a nije zavisila od toga da petlja primeti promenjenu granicu, jer njen put otkazivanja izlazi kroz izuzetak umesto kroz poređenje. Njena najunutrašnja petlja poziva RTL proceduru Abort čim Cancel postane true, čime se podiže tihi EAbort koji prolazi kroz sve tri ugnježdene for petlje do rukovaoca obuhvaćenog celim blokom za štampanje

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;

Izuzetak ne zavisi od toga koliko se for petlji nalazi između mesta na kom je podignut i rukovaoca koji ga hvata, što je upravo svojstvo potrebno ovom problemu. Ta otpornost nije bila svesna zaštita od ponašanja zaključavanja granice opisanog iznad — autor Delphi demonstracije jednostavno je posegnuo za drugim mehanizmom. Izlaz zasnovan na izuzetku ipak vredi istaći kao robusniji obrazac: preživeće dodavanje četvrtog nivoa ugnježđivanja, dok se niz ručno postavljenih naredbi Break mora zapamtiti i ponovo dodati svaki put kada se promeni ugnježđivanje petlji

Ispravka: Break na svakom nivou ugnježđivanja, uz zastavicu PrintSucceeded

Ispravka isporučena u PDFiumPas v2.27.0 zadržava strukturu sa tri petlje u demonstracijama pregledača za Lazarus i C++Builder, ali eksplicitno obrađuje otkazivanje na svakom nivou i razdvaja zaustavljanje petlji od slanja zadatka pomoću zastavice koja se čita tek nakon potpunog završetka petlje

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 se namerno izračunava jednom, neposredno nakon izlaska iz trostruko ugnježdene petlje, samo na osnovu vrednosti not Cancel. Ništa unutar tela petlje ne odlučuje samostalno da li je zadatak uspešan — petlja može da se završi samo na dva načina: tako što potroši sve grupisane setove, stranice i kopije ili tako što naiđe na niz Break naredbi koje pokreće Cancel. PrintSucceeded čita ishod naknadno umesto da ga prati dok petlja radi. Upravo tako izbor između Printer.EndDoc i Printer.Abort u bloku finally ostaje pouzdan: ne izvršava se pre nego što se petlja zaista završi

Provera sopstvenih petlji za štampanje sa otkazivanjem

Tri provere lako se primenjuju i van ove konkretne procedure za štampanje. Nikada nemojte pretpostaviti da će Pascal for petlja primetiti promenu promenljive granice nakon pokretanja; ako petlja treba ranije da se završi, navedite to direktno pomoću Break, na svakom nivou ugnježđivanja kroz koji otkazivanje mora da prođe, a ne samo u najunutrašnjoj petlji. Kada je ugnježđivanje dovoljno duboko da se zaboravljena naredba Break može očekivati, prednost dajte mehanizmu izlaska koji po konstrukciji razmotava stek, kao što je izuzetak — Delphi demonstracija je to dobila bez dodatnog rada zahvaljujući paru Abort/EAbort. Svaki korak potvrde, kao što je EndDoc, uslovite zastavicom izračunatom strogo nakon petlje, nikada unutar nje, da zadatak koji je ranije zaustavljen nikada ne bi mogao da bude proglašen završenim

Ista revizija koja je ispravila ovu petlju dodatno je pooštrila način na koji devet demonstracija pregledača obrađuje tastaturu dok je dugme za otkazivanje vidljivo: sada ignorišu svaki taster osim Esc, pa slučajni Ctrl+P ili Ctrl+F tokom aktivnog štampanja ili pretrage više ne može da pokrene drugi zadatak preko prvog. Ova ispravka ponovnog ulaska jeste druga greška sa drugačijim mehanizmom, ali je proistekla iz iste provere šta Cancel zaista radi usred operacije. Pouka koju vredi preneti iz obe ispravke više je testerska nego programska: proverite otkazivanje na svakom kompajleru za koji se zajednička procedura za štampanje isporučuje, jer ideja poput brisanja brojača i očekivanja da će petlja to primetiti može proći testiranje pod C++Builder-om, dospeti neproverena u Free Pascal izgradnju, izgledati isto u izvornom kodu i otkazati na način koji niko ne vidi sve dok neko dovoljno dugo ne drži Cancel i ne posmatra kako se zadatak ipak završi. Za podešavanje štampanja na kom se ova logika otkazivanja zasniva, uputstvo za štampanje PDF dokumenata pomoću PDFium VCL komponente obuhvata ostatak

Ova ispravka otkazivanja štampanja isporučena je kao deo PDFium Component za Delphi, C++Builder i FPC/Lazarus, koji pri svakom izdanju pokreće isti skup demonstracija na sva tri kompajlera, pa se propusti poput ovog otkrivaju matricom izgradnje, a ne prijavom korisničkoj podršci