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

Το FillChar σε αποτέλεσμα συνάρτησης διαρρέει συμβολοσειρές σε Object Pascal

Μια συνάρτηση Delphi ή FPC που επιστρέφει μια εγγραφή δεν παίρνει ένα φρέσκο, μηδενισμένο Result σε κάθε κλήση. Εκείνη η κρυμμένη μεταβλητή Result ξεκινά μηδενική ακριβώς μία φορά, και τίποτα δεν την επαναμηδενίζει αυτόματα μεταξύ κλήσεων, οπότε ο καθαρισμός της κατά την είσοδο είναι δουλειά της ίδιας της συνάρτησης. Κάντε εκείνον τον καθαρισμό με FillChar(Result, SizeOf(Result), 0) και, από τη δεύτερη κλήση και μετά, η ρουτίνα αντικαθιστά μια ζωντανή αναφορά συμβολοσειράς ή δυναμικού πίνακα αντί να την απελευθερώσει, ορφανεύοντας όποιο μπλοκ σωρού έδειχνε εκείνη η αναφορά

Το σενάριο όπου αυτό δαγκώνει είναι κοινότοπο. Μια διεργασία παρτίδας ανοίγει μια στοίβα PDF τρίτων μερών και διατρέχει κάθε annotation σε κάθε σελίδα, τραβώντας το κείμενο σχολίου σε ένα αρχείο ελέγχου. Τίποτα σε εκείνον τον βρόχο δεν φαίνεται επικίνδυνο: κάθε κλήση είναι μια απλή συνάρτηση που επιστρέφει μια απλή εγγραφή, κανένας δείκτης σε αναμμα, τίποτα που μοιάζει με χειροκίνητη διαχείριση μνήμης καθόλου. Η μέτρηση αναφορών μέσα σε μια εγγραφή είναι ένας απλός κανόνας λογιστικής Object Pascal, όχι μια ιδιαιτερότητα ειδική για κάποια βιβλιοθήκη, και οποιαδήποτε βάση κώδικα Delphi ή FPC που αναμειγνύει FillChar με τύπους εγγραφών που κρατούν συμβολοσειρές ή δυναμικούς πίνακες εκτίθεται στο ίδιο ελάττωμα

Γιατί το FillChar σε αποτέλεσμα εγγραφής διαρρέει συμβολοσειρές;

Το FillChar διαρρέει συμβολοσειρές επειδή δεν έχει καμία ιδέα τι είδος δεδομένων αντικαθιστά. Το FillChar(X, Count, Value) λειτουργεί σε οποιαδήποτε μεταβλητή καθόλου: παίρνει ένα μη τυποποιημένο μπλοκ Count byte και σφραγίζει καθένα από αυτά με Value, και αυτή είναι ολόκληρη η σύμβαση. Αυτό είναι ακριβώς αυτό που κάνει το FillChar γρήγορο και γενικού σκοπού, επειδή ποτέ δεν επιθεωρεί τον τύπο του X και ποτέ δεν διακλαδίζεται ανάλογα με το τι σημαίνουν τα υποκείμενα byte. Ένα πεδίο UnicodeString ή WideString μέσα σε μια εγγραφή δεν είναι οι ίδιοι οι χαρακτήρες· είναι ένας δείκτης σε ένα μπλοκ σωρού που φέρει έναν μετρητή αναφορών μπροστά από τα δεδομένα χαρακτήρων. Το FillChar βλέπει μια χούφτα byte που τυχαίνει να κρατούν μια τιμή δείκτη και τα αντικαθιστά με μηδέν ακριβώς όπως θα αντικαθιστούσε ένα πεδίο Integer ή Double. Ο δείκτης εξαφανίζεται, ο μετρητής αναφορών που θα έπρεπε πρώτα να μειωθεί ποτέ δεν αγγίζεται, και το μπλοκ στο οποίο έδειχνε κάθεται εκχωρημένο χωρίς τίποτα πλέον να το αναφέρεται

