مقاله فنی

FillChar روی نتیجه‌ی یک تابع رشته‌ها را در Object Pascal نشت می‌دهد

یک تابع در Delphi یا FPC که یک رکورد برمی‌گرداند، در هر فراخوانی یک Result تازه و صفرشده دریافت نمی‌کند. آن متغیر پنهان Result دقیقاً یک‌بار صفر شروع می‌شود، و هیچ چیزی به‌طور خودکار بین فراخوانی‌ها آن را دوباره صفر نمی‌کند، پس پاک‌کردن آن در ورود کار خودِ تابع است. آن پاک‌سازی را با FillChar(Result, SizeOf(Result), 0) انجام دهید و، از دومین فراخوانی به بعد، روال یک ارجاع رشته یا آرایه‌ی پویای زنده را به‌جای آزادکردنش بازنویسی می‌کند، و هر بلوک heapای که آن ارجاع به آن اشاره می‌کرد یتیم می‌کند

سناریویی که این نیش می‌زند معمولی است. یک فرآیند دسته‌ای یک انبوه از PDFهای شخص ثالث را باز می‌کند و هر حاشیه‌نویسی روی هر صفحه را می‌پیماید، و متن نظر را به یک لاگ ممیزی می‌کشد. هیچ چیزی درباره‌ی آن حلقه خطرناک به‌نظر نمی‌رسد: هر فراخوانی یک تابع ساده است که یک رکورد ساده برمی‌گرداند، هیچ اشاره‌گری در میدان دید، هیچ چیزی که شبیه مدیریت حافظه‌ی دستی اصلاً باشد. شمارش مرجع درون یک رکورد یک قاعده‌ی حسابداری ساده‌ی Object Pascal است، نه یک عجیبی مختص به یک کتابخانه‌ی خاص، و هر codebaseای در Delphi یا FPC که FillChar را با انواع رکورد حامل رشته‌ها یا آرایه‌های پویا مخلوط می‌کند، در معرض همان نقص است

چرا FillChar روی یک نتیجه‌ی رکوردی رشته‌ها را نشت می‌دهد؟

FillChar رشته‌ها را نشت می‌دهد چون هیچ ایده‌ای ندارد چه نوع داده‌ای را دارد بازنویسی می‌کند. FillChar(X, Count, Value) روی هر متغیری اصلاً کار می‌کند: یک بلوک بدون‌نوع از Count بایت می‌گیرد و هرکدام را با Value مهر می‌زند، و کل قرارداد همین است. این دقیقاً همان چیزی است که FillChar را سریع و عمومی‌منظور می‌کند، چون هرگز نوع X را بازرسی نمی‌کند و هرگز روی معنای بایت‌های زیرین شاخه نمی‌زند. یک فیلد UnicodeString یا WideString درون یک رکورد خودِ نویسه‌ها نیست؛ یک اشاره‌گر به یک بلوک heap است که یک شمارش مرجع پیش از داده‌ی نویسه حمل می‌کند. FillChar مشتی بایت می‌بیند که اتفاقاً یک مقدار اشاره‌گر نگه می‌دارند و آن‌ها را دقیقاً همان‌طور که یک فیلد Integer یا Double را بازنویسی می‌کرد با صفر بازنویسی می‌کند. اشاره‌گر ناپدید می‌شود، شمارش مرجعی که باید ابتدا کاهش می‌داد هرگز لمس نمی‌شود، و بلوکی که به آن اشاره می‌کرد تخصیص‌یافته می‌نشیند بدون اینکه چیزی به آن ارجاع دهد

کامپایلر چطور رشته‌ها و آرایه‌های پویا را درون یک رکورد پیگیری می‌کند

