لغات البرمجة عملياً الأدلة التقنية

الأنواع والذاكرة والاستثناءات Exceptions في C#

قسنا ما تكلفه أنواع القيمة والمرجع وجامع القمامة داخل Container محدود، ثم شغلنا كل خطأ شائع حتى يسقط، وكشفنا أن double يخطئ في ربع مبالغ الضريبة الحدية.

الأنواع والذاكرة والاستثناءات Exceptions في C#

في الأسبوع الثاني لك في فريق المتجر، وهو الفريق الذي يبيع القهوة والتمور وكتب خدمة الطلبات بـ C#، وصلت ثلاث رسائل في صباح واحد. الأولى من المحاسب: فاتورة لعميل اشترى بـ 1.50 ريال فيها ضريبة 0.22 ريال، بينما الحساب اليدوي يقول 0.23. والثانية من مراقبة الخوادم: مهمة تصدير الطلبات الليلية توقفت عند الثالثة فجراً برسالة NullReferenceException، مع أن المترجم لم يعترض على شيء عند البناء. والثالثة من زميلك في البنية التحتية: الـ Container الذي يشغل مهمة التقارير يعاد تشغيله كل ليلة لأنه تجاوز حد الذاكرة 512 ميجابايت، ويسألك: «ألا يفترض أن الـ Garbage Collector يدير الذاكرة؟». والرسائل الثلاث في الحقيقة سؤال واحد: كيف تمثل C# القيم في الذاكرة، ومن ينظفها، وماذا تفعل عندما يحدث خطأ.

وهذا هو المقال الثاني في مرحلة لغة C# للمطورين، وفي المقال الأول ثبتنا .NET 10 وبنينا أول حل بالأمر dotnet. وهنا ننزل تحت الكود: نظام الأنواع، والفرق بين نوع القيمة ونوع المرجع، وجامع القمامة الذي يدير الذاكرة، وكيف يتصرف داخل Container محدود، ثم طريقة C# في الأخطاء وهي الاستثناءات Exceptions. وكل فكرة معها قياس من برنامج واحد شغلناه بـ .NET 10.0.14 في Container بنواتين، وكل خطأ معها تشغيل حقيقي فاشل ورسالته كما ظهرت. والمفاهيم العامة لإدارة الذاكرة ومقارنة اللغات فيها شرحناها في مقال إدارة الذاكرة، ومقارنة طرق الأخطاء في كيف تتعامل لغات البرمجة مع الأخطاء، فلن نكررها، وإنما نركز على ما تفعله C# تحديداً.

وسوف نناقش في هذا المقال ما يلي:

  • نظام الأنواع في C#: ثابت وقوي، والأنواع تبقى موجودة وقت التشغيل على عكس TypeScript.
  • نوع القيمة Value Type ونوع المرجع Reference Type: ماذا ينسخ سطر الإسناد، وكم يكلف مليون عنصر من كل نوع، والتغليف Boxing.
  • جامع القمامة Garbage Collector: الأجيال Generations، وWorkstation وServer GC، والذاكرة داخل الـ Container، والـ Span الذي لا يخصص شيئاً.
  • الموارد غير الذاكرة: IDisposable وusing، ولماذا لا يكفي الـ GC لإغلاق ملف.
  • القيمة الفارغة null: الأنواع القابلة للفراغ، وعلامة !، وما يحدث عندما يسقط البرنامج.
  • الاستثناءات Exceptions: الفيضان Overflow الصامت، والتحويل Parsing، وكم يكلف الاستثناء الواحد، والمرشحات when.
  • المال: double أم decimal أم الهللات int، بقياس كل المبالغ حتى ألف ريال، والمشكلة التي أخفاها مثال واحد صحيح.
  • أخطاء شائعة، وجدول قرار تختار به النوع وطريقة الخطأ.

نظام الأنواع Type System في C#: ثابت وقوي ويبقى وقت التشغيل

إذا كنت تأتي من Python أو JavaScript فأول فرق تشعر به أن كل متغير في C# له نوع معروف وقت البناء، وهذا ما يسمى الأنواع الثابتة Static Typing، والمترجم يرفض أن تضع نصاً في متغير من نوع int، أو أن تستدعي دالة غير موجودة على كائن. وهذا لا يعني أنك تكتب النوع في كل سطر، فالكلمة var تجعل المترجم يستنتج النوع من القيمة Type Inference، ولكن النوع بعد الاستنتاج ثابت ولا يتغير، فالسطر var price = 990; يجعل price من نوع int إلى الأبد، وليس متغيراً حراً كما في Python. وقد شرحنا الفرق بين الأنواع الثابتة والديناميكية والقوية والضعيفة في مقال أنماط البرمجة وأنظمة الأنواع.

والفرق الثاني أدق، ويهم القادم من TypeScript أو Java: الأنواع في .NET لا تمحى بعد الترجمة. ففي TypeScript تختفي كل الأنواع عندما يتحول الكود إلى JavaScript، وفي Java تمحى معاملات الأنواع العامة Generics، فتصير List<Integer> وList<String> نفس الكلاس وقت التشغيل. أما في .NET فالأنواع العامة متجسدة Reified، أي أن List<int> نوع حقيقي مختلف عن List<string> وقت التشغيل، والـ JIT يولد لكل نوع قيمة نسخة خاصة من الكود. ولهذا نتيجتان عمليتان سوف نراهما بالأرقام بعد قليل: الأولى أن List<int> تحفظ الأعداد نفسها في مصفوفة متصلة دون أي كائن إضافي، والثانية أن البرنامج يستطيع أن يسأل عن نوع أي كائن وقت التشغيل، وأن يرمي استثناء إذا حاولت تحويل كائن إلى نوع لا يناسبه، بدل أن يمر الخطأ بصمت.

وكل الأنواع في C# تنتهي إلى أصل واحد هو object، ولكنها تنقسم إلى عائلتين تتصرفان بشكل مختلف تماماً في الذاكرة، وهذا الانقسام هو أهم ما يجب أن تفهمه في هذا المقال، لأنه يفسر نصف الأخطاء التي يقع فيها القادمون من لغات أخرى.

نوع القيمة Value Type ونوع المرجع Reference Type

العائلة الأولى هي أنواع القيمة Value Types، وفيها كل الأعداد int وlong وdouble وdecimal، وbool وchar، والتواريخ DateTime، وكل ما تعرفه أنت بالكلمة struct. والمتغير من هذه الأنواع يحمل القيمة نفسها، فإذا أسندته لمتغير آخر نسخت القيمة كلها، وإذا مررته لدالة أخذت الدالة نسخة. والعائلة الثانية هي أنواع المرجع Reference Types، وفيها كل ما تعرفه بالكلمة class وrecord، والنصوص string، والمصفوفات، والقوائم. والمتغير من هذه الأنواع لا يحمل الكائن، وإنما يحمل مرجعاً إليه، والكائن نفسه يعيش على الـ Heap الذي يديره جامع القمامة، فإذا أسندت المتغير لآخر صار عندك مرجعان لنفس الكائن.

