Techninis straipsnis

FPC ciklo klaida: paspaudus Cancel spausdinimas nesustoja

PDFium Component peržiūros programoje spausdinant paspaudus Cancel kartais niekas neįvykdavo: ciklas toliau atvaizduodavo visus likusius puslapius ir kopijas, o užduotis vis tiek pasiekdavo spausdintuvą. Priežastis buvo Free Pascal for ciklas, kuris viršutinę ribą nustato vieną kartą ciklo pradžioje, todėl ciklo viduryje nustačius nulį kintamiesiems, naudojamiems CopyCount arba ToPage, jau vykstančiam ciklui tai nieko nepakeisdavo. Ši for ciklo ribų klaida yra penktoji problema, aptikta per tą patį PDFiumPas Delphi/FPC suderinamumo auditą, per kurį rastos keturios kitos kelių kompiliatorių pinklės, o nuo jų ji skiriasi tuo, kad visiškai slypi peržiūros programos spausdinimo procedūroje — ją iš tikrųjų pastebėjo tik testuotojas, per ilgą užduotį laikęs nuspaustą Cancel ir pamatęs, kad spausdintuvas vis tiek nesustojo

Kodėl spausdinimo užduotis nesustoja paspaudus Cancel?

Spausdinimo užduotis tęsėsi todėl, kad atšaukimo tikrinimas iš naujo nustatydavo tik kintamuosius, iš kurių buvo gaunamos ciklų ribos, o ne pačius ciklus, kurių ribas Free Pascal jau buvo užfiksavęs kiekvieno ciklo pradžioje. PDFViewer ir MultiPageViewer demonstracinių programų spausdinimo mygtuko SpeedButtonPrintClick apdorojimo įvykis kiekvieną užduotį sudaro iš trijų įdėtų ciklų: išorinis ciklas eina per sugrupuotus kopijų rinkinius, vidurinis — per puslapius, o vidinis — per nesugrupuotas to paties puslapio kopijas. PrintDialog.Collate nustato, kuriame skaitiklyje, CollateCopyCount ar CopyCount, laikomas prašomas kopijų skaičius, o kito reikšmė lieka lygi vienetui. Atšaukimas turi pasiekti visus tris lygius vienu metu, tačiau pirmoji šio kodo versija bandė tai padaryti vos Cancel tapus true iš naujo nustatydama ribų kintamuosius

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

Sumanymas atrodo pakankamai aiškus: jei visi skaitikliai, apibrėžiantys likusių grupių, puslapių ir kopijų skaičių, nukrenta iki nulio, ciklai turėtų patys pritrūkti darbo ir baigtis. Tačiau Printer.EndDoc po ciklo buvo vykdomas besąlygiškai, nepaisant to, kaip ciklas baigėsi, todėl net užduotis, kurią naudotojas manė sustabdęs, vis tiek buvo pateikta spausdinimo eilei su visais iki pastebėto paspaudimo atvaizduotais puslapiais

Ką Free Pascal užfiksuoja pradėjus for ciklą

Free Pascal for ciklo galutinę reikšmę įvertina tiksliai vieną kartą, ciklo pradžioje, ir per visą to ciklo gyvavimo laiką jos nebeskaičiuoja. for Page := FromPage to ToPage do vieną kartą nuskaito ToPage, kad apskaičiuotų, kiek iteracijų atlikti, o vėliau ciklas nebesidomi kintamuoju vardu ToPage — jam rūpi tik jau užfiksuotas iteracijų skaičius. Iš ciklo kūno nustačius ToPage := 0 pakeičiamas kintamasis, kurio vykdomas ciklas jau nebeskaito, todėl Cancel galėjo būti true, o spausdintuvas dar kelias iteracijas, kartais net visas, gaudavo naujus puslapius

Tokį modelį visiškai suprantama perimti iš C ar C++, o PDFium Component C++Builder peržiūros programos demonstracinis pavyzdys yra pakankamai panašus į Pascal variantą, kad palyginimas būtų tiesioginis. Jo for (Copy = 1; Copy <= CopyCount; Copy++) kiekvieno praėjimo metu iš naujo tikrina Copy <= CopyCount pagal einamąją CopyCount reikšmę, todėl ten nustačius skaitiklį iki nulio ciklas iš tikrųjų baigiasi per kitą tikrinimą. C++Builder demonstracinėje programoje buvo toks pats skaitiklių nunulinimo kodas, ir jis veikė, todėl tą pačią idėją buvo lengva laikyti saugia Pascal versijoje, esančioje šalia to paties saugyklos kodo

Kodėl Delphi versijoje ši klaida nepasireiškė?

Delphi PDFViewer demonstracinė programa niekada nepriklausė nuo to, kad ciklas pastebės pasikeitusią ribą, nes jos atšaukimo kelias nutraukiamas per išimtį, o ne per palyginimą. Vidiniame cikle iškviečiama RTL procedūra Abort vos Cancel tapus true, ji sukelia tylią EAbort išimtį, o ši iš karto pereina per visus tris įdėtus for ciklus iki visą spausdinimo bloką gaubiančios apdorojimo dalies

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;