Πώς παρακολουθεί ο compiler συμβολοσειρές και δυναμικούς πίνακες μέσα σε μια εγγραφή

Η Object Pascal αποκαλεί έναν τύπο διαχειριζόμενο όταν ο compiler πρέπει να τρέξει επιπλέον κώδικα για να τον κρατήσει σωστό σε όλη την ανάθεση και την έξοδο εμβέλειας. Οι τύποι μακριάς συμβολοσειράς όπως τα AnsiString, UnicodeString, και WideString πληρούν, όπως και οι δυναμικοί πίνακες, οι διεπαφές, και τα Variant, μαζί με οποιαδήποτε εγγραφή ή σταθερό πίνακα που περιέχει ένα από αυτά ως πεδίο. Για κάθε διαχειριζόμενο πεδίο, ο compiler σιωπηλά εκπέμπει τη λογιστική που διαφορετικά θα ήταν κουραστική και εύκολο να πάει στραβά με το χέρι: αύξηση ενός μετρητή αναφορών κατά την ανάθεση, μείωσή του όταν η κρατούσα μεταβλητή αντικαθίσταται ή βγαίνει από την εμβέλεια, και ελευθέρωση του υποκείμενου μπλοκ μόλις εκείνος ο μετρητής φτάσει στο μηδέν. Εκείνος ο μηχανισμός είναι ο λόγος που ο συνηθισμένος κώδικας Pascal ποτέ δεν εκχωρεί ή απελευθερώνει χειροκίνητα ένα string, και γιατί η ανάθεση ενός δυναμικού πίνακα σε έναν άλλο είναι μια φθηνή, ασφαλής λειτουργία αντί για έναν χειροκίνητο βρόχο αντιγραφής. Τα System.Default και Finalize είναι οι δύο τεκμηριωμένοι τρόποι να επικαλεστείτε την ίδια λογική απελευθέρωσης κατ' απαίτηση, και είναι αυτό που πρέπει να καλεί ο κώδικας καθαρισμού μιας εγγραφής αντί για ένα ακατέργαστο γέμισμα μνήμης

type
  TLineItem = record
    Description: string;  // managed: reference-counted
    Quantity: Integer;    // unmanaged: plain ordinal
  end;

function GetLineItem(Index: Integer): TLineItem;
begin
  FillChar(Result, SizeOf(Result), 0);  // clears bytes, not the reference
  Result.Quantity := Source[Index].Qty;
  Result.Description := Source[Index].Text;
end;

var
  Item: TLineItem;
  I: Integer;
begin
  for I := 0 to High(Source) do
  begin
    Item := GetLineItem(I);  // second pass onward: leaks the prior Description
    Log.Add(Item.Description);
  end;
end;

Γιατί η διαρροή ξεκινά μόνο στη δεύτερη κλήση;

Η πρώτη κλήση σε έναν βρόχο είναι πάντα αβλαβής, κάτι που είναι ακριβώς αυτό που κάνει αυτό το ελάττωμα εύκολο να διαφύγει στη δοκιμή. Μια τοπική μεταβλητή ενός διαχειριζόμενου τύπου εγγραφής ξεκινά μηδενική, και τίποτα δεν την επαναμηδενίζει αυτόματα μεταξύ ενός περάσματος βρόχου και του επόμενου, οπότε την πρώτη φορά που ένας βρόχος αναθέτει την τιμή επιστροφής μιας συνάρτησης σε εκείνη τη μεταβλητή, το πεδίο Description ή ContentsText της είναι ακόμη nil. Το FillChar αντικαθιστά το nil με μηδέν, κάτι που δεν αλλάζει τίποτα όσον αφορά τον μετρητή αναφορών, και η κλήση επιστρέφει φαινόμενη εντελώς σωστή. Η δεύτερη κλήση είναι διαφορετική: η ίδια τοπική μεταβλητή ήδη κρατά ό,τι έγραψε μέσα της η πρώτη κλήση, και το Result της νέας κλήσης γράφεται απευθείας σε εκείνη την ίδια αποθήκευση αντί σε φρέσκια, άδεια μνήμη. Το FillChar στην κορυφή εκείνης της δεύτερης κλήσης μηδενίζει ένα πεδίο που δεν είναι πλέον nil, και όλα κατάντη από εκείνο το μοτίβο byte είναι σιωπηλά λάθος από τότε και μετά. Μια δοκιμή που καλεί τη συνάρτηση μία φορά και επιθεωρεί το αποτέλεσμα ποτέ δεν θα δει το πρόβλημα· μόνο ένας βρόχος, ή οποιαδήποτε διαδρομή κώδικα που καλεί τη συνάρτηση επανειλημμένα έναντι του ίδιου προορισμού, το εκθέτει