وإذا كنت تأتي من Python أو JavaScript فكل شيء عندك تقريباً من العائلة الثانية، فالقائمة والقاموس والكائن كلها مراجع، والأعداد غير قابلة للتعديل فلا تشعر بالفرق. وإذا كنت تأتي من Java فالفكرة قريبة، ولكن Java لا تسمح لك بتعريف أنواع قيمة خاصة بك إلى اليوم، بينما C# تسمح بها منذ أول إصدار بالكلمة struct. ولكي نرى الفرق عملياً عرفنا نفس السعر بثلاث طرق:

struct PriceS { public int Halalas; public PriceS(int h) => Halalas = h; }
class PriceC { public int Halalas; public PriceC(int h) => Halalas = h; }
record PriceR(int Halalas);

var ps = new PriceS(990); var ps2 = ps; ps2.Halalas = 1;
var pc = new PriceC(990); var pc2 = pc; pc2.Halalas = 1;
Console.WriteLine($"struct: original={ps.Halalas} copy={ps2.Halalas}");
Console.WriteLine($"class:  original={pc.Halalas} copy={pc2.Halalas}");
BumpS(ps); BumpC(pc);           // each adds 100 to its parameter
BumpRef(ref ps);                // the same, but with ref
var r1 = new PriceR(990); var r2 = new PriceR(990);

والمخرج سوف يكون كما يلي:

=== copy: struct vs class ===
struct: original=990 copy=1
class:  original=1 copy=1
after BumpS(struct)=990  BumpC(class)=101
after BumpRef(ref struct)=1090
record r1==r2: True   class a==b: False

في المخرج أعلاه لاحظ التالي:

  • السطر الأول: عدلنا النسخة ps2 فبقي الأصل 990، لأن الإسناد نسخ القيمة كلها.
  • السطر الثاني: عدلنا pc2 فتغير pc أيضاً إلى 1، لأن المتغيرين يشيران لنفس الكائن، وهذا ما تتوقعه من أي كائن في Python أو JavaScript.
  • السطر الثالث: الدالة التي أضافت 100 إلى الـ struct عدلت نسختها هي، فبقي الأصل 990، والدالة التي أضافت 100 للكلاس عدلت الكائن الأصلي فصار 101.
  • السطر الرابع: الكلمة ref تمرر المتغير نفسه وليس نسخة منه، فعدلت الدالة الأصل وصار 1090. ولاحظ أن ref تكتب عند التعريف وعند الاستدعاء معاً، فلا يمكن أن يعدل أحد متغيرك دون أن ترى ذلك في سطر الاستدعاء.
  • السطر الأخير: سجلان record بنفس القيمة متساويان، وكائنان من كلاس عادي بنفس القيمة غير متساويين، لأن == على الكلاس تقارن المراجع.

والصورة التالية تلخص ما حدث في الذاكرة، ومعها قياس المليون عنصر الذي نشرحه في الفقرة التالية:

لوحتان: في اليمين struct PriceS حيث المتغيران ps وps2 يحمل كل منهما قيمة مستقلة 990 و1، وتحتها مخرج التجربة الذي يبين أن تعديل النسخة والتمرير لدالة لا يغيران الأصل إلا مع ref، وفي اليسار class PriceC حيث المتغيران pc وpc2 يحملان مرجعين لنفس الكائن على الـ Heap فتغير الاثنان إلى 1، وتحتهما ثلاث بطاقات: مليون struct في مصفوفة 3.8 MB وكائن واحد و4 بايت للعنصر، ومليون class 30.5 MB ومليون كائن و32 بايت للعنصر، والـ record كلاس في الذاكرة ولكن المقارنة بالقيمة
الإسناد ينسخ القيمة في الـ struct، وينسخ المرجع فقط في الـ class

مليون عنصر: ذاكرة struct مقابل class Memory Layout

ولكي نرى ثمن كل عائلة بالأرقام أنشأنا مصفوفة فيها مليون سعر من كل نوع، وقسنا ما خصصه البرنامج من الذاكرة بالدالة GC.GetAllocatedBytesForCurrentThread، وهي ترجع مجموع ما خصصه الخيط Thread الحالي منذ بدايته:

=== alloc: 1,000,000 prices as struct[] vs class[] ===
struct[]: 3.8 MB allocated, heap objects: 1 (the array)
class[]:  30.5 MB allocated, heap objects: 1,000,001
bytes per item: struct 4.0, class 32.0

مصفوفة الـ struct كائن واحد على الـ Heap، وفيها المليون قيمة متجاورة، كل واحدة 4 بايت هي حجم الحقل int فقط. ومصفوفة الكلاس فيها مليون مرجع، كل مرجع 8 بايت على نظام 64 بت، وكل مرجع يشير إلى كائن مستقل على الـ Heap، والكائن الواحد في .NET على 64 بت فيه رأس Header ومؤشر للنوع بمجموع 16 بايت، ثم الحقل مع الحشو Padding إلى 8 بايت، أي 24 بايت للكائن و8 للمرجع، والمجموع 32 بايت للعنصر الواحد، أي ثمانية أضعاف. وأضف إلى ذلك أن الـ GC عنده الآن مليون كائن يجب أن يتتبعها في كل جمع، وأن المرور على المصفوفة يقفز في الذاكرة بين كائنات متفرقة بدل أن يقرأ قطعة متصلة، وهذا يضيع فائدة الذاكرة المؤقتة للمعالج CPU Cache.

وقد يتساءل البعض: إذن لماذا لا نجعل كل شيء struct؟ والإجابة أن الـ struct ينسخ كاملاً في كل إسناد وكل تمرير، فإذا كان كبيراً صار النسخ أغلى من المرجع، وأن تعديل نسخة بالخطأ بدل الأصل من أشهر أخطاء C#، كما رأينا في السطر الثالث. لذلك تضع Microsoft في دليل الاختيار بين class وstruct قاعدة واضحة: استعمل struct عندما يمثل النوع قيمة واحدة صغيرة، مثل نقطة أو مبلغ أو تاريخ، وحجمه أقل من 16 بايت، ولا يتغير بعد إنشائه، وعمره قصير أو يعيش داخل كائن آخر. وفي غير ذلك استعمل class أو record، والأخير هو الاختيار الطبيعي لأي بيانات تنقلها بين الطبقات، مثل المنتج والطلب في خدمة المتجر. وإذا احتجت سجلاً بخصائص نوع القيمة فـ C# تعطيك record struct أيضاً.

التغليف Boxing: عندما يصير int كائناً

بقي ثمن ثالث لعائلة القيم لا يظهر في الكود أبداً، وهو التغليف Boxing. فعندما تضع قيمة من نوع int في متغير من نوع object، أو في مجموعة قديمة تقبل object، أو في واجهة Interface لا تعرف النوع الحقيقي، يحتاج الـ Runtime أن يحولها إلى مرجع، فينشئ كائناً جديداً على الـ Heap وينسخ القيمة داخله، وعند القراءة يفك التغليف Unboxing، كما تشرح صفحة التغليف. ولكي نقيس ثمنه جمعنا عشرة ملايين عدد مرة في ArrayList القديمة التي تحفظ object، ومرة في List<int>:

=== boxing: 10,000,000 ints through object (ArrayList) vs generic List<int> ===
ArrayList: 4030 ms, 305 MB, gen0 GCs 41
List<int>: 192 ms, 38 MB, gen0 GCs 0, sums equal: True

