بعد شهر من كتابة C# ستلاحظ أن الكود يعمل والاختبارات تنجح، ومع ذلك تعود طلبات الدمج Pull Requests بتعليقات من نوع واحد: «هذا JavaScript بصيغة C#»، أو «في Java كنا نكتبها هكذا، أما هنا فلا». والتعليقات ليست ذوقاً شخصياً للمراجع، فأغلبها عن أخطاء حقيقية لا يكشفها المترجم: سعر 9.90 ريال يصير 990 على سيرفر إعداداته ألمانية، وقائمة تبقى نصف معدلة بعد استثناء، وطلب بتوقيت السعودية يحفظ بساعة خاطئة، ودالة async void تسقط الخدمة كلها. وهذه أخطاء لا يرتكبها من يجهل البرمجة، وإنما من يعرف لغة أخرى جيداً، فيحمل معه عاداتها إلى لغة تتصرف بشكل مختلف.
وهذا هو المقال الخامس والأخير في مرحلة لغة C# للمطورين، بعد الأدوات والأساسيات والأنواع والذاكرة والتزامن والخدمة الحقيقية. وفيه أربعة عشر خطأ يقع فيها القادمون من Python وJavaScript وJava، وكل خطأ معه تشغيل حقيقي خاطئ بمخرجه كما ظهر، ثم الإصلاح بأسلوب C# الصحيح. وكل الأمثلة في برنامج واحد في مجلد lab، شغلناه بـ .NET 10.0.14 في Container بنواتين، ثم نقارن نفس الميزة مكتوبة بأسلوب 2012 وبأسلوب C# الحديث، ونختم بخطة قراءة من المصادر الرسمية لمن يريد أن يكمل.
وسوف نناقش في هذا المقال ما يلي:
- النصوص Strings والثقافة Culture: الجمع في حلقة، والتحويل على سيرفر بإعدادات مختلفة، والمقارنة دون حساسية للحالة.
- المجموعات Collections وLINQ: التنفيذ المؤجل، والتعديل أثناء المرور، و
Firstعلى قائمة فارغة. - الأنواع Types: نسخة struct من قائمة، وسجل فيه قائمة، وتعداد يقبل أي رقم.
- الدوال Functions والأخطاء: متغير الحلقة في Lambda، و
throw ex، وasync void. - الوقت Time والأرقام:
DateTimeوالمناطق الزمنية، والتقريب الافتراضي. - أسلوب C# الحديث Modern C#: نفس الميزة بكود 2012 وبكود اليوم، وكيف تفرض الأسلوب على الفريق.
- قائمة مراجعة للكود، وخطة قراءة من المصادر الرسمية.
لماذا يحمل المطور عاداته معه؟ Language Transfer
قبل أن ندخل في الأخطاء نفسها، من المفيد أن تعرف من أين يأتي كل واحد منها، لأن هذا يخبرك أين تنظر في كودك أنت. فكل لغة تعلمك افتراضات صامتة لا تكتبها في أي مكان: في JavaScript كل الأرقام من نوع واحد، والنص يجمع بـ + دون تفكير، وlet في الحلقة تعطي كل دورة متغيرها. وفي Python المجموعات تقارن بالمحتوى، والتاريخ عادة بلا منطقة زمنية حتى تطلبها، والخطأ يلتقط بـ try في أي مكان. وفي Java التعداد مجموعة مغلقة من الكائنات، وthrow e لا تمحو الـ Stack Trace، والنصوص تقارن بـ equals ولا يفكر أحد في الثقافة. وكل افتراض من هذه صحيح في لغته، فإذا انتقل معك إلى C# كما هو صار خطأ لا يراه المترجم، لأن الكود صحيح نحوياً ويفعل شيئاً آخر غير ما تقصده.
| تأتي من | العادة التي تنقلها | الأخطاء في هذا المقال |
|---|---|---|
| JavaScript | رقم واحد لكل شيء، + للنصوص، let في الحلقات، دوال async بلا قيمة | 1، 10، 12، 14 |
| Python | مجموعات تقارن بالمحتوى، items[0]، الحذف من القائمة أثناء المرور، تواريخ بلا منطقة | 5، 6، 8، 13 |
| Java | تعداد مغلق، throw e، النص كقيمة دون ثقافة، كل شيء كلاس | 2، 3، 9، 11 |
| Go أو C | struct يعدل في مكانه عبر المؤشر | 7 |
| أي لغة | الاستعلام قائمة وليس وصفاً | 4 |
والجدول تقريبي بالطبع، فالمطور الواحد يحمل عادات من كل اللغات التي كتب بها، ولكنه يعطيك فكرة عن المكان الذي تبدأ منه عندما تراجع كودك الأول. والخبر الجيد أن أغلب هذه الأخطاء تظهر في التشغيل الأول إذا عرفت أين تنظر، وهذا ما يفعله البرنامج في التجربة: يشغل كل عادة كما هي، ويطبع ما فعلته فعلاً.
التجربة Lab: برنامج واحد بأربعة عشر قسماً
البرنامج في الملف lab/Program.cs، وكل خطأ قسم مستقل فيه الطريقة الخاطئة ثم الإصلاح، ويشغله السكربت lab/run.sh داخل Container من الـ SDK:
docker run --rm --cpus=2 --memory=1g -v "$PWD/lab":/lab \
mcr.microsoft.com/dotnet/sdk:10.0 bash /lab/run.shوالمشكلة الوحيدة التي واجهناها في التجربة كانت في البرنامج نفسه، وهي درس بحد ذاتها: في النسخة الأولى كان القسم الأخير، وهو قسم async void، جزءاً من التشغيل الكامل all، فأسقط العملية كلها في منتصف التشغيل، وظهر في آخر المخرج سطر Aborted (core dumped) بدل نهاية طبيعية، فنقلناه إلى عملية منفصلة. وسوف ترى في القسم الخاص به لماذا لا يمكن «التقاط» هذا الخطأ أصلاً.
النصوص Strings والثقافة Culture
الخطأ 1: جمع النصوص في حلقة String Concatenation
القادم من Python أو JavaScript يبني الإيصال سطراً سطراً بـ +=، لأن محركات تلك اللغات تحسن هذه الحالة أحياناً. أما في C# فالنص string غير قابل للتعديل Immutable، فكل += تنشئ نصاً جديداً وتنسخ فيه كل ما قبله، فيصير بناء النص من عشرين ألف سطر عملاً تربيعياً. والمقارنة بين الطريقتين لعشرين ألف سطر:
string s = "";
for (int i = 0; i < 20_000; i++) s += $"line {i}: 9.90 SAR\n"; // wrong
var sb = new StringBuilder();
for (int i = 0; i < 20_000; i++) sb.Append("line ").Append(i).Append(": 9.90 SAR\n");
string s2 = sb.ToString(); // fixوالمخرج سوف يكون كما يلي:
=== concat: 20,000 receipt lines ===
string += 10371 ms, 7686 MB allocated, length 408,890
StringBuilder 1 ms, 1 MB allocated, same: Trueنص نهائي طوله 408 آلاف حرف، احتاج مع += أكثر من عشر ثوان وخصص 7.6 جيجابايت من الذاكرة على مراحل، لأن كل دورة نسخت النص كله، بينما StringBuilder أخذ ملي ثانية واحدة وميجابايت واحداً، لأنه يكتب في مخزن Buffer يكبر عند الحاجة. والقاعدة: الجمع العادي أو النص المدمج $"..." لعدد ثابت من الأجزاء، وStringBuilder أو string.Join عندما يكون الجمع داخل حلقة.
وقد يتساءل البعض: لماذا لا يحول المترجم += في الحلقة إلى StringBuilder تلقائياً؟ والإجابة أن المترجم يحسن ما يستطيع إثباته في سطر واحد، فالجمع a + b + c + d في سطر واحد يصير استدعاء واحداً لـ string.Concat بأربعة معاملات، والنص المدمج $"line {i}: ..." يبنى بأداة داخلية تكتب في مخزن واحد دون نصوص وسيطة. ولكن الحلقة شيء آخر، فقيمة s بعد كل دورة نص كامل قد يقرؤه أي كود آخر في نفس اللحظة، والمترجم لا يستطيع أن يفترض أن أحداً لن يقرأه في المنتصف، فيبقى النسخ في كل دورة كما كتبته.
الخطأ 2: التحويل Parsing والتنسيق بثقافة السيرفر
هذا من أخطر الأخطاء لأنه لا يظهر على جهازك أبداً. فكل دالة في .NET تحول نصاً إلى رقم أو رقماً إلى نص، مثل double.Parse وToString، تستعمل افتراضياً الثقافة الحالية Current Culture، وهي تأتي من إعدادات النظام على السيرفر، فإذا نقل أحدهم الخدمة إلى سيرفر إعداداته مختلفة تغيرت النتائج بصمت. ولكي نرى ذلك شغلنا نفس السطر بثلاث ثقافات:
foreach (var name in new[] { "en-US", "de-DE", "ar-SA" })
{
CultureInfo.CurrentCulture = new CultureInfo(name);
bool ok = double.TryParse("9.90", out var price);
Console.WriteLine($"{name}: ... ok={ok} value={price.ToString(CultureInfo.InvariantCulture)} ToString(\"N2\")={9.90.ToString("N2")} date={new DateTime(2026, 10, 31):d}");
}والمخرج سوف يكون كما يلي:
=== culture: the same price text on servers with different cultures ===
en-US: double.Parse("9.90") ok=True value=9.9 ToString("N2")=9.90 date=10/31/2026
de-DE: double.Parse("9.90") ok=True value=990 ToString("N2")=9,90 date=31.10.2026
ar-SA: double.Parse("9.90") ok=False value=0 ToString("N2")=9٫90 date=20/5/1448 بعد الهجرة
fix (de-DE + InvariantCulture): 9.9في المخرج أعلاه لاحظ التالي:
- في الثقافة الألمانية النقطة فاصل الآلاف والفاصلة هي الفاصل العشري، فقرأ التحويل «9.90» على أنها تسعمئة وتسعون، ونجح دون أي خطأ، وهذا أسوأ احتمال: سعر أكبر بمئة مرة يمر إلى الفاتورة.
- في الثقافة العربية السعودية فشل التحويل كله، والتنسيق كتب الفاصل العشري العربي «٫»، والتاريخ ظهر بالتقويم الهجري، لأن التقويم الافتراضي لهذه الثقافة في .NET هو أم القرى. وهذا جميل في واجهة المستخدم، وكارثة في ملف CSV يقرؤه برنامج آخر.
- الإصلاح هو
CultureInfo.InvariantCulture، وهي ثقافة ثابتة لا تتغير بإعدادات السيرفر، فأعطت 9.9 حتى مع الثقافة الألمانية.
ولماذا لا يظهر هذا الخطأ إلا في الإنتاج؟ لأن أجهزة المطورين وأجهزة CI تعمل في الغالب بثقافة en-US أو بالثقافة الثابتة، وفي الاثنتين أعطى نفس التحويل 9.9 في المخرج أعلاه، ولم يتغير شيء حتى غيرنا CurrentCulture بأنفسنا. فالاختبارات تنجح، والمراجعة لا ترى شيئاً، ثم ينقل أحدهم الخدمة إلى سيرفر إعداداته مختلفة، أو يضيف متغير البيئة LANG لسبب لا علاقة له بالكود، فتتغير الأسعار. ولاحظ من المخرج نفسه أن الثقافة الألمانية لم ترم أي خطأ، وإنما أعطت 990 بنجاح، فلا يوجد استثناء في السجلات يدلك على المشكلة، وإنما فاتورة أكبر بمئة مرة. والعلامة الوحيدة التي تراها هي البيانات نفسها، ولذلك أسرع طريقة لاكتشافه قبل العميل هي أن تشغل الاختبارات مرة بثقافة غير ثقافة جهازك، كما في قسم الاختبارات أدناه، أو أن تفعل القاعدة CA1305 التي أمسكت ToString("N2") في التجربة، مع العلم أنها لم تمسك TryParse نفسها كما سوف نرى.
والقاعدة التي تشرحها صفحة العولمة Globalization: كل ما يقرؤه أو يكتبه برنامج آخر، مثل JSON وCSV والسجلات ومعاملات الروابط، يستعمل InvariantCulture دائماً، وكل ما يقرؤه إنسان يستعمل ثقافة هذا الإنسان بالتحديد، وليس ثقافة السيرفر. ولهذا أيضاً تجد في كثير من خدمات الويب الخيار InvariantGlobalization في ملف المشروع، كما في الخدمة التي بنيناها في المقال الرابع، فيعمل البرنامج كله بالثقافة الثابتة ويستغني عن مكتبة ICU في الـ Image.
الخطأ 3: المقارنة دون حساسية للحالة بـ ToUpper
المطور القادم من JavaScript يقارن النصوص دون حساسية لحالة الأحرف بتحويلها إلى أحرف كبيرة ثم المقارنة، وهذا يعمل حتى يعمل الكود على سيرفر ثقافته تركية:
=== upper: comparing a file name, Turkish culture ===
"invoice".ToUpper() = İNVOİCE == "INVOICE"? False
string.Equals(..., OrdinalIgnoreCase) = Trueففي اللغة التركية الحرف i الصغير يقابله İ كبير بنقطة، فصارت «invoice» بعد التحويل «İNVOİCE» ولم تساو «INVOICE». وهذه المشكلة معروفة باسم مشكلة الحرف التركي، وأصابت برامج كثيرة في فحوص الصلاحيات وأسماء الملفات. والإصلاح أن تقول للمقارنة صراحة كيف تقارن: StringComparison.OrdinalIgnoreCase للمعرفات وأسماء الملفات والمفاتيح، وهي مقارنة بالبايتات لا تتأثر بأي ثقافة وأسرع أيضاً، والتفاصيل في أفضل ممارسات مقارنة النصوص. وفي القواميس تمرر نفس الخيار: new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase).