Μια πραγματική διαρροή: annotations, σελιδοδείκτες, και εγγραφές συνδέσμων

Το PDFiumPas διένειμε ακριβώς αυτό το ελάττωμα πριν την έκδοση 1.56.4, σε τρεις συναρτήσεις που καθεμία επιστρέφει μια εγγραφή που κρατά τουλάχιστον ένα διαχειριζόμενο πεδίο: ο αναγνώστης annotation σε επίπεδο σελίδας επιστρέφει ένα TPdfAnnotation που φέρει συμβολοσειρές ContentsText και AuthorText, ο αναγνώστης σελιδοδεικτών επιστρέφει ένα TBookmark που φέρει μια συμβολοσειρά Title, και ο αναγνώστης annotation-συνδέσμου επιστρέφει ένα TLinkAnnotation που φέρει μια συμβολοσειρά ActionPath και έναν δυναμικό πίνακα Points. Και οι τρεις ξεκινούσαν με το ίδιο σχήμα που φαίνεται παρακάτω: καθαρισμός του Result με ένα ακατέργαστο FillChar, έπειτα συμπλήρωση των πεδίων ένα-ένα από τα υποκείμενα δεδομένα σελίδας. Η διάτρεξη κάθε annotation σε μια σελίδα ένα-ένα, ο συνηθισμένος τρόπος να χτίσετε μια λίστα ελέγχου ή ένα πάνελ ανασκόπησης, καλούσε τον αναγνώστη annotation σε βρόχο και διέρρεε το κείμενο της προηγούμενης annotation σε κάθε πέρασμα μετά το πρώτο· ένα PDF χτισμένο με ασυνήθιστα μεγάλο αριθμό annotation με κείμενο θα μπορούσε να αναπτύξει τη μνήμη μιας διεργασίας μακράς διάρκειας για όσο η διεργασία συνέχιζε να τρέχει. Η διόρθωση άγγιξε μία γραμμή σε κάθε συνάρτηση: η αντικατάσταση του FillChar(Result, SizeOf(Result), 0) με Result := Default(TPdfAnnotation) ήταν αρκετή, επειδή η ανάθεση του Default σε μια διαχειριζόμενη εγγραφή εκτελεί τη συνηθισμένη ακολουθία απελευθέρωσης-έπειτα-καθαρισμού του compiler αντί για ένα ακατέργαστο γέμισμα μνήμης

function GetPageAnnotation(Page: FPDF_PAGE; Index: Integer): TPdfAnnotation;
var
  Annotation: FPDF_ANNOTATION;
  ContentLength: LongWord;
begin
  Annotation := FPDFPage_GetAnnot(Page, Index);
  FillChar(Result, SizeOf(Result), 0);   // clears bytes, not a live reference
  Result.Subtype := DecodeAnnotationSubtype(FPDFAnnot_GetSubtype(Annotation));
  ContentLength := FPDFAnnot_GetStringValue(Annotation,
    FPDFANNOT_TEXTTYPE_Contents, nil, 0);
  if ContentLength >= 4 then
  begin
    SetLength(Result.ContentsText, ContentLength div 2 - 1);
    FPDFAnnot_GetStringValue(Annotation, FPDFANNOT_TEXTTYPE_Contents,
      Pointer(Result.ContentsText), ContentLength);
  end;