نفس الأعداد ونفس المجموع، ولكن ArrayList خصصت 305 ميجابايت لعشرة ملايين كائن صغير، واحتاجت 41 جمعاً من الـ GC، وأخذت أربع ثوان، بينما List<int> خصصت 38 ميجابايت هي المصفوفة نفسها، أي 4 بايت لكل عدد، ولم تحتج أي جمع، وأخذت أقل من خمس الثانية. وهذا هو فائدة الأنواع العامة المتجسدة التي ذكرناها في البداية، ولن تكتب ArrayList في كود جديد بالطبع، ولكن التغليف يظهر في أماكن أخرى أقل وضوحاً: تمرير struct إلى دالة تأخذ واجهة، وstring.Format القديمة مع أعداد، والقواميس من نوع Dictionary<string, object> التي يحبها القادمون من JavaScript لأنها تشبه الكائن الحر. والقاعدة: إذا رأيت object في مسار ساخن يمر عليه كل طلب، فاسأل هل هناك نوع عام يغني عنه.

من يدير الذاكرة؟ جامع القمامة Garbage Collector

في C# لا تحرر الذاكرة بنفسك، فلا يوجد free ولا delete، وكل كائن تنشئه بالكلمة new يبقى حتى يقرر جامع القمامة أن أحداً لم يعد يصل إليه، فيسترجع مكانه. وهنا فرق مهم عن Python وSwift: الـ GC في .NET ليس عداداً للمراجع Reference Counting يحرر الكائن لحظة انتهاء آخر مرجع إليه، وإنما جامع تتبعي Tracing GC: يتوقف من وقت لآخر، ويبدأ من الجذور Roots، أي المتغيرات المحلية في كل الخيوط والحقول الثابتة Static Fields، ويتبع كل مرجع ليعرف أي الكائنات ما زالت حية، وكل ما لم يصل إليه قمامة. ثم يضغط Compacts الكائنات الحية بجانب بعضها، فلا يبقى في الذاكرة فراغات متفرقة، ولذلك التخصيص في .NET رخيص جداً: مجرد تحريك مؤشر إلى الأمام. والتفاصيل في صفحة أساسيات جامع القمامة.

الأجيال Generations بالأرقام

لو مر الـ GC على كل الذاكرة في كل جمع لكان بطيئاً جداً مع Heap بعدة جيجابايتات، لذلك يقسم الكائنات إلى ثلاثة أجيال مبنية على ملاحظة بسيطة: أغلب الكائنات تموت صغيرة، مثل النص المؤقت في دالة، والكائن الذي عاش طويلاً سوف يعيش أطول غالباً. فكل كائن جديد يبدأ في الجيل 0، وعندما يمتلئ الجيل 0 يجمعه الـ GC وحده، وهو صغير فيكون الجمع سريعاً، ومن ينجو يرتقي إلى الجيل 1، ومن ينجو مرة أخرى يرتقي إلى الجيل 2، والجيل 2 يجمع نادراً لأن جمعه يمر على كل شيء. وهناك منطقة خاصة للكائنات الكبيرة Large Object Heap، وكل كائن حجمه 85,000 بايت أو أكثر يوضع فيها مباشرة كما تشرح صفحة الـ LOH، لأن نسخ كائن كبير عند الضغط مكلف. ولكي نرى هذا كله بأرقام حقيقية كتبنا القسم gc في برنامج التجربة:

var survivor = new object();
Console.WriteLine($"new object: gen {GC.GetGeneration(survivor)}");
GC.Collect(); Console.WriteLine($"after 1 GC: gen {GC.GetGeneration(survivor)}");
GC.Collect(); Console.WriteLine($"after 2 GCs: gen {GC.GetGeneration(survivor)}");
var big = new byte[100_000];
// 5M objects that die at once, then 5M objects kept in a List
for (int i = 0; i < 5_000_000; i++) { var tmp = new PriceC(i); GC.KeepAlive(tmp); }
for (int i = 0; i < 5_000_000; i++) keep.Add(new PriceC(i));

والمخرج سوف يكون كما يلي:

=== gc: generations ===
Server GC: False, latency mode: Interactive, processors: 2
total available memory seen by GC: 1536 MB
new object: gen 0
after 1 GC: gen 1
after 2 GCs: gen 2
byte[100000]: gen 2 (Large Object Heap, objects >= 85,000 bytes)
5M short-lived objects: 233 ms, GCs gen0 +28, gen1 +0, gen2 +0, pause 49.43 ms
5M kept objects:        1325 ms, GCs gen0 +21, gen1 +11, gen2 +2, pause 886.40 ms
heap now: 255 MB

في المخرج أعلاه لاحظ التالي:

  • الكائن الجديد في الجيل 0، وبعد جمع واحد نجا فيه لأن المتغير survivor ما زال يشير إليه فارتقى إلى 1، وبعد الثاني إلى 2، ولن يرتقي أكثر.
  • المصفوفة بحجم 100,000 بايت بدأت مباشرة في الجيل 2، لأن الـ LOH يعامل كجزء من الجيل 2.
  • خمسة ملايين كائن تموت فوراً احتاجت 28 جمعاً للجيل 0 فقط، ومجموع توقف البرنامج Pause فيها 49 ملي ثانية، لأن كل جمع وجد كل شيء ميتاً تقريباً فلم ينسخ شيئاً.
  • خمسة ملايين كائن تبقى حية في قائمة احتاجت جمعاً أقل للجيل 0، ولكن 11 جمعاً للجيل 1 وجمعين للجيل 2، ومجموع التوقف 886 ملي ثانية، أي 18 ضعفاً، مع أن عدد الكائنات هو نفسه.

وهذه أهم نتيجة في القسم كله: تكلفة الـ GC ليست في عدد ما تخصصه، وإنما في عدد ما يبقى حياً ويضطر الـ GC إلى نسخه بين الأجيال ومسحه في الجيل 2. لذلك الكائن المؤقت داخل دالة رخيص جداً في .NET، والخطر الحقيقي هو الكائنات التي تعيش متوسطاً ثم تموت، مثل ذاكرة مؤقتة Cache بلا حد تحفظ كل شيء، أو قائمة تكبر في حقل ثابت ولا يحذف منها أحد. والصورة التالية تجمع الأجيال والأرقام:

أربعة صناديق للأجيال: الكائنات الجديدة Gen 0 تجمع كثيراً وبسرعة، ثم الناجون مرة Gen 1، ثم طويلة العمر Gen 2 وجمعها الأغلى، وLarge Object Heap للكائنات من 85,000 بايت فأكثر، وتحتها جدول بأربعة صفوف: خمسة ملايين كائن تموت فوراً بـ Workstation GC في 233 ms مع 28 جمعاً للجيل 0 وتوقف 49 ms، وتبقى في قائمة في 1325 ms مع 21 و11 و2 جمعاً وتوقف 886 ms، وبـ Server GC تموت فوراً في 161 ms وتوقف 13 ms، وتبقى في قائمة 1425 ms وتوقف 750 ms
نفس العدد من الكائنات: التي تموت صغيرة شبه مجانية، والتي تبقى حية هي التي تكلف

Workstation أم Server GC؟ وماذا تفعل DATAS

