مقال تقني

استثناءات constructor في Delphi وdestructors آمنة مع nil

حين يرفع constructor في Delphi استثناء، يستدعي RTL الـ destructor فورًا على النسخة ناقصة البناء، فيجب على كل destructor أن تتعامل مع حقول لم تسند قط. يطبق HotXLS تلك القاعدة على destructors خاصته، بما فيها TXLSXRichText وTXLSXCells: فشل تخصيص داخل تلك الـ constructors يصل إلى كودك كـ EOutOfMemory الأصلي، لا كـ access violation ثانوية ترفع أثناء التنظيف

صنف الخطأ هذا قديم وسهل الفقدان: خدمة تحول مصنف 300 MB على خادم مشغول، تضيق الذاكرة، ويعرض log لـ access violation داخل destructor. لا شيء في تلك الرسالة يقول "نفدت الذاكرة"، ولا شيء يشير إلى أن المحاولة لاحقًا ستنجح، وتشير stack trace إلى destructor يبدو صحيحًا تمامًا. الاستثناء الأصلي عاش بضع ميكروثوان ثم استبدل. وتمشي هذه المقالة في سبب حدوث ذلك، وكيف يبني HotXLS الـ destructors خاصته لتجنبه، وكيف تكتب أصنافك الخاصة فلا تفعله أبدًا

ماذا يحدث حين يرفع constructor في Delphi؟

يدمر الكائن عنك قبل أن يصل الاستثناء إلى المستدعي، ويعمل destructor الخاصك على الحالة التي بلغها constructor أيًا كانت. التسلسل ثابت من المترجم وRTL: يخصص NewInstance النسخة وتملا InitInstance أصفارًا، فيبدأ كل مرجع كائن بـ nil وكل عدد صحيح بـ 0 وكل سلسلة فارغة. ثم يعمل جسم constructor داخل معالج استثناء ضمني. فإن رفع شيء في ذلك الجسم، استدعى الكود المولد الـ destructor الافتراضي: يعمل BeforeDestruction، ثم Destroy الخاصك، ثم يحرر FreeInstance الذاكرة، وأخيرًا يرفع الاستثناء من جديد. لا يعمل AfterConstruction أبدًا، والمتغير على يسار X := TFoo.Create لا يسند أبدًا

دورة حياة كائن Delphi تبين كيف يستدعي RTL الـ Destroy على نسخة HotXLS ناقصة البناء حين يرفع constructor، قبل أن يصل الاستثناء الأصلي إلى المستدعي
حين يرفع constructor في Delphi استثناء يدمر RTL النسخة ناقصة البناء أولًا ثم يرفع الاستثناء الأصلي من جديد، فيعمل Destroy على الحالة التي بلغها البناء

فيرى الـ destructor كائنًا توقف عند سطر اعتباطي من constructor خاصه: الحقول المسندة قبل الفشل تحمل كائنات حقيقية، والحقول بعده ما تزال nil. والـ destructor المكتوب بافتراض أن البناء اكتمل سيعمل مرجعًا لأحد تلك الحقول nil ويرفع access violation خاصة به

ذلك الاستثناء الثاني هو الضرر الحقيقي: لا تسلسل Delphi الاستثناءات المرفوعة أثناء هذا التنظيف، فتحل الـ access violation محل EOutOfMemory، ويهمل الاستثناء الأصلي، ولا يرى المستدعي أبدًا سوى الـ AV. والتشخيص المفيد، "فشل تخصيص، حاول مجددًا بذاكرة أكثر"، رحل، ومكانه شيء يشبه فساد الذاكرة

من أين تأتي الـ access violation الثانوية؟

تأتي من destructor يفترض أن constructor خاصه اكتمل ويلمس حقل قائمة يساوي nil حين يفشل أول تخصيص داخلي تمامًا. يبين صنفا HotXLS شكلها بوضوح: كل من TXLSXRichText.Create المستقلة والنسخة المملوكة للتعليق TXLSXRichText.Create(AOwner: TXLSXComment) تستدعيان inherited Create ثم تخصصان FRuns := TList.Create. و تفعل TXLSXCells.Create(AOwnerSheet: TXLSXWorksheet) الشيء نفسه بـ FList := TList.Create قبل أن تبني جداول سلاسلها المعبأة وتخزين الخلايا المعبأ. وفي Lib/lxHandleX.pas يحرس الـ destructorان تلك الحقول:

procedure TXLSXRichText.ClearCore;
var
  i: Integer;