Object Pascal یک نوع را مدیریت‌شده می‌نامد وقتی کامپایلر مجبور باشد کد اضافی اجرا کند تا آن را در سراسر انتساب و خروج از دامنه درست نگه دارد. انواع رشته‌ی بلند مثل AnsiString، UnicodeString، و WideString واجد شرایط هستند، و همین‌طور آرایه‌های پویا، اینترفیس‌ها، و Variantها، در کنار هر رکورد یا آرایه‌ی ثابتی که یکی از آن‌ها را به‌عنوان یک فیلد در بر بگیرد. برای هر فیلد مدیریت‌شده، کامپایلر بی‌سروصدا حسابداری‌ای را منتشر می‌کند که در غیر این‌صورت خسته‌کننده و به‌راحتی برای اشتباه‌گرفتن دستی می‌بود: افزایش یک شمارش مرجع در انتساب، کاهش آن وقتی متغیر نگه‌دارنده بازنویسی شود یا از دامنه خارج شود، و آزادکردن بلوک زیرین به‌محض اینکه آن شمارش به صفر برسد. آن ماشین‌آلات دلیل آن است که کد معمولی Pascal هرگز یک string را دستی تخصیص یا آزاد نمی‌کند، و اینکه چرا انتساب یک آرایه‌ی پویا به دیگری یک عملیات ارزان و امن است نه یک حلقه‌ی کپی دستی. System.Default و Finalize دو راه مستندشده برای فراخوانی همان منطق آزادسازی در تقاضا هستند، و همان چیزی هستند که کد پاک‌سازی یک رکورد باید به‌جای یک fill حافظه‌ی خام آن‌ها را فرا بخواند

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 فراخوانی جدید مستقیم در همان storage به‌جای حافظه‌ی تازه و خالی نوشته می‌شود. FillChar در ابتدای آن فراخوانی دوم فیلدی را صفر می‌کند که دیگر nil نیست، و هر چیزی پایین‌دست آن الگوی بایت از آن به بعد بی‌سروصدا اشتباه است. یک تستی که تابع را یک‌بار فراخوانی می‌کند و نتیجه را بازرسی می‌کند هرگز این مسئله را نمی‌بیند؛ فقط یک حلقه، یا هر مسیر کدی که تابع را به‌طور مکرر در برابر همان مقصد فراخوانی می‌کند، آن را افشا می‌کند

یک نشت واقعی: حاشیه‌نویسی‌ها، بوک‌مارک‌ها، و رکوردهای لینک

PDFiumPas دقیقاً همین نقص را پیش از نسخه‌ی 1.56.4 عرضه می‌کرد، در سه تابع که هرکدام یک رکورد حامل حداقل یک فیلد مدیریت‌شده برمی‌گردانند: خواننده‌ی حاشیه‌نویسی سطح-صفحه یک TPdfAnnotation حامل رشته‌های ContentsText و AuthorText برمی‌گرداند، خواننده‌ی بوک‌مارک یک TBookmark حامل یک رشته‌ی Title برمی‌گرداند، و خواننده‌ی حاشیه‌نویسی-لینک یک TLinkAnnotation حامل یک رشته‌ی ActionPath و یک آرایه‌ی پویای Points برمی‌گرداند. هر سه با همان شکل نشان‌داده‌شده در زیر باز می‌شدند: پاک‌کردن Result با یک FillChar خام، سپس پرکردن فیلدها یکی‌یکی از داده‌ی صفحه‌ی زیرین. پیمایش هر حاشیه‌نویسی روی یک صفحه یکی‌یکی، روش معمول ساخت یک فهرست ممیزی یا یک پنل بازبینی، خواننده‌ی حاشیه‌نویسی را درون یک حلقه فراخوانی می‌کرد و متن حاشیه‌نویسی قبلی را در هر پاس پس از اولی نشت می‌داد؛ یک PDF ساخته‌شده با شماری غیرمعمول بزرگ از حاشیه‌نویسی‌های حامل-متن می‌توانست حافظه‌ی یک فرآیند طولانی-اجرا را تا زمانی که آن فرآیند اجرا می‌ماند رشد دهد. رفع اشکال یک خط را در هر تابع لمس کرد: جایگزینی FillChar(Result, SizeOf(Result), 0) با Result := Default(TPdfAnnotation) کافی بود، چون انتساب Default به یک رکورد مدیریت‌شده دنباله‌ی آزادسازی-سپس-پاک‌سازی معمولی کامپایلر را اجرا می‌کند به‌جای یک fill حافظه‌ی خام

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 همان storage زیر دو نام هستند، و هر ریسک aliasingای که به Result خودِ یک تابع اعمال می‌شود دقیقاً به همان مستقیمی به هر روال کمکی‌ای که آن را به ارجاع دریافت می‌کند اعمال می‌شود. بازبینی فقط توابعی که تحت‌اللفظی یک نوع بازگشتی رکوردی اعلان می‌کنند این شکل را از قلم می‌اندازد؛ جستجو باید هر پارامتر var و outای که یک Result به آن هم forward می‌شود را دنبال کند

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 همچنان درست است، و اغلب کمی ارزان‌تر، برای یک رکورد که کاملاً از اعداد ترتیبی، فیلدهای اعشاری، آرایه‌های با اندازه‌ی ثابت از آن‌ها، یا رکوردهای ساده‌ی دیگر ساخته‌شده از همان‌ها ساخته شده، چون هیچ چیزی درونش نیست که کامپایلر بخواهد نهایی‌سازی کند. نوع مستطیل خودِ PDFiumPas دقیقاً همان حالت است: TPdfRectangle چهار فیلد Double و هیچ چیز دیگر نگه می‌دارد، و پاک‌کردن یکی با FillChar هیچ چیزی را آزاد نمی‌کند چون هیچ چیز شمارش-مرجع‌داری برای آزادکردن وجود ندارد. بررسی‌ای که این دو حالت را از هم جدا می‌کند ساده برای بیان است: آیا هر فیلدی از رکورد، در هر عمق تودرتویی، نوع string، AnsiString، WideString، یک آرایه‌ی پویا، یک اینترفیس، یا یک Variant دارد؟ یک رکورد می‌تواند در سطح بالا کاملاً عددی به‌نظر برسد و همچنان آن تست را شکست بدهد اگر یکی از فیلدهایش خودش یک رکورد باشد که یک رشته را چند لایه پایین‌تر دفن کرده، پس بررسی باید رکوردهای تودرتو را تا انتها دنبال کند به‌جای اینکه در فهرست فیلد بیرونی‌ترین متوقف شود. ممیزی یک codebase موجود برای این الگو مکانیکی است نه فرسایشی: به‌دنبال هر فراخوانی FillCharای که هدفش یک متغیر رکوردی است بگردید، سپس فهرست فیلد آن رکورد را در برابر فهرست نوع-مدیریت‌شده‌ی بالا بررسی کنید. ممیزی خودِ v1.56.4 در PDFiumPas دقیقاً همان جستجو را در سراسر کل کتابخانه اجرا کرد و این افشا را در یک واحد پیدا کرد؛ هر نقطه‌ی فراخوانی FillChar دیگر از پیش یک رکورد عددی ساده را پاک می‌کرد، جایی که FillChar ابزار درست بود و همچنان هست

