كل حلقة foreach وكل استعلام LINQ يعمل عبر واجهة صغيرة واحدة: IEnumerable<T>. فهمها يشرح لماذا LINQ كسول، ولماذا لا يمكن قراءة بعض التسلسلات إلا مرة واحدة، وكيف تتيح لك yield return كتابة تسلسلاتك الخاصة بحلقة عادية.
ما هي IEnumerable
لـ IEnumerable<T> دالة واحدة، GetEnumerator(). وللمعدِّد الذي تعيده MoveNext() (يتقدّم، ويبلّغ هل يوجد عنصر) وCurrent (العنصر). وforeach اختصار لقيادة ذلك المعدِّد:
المخرجات:
red green blue
red green blue
يبدأ المعدِّد قبل العنصر الأول، فتنقله MoveNext() الأولى إليه. وحين تعيد MoveNext() القيمة false ينتهي التسلسل. وتتخلّص كتلة using من المعدِّد، وهذا مهم للمكرِّرات التي تمسك موارد مثل الملفات المفتوحة.
IEnumerable مقابل ICollection مقابل List
تشكّل واجهات المجموعات سلّمًا. كل درجة تعد بأكثر:
| النوع | ما يضيفه | الاستخدام النموذجي |
|---|---|---|
IEnumerable<T> | foreach فقط | معاملات تمرّ عليها فقط؛ نتائج LINQ؛ تسلسلات كسولة |
IReadOnlyCollection<T> | Count | قيم معادة يجب ألا يعدّلها المستدعي |
ICollection<T> | Count وAdd وRemove وContains | شيفرة تضيف إلى مجموعة من أي نوع |
IReadOnlyList<T> | الوصول بالفهرس [i] | نتائج للقراءة فقط لها مواضع |
IList<T> | الوصول بالفهرس وInsert وRemoveAt | شيفرة تعدّل بالموضع |
List<T> | الصنف الفعلي الذي ينفّذ كل هذا | التخزين |
الدالة التي لا تفعل سوى المرور على مدخلاتها يجب أن تقبل IEnumerable<T>، كي يستطيع المستدعون تمرير مصفوفة أو قائمة أو مجموعة أو استعلام LINQ دون تحويل. أما الدالة التي تعيد بيانات فيجب عادة أن تعيد شيئًا يقول "هذا موجود في الذاكرة أصلًا"، مثل List<T> أو IReadOnlyList<T>، لأن القيمة المعادة من نوع IEnumerable<T> قد تكون استعلامًا يؤدي عملًا في كل مرة يُقرأ فيها.
المخرجات:
30
15
4.0
30
كتابة مكرِّر بـ yield return
لإنتاج تسلسلك الخاص اكتب دالة تعيد IEnumerable<T> واستخدم yield return لكل عنصر. يحوّل المترجم الدالة إلى صنف ينفّذ المعدِّد عنك.
المخرجات:
0, 2, 4, 6, 8, 10
Mon Tue Wed Thu Fri
لا توجد قائمة في أي مكان في EvenNumbers. تسلّم كل yield return قيمة واحدة إلى المستدعي وتوقف الدالة، مبقية متغيّراتها المحلية (i هنا) حية حتى يطلب المستدعي القيمة التالية.
الكسل: راقب ترتيب التنفيذ
لأن الدالة تتوقف، يتناوب جسمها وحلقة المستدعي. الطباعة من الطرفين تُظهر التداخل:
المخرجات:
Called Numbers(), nothing printed yet
start
yielding 1
Loop got 1
resumed after 1
yielding 2
Loop got 2
resumed after 2
yielding 3
Loop got 3
resumed after 3
end
لم يطبع استدعاء Numbers() كلمة "start": أنشأ القابل للتعداد فقط. بدأ الجسم عند أول MoveNext() داخل foreach، وعمل حتى أول yield return، ثم توقف. وكل خطوة لاحقة استأنفت من السطر الذي يلي yield. تتصرف Where وSelect في LINQ بالطريقة نفسها (ينفّذها وقت التشغيل بأصناف معدِّدات مكتوبة يدويًا للسرعة، لكن الأثر متطابق)، ولهذا استعلامات LINQ مؤجّلة.
yield break
تنهي yield break التسلسل مبكرًا، وهي نسخة المكرِّر من return:
المخرجات:
Subject: Invoice
From: shop@example.com
لا يمكنك الخلط بين return value; وyield return في دالة واحدة. بمجرد أن تحتوي الدالة على yield تصبح مكرِّرًا، وyield break هي الطريقة الوحيدة لمغادرتها مبكرًا.
التسلسلات اللانهائية
لأنه لا يُحسب أبدًا إلا العناصر المطلوبة، يمكن للمكرِّر أن يصف تسلسلًا بلا نهاية. والمستهلك يقرّر كم يأخذ:
المخرجات:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34
1597
2026-09-28 Mon
2026-10-05 Mon
2026-10-12 Mon
while (true) آمنة هنا لأن Take وFirst تتوقفان عن الطلب بعد أن تحصلا على ما تحتاجانه. أما استدعاء ToList() أو Count() على تسلسل لانهائي فلا يعود أبدًا، وكذلك تعداد OrderBy عليه، لأن الفرز يحتاج إلى كل عنصر أولًا.
أفخاخ التسلسلات الكسولة
فحوص الوسائط تعمل متأخرة. جسم المكرِّر كله مؤجّل، بما في ذلك التحقق في أعلاه. الإصلاح تقسيم الدالة: دالة عادية تفحص الوسائط وتعيد نتيجة مكرِّر خاص.
المخرجات:
LazyRange(-1) returned without an error
...and threw only when enumerated
EagerRange(-1) threw at the call
كل تعداد يشغّل الدالة مجددًا. المكرِّر لا يخزّن شيئًا مؤقتًا. المرور مرتين على Numbers() يطبع "start" مرتين، أو يعيد قراءة الملف مرتين، أو يستدعي خدمة الويب مرتين. تنبّه بيئات التطوير إلى ذلك بعبارة "possible multiple enumeration of IEnumerable". استدعِ ToList() مرة واحدة حين تحتاج إلى العناصر أكثر من مرة.
تعمل finally حين يتوقف المستهلك. إن خرج المستدعي من foreach مبكرًا يُتخلّص من المعدِّد وتُنفَّذ أي كتلة finally في المكرِّر، وبهذا يغلق المكرِّر الذي يقرأ ملفًا الملف:
المخرجات:
open sensor
10
20
30
close sensor
done
قواعد قليلة يفرضها المترجم على المكرِّرات: لا يمكن أن تظهر yield return داخل كتلة try لها catch (أما try/finally فمقبولة)، ولا داخل تعبير lambda أو دالة مجهولة، ولا في دالة لها معاملات ref أو out.
التدفقات غير المتزامنة
للتسلسلات التي تصل عناصرها بشكل غير متزامن (صفحات من API، صفوف من قاعدة بيانات)، تجمع C# 8 وما بعدها (مع .NET Core 3.0 أو أحدث) المكرِّرات مع await عبر IAsyncEnumerable<T>:
static async IAsyncEnumerable<string> FetchPages()
{
for (int page = 1; page <= 3; page++)
{
await Task.Delay(100); // simulate a network call
yield return $"page {page}";
}
}
await foreach (string p in FetchPages())
{
Console.WriteLine(p);
}
يستخدم المستهلك await foreach، ويُنتظر كل عنصر حين يُنتج.
أخطاء شائعة
- إعادة
IEnumerable<T>من استعلام وقراءتها مرات كثيرة. كل قراءة تعيد تشغيله؛ أعدList<T>بدلًا من ذلك. - التحقق من الوسائط داخل مكرِّر. الفحص مؤجّل؛ قسّم إلى دالة مغلّفة ومكرِّر.
- استدعاء
Count()أوToList()على مكرِّر لانهائي. لا ينتهي أبدًا؛ استخدمTakeأولًا. - توقّع أن يملك معامل
IEnumerable<T>خاصيةCountأو[i]. استخدمCount()في LINQ (التي قد تعدّد) أو اقبلIReadOnlyList<T>.
الأسئلة الشائعة
ما هي IEnumerable في C#؟
IEnumerable<T> واجهة كل ما يمكن المرور عليه بـ foreach. لها دالة واحدة، GetEnumerator()، تعيد IEnumerator<T> فيه MoveNext() وCurrent. تنفّذها المصفوفات والقوائم والقواميس والمجموعات واستعلامات LINQ ودوال المكرِّر كلها.
ما الفرق بين IEnumerable وList في C#؟
لا تعد IEnumerable<T> إلا بأنك تستطيع المرور؛ ليس فيها خاصية Count ولا مفهرس ولا Add، وقد تُحسب بكسل في كل مرة تمرّ فيها. أما List<T> فمجموعة فعلية مخزّنة في الذاكرة بكل ذلك. اقبل IEnumerable<T> في المعاملات حين تمرّ فقط، وأعد List<T> أو IReadOnlyList<T> حين يحتاج المستدعون إلى الفهرسة أو العدّ.
ماذا تفعل yield return في C#؟
تسلّم yield return value; داخل دالة تعيد IEnumerable<T> عنصرًا واحدًا إلى المستدعي وتوقف الدالة عند تلك النقطة. وفي المرة التالية التي يطلب فيها المستدعي عنصرًا تستأنف الدالة من السطر التالي. يعيد المترجم كتابة الدالة كآلة حالات، فتكتب حلقة وتحصل على تسلسل كسول.
ما هي yield break في C#؟
تنهي yield break; التسلسل: تنتهي foreach لدى المستدعي بشكل طبيعي بعد العناصر المنتجة أصلًا. هي مكافئ return في المكرِّر وتُستخدم للتوقف مبكرًا، مثلًا حين يُبلغ حد أو تكون المدخلات فارغة.
لماذا لا ترمي دالة المكرِّر عندي استثناءً حين أستدعيها؟
جسم دالة المكرِّر لا يُنفَّذ حتى يُعدَّد التسلسل، فتُنفَّذ فحوص الوسائط في أعلاه عند أول MoveNext، لا عند الاستدعاء. للتحقق الفوري ضع الفحوص في دالة عامة عادية تستدعي بعدها دالة مكرِّر خاصة تحمل عبارات yield.