begin
  if FRuns=nil then Exit;             // قد يكون الـ constructor توقف قبل FRuns
  for i:= 0 to FRuns.Count- 1 do
    TXLSXRichTextRun(FRuns[i]).Free;
  FRuns.Clear;
end;

destructor TXLSXCells.Destroy;
begin
  FPackedStorageEnabled:= False;
  if FList<>nil then Clear;          // ‏FList تساوي nil إن فشل TList.Create
  // ... التخزين المعبأ وجداول السلاسل وFList تحرر بـ Free
  inherited Destroy;
end;

نقطة الفشل دقيقة: بعدّ تخصيصات الكومة أثناء البناء، الأولى هي النسخة نفسها؛ فإن فشل NewInstance لم يعمل جسم constructor ولم يستدع destructor، فتلك الحالة نظيفة دائمًا. والتخصيص الثاني هو TList: حين يفشل تبقى FRuns أو FList عند nil، ويستدعي RTL ‏Destroy، وDestroy غير المحروسة تقرأ Count عبر مرجع nil، وهو بالضبط الـ access violation الثانوية. أما الإخفاقات اللاحقة داخل TXLSXCells.Create فغير مؤذية، لأنه ما إن توجد FList حتى يفحص Clear ‏FPackedStorage <> nil قبل استخدام التخزين المعبأ

خريطة ترتيب التخصيص لـ constructors في HotXLS على Delphi: ‏NewInstance الفاشلة نظيفة، وTList.Create الفاشلة تترك الحقل nil، والإخفاقات اللاحقة تبقى محروسة
التخصيص الثاني وحده هو المهم: فشل TList يترك الحقل nil، والـ Destroy غير المحروسة هي ما يحول EOutOfMemory إلى access violation

هذان الصنفان مهمان لأنهما يبنيان بالجملة: كل ورقة عمل محملة تبني مجموعة TXLSXCells، وكل خلية أو تعليق بمدايات منسقة يبني TXLSXRichText. والمصنف الكبير غني rich text يبني آلافًا منها، فتحت ضغط الذاكرة فإن للتخصيص الذي يفشل أخيرًا فرصة حقيقية أن يهبط على أحد تلك السطور

‏Free آمنة مع nil، والوصول إلى الحقل ليس كذلك

يفحص TObject.Free ‏Self <> nil قبل استدعاء Destroy، فـ FRuns.Free على حقل nil لا تفعل شيئًا. أما قراءة FRuns.Count فليست كذلك: تقرأ Count حقلًا عند إزاحة من مؤشر النسخة، والاستدعاء الافتراضي مثل FRuns.Clear يقرأ أولًا مؤشر VMT من النسخة. وكلاهما يخطئ على nil. تلك اللامناظرية هي المصيدة كلها: استدعاءات التحرير في destructor نموذجي آمنة سلفًا؛ والحلقات واستدعاءات التنظيف الموضوعة قبلها هي موضع الشغب. هذا هو النمط في صنف صغير من صنعك:

type
  TStyledText = class
  private
    FRuns: TList;
    FIndex: TStringList;
  public
    constructor Create;
    destructor Destroy; override;
  end;

constructor TStyledText.Create;
begin
  inherited Create;
  FRuns := TList.Create;          // ‏EOutOfMemory هنا تترك FRuns = nil
  FIndex := TStringList.Create;   // ... وهنا تترك FIndex = nil
end;

destructor TStyledText.Destroy;   // سيئ: يفترض أن الـ constructor انتهى
var
  I: Integer;
begin
  for I := 0 to FRuns.Count - 1 do   // access violation حين FRuns = nil
    TObject(FRuns[I]).Free;
  FIndex.Free;
  FRuns.Free;
  inherited Destroy;
end;

الـ destructor المصحح يحرس كل مرجع ويحرر الحقول عبر استدعاءات آمنة مع nil فقط:

destructor TStyledText.Destroy;
var
  I: Integer;
begin
  if FRuns <> nil then
    for I := 0 to FRuns.Count - 1 do
      TObject(FRuns[I]).Free;
  FreeAndNil(FIndex);
  FreeAndNil(FRuns);
  inherited Destroy;
end;