في .NET نوعان من الـ GC كما تشرح صفحة Workstation وServer GC. الأول Workstation GC، وهو الافتراضي لبرامج سطر الأوامر وتطبيقات سطح المكتب، وفيه Heap واحد وجمع على خيط واحد. والثاني Server GC، وهو الافتراضي لتطبيقات ASP.NET Core، وفيه Heap لكل نواة وجمع بعدة خيوط بالتوازي، فيعطي إنتاجية Throughput أعلى ولكنه تقليدياً يحجز ذاكرة أكثر. وقد شغلنا نفس القسم مرة ثانية بالمتغير DOTNET_gcServer=1:

Server GC: True, latency mode: Interactive, processors: 2
5M short-lived objects: 161 ms, GCs gen0 +45, gen1 +0, gen2 +0, pause 12.62 ms
5M kept objects:        1425 ms, GCs gen0 +7, gen1 +5, gen2 +4, pause 750.45 ms
heap now: 178 MB

ومع Server GC كان التوقف في الكائنات قصيرة العمر 13 ملي ثانية بدل 49، لأن نواتين تجمعان معاً، وحجم الـ Heap في النهاية 178 ميجابايت بدل 255، وهذه النتيجة الثانية مفاجئة لمن قرأ أن Server GC يستهلك ذاكرة أكثر. والسبب ميزة اسمها DATAS، أي التكيف مع حجم التطبيق Dynamic Adaptation To Application Sizes، وقد صارت مفعلة افتراضياً مع Server GC منذ .NET 9، فهي تغير عدد الـ Heaps وحجمها بحسب ما يحتاجه التطبيق فعلاً، بدل أن تحجز Heap كبيراً لكل نواة من البداية. ولاحظ أن الأرقام على جهاز مشغول بتجارب أخرى، وأن الفرق بين تشغيلين لنفس الإعداد قد يصل إلى عشرات بالمئة، فلا تأخذ منها إلا الاتجاه، وقس تطبيقك أنت قبل أن تغير الإعداد.

الذاكرة داخل الـ Container: حد الـ Heap Hard Limit

نعود الآن إلى رسالة زميلك عن الـ Container الذي يتجاوز 512 ميجابايت. لاحظ في المخرج السابق السطر total available memory seen by GC: 1536 MB، فالـ Container كان محدوداً بـ 2 GB، والـ GC رأى منها 1536 ميجابايت فقط، أي 75%. وهذا مكتوب في صفحة إعدادات الـ GC: عندما يعمل البرنامج داخل بيئة لها حد ذاكرة مثل الـ Container، يعامل .NET هذا الحد كأنه ذاكرة الجهاز، ويضع حداً صلباً للـ Heap افتراضياً عند 75% منه، ويترك الباقي للـ Runtime نفسه والـ JIT والمكتبات الأصلية والـ Stacks. ولكي نرى ماذا يحدث عند الحد كتبنا القسم limit، وهو يخصص كتلاً بحجم 10 ميجابايت ويحفظها في قائمة حتى يرفض الـ GC، وشغلناه في Container بـ 256 ميجابايت:

docker run --rm --cpus=2 --memory=256m -v "$PWD/lab":/lab \
  mcr.microsoft.com/dotnet/sdk:10.0 bash /lab/run.sh limit

والمخرج سوف يكون كما يلي:

=== limit: allocate 10 MB blocks and keep them ===
GC: total available memory 192 MB, Server GC False
OutOfMemoryException after 18 blocks = 180 MB: Exception of type 'System.OutOfMemoryException' was thrown.

الحد 192 ميجابايت، أي 75% من 256، وبعد 18 كتلة، أي 180 ميجابايت، رمى الـ GC استثناء OutOfMemoryException، والكتلة التالية كانت ستتجاوز الحد. والمهم هنا أن البرنامج أخذ استثناء عادياً التقطه وطبعه، ولم يقتله الـ Kernel فجأة بالـ OOM Killer كما يحدث لبرنامج يتجاوز حد الـ Container، وهذا فرق كبير في التشخيص: في الحالة الأولى عندك رسالة في السجلات Logs وStack Trace، وفي الثانية عندك فقط Container أعيد تشغيله بالرمز 137.

وماذا يحدث إذا غيرنا النسبة؟ الإعداد GCHeapHardLimitPercent يقبل نسبة أخرى، ويكتب في متغير البيئة بالنظام الست عشري Hexadecimal، فالقيمة 0x32 تعني 50، وهذا من الأشياء التي تخطئ فيها أول مرة لأنك تكتب 50 فتحصل على 80%:

### limit, --memory=256m, GCHeapHardLimitPercent=0x32 (50%)
GC: total available memory 128 MB, Server GC False
OutOfMemoryException after 12 blocks = 120 MB: Exception of type 'System.OutOfMemoryException' was thrown.

صار الحد 128 ميجابايت، والاستثناء بعد 120. والصورة التالية تجمع الحالات الثلاث:

ثلاثة أشرطة: Container بحد 2 GB يرى الـ GC منه 1536 MB أي 75%، وContainer بحد 256 MB يرى منه 192 MB ويرمي الاستثناء بعد 180 MB، ونفس الحد مع GCHeapHardLimitPercent=0x32 أي 50% يرى 128 MB والاستثناء بعد 120 MB، وتحتها بطاقتان: البرنامج يأخذ OutOfMemoryException يمكن التقاطه قبل أن يقتله الـ Kernel، والسبب في عدم أخذ 100% أن الـ Runtime والـ JIT والمكتبات الأصلية والـ Stacks تحتاج ذاكرة خارج الـ Heap
الـ GC يحترم حد الـ Container ويأخذ 75% منه افتراضياً

وعندما رجعنا لمهمة التقارير في القصة وجدنا السبب الحقيقي: المهمة تقرأ كل طلبات الشهر في قائمة واحدة قبل أن تكتبها في ملف CSV، فكل الكائنات تبقى حية حتى النهاية وترتقي إلى الجيل 2، وهذا بالضبط الصف الثاني في جدول الأجيال. والحل لم يكن زيادة الذاكرة، وإنما قراءة الطلبات دفعة بعد دفعة بـ IAsyncEnumerable وكتابة كل دفعة ثم تركها، فلا يبقى في الذاكرة إلا دفعة واحدة.

التخصيص الصفري Zero Allocation بالـ Span

الأداة الأخيرة في هذا القسم هي Span<T> وأختها ReadOnlySpan<T>، وهي نافذة على جزء من ذاكرة موجودة أصلاً، سواء مصفوفة أو نص أو ذاكرة على الـ Stack، دون أن تنسخها، كما تشرح صفحة الذاكرة والـ Spans. ولنأخذ مثالاً من ملف استيراد الطلبات الذي يأتي فيه كل سطر بالشكل 1234:3، أي رقم المنتج والكمية، فالطريقة المعتادة هي Split، والطريقة الثانية هي البحث عن النقطتين في Span وتحويل الجزأين مباشرة:

// Split: a new array and two new strings for every line
var parts = line.Split(':'); t1 += int.Parse(parts[0]) + int.Parse(parts[1]);
// Span: two windows over the same string, nothing allocated
ReadOnlySpan<char> s = line; int k = s.IndexOf(':');
t2 += int.Parse(s[..k]) + int.Parse(s[(k + 1)..]);