المجموعات Collections مع LINQ
الخطأ 4: نسيان أن LINQ مؤجلة التنفيذ Deferred Execution
ذكرنا في المقال الأول أن الاستعلام في LINQ وصف وليس نتيجة، وهنا نرى ثمنه. المتغير withVat في المثال التالي يحسب السعر مع الضريبة، وفيه عداد لعدد مرات تنفيذ الحساب:
var withVat = prices.Select(p => { calls++; return (p * 115 + 50) / 100; });
Console.WriteLine($"Count={withVat.Count()}, Sum={withVat.Sum()}, Max={withVat.Max()} ...");والمخرج سوف يكون كما يلي:
=== deferred: LINQ runs every time you enumerate it ===
Count=3, Sum=21149, Max=14835 -> selector ran 9 times for 3 items
with ToList(): Count=3, Sum=21149, Max=14835 -> selector ran 3 timesثلاثة عناصر وتسعة حسابات، لأن كل من Count وSum وMax مر على الاستعلام من بدايته. وفي هذا المثال الثمن صغير، ولكن عندما يكون المصدر استعلام Entity Framework على قاعدة البيانات، أو قراءة ملف، أو استدعاء API في الدالة، يصير ثلاثة استعلامات أو ثلاث قراءات بدل واحدة، وقد تختلف نتائجها إذا تغيرت البيانات بينها. والإصلاح أن تحول الاستعلام إلى قائمة بـ ToList() أو ToArray() مرة واحدة عندما تريد أن تمر عليه أكثر من مرة، كما في السطر الثاني. والعكس أيضاً خطأ: لا تضع ToList() بعد كل سطر LINQ «احتياطاً»، فهذا ينشئ قوائم لا يحتاجها أحد، وإنما عندما تعرف أنك سوف تمر على النتيجة أكثر من مرة.
والحالة الأخطر هي IQueryable في Entity Framework Core، لأن الاستعلام هناك لا يعمل في الذاكرة أصلاً، وإنما يترجم إلى SQL ويرسل إلى قاعدة البيانات في كل مرة تمر عليه. فالمتغير الذي يبدو قائمة طلبات العميل، إذا مررت عليه مرة لتعدها ومرة لتعرضها، أرسل استعلامين، وفي صفحة فيها حلقة على العملاء يصير هذا مشكلة N+1 التي ذكرناها في مقال تصميم الأنظمة. ولهذا تحذر Microsoft من التعداد المتعدد بقاعدة تحليل خاصة هي CA1851، ولكنها غير مفعلة افتراضياً، ولم تظهر حتى مع AnalysisMode=All في تجربتنا كما سوف نرى.
الخطأ 5: الحذف من قائمة أثناء المرور عليها Modifying While Iterating
المهمة: احذف المنتجات التي نفد مخزونها من القائمة. والطريقة البديهية من Python القديمة أو JavaScript هي المرور بـ foreach والحذف عند الشرط:
var stock = new List<(string Name, int Qty)> { ("coffee", 40), ("dates", 0), ("dallah", 0) };
foreach (var p in stock) if (p.Qty == 0) stock.Remove(p); // wrong
int removed = stock.RemoveAll(p => p.Qty == 0); // fixوالمخرج سوف يكون كما يلي:
=== modify: remove sold-out products inside foreach ===
InvalidOperationException: Collection was modified; enumeration operation may not execute.
fix RemoveAll: removed 1, left coffeeالاستثناء متوقع، فـ .NET تكتشف أن القائمة تغيرت أثناء المرور وترفض أن تكمل. ولكن انظر للسطر الثاني: RemoveAll حذفت عنصراً واحداً فقط وليس اثنين، والسبب أن الحلقة الخاطئة حذفت «dates» فعلاً قبل أن يرمي الاستثناء في الدورة التالية. فالقائمة بقيت نصف معدلة، وإذا كان هذا الكود داخل try يبتلع الاستثناء، فسوف تستمر الخدمة ببيانات لا تطابق أي حالة متوقعة. والإصلاح RemoveAll بشرط، أو بناء قائمة جديدة بـ Where، أو المرور بحلقة for من الآخر إلى الأول.
الخطأ 6: First على مجموعة فارغة Empty Sequence
القادم من Python معتاد على items[0] التي ترمي استثناء على القائمة الفارغة، فيكتب First() في C# بنفس العقلية، والمخرج على قائمة فارغة:
=== first: the cheapest product in an empty category ===
InvalidOperationException: Sequence contains no elements
FirstOrDefault on value tuples = (null, 0) <- a default, not null
FirstOrDefault on strings = nullرسالة الاستثناء لا تقول أي مجموعة كانت فارغة، وفي سجلات الإنتاج تراها بلا سياق. والبديل FirstOrDefault، ولكن انتبه للسطر الثاني: على نوع قيمة مثل الصف Tuple لا ترجع null، وإنما القيمة الافتراضية للنوع، أي اسم فارغ وسعر صفر، فإذا لم تفحص فقد يظهر في الصفحة «أرخص منتج: صفر ريال». والطريقة الصحيحة أن تقرر ماذا تعني المجموعة الفارغة في هذا المكان، ثم تكتبها: FirstOrDefault() مع فحص is null للمراجع، أو FirstOrDefault(defaultValue) بقيمة تختارها، أو Any() قبل أن تطلب العنصر.