القواعد التي تنبثق عن هذا قصيرة بما يكفي للتطبيق في مراجعة كود:

  • كل استخدام لحقل مملوك في destructor، سوى Free أو FreeAndNil، يحتاج فحص nil أو Assigned، بما فيها الاستدعاءات إلى مساعدات مثل Clear أو ClearCore التي تمشي في الحقل
  • حرر الكائنات المملوكة بـ Free أو FreeAndNil، ولا تستدع Destroy مباشرة أبدًا فهي تتخطى فحص nil
  • أبق inherited Destroy العبارة الأخيرة، فيهدم الجد حقوله الخاصة بعد زوال حقولك
  • لا تستدل من ترتيب الحقول في constructor؛ فالصائن التالي سيدرج تخصيصًا فوق تخصيصك وينقل نقطة الفشل بصمت

أين ينتمي inherited Create

استدع inherited Create أولًا، كما يفعل كلا constructorي HotXLS: فتوجد حقول الجد قبل أن يعمل كودك، وصورة الـ destructor المعكوسة، أي inherited Destroy آخرًا، تفك بالترتيب المعاكس. وإن رفع constructor الجد استثناء بنفسه، فلا يزال destructor الخاصك يعمل وكل حقولك nil، وهو ما يعالجه destructor آمن مع nil بلا حالات خاصة. والإسنادات الصريحة مثل FOwner := nil في constructor زائدة بعد InitInstance، لكنها غير مؤذية وأحيانًا تستحق البقاء توثيقًا

لا تلتقط وتحرر داخل constructor

نمطان حسنا النية يجعلان هذا أسوأ: الأول تغليف جسم constructor بـ try ... except Free; raise; end، فـ RTL ستستدعي destructor على أي حال فيدمر الكائن مرتين. والثاني ابتلاع الاستثناء "لنستمر": فيستلم المستدعي مرجعًا يبدو صالحًا لكائن حقوله nil، وينتقل الفشل إلى استدعاء تابع لا صلة له بعد وقت طويل. وإن أنشأ constructor مؤقتًا لا يحتفظ به، فحرر ذلك المؤقت في try ... finally محلي ودع الاستثناء يصعد دون مساس

كيف تختبر مسارات فشل constructor؟

أفشل كل نقطة تخصيص في constructor بقصد، واحدة تلو الأخرى، واشترط EOutOfMemory كل مرة، تليها بناء طبيعي ناجح. والأداة لذلك مدير ذاكرة بديل تعد GetMem وAllocMem وReallocMem الطلبات ذات الحجم الموجب وتعيد nil عند رقم ترتيبي مختار. عد تخصيصات constructor على بناء هدفك أولًا، ثم شغل حقن فشل واحد لكل رقم ترتيبي

بعد كل فشل محقون، ابن الصنف نفسه مجددًا بعد إعادة المخصص وجرّبه: في صنف rich text أضف مدى، واضبط نصه، وتأكد أن النص الصافي يعيد كما توقع. وفحوص دورة الحياة العادية تجاورها، مثل حفظ مصنف بمدايات منسقة وتعليقات إلى ملف وإلى stream، وتحرير المصنف المصدر، وإعادة الفتح ومقارنة النسختين

أعداد التخصيصات تعود لمترجم ومنصة واحدَين؛ يمكن لمترجم أو RTL آخر أن يخصص اختلافًا، فقس على البناء الذي تشحنه. يطبق HotXLS القاعدة الآمنة مع nil عبر المكتبة كلها، من مجموعات الأنماط والرسوم البيانية وpivot إلى جداول hash وكاتب zip ومصدّر PDF. وجداول hash تستحق ملاحظة خاصة: جدول يغير قناعه قبل إعادة تخصيص تفشل بعدها يترك destructorه يمشي في الذاكرة بعد الجدول القديم، فيجب أن يتغير القناع فقط بعد وجود التخزين الجديد. وحالة واحدة تجلس في RTL نفسها: ‏TInterfaceList تخطئ في destructorها حين يفشل تخصيصها الداخلي. ولا يساوي أي من هذا تراجعًا لعملية اعتباطية تفشل في المنتصف، وليس ضمان استرداد عام لنفاد الذاكرة

ماذا يغير هذا للكود الذي يفتح مصنفات كبيرة؟

عمليًا، ‏EOutOfMemory من تلك مسارات البناء إشارة تستطيع التقاطها والتصرف عليها، بدل access violation تضطر لمعاملتها انهيارًا. لا تأخذ TXLSXWorkbook.Create وسائط، وتعيد TXLSXWorkbook.Open(const FileName: WideString): Integer ‏1 عند النجاح و-1 عند الفشل. وتسجل الاستثناءات المرفوعة أثناء التحميل في مجموعة Diagnostics للمصنف كـ xlsDiagnosticUnhandledException ثم ترفع من جديد، وهو السلوك الموصوف في مقالة التشخيصات المهيكلة في HotXLS. وغلاف إعادة المحاولة بهذا الشكل:

