Τεχνικό Άρθρο

Σφάλμα βρόχου for του FPC: το κλικ Cancel δεν σταματά την εκτύπωση

Το κλικ στο Cancel κατά τη διάρκεια μιας εργασίας εκτύπωσης σε έναν viewer PDFium Component μερικές φορές δεν έκανε τίποτα: ο βρόχος συνέχιζε να αποδίδει κάθε εναπομείνασα σελίδα και αντίγραφο, και η εργασία εξακολουθούσε να φτάνει στον εκτυπωτή. Η αιτία ήταν ο βρόχος for του Free Pascal, ο οποίος καθορίζει το άνω όριό του μία φορά κατά την είσοδο στον βρόχο, οπότε το μηδενισμός της μεταβλητής πίσω από το CopyCount ή το ToPage στη μέση του βρόχου δεν άλλαζε τίποτα που ήδη έτρεχε. Το σφάλμα ορίων βρόχου for είναι η πέμπτη παγίδα που προέκυψε από τον ίδιο έλεγχο συμβατότητας Delphi/FPC του PDFiumPas που παρήγαγε τέσσερις άλλες παγίδες μεταξύ compiler, και σε αντίθεση με εκείνες τις τέσσερις ζει εξ ολοκλήρου μέσα στη ρουτίνα εκτύπωσης ενός viewer — χρειάστηκε ένας δοκιμαστής να κρατά το Cancel κατά τη διάρκεια μιας μακράς εργασίας για να παρατηρήσει πράγματι ότι ο εκτυπωτής ποτέ δεν σταματούσε

Γιατί συνεχίζει η εργασία εκτύπωσης μετά το κλικ στο Cancel;

Η εργασία εκτύπωσης συνέχιζε επειδή ο έλεγχος ακύρωσης επαναφέρει μόνο τις μεταβλητές που τροφοδοτούσαν τα όρια του βρόχου, όχι τους ίδιους τους βρόχους, τα οποία το Free Pascal είχε ήδη κλειδώσει όταν ξεκίνησε κάθε βρόχος. Ο χειριστής SpeedButtonPrintClick πίσω από το κουμπί Print στις επιδείξεις PDFViewer και MultiPageViewer χτίζει κάθε εργασία από τρεις εμφωλευμένους βρόχους: έναν εξωτερικό βρόχο πάνω από συρραμμένα (collated) σύνολα αντιγράφων, έναν μεσαίο βρόχο πάνω από σελίδες, και έναν εσωτερικό βρόχο πάνω από μη συρραμμένα αντίγραφα της ίδιας σελίδας. Το PrintDialog.Collate αποφασίζει ποιος μετρητής, CollateCopyCount ή CopyCount, πράγματι κρατά τον ζητούμενο αριθμό αντιγράφων, ενώ ο άλλος παραμένει σε ένα. Η ακύρωση πρέπει να φτάσει και στα τρία επίπεδα ταυτόχρονα, και η πρώτη έκδοση αυτού του κώδικα προσπάθησε να το κάνει αυτό επαναφέροντας τις ίδιες τις μεταβλητές ορίου τη στιγμή που το Cancel γινόταν αληθές

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 έπειτα έτρεχε άνευ όρων μετά τον βρόχο ανεξάρτητα από το πώς τελείωσε, οπότε ακόμη και μια εργασία που ένας χρήστης πίστευε ότι είχε σταματήσει εξακολουθούσε να υποβάλλεται στον spooler με κάθε σελίδα αποδοθείσα προτού παρατηρηθεί το κλικ

Τι κλειδώνει το Free Pascal όταν ξεκινά ένας βρόχος for

Το Free Pascal αξιολογεί την τελική τιμή ενός βρόχου for ακριβώς μία φορά, τη στιγμή που ξεκινά ο βρόχος, και ποτέ ξανά για όλη τη διάρκεια ζωής εκείνου του βρόχου. Το for Page := FromPage to ToPage do διαβάζει το ToPage μία μοναδική φορά για να υπολογίσει πόσες επαναλήψεις θα εκτελέσει, και μετά από αυτό ο βρόχος δεν έχει κανένα περαιτέρω ενδιαφέρον για μια μεταβλητή ονόματι ToPage, μόνο για τον αριθμό επαναλήψεων που ήδη συνέλαβε. Η ρύθμιση ToPage := 0 από μέσα από το σώμα του βρόχου αλλάζει μια μεταβλητή που ο τρέχων βρόχος δεν συμβουλεύεται πλέον, κάτι που είναι ακριβώς γιατί το Cancel μπορούσε να είναι αληθές ενώ ο εκτυπωτής συνέχιζε να λαμβάνει σελίδες για αρκετές ακόμη επαναλήψεις, μερικές φορές όλες