الأنواع Types: القيم والسجلات والتعدادات
الخطأ 7: تعديل struct أخذته من قائمة
رأينا في مقال الأنواع والذاكرة أن الـ struct ينسخ في كل إسناد، وهذا يظهر في أكثر مكان مفاجئ: عنصر من قائمة. المثال سلة فيها سطر واحد من نوع struct، ونريد أن نطبق خصماً عليه:
=== struct: discount on an item from List<struct> ===
after first.Price = 500: cart[0].Price = 990
after cart[0] = first: cart[0].Price = 500
record with: original 990, new 500السطر var first = cart[0]; نسخ العنصر، والتعديل first.Price = 500 عدل النسخة، فبقي السعر في السلة 990، ولم يظهر أي خطأ أو تحذير. ولو كتبت cart[0].Price = 500 مباشرة لرفضه المترجم بالخطأ CS1612، ولكن الشكل الذي فيه متغير وسيط يمر. والإصلاح الأول أن تكتب النسخة المعدلة في القائمة، كما في السطر الثاني، والإصلاح الأفضل ألا يكون الـ struct قابلاً للتعديل أصلاً، وأن تستعمل record مع with، فيكون واضحاً من الكود أنك أنشأت قيمة جديدة ولم تعدل القديمة، كما في السطر الثالث. وإذا كان النوع يجب أن يبقى struct لأسباب الأداء، فاجعله readonly struct، فيرفض المترجم أي تعديل على حقوله بعد الإنشاء، ويصير السطر الخاطئ first.Price = 500 خطأ بناء بدل خطأ صامت، وتصير الطريقة الوحيدة للتغيير أن تنشئ قيمة جديدة وتضعها في مكانها بنفسك.
الخطأ 8: سجل record فيه قائمة
بعد أن يتعلم المطور أن السجلات تقارن بالقيمة يستعملها في كل مكان، ثم يفاجأ بهذا المخرج:
=== record: equality with a List inside ===
same content, a == b: False
with SequenceEqual in Equals: c == d: Trueطلبان بنفس الرقم ونفس المنتجات غير متساويين، لأن السجل يقارن حقوله بمقارنة كل حقل الافتراضية، والحقل هنا List<int>، وهو كلاس يقارن بالمرجع، والقائمتان كائنان مختلفان. فالمقارنة بالقيمة في السجلات سطحية Shallow، وليست عميقة كما في Kotlin data class مع القوائم مثلاً. والإصلاح إما أن تكتب Equals بنفسك كما في السجل الثاني في التجربة، مع SequenceEqual وGetHashCode متوافقة، أو أن تتجنب المقارنة على هذه السجلات وتقارن بمفتاح واضح مثل رقم الطلب.
الخطأ 9: التعداد Enum يقبل أي رقم
القادم من Java يظن أن enum في C# مجموعة مغلقة من القيم كما في Java، ولكنه في C# اسم لعدد صحيح فقط، والمخرج التالي يبين ما يعنيه ذلك عندما يرسل عميل قديم حالة طلب غير معروفة:
=== enum: a status from an old client ===
(OrderStatus)42 = 42, defined? False
Enum.TryParse("Shipped") = True Shipped
Enum.TryParse("42") = True 42 <- also trueالتحويل (OrderStatus)42 نجح دون أي خطأ، مع أن التعداد فيه ثلاث قيم فقط هي 1 و2 و3، والأغرب أن Enum.TryParse نفسها قبلت النص «42» ورجعت true، لأنها تقبل الأسماء والأرقام معاً. وهذه القيمة سوف تمر إلى switch لا يغطيها، أو تحفظ في قاعدة البيانات. والإصلاح أن تفحص كل قيمة تأتي من خارج البرنامج بـ Enum.IsDefined قبل أن تقبلها، وأن تضع في كل switch على تعداد فرعاً أخيراً يرمي استثناء للقيمة غير المعروفة، بدل أن يفترض أن الفروع تغطي كل شيء.
والمترجم يساعدك هنا قليلاً: إذا كتبت تعبير switch على تعداد وغطيت كل الأسماء المعرفة دون فرع _ أخير، فإنه يحذرك بالتحذير CS8524 بأن القيم غير المسماة، مثل 42، لا يغطيها أي فرع، وأن التعبير سوف يرمي SwitchExpressionException عند وصولها. وهذا التحذير بالذات يستحق أن يكون خطأ في ملف المشروع، لأنه يجبر كل من يكتب switch على تعداد أن يقرر صراحة ماذا يحدث للقيمة غير المعروفة.