end;

Ο ίδιος κίνδυνος πίσω από μια παράμετρο var

Ο αναγνώστης σελιδοδεικτών δείχνει μια πιο ύπουλη εκδοχή του ίδιου προβλήματος, επειδή η εγγραφή που καθαρίζεται με FillChar δεν είναι το ίδιο το Result της συνάρτησης αλλά μια παράμετρος var μία κλήση παρακάτω. Το SetBookmarkData παίρνει την έξοδό του ως var Data: TBookmark και συνήθιζε να καθαρίζει το Data στην κορυφή του σώματός του με FillChar· το GetBookmark, η δημόσια συνάρτηση που πράγματι επιστρέφει ένα TBookmark, καλεί το SetBookmarkData και περνά το δικό του Result απευθείας ως εκείνο το όρισμα var. Μια παράμετρος var περνά κατά αναφορά, οπότε το Data μέσα στο SetBookmarkData και το Result μέσα στο GetBookmark είναι η ίδια αποθήκευση κάτω από δύο ονόματα, και όποιος κίνδυνος ψευδωνυμίας εφαρμόζεται στο δικό της Result μιας συνάρτησης εφαρμόζεται εξίσου άμεσα σε οποιαδήποτε βοηθητική ρουτίνα που το λαμβάνει κατά αναφορά. Η αναθεώρηση μόνο των συναρτήσεων που κυριολεκτικά δηλώνουν έναν τύπο επιστροφής εγγραφής χάνει αυτό το σχήμα· η αναζήτηση πρέπει να ακολουθήσει κάθε παράμετρο var και out στην οποία προωθείται επίσης ένα Result

procedure TPdf.SetBookmarkData(Bookmark: FPDF_BOOKMARK; var Data: TBookmark);
var
  BufferSize: LongWord;
begin
  Data := Default(TBookmark);   // fixed: was FillChar(Data, SizeOf(Data), 0)
  Data.Handle := Bookmark;
  if Bookmark <> nil then
  begin
    BufferSize := FPDFBookmark_GetTitle(Bookmark, nil, 0);
    if BufferSize >= 4 then
    begin
      SetLength(Data.Title, BufferSize div 2 - 1);
      FPDFBookmark_GetTitle(Bookmark, PWideChar(Data.Title), BufferSize);
    end;
  end;
end;

function TPdf.GetBookmark(const Title: WString): TBookmark;
begin
  CheckActive;
  SetBookmarkData(FPDFBookmark_Find(FDocument, PWideChar(Title)), Result);
end;

Πότε το FillChar εξακολουθεί να είναι η σωστή κλήση;