همان رفتار کامپایلری که یک Result دوباره-استفاده‌شده را اینجا خطرناک می‌کند، خانواده‌ای مرتبط از ناسازگاری‌های Delphi-در-برابر-FPC را هم جای دیگری در این codebase هدایت می‌کند؛ مقاله‌ی همراه درباره‌ی دام‌های بین-کامپایلری یک موردی را پوشش می‌دهد که در آن FPC و Delphi درباره‌ی اینکه دقیقاً چه زمانی یک موقتی نتیجه-رکوردی درون یک عبارت تکی نهایی‌سازی می‌شود ناموافق‌اند، یک علامت متفاوت از همان واقعیت زیرین که Result رکوردی یک تابع همیشه همان storage تازه و خصوصی‌ای که به‌نظر می‌رسد نیست. حلقه‌ی حاشیه‌نویسی استفاده‌شده به‌عنوان مثال در سراسر این مقاله هم فرضی نیست: این همان پیمایش صفحه-به-صفحه‌ای است که هنگام ساخت یک پنل بازبینی حاشیه‌نویسی می‌نویسید، که دقیقاً همان شکل کدی است که یک FillChar تک‌خطی را در وهله‌ی اول به یک نشت حافظه‌ی کند تبدیل کرد

هیچ‌کدام از این‌ها به تعویض کتابخانه یا تعقیب یک باگ در کد کامپایل‌شده‌ی کس دیگری نیاز ندارد: این ویژگی‌ای از خودِ زبان Object Pascal است، ویژگی‌ای که هر توسعه‌دهنده‌ی Delphi و FPC روزانه با آن کار می‌کند، و رفع اشکال به‌محض اینکه بدانید به‌دنبال چه چیزی بگردید یک فراخوانی تابع تکی است. APIهای حاشیه‌نویسی، بوک‌مارک، و حاشیه‌نویسی-لینک توصیف‌شده در اینجا به‌عنوان بخشی از کامپوننت PDFium برای Delphi، C++Builder، و Lazarus/FPC عرضه می‌شوند، در کنار بقیه‌ی سطح خواندن، رندر، و حاشیه‌نویسی PDF که جای دیگری در این وبلاگ پوشش داده شده