Αυτό το μοτίβο είναι μια απολύτως λογική συνήθεια να τη μεταφέρετε από τη C ή τη C++, και η επίδειξη viewer C++Builder του PDFium Component αντικατοπτρίζει την Pascal αρκετά στενά ώστε να κάνει άμεση τη σύγκριση. Το δικό της for (Copy = 1; Copy <= CopyCount; Copy++) ξαναδοκιμάζει το Copy <= CopyCount έναντι της ζωντανής τιμής του CopyCount σε κάθε πέρασμα, οπότε ο μηδενισμός του μετρητή εκεί γνησίως τερματίζει τον βρόχο στον επόμενο έλεγχο. Η επίδειξη C++Builder έφερε τον ταυτόσημο κώδικα μηδενισμού-των-μετρητών και δούλευε, κάτι που είναι ακριβώς αυτό που έκανε την ίδια ιδέα να φαίνεται ασφαλής για επαναχρησιμοποίηση στο build Pascal που κάθεται ακριβώς δίπλα του στο ίδιο αποθετήριο

Γιατί δεν χτύπησε το ίδιο σφάλμα το build Delphi;

Η επίδειξη PDFViewer της Delphi ποτέ δεν εξαρτήθηκε από το να παρατηρήσει ένας βρόχος ένα αλλαγμένο όριο, επειδή η διαδρομή ακύρωσής της ξετυλίγεται μέσω εξαίρεσης αντί σύγκρισης. Ο πιο εσωτερικός βρόχος της καλεί τη διαδικασία Abort της RTL τη στιγμή που το Cancel γίνεται αληθές, η οποία εγείρει ένα σιωπηλό 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 κρατά τη δομή τριών βρόχων στις επιδείξεις viewer 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 πίσω από μια σημαία υπολογισμένη αυστηρά μετά τον βρόχο, ποτέ μέσα σε αυτόν, ώστε μια εργασία που σταμάτησε νωρίς να μην μπορεί ποτέ να μπερδευτεί με μία που τελείωσε

Η ίδια αναθεώρηση που διόρθωσε αυτόν τον βρόχο επίσης σφίγγει το πώς οι εννέα επιδείξεις viewer χειρίζονται το πληκτρολόγιο ενώ είναι ορατό ένα κουμπί ακύρωσης: τώρα καταπίνουν κάθε πλήκτρο εκτός από Esc, ώστε ένα περιπλανώμενο Ctrl+P ή Ctrl+F κατά τη διάρκεια μιας ενεργής εκτύπωσης ή αναζήτησης να μην μπορεί πλέον να ξεκινήσει μια δεύτερη πάνω από αυτήν. Αυτή η διόρθωση επανεισόδου είναι ένα διαφορετικό σφάλμα με διαφορετικό μηχανισμό, αλλά προέκυψε από την ίδια αναθεώρηση του τι πράγματι κάνει το Cancel στη μέση μιας λειτουργίας. Η συνήθεια που αξίζει να αποκομίσετε από τις δύο διορθώσεις είναι μια συνήθεια δοκιμών περισσότερο παρά μια συνήθεια κωδικοποίησης: ασκήστε την ακύρωση σε κάθε compiler στον οποίο πράγματι διανέμεται μια κοινόχρηστη ρουτίνα εκτύπωσης, επειδή μια ιδέα όπως το καθάρισμα των μετρητών και η εμπιστοσύνη στον βρόχο να το παρατηρήσει μπορεί να επιβιώσει από τη δοκιμή κάτω από το C++Builder, να φτάσει σε ένα build Free Pascal ανεπιβεβαίωτη, να διαβάζεται πανομοιότυπα στην πηγή, και να αποτύχει με τρόπο που κανείς δεν βλέπει μέχρι κάποιος να κρατήσει το Cancel αρκετά ώστε να δει την εργασία να τελειώνει ούτως ή άλλως. Για τη ρύθμιση εκτύπωσης πάνω στην οποία κάθεται αυτή η λογική ακύρωσης, η περιήγηση για την εκτύπωση εγγράφων PDF με το εξάρτημα PDFium VCL καλύπτει τα υπόλοιπα

Αυτή η διόρθωση ακύρωσης εκτύπωσης κυκλοφόρησε ως μέρος του εξαρτήματος PDFium για Delphi, C++Builder, και FPC/Lazarus, το οποίο εκτελεί την ίδια σουίτα επιδείξεων σε όλους τους τρεις compiler σε κάθε έκδοση ώστε κενά όπως αυτό να πιάνονται από ένα πλέγμα build αντί από ένα εισιτήριο υποστήριξης