Το FillChar εξακολουθεί να είναι σωστό, και συχνά ελαφρώς φθηνότερο, για μια εγγραφή χτισμένη εξ ολοκλήρου από τακτικούς, πεδία κινητής υποδιαστολής, σταθερού μεγέθους πίνακες αυτών, ή άλλες απλές εγγραφές φτιαγμένες από τα ίδια, επειδή δεν υπάρχει τίποτα μέσα της για να οριστικοποιήσει ο compiler. Ο δικός του τύπος ορθογωνίου του PDFiumPas είναι ακριβώς αυτή η περίπτωση: το TPdfRectangle κρατά τέσσερα πεδία Double και τίποτα άλλο, και ο καθαρισμός ενός με FillChar δεν απελευθερώνει τίποτα επειδή δεν υπάρχει τίποτα μετρημένο με αναφορές προς απελευθέρωση. Ο έλεγχος που διαχωρίζει τις δύο περιπτώσεις είναι απλός να τον διατυπώσετε: έχει κάποιο πεδίο της εγγραφής, σε οποιοδήποτε βάθος ενσωμάτωσης, τύπο string, AnsiString, WideString, δυναμικό πίνακα, διεπαφή, ή Variant; Μια εγγραφή μπορεί να φαίνεται τέλεια αριθμητική στο ανώτερο επίπεδο και ακόμη να αποτυγχάνει σε εκείνον τον έλεγχο αν ένα από τα πεδία της είναι το ίδιο μια εγγραφή που θάβει μια συμβολοσειρά μερικά επίπεδα βαθύτερα, οπότε ο έλεγχος πρέπει να ακολουθήσει ενσωματωμένες εγγραφές μέχρι το τέλος αντί να σταματήσει στην εξωτερικότερη λίστα πεδίων. Ο έλεγχος μιας υπάρχουσας βάσης κώδικα για αυτό το μοτίβο είναι μηχανικός παρά εξαντλητικός: αναζητήστε κάθε κλήση FillChar της οποίας ο στόχος είναι μια μεταβλητή εγγραφής, έπειτα ελέγξτε τη λίστα πεδίων εκείνης της εγγραφής έναντι της παραπάνω λίστας διαχειριζόμενων τύπων. Ο δικός του έλεγχος v1.56.4 του PDFiumPas εκτέλεσε ακριβώς εκείνη την αναζήτηση σε όλη τη βιβλιοθήκη και βρήκε αυτή την έκθεση σε μία μονάδα· κάθε άλλο σημείο κλήσης FillChar ήδη καθάριζε μια απλή αριθμητική εγγραφή, όπου το FillChar ήταν, και παραμένει, το σωστό εργαλείο

Η ίδια συμπεριφορά compiler που κάνει επικίνδυνο ένα επαναχρησιμοποιημένο Result εδώ οδηγεί επίσης μια σχετική οικογένεια διαφωνιών Delphi-έναντι-FPC αλλού σε αυτή τη βάση κώδικα· ένα συνοδευτικό άρθρο για παγίδες μεταξύ compiler καλύπτει μια περίπτωση όπου το FPC και η Delphi διαφωνούν σχετικά με το ακριβώς πότε οριστικοποιείται μια προσωρινή εγγραφή-αποτέλεσμα μέσα σε μία μοναδική έκφραση, ένα διαφορετικό σύμπτωμα του ίδιου υποκείμενου γεγονότος ότι το Result εγγραφής μιας συνάρτησης δεν είναι πάντα η φρέσκια, ιδιωτική αποθήκευση που φαίνεται να είναι. Ο βρόχος annotation που χρησιμοποιείται ως το τρέχον παράδειγμα σε όλο αυτό το άρθρο δεν είναι υποθετικός επίσης: είναι η ίδια διάτρεξη σελίδα-προς-σελίδα που θα γράφατε ενώ χτίζατε ένα πάνελ ανασκόπησης annotation, που είναι ακριβώς το σχήμα κώδικα που μετέτρεψε ένα FillChar μίας γραμμής σε αργή διαρροή μνήμης εξαρχής

Τίποτα από αυτά δεν απαιτεί αλλαγή βιβλιοθηκών ή κυνήγι ενός σφάλματος στον μεταγλωττισμένο κώδικα κάποιου άλλου: είναι μια ιδιότητα της ίδιας της γλώσσας Object Pascal, μία με την οποία δουλεύει καθημερινά κάθε προγραμματιστής Delphi και FPC, και η διόρθωση είναι μία μοναδική κλήση συνάρτησης μόλις ξέρετε να την αναζητήσετε. Τα API annotation, σελιδοδεικτών, και annotation-συνδέσμου που περιγράφονται εδώ διατίθενται ως μέρος του εξαρτήματος PDFium για Delphi, C++Builder, και Lazarus/FPC, μαζί με το υπόλοιπο της επιφάνειας ανάγνωσης, απόδοσης, και annotation PDF που καλύπτεται αλλού σε αυτό το blog