Išimtis nepriklauso nuo to, kiek for ciklų skiria jos sukėlimo vietą nuo ją pagaunančios apdorojimo dalies, todėl būtent ši savybė tinka šiai problemai. Išimtimis grindžiamas išėjimas nebuvo sąmoninga apsauga nuo pirmiau aprašyto ribų fiksavimo elgesio — Delphi demonstracinės programos autorius tiesiog pasirinko kitą įrankį. Vis dėlto išimties naudojimas yra tvirtesnis šablonas: jis išlieka veiksmingas vėliau pridėjus ketvirtą įdėjimo lygį, o rankiniu būdu išdėstytas Break komandų rinkinys turi būti prisimintas ir papildytas kiekvieną kartą pakeitus ciklų įdėjimą

Taisymas: Break kiekviename įdėjimo lygyje, valdomas PrintSucceeded vėliavėle

PDFiumPas v2.27.0 išleistas taisymas palieka tris ciklus Lazarus ir C++Builder peržiūros programų demonstraciniuose pavyzdžiuose, tačiau atšaukimą aiškiai apdoroja kiekviename lygyje, o ciklų sustabdymą atskiria nuo užduoties pateikimo naudodamas vėliavėlę, kuri nuskaitoma tik ciklui visiškai pasibaigus

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 tyčia apskaičiuojamas vieną kartą iš karto po to, kai baigiasi trigubai įdėtas ciklas, ir remiasi tik not Cancel. Ciklo kūne niekas negali savarankiškai nuspręsti, ar užduotis pavyko — ciklas gali baigtis tik dviem būdais: išnaudojęs grupes, puslapius ir kopijas arba pasiekęs Break grandinę, kurią sukelia Cancel, o PrintSucceeded rezultatą nuskaito jau įvykus faktui, o ne seka ciklui vykstant. Būtent taip apskaičiuojant finally bloko pasirinkimas tarp Printer.EndDoc ir Printer.Abort tampa patikimas: jis niekada neįvyksta prieš ciklui iš tikrųjų nusistovint

Kaip patikrinti savo atšaukiamus spausdinimo ciklus

Trys patikros praverčia ne tik šioje spausdinimo procedūroje. Niekada nemanykite, kad Pascal for ciklas pastebės po jo pradžios pakeistą ribos kintamąjį; jei ciklą reikia baigti anksčiau, aiškiai naudokite Break kiekviename įdėjimo lygyje, kurį turi peržengti atšaukimas, o ne tik vidiniame cikle. Kai įdėjimas tampa pakankamai gilus, kad galima pamiršti Break, pirmenybę teikite išėjimo mechanizmui, kuris pats užtikrina išvyniojimą, pavyzdžiui, išimčiai — Delphi demonstracinė programa tai gavo naudodama Abort/EAbort porą. Bet kokį patvirtinimo veiksmą, pavyzdžiui, EndDoc, susiekite su vėliavėle, apskaičiuota griežtai po ciklo, o ne jo viduje, kad anksti sustabdyta užduotis niekada nebūtų palaikyta baigta

Toje pačioje peržiūroje, kurioje buvo ištaisytas šis ciklas, taip pat sugriežtintas devynių peržiūros programų demonstracinių pavyzdžių klaviatūros valdymas, kai matomas atšaukimo mygtukas: dabar jie praleidžia visus klavišus, išskyrus Esc, todėl aktyviai spausdinant ar ieškant atsitiktinis Ctrl+P arba Ctrl+F nebegali pradėti antros operacijos. Ši pakartotinio įėjimo klaida turi kitą mechanizmą, tačiau ji taip pat išryškėjo peržiūrint, ką iš tikrųjų daro Cancel vykstant operacijai. Svarbiausia abiejų taisymų pamoka labiau susijusi su testavimu nei su kodavimu: atšaukimą reikia tikrinti kiekviename kompiliatoriuje, kuriam iš tikrųjų pateikiama bendra spausdinimo procedūra, nes tokia idėja kaip skaitiklių išvalymas ir pasitikėjimas, kad ciklas tai pastebės, gali sėkmingai praeiti C++Builder testus, pasiekti nepatikrintą Free Pascal versiją, šaltinyje atrodyti visiškai vienodai ir sugesti taip, kad niekas to nepastebėtų, kol kas nors pakankamai ilgai nelaikytų nuspausto Cancel ir nepamatytų, jog užduotis vis tiek baigėsi. Apie likusią spausdinimo sąranką, ant kurios remiasi ši atšaukimo logika, rašoma PDF dokumentų spausdinimo su PDFium VCL komponentu apžvalgoje

Šis spausdinimo atšaukimo taisymas buvo išleistas kartu su PDFium Component, skirtu Delphi, C++Builder ir FPC/Lazarus, kurio demonstracinių pavyzdžių rinkinys per kiekvieną leidimą paleidžiamas su visais trimis kompiliatoriais, kad tokios spragos būtų aptiktos kūrimo matricoje, o ne pagal naudotojo pranešimą