چرا Any همیشه برندس

مقدمه فرض کن یه API داری که باید چک کنه کاربر حداقل یه سفارش باز داره یا نه. خیلی راحت می‌نویسی: if (orders.Count(o => o.Status == OrderStatus.…

مقدمه

فرض کن یه API داری که باید چک کنه کاربر حداقل یه سفارش باز داره یا نه. خیلی راحت می‌نویسی:

if (orders.Count(o => o.Status == OrderStatus.Open) > 0)
{
    // ...
}

کار میکنه، تست‌ها هم سبزن، ردش میکنی. اما اگه orders هزار تا آیتم داشته باشه و اولین سفارش باز همون آیتم سوم باشه، این کد داره ۹۹۷ تا آیتم اضافه رو بی‌دلیل چک میکنه. اینجا داستان Any در برابر Count شروع میشه — نه یه بحث سلیقه‌ای، بلکه یه تفاوت واقعی توی اینکه enumerator کِی متوقف میشه.

دو تا متد، دو تا هدف متفاوت

Any جواب یه سوال بله/خیره: «حداقل یکی هست؟». Count جواب یه سوال عددیه: «دقیقاً چندتا هست؟». همین تفاوت هدف، رفتار runtime رو کاملاً عوض میکنه:

  • برای جواب دادن به «حداقل یکی هست؟» کافیه به اولین match برسی و دیگه لازم نیست جلوتر بری.
  • برای جواب دادن به «چندتا هست؟» راهی نداری جز اینکه همه رو بشمری، چون تا آخرین آیتم رو نبینی نمیتونی مطمئن باشی عدد نهایی چیه.

زمان اجرا: کجا متوقف میشه؟

IEnumerable<int> Numbers()
{
    for (int i = 0; i < 5; i++)
    {
        Console.WriteLine($"item {i}");
        yield return i;
    }
}

Numbers().Any(x => x == 2);
// item 0
// item 1
// item 2   ← تمام، دیگه سراغ 3 و 4 نمیره

Numbers().Count(x => x == 2);
// item 0
// item 1
// item 2
// item 3   ← باید ادامه بده
// item 4

پیاده‌سازی واقعی Enumerable.cs هم دقیقاً همین رفتار رو نشون میده:

public static bool Any<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
{
    foreach (var item in source)
        if (predicate(item))
            return true;   // خروج فوری
    return false;
}

public static int Count<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
{
    int count = 0;
    foreach (var item in source)
        if (predicate(item))
            count++;       // بدون break، تا آخر ادامه میده
    return count;
}

یه استثنا هم هست که کمتر بهش توجه میشه: Count() بدون predicate، اگه source از نوع ICollection<T> باشه (مثل List<T> یا آرایه)، اصلاً enumerate نمیکنه و مستقیم از property داخلی Count/Length میخونه:

List<int> list = new() { 1, 2, 3 };
list.Count();               // O(1) — property، نه enumeration
list.Where(x => x > 1).Count(); // O(n) — خروجی Where دیگه ICollection نیست

پیچیدگی زمانی

حالت Best Worst Average
Any(predicate) O(1) O(n) O(n/2)
Count(predicate) O(n) O(n) O(n)
Count() روی ICollection<T> O(1) O(1) O(1)
Count() روی IEnumerable ساده O(n) O(n) O(n)

Count(predicate) هیچ best-case بهتر از O(n) نداره چون کارش شمردنه، نه پیدا کردن؛ باید همه رو ببینه. Any فقط وقتی به O(n) میرسه که match در آخرین آیتم باشه یا اصلاً نباشه.

وقتی object باشه، نه int

با value typeها (مثل int) قصه ساده‌ست، اما وقتی T یک reference type باشه چندتا نکته اضافه میشه:

public class User
{
    public int Id { get; set; }
    public string Name { get; set; }
}

string targetName = "admin";
bool hasAdmin = users.Any(u => u.Name == targetName);
  • چون predicate داره targetName رو از بیرون capture میکنه، compiler یک closure class پشت صحنه میسازه که این متغیر رو نگه میداره. این closure یک بار روی heap allocate میشه — مستقل از اینکه Any صداش بزنی یا Count؛ این هزینه ربطی به short-circuit نداره.
  • اگه predicate هیچ متغیر بیرونی capture نکنه (u => u.Name == "admin")، compiler یک delegate ثابت میسازه که فقط یک‌بار در کل عمر برنامه allocate میشه، نه هر بار که خط اجرا میشه.
  • برای Any، به محض true شدن predicate، حتی ارجاعی هم به objectهای باقی‌مونده گرفته نمیشه؛ اگه اون objectها قرار بود lazy از یه منبع بیرونی (فایل، دیتابیس) ساخته بشن، ساختشون اصلاً اتفاق نمی‌افته.

ساختار حافظه: Stack در برابر Heap

فرض کن users یک List<User> با ۵ آیتمه و predicate یک closure داره:

Stack                          Heap
─────                          ────
targetName ──────────────────► "admin"
users (reference) ───────────► List<User>
                                  ├─ _items[] ──► [User0][User1][User2][User3][User4]
                                  └─ _size = 5
predicate (reference) ────────► <>c__DisplayClass
                                  └─ targetName ──► همون "admin"

Any

Enumerator (struct، روی Stack) ساخته میشه
 → MoveNext #1: User0 → false
 → MoveNext #2: User1 → false
 → MoveNext #3: User2 → true → STOP
User3, User4 هیچوقت لمس نمیشن

Count

Enumerator (همون struct) + count (int، روی Stack)
 → MoveNext #1: User0 → false
 → MoveNext #2: User1 → false
 → MoveNext #3: User2 → true → count++
 → MoveNext #4: User3 → false
 → MoveNext #5: User4 → false
 → return count

نکته‌ای که این رسم نشون میده: چون List<T>.Enumerator یک struct هست، خودش هیچوقت روی Heap allocate نمیشه — نه برای Any، نه برای Count. تفاوت heap-wise فقط سر closure هست که هر دو به یک اندازه بهش نیاز دارن. تفاوت واقعی جای دیگه‌ست: تعداد دفعاتی که MoveNext صدا زده میشه و در نتیجه زمان اجرا.

Benchmark

[MemoryDiagnoser]
public class AnyVsCountBenchmark
{
    private readonly List<int> _list = Enumerable.Range(0, 10_000).ToList();

    [Benchmark]
    public bool UseAny() => _list.Any(x => x == 5000);

    [Benchmark(Baseline = true)]
    public int UseCount() => _list.Count(x => x == 5000);
}

روی یه لیست ساده، هرچی match زودتر توی sequence باشه فاصلهٔ بین این دو بیشتر میشه؛ هرچی match دیرتر باشه (یا اصلاً نباشه) فاصله به صفر میل میکنه. حافظهٔ allocate‌شده تقریباً یکسانه، چون تفاوت اصلی سر iteration count هست، نه allocation.

جمع‌بندی

  • سوالت بله/خیره («حداقل یکی هست؟» یا «خالیه؟»): Any بزن.
  • سوالت عدد دقیقه: Count بزن، ولی بدون predicate و روی یه ICollection<T> واقعی، وگرنه O(n) میخوری.
  • Count() > 0 تقریباً همیشه قابل جایگزینیه با Any()، و هیچوقت گرون‌تر از اون نیست.
نوشته‌شده توسط محمد نظری من اینجا هستم تا دانشم رو با شما به اشتراک بذارم.

دیدگاه‌ها 0

برای ارسال دیدگاه یا پسند، وارد شوید.

ورود / ثبت‌نام

هنوز دیدگاهی نیست — اولین نفر باشید.