الدوال Functions والأخطاء Errors
الخطأ 10: التقاط متغير حلقة for في Lambda
عندما تنشئ دوال Lambda داخل حلقة، كأن تسجل ثلاث مهام لثلاثة منتجات، فالسؤال: أي قيمة للمتغير سوف تراها كل دالة؟ والمخرج:
=== closure: three callbacks created in a for loop ===
for: 3,3,3
foreach: 0,1,2
for with a copy: 0,1,2في حلقة for يوجد متغير واحد i للحلقة كلها، والدوال الثلاث التقطت هذا المتغير نفسه وليس قيمته، فعندما استدعيناها بعد الحلقة كانت قيمته 3، فطبعت الثلاث 3. أما foreach فتنشئ متغيراً جديداً في كل دورة منذ C# 5، كما تذكر صفحة جمل التكرار، فالتقطت كل دالة متغيرها. وهذا نفس سلوك var مقابل let في JavaScript القديمة، ولكن هنا الحلقتان في نفس اللغة تتصرفان بشكل مختلف. والإصلاح نسخ المتغير داخل الحلقة int copy = i;، أو استعمال foreach.
الخطأ 11: throw ex بدل throw
عندما تلتقط استثناء لتسجله ثم تعيد رميه، فالكلمة الصحيحة throw; وحدها، أما throw ex; التي تبدو منطقية للقادم من Java فتمحو مكان الخطأ الأصلي. والتجربة التالية ترمي استثناء من LoadPrice، وتلتقطه Wrapper وتعيد رميه بالطريقتين، ثم تطبع الإطارات في الـ Stack Trace:
=== rethrow: where did the error happen? ===
throw ex -> <<Main>$>g__Wrapper|0_11:144 <- <<Main>$>g__Rethrow|0_10:131
throw -> <<Main>$>g__LoadPrice|0_12:148 <- <<Main>$>g__Wrapper|0_11:141 <- <<Main>$>g__Rethrow|0_10:131مع throw ex يبدأ الـ Stack Trace من السطر 144، وهو سطر إعادة الرمي، والدالة LoadPrice التي حدث فيها الخطأ فعلاً اختفت. ومع throw بقي كل شيء، بدءاً من السطر 148 في LoadPrice. وفي حادثة حقيقية الفرق بين الحالتين هو الفرق بين أن تعرف السبب من السجل في دقيقة، أو أن تعيد إنتاج الخطأ ساعة. وإذا أردت أن تضيف سياقاً فارم استثناء جديداً وضع القديم داخله: throw new PriceException("...", ex);، فيبقى الأصل كاملاً في InnerException. وفي الحالات النادرة التي تحتاج فيها أن تحفظ الاستثناء ثم ترميه لاحقاً من مكان آخر، مثل خيط ينقل خطأ إلى خيط آخر، فالأداة هي ExceptionDispatchInfo.Capture(ex).Throw()، وهي تحفظ الـ Stack Trace الأصلي وتضيف إليه مكان الرمي الجديد، وهذا ما تفعله await نفسها خلف الكواليس عندما تعيد رمي استثناء المهمة في مكان الاستدعاء.
الخطأ 12: async void يسقط العملية Process Crash
القادم من JavaScript يكتب دالة غير متزامنة لا ترجع شيئاً، فيضع لها async void لأن «void» تعني لا شيء. والتجربة التالية تقارن دالتين متطابقتين ترمي كل منهما استثناء بعد الانتظار، واحدة async Task والثانية async void، وكل منهما داخل try وcatch:
try { await SaveTask(); } catch (Exception e) { Console.WriteLine($" async Task: caught {e.Message}"); }
try { SaveVoid(); } catch (Exception e) { Console.WriteLine($" async void: caught {e.Message}"); }
await Task.Delay(200);
Console.WriteLine(" still running?");والمخرج سوف يكون كما يلي:
=== asyncvoid: an exception in async Task vs async void ===
async Task: caught disk full (Task)
Unhandled exception. System.IO.IOException: disk full (void)
at Program.<<Main>$>g__SaveVoid|0_20() in /w/Program.cs:line 215
at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_1(Object state)
at System.Threading.ThreadPoolWorkQueue.Dispatch()
at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()
at System.Threading.Thread.StartCallback()
still running?
/lab/run.sh: line 11: 70 Aborted (core dumped) dotnet /out/Idioms.dll asyncvoid
exit code: 134استثناء async Task التقطته catch كما يجب، لأن await أعادت رميه في مكان الاستدعاء. أما async void فلا يوجد مهمة يرجع فيها الاستثناء، فرماه الـ Runtime على خيط من المجمع لا يوجد فوقه أي catch، فصار استثناء غير معالج أسقط العملية كلها بالرمز 134. ولاحظ أن «still running?» طبعت لأن الخيط الرئيسي كان ما زال يعمل في لحظة السقوط، ثم انتهى كل شيء. وفي خدمة ويب يعني هذا أن خطأ واحداً في دالة async void يسقط الخدمة لكل المستخدمين. والقاعدة كما في صفحة سيناريوهات async: الدالة غير المتزامنة ترجع Task دائماً، وasync void فقط لمعالجات الأحداث في تطبيقات الواجهات حيث يفرضها التوقيع.
الوقت Time والأرقام Numbers
الخطأ 13: DateTime والمناطق الزمنية Time Zones
عميل في السعودية أرسل طلباً الساعة العاشرة صباحاً بتوقيته، والتطبيق أرسل الوقت بصيغة ISO 8601 مع الفرق +03:00، والسيرفر في Container يعمل بتوقيت UTC كأغلب السيرفرات:
=== dates: an order placed at 10:00 Saudi time (+03:00), server in UTC ===
TimeZoneInfo.Local = UTC
DateTime.Parse -> 2026-10-31 07:00 Kind=Local
DateTimeOffset.Parse -> 2026-10-31 10:00 +03:00, UTC 07:00
DateTime.Now.Kind=Local, DateTime.UtcNow.Kind=UtcDateTime.Parse قرأ الفرق الزمني، ثم حول الوقت إلى توقيت السيرفر المحلي وتخلص من الفرق، فصار الطلب «الساعة 7:00» بنوع Local، أي «محلي» بمعنى توقيت السيرفر الذي لا يعرف عنه العميل شيئاً. وإذا نقلت الخدمة إلى سيرفر آخر بتوقيت مختلف تغير معنى كل التواريخ المحفوظة. أما DateTimeOffset فاحتفظ بالوقت والفرق معاً، 10:00 بفرق +03:00، ويعطيك UTC متى طلبته. والقاعدة كما في صفحة الاختيار بين أنواع التاريخ: DateTimeOffset لكل لحظة حدثت فعلاً، مثل وقت الطلب، ويقابلها timestamptz في PostgreSQL، وDateTime.UtcNow إذا احتجت DateTime، ولا تستعمل DateTime.Now في كود سيرفر، وحول إلى توقيت المستخدم عند العرض فقط. وفي .NET 8 وما بعده توجد أيضاً TimeProvider التي تجعل الوقت قابلاً للاختبار بدل استدعاء الساعة مباشرة.
وقد يبدو هذا كله مبالغة لخدمة كل عملائها داخل المملكة، حيث لا يوجد توقيت صيفي والفرق ثابت ثلاث ساعات طوال السنة. ولكن الخدمة نفسها تعمل على سيرفر بتوقيت UTC، وتتصل ببوابة دفع تسجل أوقاتها بتوقيت آخر، وتصدر تقارير يومية يجب أن يبدأ يومها عند منتصف الليل بتوقيت السعودية وليس بتوقيت السيرفر، وأي طلب بين التاسعة مساء ومنتصف الليل بتوقيت UTC يقع في يوم مختلف بين التوقيتين. وعندما تتوسع الخدمة لعملاء في دول فيها توقيت صيفي يصير الفرق نفسه متغيراً خلال السنة، ولا يحله إلا أن تحفظ اللحظة كاملة بفرقها، وتحول عند العرض بـ TimeZoneInfo فقط.
الخطأ 14: Math.Round يقرب إلى الزوجي
رأينا في مقال الأنواع أن Math.Round دون تحديد الطريقة أعطت النتيجة الصحيحة في مثال الضريبة بالصدفة، وهنا أربع قيم تبين الصدفة:
=== round: Math.Round default (ToEven) vs AwayFromZero ===
0.125: Round= 0.12 AwayFromZero= 0.13
0.135: Round= 0.14 AwayFromZero= 0.14
2.5: Round= 2 AwayFromZero= 3
3.5: Round= 4 AwayFromZero= 4الطريقة الافتراضية هي التقريب إلى الزوجي ToEven، ويسميها البعض تقريب البنوك Banker's Rounding، فالنصف يذهب إلى الرقم الزوجي: 0.125 صارت 0.12 لأن 2 زوجي، و0.135 صارت 0.14 لأن 4 زوجي، و2.5 صارت 2. وهذه الطريقة لها منطق إحصائي لأنها لا تنحاز للأعلى في المتوسط، ولكنها ليست ما يتوقعه المحاسب ولا ما تقوله قاعدة الضريبة في خدمتنا. والإصلاح أن تكتب الطريقة في كل استدعاء دائماً، أو أن تحسب بالأعداد الصحيحة كما فعلنا بالهللات. ولاحظ أن الافتراضي نفسه مختلف بين اللغات: round في Python 3 تقرب إلى الزوجي أيضاً، بينما Math.round في JavaScript وMath.round في Java تقربان النصف للأعلى، فالقادم من JavaScript أو Java هو من يفاجأ هنا أكثر من غيره.