والمخرج لمليون سطر:

=== span: parsing 1,000,000 'id:qty' strings ===
Split: 548 ms, 91 MB allocated
Span:  93 ms, 0 bytes allocated, same result: True

نفس النتيجة، بصفر بايت بدل 91 ميجابايت، وأسرع قرابة ست مرات. ولا تحتاج أن تكتب كل كودك بالـ Span، فالكود العادي في .NET سريع بما يكفي، ولكن عندما يظهر في القياس مسار ساخن يحلل نصوصاً كثيرة، مثل قراءة ملف كبير أو تحليل بروتوكول، فهذا أول ما تجربه. ولاحظ أن الـ Span له قيود: لا يمكن حفظه في حقل كلاس ولا استعماله عبر await، لأنه قد يشير إلى ذاكرة على الـ Stack تختفي بعد انتهاء الدالة، والمترجم يمنعك من ذلك.

الموارد Resources: لماذا لا يكفي الـ GC؟ IDisposable مع using

الـ GC يدير الذاكرة فقط، ولا يعرف شيئاً عن الملف المفتوح أو اتصال قاعدة البيانات أو الـ Socket، وهذه موارد محدودة يجب أن تغلق في وقت تعرفه أنت، لا عندما يقرر الـ GC أن يمر. لذلك كل كلاس يمسك مورداً من هذا النوع يطبق الواجهة IDisposable التي فيها دالة واحدة هي Dispose، وأنت تستدعيها بالكلمة using، فيغلق المورد عند نهاية الكتلة حتى لو رمي استثناء في وسطها، كما تشرح صفحة using، وهذا يقابل with في Python وtry-with-resources في Java. ولكي نرى الفرق كتبنا كلاس إيصال يطبع متى يغلق، وفيه منهي Finalizer، وهو دالة يستدعيها الـ GC قبل أن يسترجع الكائن، كشبكة أمان أخيرة:

using (var r = new Receipt("A")) { Console.WriteLine("  writing receipt A"); }
MakeAndDrop();                       // creates Receipt("B") and forgets it
GC.Collect(); GC.WaitForPendingFinalizers();

والمخرج سوف يكون كما يلي:

=== dispose: using vs forgetting ===
  open A
  writing receipt A
  Dispose A: file closed now
  open B
  B created and dropped without Dispose
  finalizer B: closed late, on the finalizer thread
  end of DisposeDemo

الإيصال A أغلق في السطر الذي انتهت فيه كتلة using بالضبط، والإيصال B لم يغلقه أحد، وبقي مفتوحاً حتى أجبرنا الـ GC على الجمع وانتظرنا المنهيات، فأغلقه المنهي على خيط آخر. وفي برنامج حقيقي لا أحد يستدعي GC.Collect، فقد يبقى الملف مفتوحاً دقائق أو ساعات، وقد ينفد عدد الاتصالات في المجمع Connection Pool لأن كل اتصال نسيته ينتظر الـ GC. والقاعدة بسيطة: كل كائن يطبق IDisposable تنشئه أنت يجب أن يكون في using، والصيغة القصيرة using var conn = ...; تغلقه عند نهاية الدالة، وسوف تراها في كل سطر يفتح اتصالاً بقاعدة البيانات في مقال الخدمة الحقيقية. والمنهي نفسه لا تكتبه أبداً إلا إذا كان كلاسك يمسك مورداً أصلياً مباشرة، والتفاصيل في صفحة تطبيق Dispose.

القيمة الفارغة null: خطأ المليار دولار Billion-Dollar Mistake

في 2009 وقف Tony Hoare، الذي أدخل المرجع الفارغ إلى لغة ALGOL W عام 1965، وقدم محاضرة بعنوان Null References: The Billion Dollar Mistake، وقال فيها إنه أضاف null لأنه كان سهل التطبيق، وإن هذا القرار سبب أخطاء وأعطالاً لا تحصى. وC# ورثت null من C وJava، فكل متغير من نوع مرجع يمكن أن يكون null، وأي استدعاء عليه يرمي NullReferenceException، وهذا الاستثناء هو الأكثر ظهوراً في سجلات أي خدمة C# قديمة. ولذلك أضافت C# 8 الأنواع القابلة للفراغ Nullable Reference Types التي رأيناها في المقال الأول، فصار المترجم يتابع أي القيم قد تكون null ويحذرك.

ولكن تذكر أن هذا تحليل وقت البناء فقط، ويمكن إسكاته بعلامة التعجب !، وهي عامل التسامح مع null. وهذا بالضبط ما حدث في مهمة التصدير الليلية في القصة، فقد كتب أحدهم:

string? coupon = FindCoupon("ahmad");          // returns null for non-VIP customers
Console.WriteLine($"coupon length: {coupon!.Length}");

والمترجم لم يطبع أي تحذير، لأن ! قالت له «أنا متأكد». والتشغيل دون معالجة للخطأ أعطى:

=== null: a nullable warning that became an exception ===
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
   at Program.<<Main>$>g__NullDemo|0_12() in /w/Program.cs:line 147
   at Program.<Main>$(String[] args) in /w/Program.cs:line 22
exit code: 134
/lab/run.sh: line 10:    76 Aborted                 (core dumped) dotnet /out/Types.dll crash

في المخرج أعلاه لاحظ التالي:

  • السطر الأول نوع الاستثناء ورسالته، ورسالة NullReferenceException لا تقول أي متغير كان null، ولذلك تعتمد على رقم السطر في الـ Stack Trace، وطريقة قراءته شرحناها في مقال قراءة الـ Stack Trace.
  • الأسماء الغريبة مثل <<Main>$>g__NullDemo|0_12 هي ما ولده المترجم للدوال المحلية Local Functions داخل التعليمات العليا، فاقرأ منها الاسم NullDemo فقط.
  • أرقام الأسطر موجودة لأن البناء ينتج ملف رموز .pdb بجانب الـ dll، وإذا نشرت دون هذا الملف تفقد الأرقام، فاتركه في الـ Image.
  • رمز الخروج 134 يعني أن العملية أوقفت بالإشارة SIGABRT، أي 128 زائد 6، فالـ Runtime عندما يجد استثناء غير معالج لا يخرج خروجاً عادياً، وإنما يوقف العملية كلها وقد يكتب ملف Core Dump. وفي خدمة ويب لا يحدث هذا لكل طلب، لأن ASP.NET Core يلتقط الاستثناء ويرجع 500، ولكن في مهمة خلفية أو خيط أنشأته بنفسك يسقط البرنامج كله.

والحل ليس حذف ! فقط، وإنما أن تقرر ماذا يعني null في هذا المكان: إذا كان العميل العادي بلا كوبون حالة متوقعة، فالكود الصحيح coupon?.Length ?? 0، أو فحص صريح if (coupon is null). والقاعدة التي نتبعها في الفريق: كل ! في الكود تحتاج تعليقاً يشرح لماذا يستحيل أن تكون القيمة null، وإلا فهي خطأ ينتظر الليلة المناسبة.

الأخطاء في C#: الاستثناءات Exceptions