type
  EWorkbookOpenFailed = class(Exception);

function OpenWorkbookWithRetry(const FileName: WideString;
  MaxAttempts: Integer): TXLSXWorkbook;
var
  Attempt: Integer;
  Book: TXLSXWorkbook;
begin
  Result := nil;
  for Attempt := 1 to MaxAttempts do
  begin
    Book := nil;
    try
      try
        Book := TXLSXWorkbook.Create;          // بلا وسائط constructor
        if Book.Open(FileName) <> 1 then        // ‏1 = نجاح، و-1 = فشل
          raise EWorkbookOpenFailed.CreateFmt('HotXLS could not open %s',
            [string(FileName)]);
        Result := Book;
        Book := nil;                           // الملكية تنتقل إلى المستدعي
        Exit;
      finally
        Book.Free;                             // آمنة مع nil بعد التسليم
      end;
    except
      on E: EOutOfMemory do
      begin
        if Attempt = MaxAttempts then
          raise;
        ReleaseApplicationCaches;              // كودك: أسقط caches وكتبًا أخرى
      end;
    end;
  end;
end;

بضع تفاصيل في ذلك الغلاف مقصودة: كل محاولة تبني TXLSXWorkbook جديدًا، لأن destructors الآمنة مع nil تجعل إخفاقات البناء نظيفة لكنها لا تجعل Open ناقص الإتمام قابلًا للاستئناف، فالنسخة الفاشلة تحرر ببساطة. ولا تعاد إلا EOutOfMemory؛ أما EWorkbookOpenFailed وأخطاء password وكل ما سواها فيصعد أول محاولة. ولا يجب إعادة EAccessViolation إطلاقًا، لأنها تشير إلى عيب لا إلى نقص مورد. وإعادة المحاولة لا معنى لها إلا إذا تغير شيء بين المحاولات، ولهذا يحرر المعالج الذاكرة أولًا. وإن احتجت أثر التشخيص للمحاولة الفاشلة، فاقرأ Book.Diagnostics قبل أن تحررها كتلة finally

مصفوفة قرار إعادة المحاولة في Delphi مع HotXLS: ‏EOutOfMemory تعاد بمصنف TXLSXWorkbook جديد، والإخفاقات الأخرى تصعد فورًا، ولا تعاد access violation أبدًا
كل محاولة تبني مصنفًا جديدًا لأن Open ناقص الإتمام غير قابل للاستئناف، ولا تكسب EOutOfMemory وحدها محاولة ثانية

إعادة المحاولة تخفيف لا خطة سعة: عملية 32-bit تصطدم بـ EOutOfMemory باستمرار على ملفات كبيرة تنقصها فضاء عناوين متصل غالبًا لا ذاكرة فيزيائية، والجواب الدائم بناء Win64. وينطبق التفكير نفسه على جهة الكتابة، حيث تبقي حفظات HotXLS الآمنة من الانهيار بملفات temp مرحلية حفظًا فاشلًا عن إتلاف الملف القائم. وإن تشاركت عدة threads مصنفًا واحدًا، فـ عقود القراءة وحراس الكتابة في HotXLS تصف كيف ينسق الوصول إلى تلك النسخة

قائمة تحقق لأصناف Delphi الخاصة بك

الحراسة نفسها سطر واحد لكل واحدة، والتدقيق نفسه يأخذ دقائق على كودك: ابحث عن destructors تستدعي تابع Clear أو Reset أو ClearCore قبل تحرير الحقول، ثم افحص هل يلمس ذلك التابع حقلًا ربما لم يبلغه constructor. وابحث عن حلقات على قوائم مملوكة لا يسبقها فحص nil. وابحث عن constructors تلتقط استثناءات، وأزل الابتلاع والـ Free اليدوي معًا. وإن أردت برهانًا لا تفقّدًا، فمدير الذاكرة العد الموصوف أعلاه يسع صفحة كود ويحول كل تخصيص في constructor إلى حالة اختبار

أما المزايا الكاملة وإصدارات Delphi وC++Builder المدعومة والترخيص، فراجع صفحة منتج مكوّن HotXLS لـ Excel في Delphi