وثلاثة أخطاء أخرى رأيناها بالأرقام في مقالات المرحلة السابقة، فلن نعيد تجربتها هنا: double للمال الذي أخطأ في 1,302 من 5,000 مبلغ حدي في مقال الأنواع، وعلامة ! التي أسكتت تحذير null حتى سقطت المهمة الليلية، و.Result الذي جمد مجمع الخيوط 23 ثانية في مقال التزامن.
ماذا تمسك المحللات Analyzers من هذه الأخطاء؟
ذكرنا في المقال الأول أن C# تحلل الكود داخل البناء نفسه، فالسؤال الطبيعي: لماذا لا نترك المحللات تمسك هذه الأخطاء بدل أن نحفظها؟ ولكي نجيب بالأرقام بنينا نفس البرنامج مرتين بالسكربت lab/run-analyzers.sh: مرة بالإعداد الافتراضي، ومرة بتفعيل كل قواعد الجودة بالخيار AnalysisMode=All:
dotnet build -c Release --no-incremental
dotnet build -c Release --no-incremental -p:AnalysisMode=Allوالمخرج سوف يكون كما يلي:
=== default build
2 warning CA2200
=== AnalysisMode=All
5 warning CA2007
5 warning CA1515
4 warning CA1031
3 warning CA1852
3 warning CA1305
3 warning CA1002
2 warning CA1311
2 warning CA1304
2 warning CA1303
1 warning CA2200
1 warning CA1862
1 warning CA1510
1 warning CA1507
1 warning CA1050
--- lines
Program.cs(144,33): warning CA2200: Re-throwing caught exception changes stack information
Program.cs(176,14): warning CA1305: The behavior of 'DateTime.Parse(string)' could vary based on the current user's locale settings. ...
Program.cs(177,15): warning CA1305: The behavior of 'DateTimeOffset.Parse(string)' could vary based on the current user's locale settings. ...
Program.cs(53,142): warning CA1305: The behavior of 'double.ToString(string)' could vary based on the current user's locale settings. ...
Program.cs(67,52): warning CA1304: The behavior of 'string.ToUpper()' could vary based on the current user's locale settings. ...
Program.cs(67,56): warning CA1311: Specify a culture or use an invariant version to avoid implicit dependency on current culture
Program.cs(67,85): warning CA1862: Prefer using 'string.Equals(string, StringComparison)' to perform a case-insensitive comparison, ...في المخرج أعلاه لاحظ التالي:
- البناء الافتراضي أمسك خطأ واحداً فقط من الأربعة عشر، وهو
throw exبالقاعدة CA2200، وهي مفعلة افتراضياً لأن ضررها واضح ولا يوجد سبب مشروع لها تقريباً. ولاحظ أن هذا التحذير لم يظهر في مخرجrun.shالأول، لأن السكربت كان يبحث عن تحذيرات CS فقط، وهذا درس صغير آخر: إذا كان سكربت CI يرشح المخرج، فتأكد أن المرشح لا يخفي ما تريد رؤيته. - مع
AnalysisMode=Allظهرت قواعد الثقافة: CA1305 أمسكتDateTime.ParseوDateTimeOffset.ParseوToString("N2")بدون ثقافة، وCA1304 وCA1311 وCA1862 أمسكتToUpperفي المقارنة، أي الخطأين 2 و3 والجزء الأول من 13. - ولكن انظر لما لم تمسكه:
double.TryParse("9.90", out ...)نفسها، التي أعطت 990، لم يظهر عليها أي تحذير، ولا على+=في الحلقة، ولا على التنفيذ المؤجل، ولا على الحذف أثناء المرور، ولا على نسخة الـ struct، ولا على Lambda الحلقة، ولا على التعداد، ولا علىFirst، ولا علىMath.Round، ولا علىasync void. أي أن قرابة عشرة أخطاء من أربعة عشر مرت من كل المحللات المدمجة. - وظهرت معها عشرات التحذيرات التي لا علاقة لها بأخطائنا، مثل CA2007 التي تطلب
ConfigureAwaitفي كلawait، وCA1515 التي تطلب جعل الأنواعinternal، وهذا سبب أن أحداً لا يفعلAnalysisMode=Allكما هو، وإنما يختار القواعد.
والنتيجة العملية أمران. الأول أن تفعل في ملف .editorconfig قواعد الثقافة والنصوص بمستوى تحذير على الأقل في أي خدمة تقرأ أو تكتب أرقاماً وتواريخ، مثل CA1305 وCA1304 وCA1309 وCA1310 وCA1311، فهي رخيصة وتمسك أخطاء حقيقية كما رأينا. والثاني أن المحللات شبكة أمان وليست بديلاً عن معرفة اللغة، فأغلب ما في هذا المقال يمر منها، ولا يمسكه إلا مراجع يعرفه أو اختبار يشغله. ويوجد محللات إضافية من حزم NuGet تغطي جزءاً من الباقي، مثل حزمة Microsoft.VisualStudio.Threading.Analyzers التي فيها قاعدة ضد async void، فإذا كان فريقك يكتب كوداً غير متزامن كثيراً فهي تستحق التجربة.
كيف تمسك هذه الأخطاء بالاختبارات Tests؟
إذا كانت المحللات تمسك أقل من نصف هذه الأخطاء، فالباقي يمسكه اختبار يشغل الكود في الظروف التي يحدث فيها الخطأ، والمشكلة أن أغلب هذه الظروف لا توجد على جهاز المطور ولا في CI بإعداداته الافتراضية. فجهازك ثقافته إنجليزية أمريكية في الغالب، والـ Container في CI توقيته UTC وثقافته ثابتة، والقوائم في بيانات الاختبار فيها عناصر دائماً. لذلك الاختبارات التي تمسك هذه الأخطاء تغير الظروف عمداً:
- الثقافة: شغل اختبارات التحويل والتنسيق مرة بثقافة ألمانية ومرة بثقافة عربية ومرة بتركية، بتغيير
CultureInfo.CurrentCultureفي بداية الاختبار كما فعلنا في التجربة، أو بتشغيل الاختبارات كلها في CI مرة إضافية بمتغير البيئةLANG=de_DE.UTF-8. فلو كان في خدمة المتجر اختبار واحد بثقافة de-DE لظهر سعر 990 قبل أن يصل لأي عميل. - الوقت: لا تستدعي
DateTime.NowولاDateTimeOffset.UtcNowمباشرة في منطق الأعمال، وإنما خذTimeProviderمن حقن الاعتماديات، فتستطيع في الاختبار أن تثبت الوقت على لحظة تعرفها، مثل الساعة 23:59 بتوقيت السعودية في آخر يوم من الشهر، وهي اللحظة التي تظهر فيها أخطاء المناطق الزمنية عادة، لأن اليوم بتوقيت UTC ما زال اليوم السابق. - الحدود: لكل دالة تأخذ مجموعة اختبار بمجموعة فارغة، ولكل دالة تأخذ تعداداً من الخارج اختبار بقيمة غير معرفة، ولكل حساب مالي اختبار على قيمة حدية تنتهي بنصف وحدة، كما فعلنا مع الضريبة في مقال الأنواع.
- الأداء: خطأ
+=لا يظهر في اختبار بعشرة أسطر، وإنما في اختبار بحجم بيانات الإنتاج، لذلك ضع للعمليات التي تبني نصوصاً كبيرة أو تمر على مجموعات كبيرة اختباراً واحداً على الأقل بحجم حقيقي، مع حد للزمن أو الذاكرة.
والفكرة العامة خلف كل هذا أن الكود الذي يعتمد على شيء من البيئة، الثقافة أو الوقت أو المنطقة الزمنية، يجب أن يأخذ هذا الشيء صراحة كمعامل أو كاعتمادية، لا أن يقرأه من النظام في مكان مخفي، وعندها يصير اختباره سهلاً، ويصير الخطأ ظاهراً في توقيع الدالة نفسها.
ماذا تفعل في أسبوعك الأول في فريق C#؟ Onboarding
إذا كنت انضممت لفريق يكتب C# كما في قصة هذه المرحلة، فهذه خطوات عملية تختصر عليك أغلب تعليقات المراجعة قبل أن تأتي:
- اقرأ ملف
.editorconfigوDirectory.Build.propsفي المستودع قبل أي كود، فهما يقولان لك ما يفرضه الفريق، وما يعتبره تحذيراً أو خطأ. - ابن المشروع بـ
dotnet build --no-incrementalمرة واحدة، واقرأ كل التحذيرات الموجودة حتى لو كانت كثيرة، فهي خريطة للأخطاء المتكررة في هذا الكود بالذات. - ابحث في المستودع عن الكلمات التي في قائمة المراجعة أدناه:
async voidو.ResultوDateTime.NowوToUpper()وthrow ex، وانظر كيف تعامل الفريق معها، فقد تجد أن بعضها مقصود وله تعليق، وبعضها دين تقني Technical Debt ينتظر من يصلحه. - في أول طلب دمج لك اكتب في الوصف أنك قادم من لغة أخرى واطلب من المراجع أن يشير إلى ما لا يبدو C#، فهذا يختصر أسابيع من التخمين.
- شغل اختبارات المشروع محلياً بثقافة غير ثقافة جهازك مرة واحدة، فإذا فشل شيء فقد وجدت خطأ حقيقياً في أسبوعك الأول.
أسلوب C# الحديث Modern C#: نفس الميزة بكود 2012 وكود اليوم
جزء كبير من تعليقات المراجعة ليس عن أخطاء، وإنما عن أسلوب قديم، لأن أغلب ما تجده على الإنترنت من أمثلة C# كتب قبل عشر سنوات. والمثال التالي تقرير صغير بالمنتجات التي سعرها أكبر من عشرة ريالات وحالة مخزونها، مكتوب مرتين في الملف lab/Modern.cs. الأول بأسلوب 2012:
public class Product
{
private readonly string _name; private readonly int _price; private readonly int _stock;
public Product(string name, int price, int stock) { _name = name; _price = price; _stock = stock; }
public string Name { get { return _name; } }
public int Price { get { return _price; } }
public int Stock { get { return _stock; } }
}
public static List<string> Lines(List<Product> products)
{
if (products == null) throw new ArgumentNullException("products");
var result = new List<string>();
foreach (var p in products)
{
string label;
if (p.Stock == 0) label = "sold out";
else if (p.Stock < 15) label = "low";
else label = "ok";
if (p.Price > 1000) result.Add(p.Name + ": " + label);
}
result.Sort();
return result;
}والثاني بأسلوب C# الحديث:
public record Product(string Name, int Price, int Stock);
public static List<string> Lines(IEnumerable<Product> products)
{
ArgumentNullException.ThrowIfNull(products);
return [.. products
.Where(p => p.Price > 1000)
.Select(p => $"{p.Name}: {Label(p)}")
.Order()];
}
static string Label(Product p) => p.Stock switch
{
0 => "sold out",
< 15 => "low",
_ => "ok",
};والمخرج يبين أن النتيجة واحدة:
=== modern: the same report, 2012 style vs modern C# ===
legacy: dallah: sold out | dates: low
modern: dallah: sold out | dates: low
same output: True
raw string literal: {"name": "dallah", "price_halalas": 12900}في الكود الحديث لاحظ التالي:
- الكلاس بثلاثة حقول وثلاث خصائص ومشيد Constructor صار سطراً واحداً بالسجل، ومعه المقارنة بالقيمة و
ToStringمجاناً. ArgumentNullException.ThrowIfNullتكتب اسم المعامل تلقائياً، فلا يوجد نص"products"ينسى أحدهم تغييره عند إعادة التسمية.- المعامل صار
IEnumerable<Product>بدلList، فالدالة تقبل أي مجموعة، وهذه من إرشادات تصميم المكتبات: اقبل أعم نوع تحتاجه، وأرجع أدق نوع عندك. - الشروط المتداخلة صارت تعبير
switchبأنماط، وكل فرع سطر، والمترجم يحذرك إذا نسيت حالة. [.. query]تعبير مجموعات Collection Expression يحول الاستعلام إلى النوع الذي ترجعه الدالة، وOrder()ترتب دون أن تكتبx => x.- السطر الأخير في المخرج نص خام Raw String Literal بثلاث علامات تنصيص، يكتب JSON كما هو دون أي هروب للعلامات.
وهناك ميزات حديثة أخرى ستراها في كل مستودع جديد: المشيدات الأساسية Primary Constructors للكلاسات، مثل OrderService(NpgsqlDataSource db) في خدمة المقال الرابع، فتأخذ الاعتماديات دون حقول ومشيد، ومساحة الأسماء على مستوى الملف، والخصائص المطلوبة required، وis null وis not null بدل == null. ولا تحتاج أن تحفظ كل هذا، فالمحررات تقترح التحويل بنفسها، والأهم أن يفرض الفريق أسلوباً واحداً عن طريق ملف .editorconfig فيه قواعد الأسلوب مع الخيار EnforceCodeStyleInBuild، فيصير مخالف الأسلوب تحذيراً في البناء، ومع TreatWarningsAsErrors خطأ، فلا يبقى في المراجعة نقاش عن الشكل.

