Ο ίδιος πηγαίος κώδικας Object Pascal μπορεί να συμπεριφέρεται διαφορετικά στο Delphi και στο FPC/Lazarus με τέσσερις τρόπους που επηρεάζουν επανειλημμένα τον κώδικα του PDFium Component: το FPC καταστρέφει τα προσωρινά records-αποτελέσματα συναρτήσεων πριν ολοκληρωθεί ένας έλεγχος συμμετοχής in, ο dcc32 αποστέλλεται με απενεργοποιημένο τον έλεγχο ορίων (range checking) οπότε οι εκτός ορίων δείκτες πινάκων διαβάζουν σιωπηρά σκουπίδια στη μνήμη, μόνο το Delphi 13 δέχεται την ανάθεση ενός ανώνυμου array of Byte σε TBytes χωρίς cast, και η συνένωση (concatenation) AnsiString του Delphi μπορεί να καταστρέψει bytes ίσα ή μεγαλύτερα από $80 μέσω ενός κρυφού round-trip κωδικοσελίδας. Καθένας από αυτούς τους τρόπους παράγει μια σουίτα δοκιμών που είναι πράσινη στον έναν μεταγλωττιστή και κόκκινη, ή ακόμη χειρότερα, σιωπηρά λανθασμένη, στον άλλον
Εάν στήνετε ένα έργο διπλού μεταγλωττιστή για πρώτη φορά, ο οδηγός προβολής Lazarus και FPC καλύπτει την εύκολη διαδρομή: πακέτα, διαδρομές αναζήτησης και εμφάνιση ενός παραθύρου απόδοσης στην οθόνη. Αυτό το άρθρο είναι το αντίθετο ενός σεμιναρίου. Είναι η λίστα των πραγμάτων που συναντήσαμε αφού λειτούργησε η εύκολη διαδρομή, όταν το CI ήταν πράσινο υπό FPC, πράσινο υπό Delphi, και στη συνέχεια μια αλλαγή που πέρασε στη μία πλευρά ανατινάχθηκε στην άλλη. Κάθε παγίδα παρακάτω προέρχεται από μια πραγματική αποτυχία στη σουίτα δοκιμών PDFiumPas ή στις επιδείξεις της, με την ανάλυση επιπέδου commit να συμπυκνώνεται σε μια ελάχιστη αναπαραγωγή, τη βασική αιτία και τη διόρθωση που καθιερώσαμε
Γιατί ένα σύνολο (set) διαβάζεται κενό υπό FPC αλλά όχι στο Delphi;
Η έκδοση σε μία πρόταση: το FPC ενδέχεται να καταστρέψει την προσωρινή μεταβλητή που κρατά το αποτέλεσμα-record μιας συνάρτησης πριν ολοκληρωθεί μια έκφραση που διαβάζει ένα πεδίο αυτού του αποτελέσματος, οπότε η X in Func().Issues μπορεί να ελέγξει τη συμμετοχή έναντι ενός ήδη αποδεσμευμένου συνόλου, ενώ η αντίστοιχη έκφραση στο Delphi λειτουργεί κανονικά. Οι δοκιμές συμμόρφωσης PDF/E συνάντησαν αυτό το πρόβλημα στην πρώτη τους έκδοση. Ο επικυρωτής επιστρέφει ένα record του οποίου το πεδίο Issues είναι ένα σύνολο σημαιών παραβίασης, και οι ισχυρισμοί (assertions) είχαν ενσωματωμένη την κλήση
// Unreliable under FPC: the function-result record temporary
// can be released before the 'in' test reads Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);
// Reliable on both compilers: pin the result to a local first
var
Vr: TPdfEValidationResult;
begin
Vr := ValidateAnsi(Pdf);
AssertTrue(pveiLzwUsed in Vr.Issues);
end;
Η ενσωματωμένη μορφή διάβαζε το σύνολο ως κενό υπό FPC, οπότε κάθε ισχυρισμός που ανέμενε μια σημαία αποτύγχανε, ενώ η πανομοιότυπη έκδοση στο Delphi περνούσε επιτυχώς. Η βασική αιτία είναι μια διαφορά στον τρόπο με τον οποίο οι δύο μεταγλωττιστές διαχειρίζονται το χρόνο ζωής των προσωρινών αποτελεσμάτων συναρτήσεων μέσα σε μεγαλύτερες εκφράσεις: το Delphi διατηρεί το προσωρινό στοιχείο ζωντανό μέχρι το τέλος της δήλωσης, ενώ η απόρριψη του προσωρινού record από το FPC μπορεί να προλάβει τον τελεστή συμμετοχής σε σύνολο που εξακολουθεί να το διαβάζει. Είχαμε ήδη τεκμηριώσει την ίδια συμπεριφορά άλλη μία φορά στο παρελθόν, σε ένα σχόλιο για τον βοηθό FlagPresent στη μονάδα δοκιμών PDF/A, και στη συνέχεια εισαγάγαμε ξανά το σφάλμα όταν γράφαμε νέες δοκιμές από το μηδέν, γεγονός που δείχνει πόσο φυσική φαίνεται η σπασμένη μορφή. Η διόρθωση είναι μηχανική και αξίζει να υιοθετηθεί ως γενικός κανόνας: μην συνδέετε ποτέ την πρόσβαση σε ένα πεδίο ή τον έλεγχο συνόλου απευθείας σε μια κλήση συνάρτησης που επιστρέφει record· αναθέστε πρώτα το αποτέλεσμα σε μια τοπική μεταβλητή, και στη συνέχεια διαβάστε το πεδίο. Κοστίζει μία γραμμή και αφαιρεί μια ολόκληρη κατηγορία αστάθειας που εξαρτάται από τον μεταγλωττιστή
Γιατί το Delphi δέχεται έναν δείκτη πίνακα που το FPC αρνείται να μεταγλωττίσει;
Η έκδοση σε μία πρόταση: ο dcc32 μεταγλωττίζει έναν δείκτη εκτός ορίων σε έναν πίνακα σταθερών ορίων και, με απενεργοποιημένο τον προεπιλεγμένο έλεγχο ορίων, διαβάζει ή γράφει διπλανή μνήμη κατά το χρόνο εκτέλεσης χωρίς κανένα σφάλμα, ενώ το FPC απορρίπτει τον ίδιο δείκτη κατά το χρόνο μεταγλώττισης. Το PDFium Component δηλώνει τα σημεία quad ως πίνακα με βάση το 1, TQuadrilateralPoint = array [1..4] of TPdfPoint, που ταιριάζει με τον τρόπο που αριθμούνται συνήθως οι καταχωρίσεις QuadPoints του PDF. Μια επίδειξη που τον συμπλήρωνε με τον κλασικό βρόχο με βάση το 0 λειτουργούσε για μήνες στο Delphi
var
I: Integer;
begin
for I := 0 to 3 do // wrong: the array is [1..4]
Data.AttachmentPoints[I] := Corner[I]; // dcc32 default: compiles, index 0
// silently touches adjacent memory
// FPC: compile-time range check error
for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
Data.AttachmentPoints[I] := Corner[I - 1]; // correct on both compilers
end;
Η έκδοση του Delphi ήταν ένα ψευδώς θετικό αποτέλεσμα: με απενεργοποιημένο τον έλεγχο ορίων, που είναι η προεπιλογή του dcc32, ο δείκτης 0 κατέληγε σε οποιοδήποτε πεδίο προηγείται του πίνακα στο record, και η επίδειξη φαινόταν να εκτελείται κανονικά. Η μεταφορά της ίδιας επίδειξης στο Lazarus παρήγαγε ένα άμεσο σφάλμα ελέγχου ορίων κατά τη μεταγλώττιση από το FPC, και η διόρθωση του δείκτη αποκάλυψε στη συνέχεια ένα δεύτερο, βαθύτερο σφάλμα στη διαδρομή σχολιασμών της βιβλιοθήκης το οποίο κάλυπταν οι άκυρες αναγνώσεις, αυτό που αναλύεται στο άρθρο για τους σχολιασμούς QuadPoints. Δύο μαθήματα προέκυψαν από αυτό το περιστατικό. Πρώτον, προτιμάτε τις Low() και High() έναντι των κυριολεκτικών ορίων (literal bounds) όποτε ο τύπος του πίνακα δεν έχει ως βάση το 0 εξ ορισμού. Δεύτερον, αντιμετωπίζετε τη μεταγλώττιση στο FPC, ή τουλάχιστον μια έκδοση του Delphi με ενεργοποιημένο το {$R+}, ως υποχρεωτικό έλεγχο πρώτης εκτέλεσης για οποιαδήποτε νέα επίδειξη ή δοκιμή: οι προεπιλογές του dcc32 δεν θα σας πουν για αυτήν την κατηγορία σφαλμάτων, και ένα πρόγραμμα που εκτελείται δεν αποτελεί απόδειξη ότι είναι σωστό
Η ανάθεση TBytes που μόνο το Delphi 13 δέχεται
Η έκδοση σε μία πρόταση: η ανάθεση ενός πεδίου που έχει δηλωθεί ως ανώνυμο array of Byte σε μια μεταβλητή TBytes μεταγλωττίζεται στο Delphi 13 (έκδοση μεταγλωττιστή 37.0) αλλά αποτυγχάνει στο Delphi 12 Athens και σε κάθε προηγούμενη έκδοση με το σφάλμα E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Αυτό δεν είναι τόσο διαχωρισμός Delphi-έναντι-FPC όσο διαχωρισμός του Delphi έναντι του ίδιου του του παρελθόντος, αλλά επηρεάζει την ίδια βάση κώδικα πολλαπλών μεταγλωττιστών με τον ίδιο τρόπο: ο νεότερος μεταγλωττιστής δέχεται σιωπηρά μια κατασκευή που όλα τα άλλα απορρίπτουν
type
TValidator = class
private
FBuffer: array of Byte; // anonymous dynamic array type
end;
var
OrigBytes: TBytes;
begin
OrigBytes := FBuffer; // Delphi 13 only; E2010 on Delphi 12
// Athens and earlier
OrigBytes := TBytes(FBuffer); // compiles everywhere; same byte layout,
// safe hard cast
end;
Στείλαμε ακριβώς αυτό σε μια ρουτίνα επικύρωσης, η οποία αναπτύχθηκε και δοκιμάστηκε τοπικά στο Delphi 13, όπου η έμμεση μετατροπή έγινε σιωπηρά αποδεκτή. Ο εγκαταστάτης πλήρους πηγαίου κώδικα εξυπηρετεί χρήστες σε Delphi 12 και παλαιότερες εκδόσεις σε μεγάλο βαθμό, και για αυτούς η μονάδα απλά δεν μεταγλωττιζόταν. Η δομική διόρθωση είναι είτε το σκληρό cast (hard cast) που εμφανίζεται παραπάνω, το οποίο είναι ασφαλές επειδή ένα ανώνυμο array of Byte και το TBytes μοιράζονται μια πανομοιότυπη διάταξη δυναμικού πίνακα, ή ακόμα καλύτερα, η δήλωση του πεδίου ως ονομαστικού τύπου όπως TBytes εξαρχής, ώστε να μην προκύπτει ποτέ μετατροπή. Η διόρθωση της διαδικασίας έχει μεγαλύτερη σημασία: μια κατασκευή που μεταγλωττίζεται στην πιο πρόσφατη εργαλειοσειρά σας δεν αποδεικνύει τίποτα για τους παλαιότερους μεταγλωττιστές που εκτελούν στην πραγματικότητα οι χρήστες σας, και αυτή η κατηγορία παλινδρόμησης (regression) είναι αόρατη μέχρι να κάνετε build έναντι κάθε υποστηριζόμενης έκδοσης. Τα σενάρια έκδοσής μας τώρα μεταγλωττίζουν τη βιβλιοθήκη σε ολόκληρο τον πίνακα μεταγλωττιστών ακριβώς επειδή ένα τοπικό build έκδοσης 37.0 δεν μπορεί να εντοπίσει μια επιείκεια που υπάρχει μόνο στην έκδοση 13
Το byte AnsiString που εξαφανίζεται σε ένα κινεζικό μηχάνημα Windows
Η έκδοση σε μία πρόταση: η συνένωση (concatenation) ενός ακατέργαστου byte ίσου ή μεγαλύτερου από $80 σε μια AnsiString με + μπορεί να αντικαταστήσει σιωπηρά αυτό το byte με ? ($3F) στο Delphi, επειδή η έκφραση εκτελεί ένα έμμεσο round-trip από AnsiString σε UnicodeString και πίσω σε AnsiString μέσω της κωδικοσελίδας του συστήματος. Το ανακαλύψαμε αυτό μέσω μιας δοκιμής PDF/A που κατασκευάζει ένα όνομα που περιέχει ένα απομονωμένο byte $FE, το οποίο δεν είναι ποτέ έγκυρο byte έναρξης UTF-8, για να επαληθεύσουμε ότι ο επικυρωτής επισημαίνει ονόματα που δεν είναι έγκυρα UTF-8 σύμφωνα με την ρήτρα 6.1.8 του ISO 19005-2
var
BadName: AnsiString;
begin
// On Delphi with a multi-byte system code page (observed on CP936),
// the concatenation round-trips through UnicodeString and $FE, which
// is not a valid CP936 sequence, comes back as '?' ($3F)
BadName := '/Bad' + AnsiChar($FE) + 'Name';
// Safe: build with an ASCII placeholder, then patch the byte in place;
// indexed assignment into a settled AnsiString does not round-trip
BadName := '/Bad' + #1 + 'Name';
BadName[5] := AnsiChar($FE);
end;
Σε ένα κινεζικό σύστημα Windows που εκτελεί την κωδικοσελίδα 936, η συνενωμένη συμβολοσειρά δεν περιέλαβε ποτέ το byte $FE, οπότε η βιβλιοθήκη σωστά δεν ανέφερε τίποτα και η δοκιμή έγινε κόκκινη ενώ φαινόταν για σφάλμα της βιβλιοθήκης. Η βιβλιοθήκη δεν έκανε ποτέ λάθος: μια δοκιμή FPC που τροφοδότησε ένα PDF που περιείχε πραγματικά το byte $FE έλαβε την αναμενόμενη σημαία. Η αλλοίωση συνέβη μέσα στο εκτελέσιμο δοκιμών του Delphi κατά την αξιολόγηση της έκφρασης της συμβολοσειράς, επειδή το μοντέλο συμβολοσειρών του Delphi (που δίνει προτεραιότητα στο Unicode) μετατρέπει τις μικτές εκφράσεις AnsiString μέσω UnicodeString, και το $FE δεν είναι έγκυρο byte έναρξης στην CP936, οπότε το round-trip το αντικαθιστά. Ας είμαστε ειλικρινείς σχετικά με το όριο εδώ: σε μια μονοπαραγοντική δυτική κωδικοσελίδα όπως η CP1252 η ίδια έκφραση συνήθως επιβιώνει, και αυτός είναι ο λόγος που αυτό το σφάλμα κρύβεται στα περισσότερα μηχανήματα ανάπτυξης και εμφανίζεται μόνο σε συστήματα της Ανατολικής Ασίας ή σε τοπικούς CI runners. Ο κανόνας που υιοθετήσαμε: μην κατασκευάζετε ποτέ δυαδικά διανύσματα δοκιμών (test vectors) που περιέχουν bytes ίσα ή μεγαλύτερα από $80 μέσω συνένωσης AnsiString· είτε διορθώστε τα bytes επί τόπου αφού η συμβολοσειρά έχει σταθεροποιηθεί, όπως παραπάνω, είτε κατασκευάστε το διάνυσμα σε TBytes εξαρχής
Τι θα πρέπει να ελέγχει από προεπιλογή μια ροή εργασίας διπλού μεταγλωττιστή
Τέσσερις παγίδες, ένα μοτίβο: κάθε μεταγλωττιστής σας ενημερώνει για ένα διαφορετικό υποσύνολο των σφαλμάτων σας. Η ανάλυση ορίων του FPC κατά το χρόνο μεταγλώττισης εντόπισε έναν δείκτη εκτός ορίων τον οποίο ο dcc32 εκτελούσε σιωπηρά για μήνες, και το μοντέλο συμβολοσειρών Unicode του dcc32 αποκάλυψε μια εξάρτηση από την κωδικοσελίδα την οποία ένα καθαρά byte-προσανατολισμένο build του FPC δεν θα πυροδοτούσε ποτέ. Η πρακτική συνέπεια είναι ότι καμία από τις δύο πράσινες ροές εργασίας (pipelines) δεν είναι επαρκής από μόνη της. Η διασταυρούμενη μεταγλώττιση (cross-compiling) δεν είναι απλώς ένα πλαίσιο ελέγχου φορητότητας, είναι ένας δεύτερος στατικός αναλυτής και ένα δεύτερο μοντέλο χρόνου εκτέλεσης που εφαρμόζεται στην ίδια πηγή, στο ίδιο πνεύμα με τους αμυντικούς ελέγχους ορίων στο άρθρο για τη θωράκιση του ABI και της ασφάλειας μνήμης
Οι πάγιοι κανόνες που προέκυψαν από αυτά τα περιστατικά είναι αρκετά σύντομοι για να τους απομνημονεύσετε. Κλειδώνετε τα records-αποτελέσματα συναρτήσεων σε μια τοπική μεταβλητή πριν διαβάσετε πεδία. Διατρέχετε τους πίνακες σταθερών ορίων με τις Low() και High(), και εκτελείτε τουλάχιστον μία μεταγλώττιση με έλεγχο ορίων ή με FPC πριν εμπιστευτείτε οποιαδήποτε νέα επίδειξη. Κάνετε ρητό cast στα ανώνυμα πεδία δυναμικών πινάκων, ή δηλώνετε τα με ονομαστικούς τύπους, και χτίζετε ολόκληρο τον πίνακα μεταγλωττιστών πριν από την κυκλοφορία. Κρατάτε τα ακατέργαστα high bytes εντελώς εκτός της συνένωσης AnsiString. Κανένας από αυτούς τους κανόνες δεν κοστίζει μετρήσιμη προσπάθεια μόλις γίνει συνήθεια, και καθένας κείνει μια κατάσταση αποτυχίας την οποία μια ροή εργασίας μονού μεταγλωττιστή δομικά δεν μπορεί να δει
Και τα τέσσερα ζητήματα εντοπίστηκαν και διορθώθηκαν κατά τη διάρκεια της συντήρησης του PDFium Component, το οποίο αποστέλλει τον ίδιο πηγαίο κώδικα Object Pascal για Delphi, C++Builder και FPC/Lazarus και εκτελεί τις σουίτες συμμόρφωσης και παλινδρόμησης σε καθεμία από αυτές τις εργαλειοσειρές, οπότε οι παγίδες σε αυτό το άρθρο φυλάσσονται από δοκιμές και όχι από τη μνήμη