طريقة C# في الأخطاء واحدة تقريباً في كل مكان: الدالة التي لا تستطيع إكمال عملها ترمي استثناء بالكلمة throw، والاستثناء يصعد في سلسلة الاستدعاءات حتى يجد catch مناسبة، وكتلة finally تعمل في كل الأحوال، كما تشرح صفحة معالجة الاستثناءات. وإذا كنت تأتي من Go أو Rust فلا يوجد هنا قيمة خطأ ترجعها الدالة بجانب النتيجة، وإذا كنت تأتي من Java فلا توجد استثناءات مفحوصة Checked Exceptions تجبرك على ذكرها في توقيع الدالة، فأي دالة قد ترمي أي استثناء، والمترجم لا يقول لك شيئاً. وهذا يعني أن معرفة ما ترميه الدالة تأتي من التوثيق، فصفحة كل دالة في مكتبة .NET فيها قسم Exceptions يذكر ما ترميه ومتى.

وكل الاستثناءات ترث من Exception، وأهم ما تحتاجه منها في البداية: ArgumentException وأبناؤه للمعاملات الخاطئة، وInvalidOperationException عندما تستدعى الدالة في حالة لا تسمح بها، وFormatException للتحويل الفاشل، وOverflowException للفيضان، وNullReferenceException الذي رأيناه، وOperationCanceledException للعمليات الملغاة. ونصائح Microsoft نفسها مجمعة في صفحة أفضل الممارسات للاستثناءات، وأهمها ثلاث: لا تستعمل الاستثناء لمسار متوقع، ولا تلتقط Exception العامة إلا في أعلى البرنامج لتسجيلها، وإذا أعدت رمي الاستثناء فاكتب throw; وليس throw ex; لأن الثانية تمحو الـ Stack Trace الأصلي.

الفيضان الصامت Overflow والكلمة checked

بعض الأخطاء في C# لا ترمي شيئاً افتراضياً، وأخطرها الفيضان في الأعداد الصحيحة. فالنوع int يحمل حتى 2,147,483,647، وإذا تجاوزته العملية الحسابية لا يحدث خطأ، وإنما يلتف العدد إلى السالب، لأن C# لا تفحص الفيضان افتراضياً لأسباب الأداء. ولنأخذ طلب جملة لمعدات مقهى، السعر فيه 1.29 مليون ريال للوحدة، أي 129 مليون هللة، والكمية 20:

int price = 129_000_000; int qty = 20;   // halalas
int total = price * qty;
Console.WriteLine($"unchecked: {price} * {qty} = {total}");
long ok = (long)price * qty;
Console.WriteLine($"as long:   {ok}");
int t2 = checked(price * qty);

والمخرج سوف يكون كما يلي:

=== overflow: int money math ===
unchecked: 129000000 * 20 = -1714967296
as long:   2580000000
[overflow] System.OverflowException: Arithmetic operation resulted in an overflow.
   at Program.<<Main>$>g__OverflowDemo|0_14() in /w/Program.cs:line 153

الضرب العادي أعطى رقماً سالباً، وهذا الرقم سوف يمر بهدوء إلى قاعدة البيانات أو إلى بوابة الدفع إذا لم يفحصه أحد، أما checked فرمت OverflowException في نفس السطر. والحل في الكود المالي واحد من اثنين: نوع أكبر مثل long عندما يكون المبلغ قد يتجاوز 21 مليون ريال، أو checked حول كل حساب مالي حتى يتحول الخطأ الصامت إلى استثناء واضح، أو الاثنان معاً. ويمكن تفعيل الفحص للمشروع كله بالخيار CheckForOverflowUnderflow في ملف المشروع، وهذا ما نفضله في مشاريع المال، لأن تكلفته على الأداء صغيرة جداً مقارنة بفاتورة سالبة.

التحويل Parsing: ‏Parse أم TryParse؟

نموذج الطلب في المتجر يرسل الكمية نصاً، وأول ما يكتبه القادم من JavaScript هو int.Parse، والتجربة التالية تبين ما يحدث مع ثلاثة مدخلات حقيقية: رقم عادي، ورقم بالأرقام العربية الهندية لأن لوحة المفاتيح العربية تكتبها، ورقم معه كلمة:

=== parse: input from a form ===
TryParse("3")      -> True 3
TryParse("٣")      -> False 0
TryParse("3 pcs")  -> False 0
[parse] System.FormatException: The input string '3 pcs' was not in a correct format.
   at System.Number.ThrowFormatException[TChar](ReadOnlySpan`1 value)
   at System.Int32.Parse(String s)
   at Program.<<Main>$>g__ParseDemo|0_15() in /w/Program.cs:line 169

في المخرج أعلاه لاحظ التالي:

  • int.TryParse لا ترمي شيئاً، وإنما ترجع true أو false وتضع النتيجة في متغير out، وهذا هو نمط Try الذي تستعمله .NET في كل مكان يكون فيه الفشل متوقعاً.
  • الرقم «٣» رفض، لأن التحويل في .NET يقبل الأرقام 0 إلى 9 فقط، حتى مع ثقافة عربية، فإذا كان المستخدمون يكتبون بالأرقام العربية الهندية فحولها أنت قبل التحويل، ولا تفترض أن المكتبة تفعل ذلك.
  • int.Parse رمت FormatException، ورسالتها تذكر النص كما هو، وهذا مفيد في السجلات، ولكن انتبه إذا كان النص فيه بيانات شخصية.

كم يكلف الاستثناء؟ Exception Cost

قد يتساءل البعض: ما المشكلة في أن أكتب try { int.Parse(x) } catch { ... } كما أفعل في Python؟ والإجابة بالأرقام: شغلنا مئة ألف إدخال خاطئ بالطريقتين:

=== cost: 100,000 bad inputs, exception vs TryParse ===
throw/catch: 2002 ms (20.02 us each)
TryParse:    11.5 ms (0.115 us each), bad=200000
filtered catch: qty ('0') must be a non-negative and non-zero value. (Parameter 'qty')
Actual value was 0.

الاستثناء الواحد كلف 20 ميكروثانية، والفحص بـ TryParse كلف 0.12، أي أن الاستثناء أغلى بأكثر من مئة وسبعين مرة، والسبب أن رمي الاستثناء يجمع الـ Stack Trace ويمر على إطارات الاستدعاء بحثاً عن catch مناسبة. وعشرون ميكروثانية لا تهم في خطأ نادر، ولكنها تهم كثيراً إذا كان «الخطأ» مساراً عادياً يمر عليه نصف الطلبات، مثل مستخدمين يرسلون كمية فارغة، أو هجوم يرسل آلاف الطلبات الخاطئة في الثانية. والقاعدة: الاستثناء للحالات الاستثنائية فعلاً، والمتوقع يفحص بـ Try أو بشرط.

والسطر الأخير في المخرج يبين ميزتين في C# الحديثة. الأولى الدوال المساعدة مثل ArgumentOutOfRangeException.ThrowIfNegativeOrZero(qty)، فهي تفحص وترمي الاستثناء برسالة واضحة تذكر اسم المعامل وقيمته في سطر واحد، بدل if وthrow في كل دالة. والثانية مرشحات الاستثناء Exception Filters بالكلمة when:

try { PlaceOrder(0); }
catch (ArgumentOutOfRangeException e) when (e.ParamName == "qty")
{
    Console.WriteLine($"filtered catch: {e.Message}");
}

والمرشح يقرر قبل أن يلتقط الاستثناء، فإذا كان الشرط خطأ يستمر الاستثناء في الصعود كأن هذه الـ catch غير موجودة، دون أن يفقد الـ Stack Trace، وهذا أفضل من التقاط الاستثناء ثم إعادة رميه.

وهل يوجد Result مثل Rust؟

لا يوجد في مكتبة .NET نوع Result رسمي، والطريقة المعتادة لإرجاع نتيجة متوقعة الفشل هي نمط Try الذي رأيناه. ولكن في منطق الأعمال، حيث الفشل جزء طبيعي من النتيجة مثل «المخزون لا يكفي» أو «المنتج غير موجود»، يكتب كثير من الفرق اليوم نتيجة بأنواع مغلقة عن طريق السجلات ومطابقة الأنماط، وهذا ما سوف نفعله في خدمة الطلبات في المقال الرابع:

public abstract record OrderResult
{
    public sealed record Created(OrderDto Order) : OrderResult;
    public sealed record UnknownProduct(int ProductId) : OrderResult;
    public sealed record OutOfStock(int ProductId, int Requested, int Available) : OrderResult;
}

والمستدعي يمر على النتيجة بالتعبير switch، فيرجع 201 أو 400 أو 409 دون أي استثناء، وتبقى الاستثناءات للأشياء التي لا يتوقعها أحد، مثل انقطاع الاتصال بقاعدة البيانات. وهذه القسمة بين الفشل المتوقع والمفاجئ هي أهم قرار في تصميم الأخطاء في أي خدمة، وشرحناها بعمق أكبر في مقال كيف تتعامل لغات البرمجة مع الأخطاء.

المال Money: ‏double أم decimal أم الهللات int؟

نصل الآن إلى رسالة المحاسب عن فاتورة 1.50 ريال. لـ C# ثلاثة اختيارات للمبالغ، وكل واحد منها له منطق. الأول double، وهو عدد عشري ثنائي Binary Floating Point بحجم 8 بايت، وهو ما تستعمله JavaScript لكل الأعداد، ولا يستطيع تمثيل 0.1 بالضبط، كما تشرح صفحة الأنواع العشرية. والثاني decimal، وهو عدد عشري بأساس 10 بحجم 16 بايت، صمم للمال، ويمثل 0.1 بالضبط. والثالث أن تحفظ المبلغ عدداً صحيحاً بأصغر وحدة، أي بالهللة، وهذا ما نفعله في كل خدمات هذه المرحلة. وكتبنا القسم money ليقارن الثلاثة:

=== money: double vs decimal vs int halalas ===
double  0.1+0.2 = 0.30000000000000004, == 0.3 ? False
decimal 0.1+0.2 = 0.3, == 0.3 ? True
VAT double  4.455  Round 4.46  AwayFromZero 4.46
VAT decimal 4.4550  Round 4.46  AwayFromZero 4.46
VAT int halalas (2970*15+50)/100 = 446

وهنا المشكلة التي واجهناها في التجربة: النسخة الأولى من القسم كانت تنتهي عند هذه الأسطر، والمثال الذي اخترناه، وهو ثلاثة أكياس قهوة بـ 29.70 ريال، أعطى الضريبة الصحيحة 4.46 مع double أيضاً. فلو توقفنا هنا لقلنا إن double يكفي ما دمنا نقرب. ولكن رسالة المحاسب تقول غير ذلك، فأضفنا للقسم حلقة تمر على كل مبلغ من هللة واحدة حتى ألف ريال، وتأخذ فقط المبالغ التي تنتهي ضريبتها بنصف هللة بالضبط، لأنها الحالات التي يقرر فيها التقريب، ثم تحسب الضريبة بالطرق الثلاث وتقارنها بالحساب الصحيح بالأعداد الصحيحة:

for (int h = 1; h <= 100_000; h++)
{
    if (h * 15 % 100 != 50) continue;           // VAT ends in exactly half a halala
    int exact = (h * 15 + 50) / 100;
    int viaDouble = (int)Math.Round(Math.Round(h / 100.0 * 0.15, 2, MidpointRounding.AwayFromZero) * 100);
    int viaDecimal = (int)(Math.Round(h / 100m * 0.15m, 2, MidpointRounding.AwayFromZero) * 100);
    ...
}

والمخرج سوف يكون كما يلي:

midpoint subtotals up to 1000 SAR: 5,000; double wrong 1,302, decimal wrong 0
first wrong: subtotal 1.50 SAR -> double VAT 0.22499999999999998 -> 0.22, correct 0.23
sizeof: double 8, decimal 16, int 4

من خمسة آلاف حالة حدية، أخطأ double في 1,302 حالة، أي أكثر من ربعها، وdecimal لم يخطئ في أي حالة. وأول خطأ هو بالضبط فاتورة المحاسب: 1.50 ريال ضريبتها 0.225، وdouble يحسبها 0.22499999999999998 لأن 0.15 نفسها لا تمثل بالضبط في النظام الثنائي، فيقربها التقريب إلى 0.22، والصحيح 0.23. والصورة التالية تجمع الطرق الثلاث:

ثلاثة صناديق: double بثمانية بايت ومخرجه 0.1 زائد 0.2 يساوي 0.30000000000000004 و1.50 ضرب 0.15 يساوي 0.22499999999999998 فيقرب إلى 0.22، وأخطأ في 1,302 من 5,000 مبلغ حدي حتى ألف ريال، وdecimal بستة عشر بايت يعطي 0.3 و4.4550 ويقرب إلى 4.46 وأخطأ في صفر بشرط تحديد طريقة التقريب، وint بالهللة بأربعة بايت يحسب 2970 ضرب 15 زائد 50 مقسوماً على 100 يساوي 446 وأخطأ في صفر وخطره الوحيد الفيضان
مثال واحد صحيح لا يثبت شيئاً: double أخطأ في ربع المبالغ الحدية

ولاحظ في المخرج الأول أن Math.Round(4.4550m, 2) دون تحديد الطريقة أعطت 4.46 أيضاً، وهذا حظ وليس صواباً، فالطريقة الافتراضية في .NET هي التقريب إلى الزوجي ToEven، أي أن نصف الوحدة يذهب إلى الرقم الزوجي، و6 زوجي فوافق التقريب للأعلى هنا، ولكنه في مبلغ آخر سوف يذهب للأسفل. والطرق كلها مذكورة في صفحة MidpointRounding، والقاعدة: لا تستدعي Math.Round على مبلغ دون أن تكتب الطريقة صراحة.

فأي الثلاثة تختار؟ double لا يستعمل للمال أبداً. وdecimal صحيح ومريح، ويقابل النوع numeric في PostgreSQL، ولكنه أبطأ بكثير من الأعداد الصحيحة لأنه يحسب بالبرمجيات وليس بالمعالج مباشرة، وحجمه ضعف double. والأعداد الصحيحة بالهللة أسرع وأبسط، ولا يوجد فيها كسر أصلاً، وكل تقريب فيها مكتوب صراحة في سطر واحد، وخطرها الوحيد الفيضان الذي رأينا علاجه. ولهذا اختارت مواصفة الخدمة المشتركة في هذه المرحلة الأعداد الصحيحة بالهللة في قاعدة البيانات وفي الكود.

ثلاث مقارنات: مئة ألف إدخال خاطئ حيث int.Parse مع catch يكلف 20 ميكروثانية للخطأ وint.TryParse يكلف 0.12، وعشرة ملايين int حيث ArrayList تأخذ 4030 ms و305 MB و41 جمعاً للجيل 0 وList<int> تأخذ 192 ms و38 MB وصفر جمع، ومليون سطر 1234:3 حيث Split يأخذ 548 ms و91 MB وReadOnlySpan يأخذ 93 ms وصفر بايت
ثلاث تكاليف لا تظهر في الكود: الاستثناء والتغليف والتخصيص

أخطاء شائعة Common Mistakes

  • تعديل نسخة struct وأنت تظن أنك تعدل الأصل: مثل list[0].Halalas = 1 على قائمة structs، والمترجم يرفض هذا السطر بالذات، ولكنه لا يرفض var p = list[0]; p.Halalas = 1; التي تعدل نسخة وتتركها. والحل أن تجعل الـ struct غير قابل للتعديل readonly struct.
  • مقارنة كائنات class بالـ ==: فتقارن المراجع، واستعمل record إذا أردت المقارنة بالقيمة.
  • إسكات تحذيرات null بالـ !: كما في مهمة التصدير الليلية.
  • نسيان using مع الاتصالات والملفات: فتنفد الاتصالات في المجمع قبل أن يمر الـ GC.
  • استدعاء GC.Collect() لحل مشكلة ذاكرة: فيرتقي ما هو حي إلى الجيل 2 قبل وقته، وتبقى المشكلة، والحل أن تعرف من يمسك الكائنات، وهذا ما تجيب عليه أدوات مثل dotnet-counters وdotnet-gcdump.
  • ذاكرة مؤقتة في حقل static بلا حد: أشهر سبب لنمو الذاكرة في خدمات .NET، لأن كل ما فيها يبقى حياً إلى الأبد.
  • throw ex; بدل throw;: فيضيع الـ Stack Trace الأصلي ويبدأ من سطر الإعادة.
  • catch (Exception) فارغة: فيختفي كل خطأ، بما فيه OutOfMemoryException والإلغاء OperationCanceledException.
  • الاستثناء لمسار متوقع: أغلى بأكثر من مئة مرة من Try، كما رأينا.
  • double للمال، وMath.Round دون طريقة: ربع المبالغ الحدية خاطئة في الأولى، والثانية تقرب إلى الزوجي.
  • الأعداد الصحيحة دون checked في الحسابات المالية: فاتورة سالبة بدل استثناء.

جدول قرار Decision Table: أي نوع وأي طريقة خطأ؟

الحالةالاختيارالسبب
بيانات تنقلها بين الطبقات: منتج، طلب، رد APIrecord (class)مقارنة بالقيمة، وعدم تعديل، ونسخ بـ with
قيمة صغيرة أقل من 16 بايت لا تتغير: مبلغ، إحداثيreadonly record structبلا كائن على الـ Heap، ولا خطر في النسخ
كائن له هوية وحالة تتغير: اتصال، خدمة، مجمعclassمرجع واحد يشترك فيه الجميع
المالint أو long بالهللة مع checked، أو decimaldouble أخطأ في 1,302 من 5,000 حالة
إدخال من المستخدم قد يكون خاطئاًTryParse أو تحققالاستثناء أغلى بأكثر من مئة مرة
فشل متوقع في منطق الأعمالنتيجة بأنواع مغلقة مع switchالمستدعي يرى كل الحالات
شيء لا يجب أن يحدث: معامل خاطئ من مبرمج، قاعدة بيانات لا ترداستثناءيصعد إلى من يستطيع التسجيل أو الإعادة
ملف، اتصال، Socketusingالـ GC لا يغلق الموارد في وقت تعرفه
خدمة في Containerاترك 75% الافتراضية، وقس قبل التغييرالباقي للـ Runtime والمكتبات الأصلية

الخلاصة

  • الأنواع في C# ثابتة وتبقى وقت التشغيل، والأنواع العامة متجسدة، ولذلك List<int> تحفظ الأعداد بلا كائنات، بينما ArrayList خصصت 305 ميجابايت لنفس العشرة ملايين.
  • الإسناد ينسخ القيمة في الـ struct والمرجع في الـ class، ومليون عنصر كلف 4 بايت للعنصر في الأولى و32 في الثانية.
  • جامع القمامة تتبعي بالأجيال: الكائنات التي تموت فوراً كلفت توقفاً 49 ملي ثانية لخمسة ملايين، والتي تبقى حية 886.
  • داخل الـ Container يأخذ الـ GC 75% من الحد افتراضياً، ويرمي OutOfMemoryException يمكن التقاطه، ونسبة GCHeapHardLimitPercent تكتب بالست عشري.
  • الـ GC لا يغلق الموارد، والـ using يغلقها في سطرها، والمنهي يغلقها متأخراً إن أغلقها.
  • الأنواع القابلة للفراغ تحليل وقت البناء، والـ ! تسكته، والاستثناء غير المعالج يوقف العملية بالرمز 134.
  • الاستثناء كلف 20 ميكروثانية مقابل 0.12 لـ TryParse، والفيضان صامت إلا مع checked.
  • مثال الضريبة الواحد نجح مع double، ولكن المرور على كل المبالغ حتى ألف ريال كشف 1,302 خطأ من 5,000، ولا خطأ مع decimal أو الهللات.

وفي المقال التالي من مرحلة لغة C# للمطورين ننتقل إلى التزامن: async وawait والمهام Tasks ومجمع الخيوط Thread Pool، بنفس المهمتين اللتين نقيسهما في كل لغات المسار. وهذه مقالات تكمل هذا المقال:

دليلC# لمن يعرف البرمجة: الأدوات Toolchain والأساسياتتثبيت .NET 10 وجولة في C# مقارنة بـ Python وJavaScript، ثم حل كامل بالأمر dotnet من البناء والاختبار إلى أربعة أشكال للنشر، مع فخ تحذيرات حقيقي واجهناه.دليلإدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.دليلكيف تتعامل لغات البرمجة مع الأخطاء؟لماذا ترمي Java وPython الاستثناءات بينما ترجع Go وRust الخطأ كقيمة، وكيف تمنع الخطأ من أن يختفي بصمت في كودك.دليلأنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.دليلقراءة ال Stack Trace وتصحيح الأخطاء Debugging للمبتدئينكيف تقرأ رسالة الخطأ في Python وJavaScript سطراً بسطر، ومتى تستخدم الطباعة أو نقاط التوقف في VS Code، وكيف يجد git bisect التعديل الذي أدخل الخطأ.دليلأمان الذاكرة Memory Safety: لماذا تدفع الحكومات نحو Rust؟ماذا طلبت الحكومات فعلاً بشأن C وC++، ولماذا تسبب أخطاء الذاكرة أغلب الثغرات الخطيرة، ومتى يكون Rust هو الاختيار الصحيح.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجارب بـ .NET SDK 10.0.403 وRuntime 10.0.14 في Containers محدودة بنواتين وبحدود ذاكرة 2 GB و256 MB.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.