قائمة مراجعة الكود Code Review Checklist
| ابحث في الكود عن | لماذا | البديل |
|---|---|---|
+= على string داخل حلقة | 10 ثوان و7.6 GB لعشرين ألف سطر | StringBuilder أو string.Join |
Parse أو ToString بدون ثقافة لبيانات تقرؤها برامج | 9.90 صارت 990 على de-DE | CultureInfo.InvariantCulture |
ToUpper() أو ToLower() للمقارنة | «İNVOİCE» على tr-TR | StringComparison.OrdinalIgnoreCase |
| متغير LINQ يمر عليه أكثر من مرة | 9 حسابات لثلاثة عناصر | ToList() مرة واحدة |
Remove داخل foreach | استثناء وقائمة نصف معدلة | RemoveAll أو Where |
First() على مجموعة قد تكون فارغة | استثناء بلا سياق | FirstOrDefault مع فحص |
| struct قابل للتعديل | تعديل نسخة دون أن تعرف | readonly struct أو record مع with |
| record فيه List أو مصفوفة | المقارنة بالمرجع | Equals مخصصة أو مفتاح |
| تحويل عدد إلى enum من الخارج | 42 قيمة صالحة | Enum.IsDefined |
Lambda داخل for | 3,3,3 | نسخة محلية أو foreach |
throw ex; | يضيع مكان الخطأ | throw; |
async void | يسقط العملية بالرمز 134 | async Task |
DateTime.Now وDateTime.Parse | الوقت بتوقيت السيرفر | DateTimeOffset وUtcNow |
Math.Round بدون طريقة | 2.5 تصير 2 | MidpointRounding.AwayFromZero |
double للمال، !، .Result | من المقالات السابقة | هللات int، فحص null، await |
خطة القراءة Reading Plan من المصادر الرسمية
كل ما تحتاجه لتكمل موجود مجاناً عند Microsoft نفسها، والمشكلة أنه كثير جداً، لذلك هذه خطة بالترتيب الذي يناسب من يعرف البرمجة، أسبوع لكل مجموعة، والصورة أعلاه تلخصها:
الأسبوع الأول، اللغة: ابدأ بـ جولة في C#، فهي مكتوبة لمن يعرف لغة أخرى، ثم أساسيات نظام الأنواع لتثبيت ما رأيناه عن القيم والمراجع، ثم مطابقة الأنماط، ثم مقدمة LINQ، وأخيراً الأنواع القابلة للفراغ. وفي نهاية الأسبوع افتح تاريخ إصدارات C# وتصفحه سريعاً، لتعرف عندما ترى كوداً قديماً من أي سنة هو، وما البديل الحديث له.
الأسبوع الثاني، المنصة: مقدمة .NET وأداة dotnet، ثم الاستثناءات مع أفضل ممارساتها، ثم البرمجة غير المتزامنة، ثم جامع القمامة، ثم أفضل ممارسات النصوص والعولمة، لأنهما مصدر نصف الأخطاء في هذا المقال.
الأسبوع الثالث، الويب: الـ Minimal APIs في ASP.NET Core، ثم حقن الاعتماديات Dependency Injection لأنه أساس كل خدمة، ثم اختبارات التكامل مع WebApplicationFactory كما في خدمة المقال الرابع، ثم أفضل ممارسات ASP.NET Core، وهي صفحة واحدة تستحق أن تقرأها مرتين.
الأسبوع الرابع، العمق: إرشادات تصميم المكتبات Framework Design Guidelines، وهي الأسلوب الذي كتبت به مكتبة .NET نفسها، ثم ما الجديد في .NET 10 وC# 14، ثم المقال السنوي الطويل Performance Improvements in .NET 10 الذي يشرح كيف تعمل المنصة من الداخل، وعندما تريد إجابة دقيقة عن سلوك اللغة فالمرجع مواصفة C#، وعن سلوك المكتبة فالكود نفسه في مستودع dotnet/runtime.
الخلاصة
- أغلب أخطاء القادمين من لغات أخرى لا يكشفها المترجم، لأنها عادات صحيحة في لغتهم وخاطئة في C#.
- النص لا يتغير، فـ
+=في حلقة أخذ عشر ثوان و7.6 جيجابايت، وStringBuilderملي ثانية واحدة. - التحويل والتنسيق يتبعان ثقافة السيرفر: 9.90 صارت 990 على de-DE وفشلت على ar-SA، و«invoice» لم تساو «INVOICE» على tr-TR، والإصلاح InvariantCulture وOrdinalIgnoreCase.
- LINQ مؤجلة فنفذت الحساب تسع مرات لثلاثة عناصر، والحذف أثناء المرور ترك القائمة نصف معدلة، و
FirstOrDefaultترجع قيمة افتراضية وليس null لأنواع القيمة. - نسخة struct من قائمة لا تعدل القائمة، والسجل يقارن القوائم بالمرجع، والتعداد قبل 42 حتى عبر
TryParse. - Lambda داخل
forطبعت 3,3,3، وthrow exمحت مكان الخطأ، وasync voidأسقطت العملية بالرمز 134 ولم تلتقطهاcatch. DateTime.Parseحول 10:00 بتوقيت السعودية إلى 07:00 بتوقيت السيرفر ونسي الفرق، وMath.Roundيقرب إلى الزوجي.- الأسلوب الحديث بالسجلات والأنماط وتعبيرات المجموعات يعطي نفس النتيجة بنصف الكود، ويفرضه على الفريق
.editorconfigمع البناء.
وبهذا تكتمل مرحلة لغة C# للمطورين، وتجد مقالاتها الخمسة ومقالات تكملها هنا:
دليلC# لمن يعرف البرمجة: الأدوات Toolchain والأساسياتتثبيت .NET 10 وجولة في C# مقارنة بـ Python وJavaScript، ثم حل كامل بالأمر dotnet من البناء والاختبار إلى أربعة أشكال للنشر، مع فخ تحذيرات حقيقي واجهناه.
دليلالأنواع والذاكرة والاستثناءات Exceptions في C#قسنا ما تكلفه أنواع القيمة والمرجع وجامع القمامة داخل Container محدود، ثم شغلنا كل خطأ شائع حتى يسقط، وكشفنا أن double يخطئ في ربع مبالغ الضريبة الحدية.
دليلالتزامن في C#: async/await والمهام Tasks بالأرقامقسنا نموذج التزامن في .NET بمئة طلب HTTP ومهمة SHA-256 على نواة ونواتين وأربع، ورأينا .Result يجمد مجمع الخيوط 23 ثانية، ثم سباق بيانات وقفلاً مميتاً وإصلاح كل منهما.
دليلخدمة حقيقية بـ C#: ASP.NET Core مع PostgreSQL والاختباراتبنينا واجهة طلبات بـ ASP.NET Core وNpgsql بقفل الصفوف وضريبة بالهللات، واختبرناها بعشرين طلباً متوازياً على PostgreSQL حقيقي، ثم قسنا الـ Image والبدء البارد والذاكرة وwrk بأمانة.
دليلالبرمجة بعدة لغات Polyglot: متى تفيد ومتى تعقد مشروعك؟نستدعي نفس الدالة من Python بخمس طرق، عبر FFI إلى C وGo وعبر HTTP وgRPC، ونقيس كلفة كل حد بين لغتين وتعقيد البناء وما يحدث عندما يتعطل الطرف الآخر.
دليلقراءة ال Stack Trace وتصحيح الأخطاء Debugging للمبتدئينكيف تقرأ رسالة الخطأ في Python وJavaScript سطراً بسطر، ومتى تستخدم الطباعة أو نقاط التوقف في VS Code، وكيف يجد git bisect التعديل الذي أدخل الخطأ.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ الأقسام الأربعة عشر بـ .NET SDK 10.0.403 وRuntime 10.0.14 في Container بنواتين.