چرا for ایندکس داره ولی foreach نه؟
این سؤال اصل ماجراست. جوابش برمیگرده به اینکه این دو تا اصلاً دو تا قرارداد متفاوت هستن، نه دو تا syntax مختلف برای یه کار:
for ذاتاً بر پایهٔ دسترسی تصادفی (random access) ساخته شده. وقتی مینویسی arr[i]، داری از یه indexer استفاده میکنی — یعنی ساختار داده باید بتونه بگه "المان شمارهٔ i رو مستقیم بده به من"، بدون اینکه مجبور باشه از اول شمارش کنه. این فقط روی ساختارهایی ممکنه که IList<T> (یا حداقل یه indexer + Count) دارن — مثل Array و List<T>.
foreach بر پایهٔ الگوی Iterator کار میکنه، نه indexer. زیرِ کاپوت، کامپایلر foreach رو به این تبدیل میکنه:
// این کدی که مینویسی:
foreach (var item in collection) { DoSomething(item); }
// دقیقاً معادل این کامپایل میشه:
using (var enumerator = collection.GetEnumerator())
{
while (enumerator.MoveNext())
{
var item = enumerator.Current;
DoSomething(item);
}
}
این قرارداد فقط سهتا چیز میخواد: GetEnumerator()، MoveNext()، Current. هیچجا نیازی به index یا Count از قبل نیست. به همین دلیله که foreach میتونه روی چیزهایی کار کنه که اصلاً indexer ندارن یا حتی طولشون از قبل مشخص نیست: Dictionary<TKey,TValue>، HashSet<T>، LinkedList<T>، یا حتی یه IEnumerable<T> که با yield return تولید میشه و میتونه نامتناهی باشه (مثل خوندن خطبهخط یه فایل چند گیگابایتی بدون لود کامل توی حافظه).
نکتهٔ خیلی سینیور: foreach روی آرایه اصلاً Iterator نمیسازه!
اینجا یه جزئیات ظریفه که خیلیها نمیدونن: کامپایلر C# برای آرایهها (T[]) بهطور خاص foreach رو به یه for معمولی با ایندکس تبدیل میکنه — نه به فراخوانی GetEnumerator(). یعنی:
foreach (var x in myArray) { }
عملاً همون IL رو تولید میکنه که:
for (int i = 0; i < myArray.Length; i++) { var x = myArray[i]; }
پس روی آرایه، هیچ تفاوت performance ای بین for و foreach نیست.
روی List<T> هم داستان کمی متفاوته: List<T>.GetEnumerator() یه struct برمیگردونه (نه class)، پس وقتی مستقیم روی نوع List<T> (نه IEnumerable<T>) از foreach استفاده کنی، کامپایلر میتونه از dispatch مجازی (virtual call) و allocation روی heap صرفنظر کنه. ولی اگه همون List<T> رو از پشت یه متغیر از نوع IEnumerable<T> پاس بدی، کامپایلر مجبوره از interface استفاده کنه، enumerator باکس میشه، و یه heap allocation اضافه میکنی. این دقیقاً همون نوع تصمیمیه که یه سینیور توی hot path مراقبشه.
ارتباط با Eager vs Lazy Loading — اینجا جواب اصلی سؤالته
این دقیقاً همونجاییه که این دو مفهوم به هم گره میخورن، و فکر میکنم اینه که واقعاً دنبالش بودی:
foreach ذاتاً با پیمایش تنبل (lazy / pull-based) سازگاره، چون فقط یکییکی MoveNext() صدا میزنه. این یعنی منبع دیتا میتونه هر بار در لحظه یه المان جدید تولید کنه، بدون اینکه از قبل همهچیز رو حاضر کرده باشه:### مثال ملموس: EF Core و lazy پیمایش
// این خط هنوز هیچ query ای به دیتابیس نمیفرسته! (Deferred Execution)
IEnumerable<Wallet> query = dbContext.Wallets.Where(w => w.Balance > 1000);
// فقط وقتی foreach صداش میزنه، query واقعاً اجرا میشه
// و ردیفها یکییکی از DB reader میآن، نه همه یکجا
foreach (var wallet in query)
{
Process(wallet); // هر تکرار، یه MoveNext() جدید = یه fetch جدید از DB
}
اما اگه بخوای همین کار رو با for انجام بدی، مجبوری اول کل نتیجه رو eager بگیری:
var wallets = dbContext.Wallets.Where(w => w.Balance > 1000).ToList(); // اینجا کل query اجرا و materialize میشه
for (int i = 0; i < wallets.Count; i++)
{
Process(wallets[i]);
}
این دقیقاً همون رابطهٔ عمیقیه که دنبالش بودی: for به یه Count و indexer از قبل نیاز داره، پس مجبورت میکنه collection رو eager بسازی. foreach هیچ نیازی به دونستن اندازهٔ کل مجموعه نداره، پس طبیعتاً با pull تنبل (lazy) جور درمیاد. همین منطق دقیقاً روی Lazy Loading توی EF Core هم صادقه — وقتی یه navigation property رو virtual میکنی و lazy loading proxy فعاله، اولین باری که foreach روش اجرا میشه، پروکسی در پسزمینه query میفرسته؛ ولی Include() (eager loading) از قبل همهچیز رو توی یه List کامل میکنه.
IEnumerable<T> در برابر List<T> — کدوم با کدوم؟
IEnumerable<T> |
List<T> |
|
|---|---|---|
foreach |
✅ همیشه کار میکنه | ✅ کار میکنه (و سریعتره، struct enumerator) |
for |
❌ اصلاً امکانش نیست — نه indexer داره نه Count تضمینشده | ✅ چون IList<T> رو implement کرده |
| نوع پیمایش | معمولاً lazy (مگه اینکه خودش از یه لیست ساخته شده باشه) | همیشه eager (چون از قبل توی حافظه کامله) |
اگه یه امضای متد IEnumerable<T> بگیره و بخوای توش for بزنی، مجبوری اول .ToList() یا .ToArray() صداش بزنی — که یعنی صریحاً داری از lazy به eager تبدیلش میکنی (و این خودش یه هزینهٔ حافظه و زمانه که باید آگاهانه انتخابش کنی).
یه تلهٔ سینیوری که حتماً باید بدونی: Multiple Enumeration
IEnumerable<Wallet> query = GetExpensiveWallets(); // فرض کن یه query دیتابیسیه
var count = query.Count(); // یه بار کامل query اجرا میشه!
foreach (var w in query) { } // دوباره از اول query اجرا میشه!
چون IEnumerable<T> نتیجه رو کش نمیکنه، هر بار که پیمایشش کنی (چه با foreach، چه با .Count()، چه با .First())، اگه پشتش یه query lazy باشه، دوباره از صفر اجرا میشه — یه باگ خیلی رایج و پرهزینهست که هزینهش رو ReSharper/Roslyn analyzer ها با warning "Possible multiple enumeration of IEnumerable" نشون میدن. راهحل: اگه قراره چندبار پیمایشش کنی، صریح .ToList() بزن و نتیجه رو نگه دار.