نیازی به گفتن نیست که در برنامه‌نویسی دات‌نت، هر شیئی که می‌سازیم هزینه خودش را دارد؛ هم از نظر حافظه Heap، هم از نظر زمان پردازش، هم گاهی از نظر اتصال به منابعی مثل دیتابیس، فایل یا سرویس‌های خارجی. شاید ساخت یک شیء ساده مسئله خاصی نباشد، اما وقتی سازنده کلاس کار سنگین انجام می‌دهد، ساخت زودهنگام آن می‌تواند بی‌دلیل به برنامه فشار وارد کند.

اینجاست که مفهوم Lazy Object Instantiation وارد بازی می‌شود. ایده ساده است: شیء را همان لحظه‌ای نساز که تعریفش می‌کنی؛ صبر کن تا واقعاً به آن نیاز پیدا شود. در سی‌شارپ، کلاس generic به نام Lazy<T> دقیقاً برای همین سناریو طراحی شده و کمک می‌کند ساخت اشیای سنگین را تمیزتر، امن‌تر و قابل‌کنترل‌تر انجام بدهیم.

مشکل ساخت زودهنگام اشیا و سربار آن در حافظه و پردازش

تصور کنید کلاسی داریم که قرار است سفارش‌ها را مدیریت کند، اما سازنده آن همان اول کار ۱۰ هزار شیء از نوع Order می‌سازد. شاید در یک پروژه کوچک خیلی به چشم نیاید، ولی در برنامه واقعی، مخصوصاً وقتی چندین Repository، سرویس یا مدل سنگین داریم، همین ساخت‌های زودهنگام می‌تواند هم حافظه Heap را بی‌دلیل پر کند و هم زمان شروع برنامه را بالا ببرد.

مشکل اصلی اینجاست که ما فقط یک نمونه از OrdersRepository می‌سازیم، اما هنوز معلوم نیست واقعاً قرار است از لیست سفارش‌ها استفاده کنیم یا نه. با این حال هزینه ساخت همه سفارش‌ها را پرداخت کرده‌ایم.

using System;
using System.Collections.Generic;

public class Order
{
    public int Id { get; set; }
    public string Title { get; set; }
    public DateTime Date { get; set; }
}

public class OrdersRepository
{
    private readonly List<Order> allOrders;

    public OrdersRepository()
    {
        allOrders = new List<Order>();

        for (var counter = 0; counter < 10000; counter++)
        {
            allOrders.Add(new Order
            {
                Id = counter + 1,
                Title = $"Order {counter + 1}",
                Date = DateTime.Now
            });
        }
    }

    public List<Order> All
    {
        get { return allOrders; }
    }
}

public class Program
{
    public static void Main()
    {
        var repository = new OrdersRepository();

        Console.WriteLine("Repository created.");

        // شاید اصلاً در این اجرای برنامه به repository.All نیاز نداشته باشیم
    }
}

در این کد، همین که خط new OrdersRepository() اجرا شود، حلقه سازنده هم اجرا می‌شود و ۱۰ هزار شیء ساخته می‌شود؛ حتی اگر هیچ‌وقت به پراپرتی All دسترسی نزنیم. این دقیقاً همان نقطه‌ای است که Lazy Object Instantiation ارزش خودش را نشان می‌دهد: ساخت شیء یا داده سنگین باید تا لحظه نیاز واقعی عقب بیفتد، نه اینکه صرفاً به خاطر ساخته شدن کلاس، همه هزینه‌ها همان ابتدا تحمیل شوند.

پیاده‌سازی ساده Lazy Loading با پراپرتی و محدودیت‌های آن

یک راه سریع برای عقب انداختن ساخت لیست سفارش‌ها این است که عملیات سنگین را از سازنده بیرون بکشیم و داخل getter پراپرتی قرار بدهیم. یعنی تا وقتی کسی واقعاً سراغ All نرفته، لیست ساخته نشود. البته اگر این کار را خام انجام بدهیم، ممکن است با هر بار خواندن پراپرتی، لیست دوباره ساخته شود؛ پس باید مقدار ساخته‌شده را کش کنیم.

using System;
using System.Collections.Generic;

public class Order
{
    public int Id { get; set; }
    public string Title { get; set; }
    public DateTime Date { get; set; }
}

public class OrdersRepository
{
    private List<Order> allOrders;

    public List<Order> All
    {
        get
        {
            if (allOrders == null)
            {
                allOrders = new List<Order>();

                for (var counter = 0; counter < 10000; counter++)
                {
                    allOrders.Add(new Order
                    {
                        Id = counter + 1,
                        Title = $"Order {counter + 1}",
                        Date = DateTime.Now
                    });
                }
            }

            return allOrders;
        }
    }
}

public class Program
{
    public static void Main()
    {
        var repository = new OrdersRepository();

        Console.WriteLine("Repository object created, but orders are not loaded yet.");

        var orders = repository.All;

        Console.WriteLine($"Orders loaded: {orders.Count}");
    }
}

با این تغییر، ساخت خود OrdersRepository سبک‌تر می‌شود و هزینه اصلی فقط هنگام اولین دسترسی به All پرداخت می‌شود. اما این روش هنوز یک پیاده‌سازی دستی است؛ باید null check را درست بنویسیم، باید مراقب اجرای چندنخی باشیم، و منطق بارگذاری داده هم داخل پراپرتی پنهان می‌شود. برای سناریوهای ساده قابل قبول است، ولی وقتی ساخت شیء پیچیده‌تر، حساس‌تر یا چندنخی شد، بهتر است سراغ کلاس استاندارد Lazy<T> برویم.

آشنایی با کلاس Lazy و نقش پراپرتی Value در زمان ساخت شیء

اینجا کلاس Lazy<T> کار را تمیزتر می‌کند. به‌جای اینکه خودمان null check بنویسیم و نگران کش شدن مقدار باشیم، یک نمونه از Lazy می‌سازیم و نوع شیء اصلی را به‌عنوان پارامتر generic مشخص می‌کنیم؛ مثلاً Lazy<OrdersRepository>.

نکته اصلی اینجاست که ساخت واقعی OrdersRepository همان لحظه ایجاد Lazy انجام نمی‌شود. تا وقتی سراغ پراپرتی Value نرویم، شیء داخلی ساخته نشده است. به محض اولین دسترسی به Value، دات‌نت نمونه اصلی را می‌سازد و همان مقدار را برای دفعات بعد نگه می‌دارد.

پس Value مرز بین «تعریف نیاز» و «مصرف واقعی» است. این دقیقاً همان چیزی است که در Lazy Object Instantiation می‌خواهیم: وابستگی را معرفی می‌کنیم، اما هزینه ساخت آن را فقط وقتی پرداخت می‌کنیم که واقعاً لازم شده باشد.

استفاده از Factory برای کنترل نحوه ساخته شدن شیء Lazy

گاهی فقط عقب انداختن ساخت شیء کافی نیست؛ می‌خواهیم دقیقاً مشخص کنیم شیء چطور ساخته شود. اینجاست که Factory در Lazy به درد می‌خورد. به‌جای اینکه Lazy خودش با سازنده پیش‌فرض کلاس کار کند، یک تابع یا Lambda به آن می‌دهیم تا هنگام اولین دسترسی به Value اجرا شود.

مزیت این روش این است که می‌توانیم قبل از ساخت شیء لاگ بگیریم، پارامترهای خاص پاس بدهیم، تنظیمات بخوانیم یا حتی تصمیم بگیریم کدام پیاده‌سازی ساخته شود. نکته مهم اینجاست که خود Factory هم بلافاصله اجرا نمی‌شود؛ فقط داخل Lazy ذخیره می‌شود و تا وقتی Value صدا زده نشده، هیچ هزینه‌ای پرداخت نخواهد شد.

پس Factory در Lazy فقط یک روش زیباتر برای ساخت شیء نیست؛ ابزاری است برای کنترل فرآیند ساخت، بدون از دست دادن مزیت اصلی Lazy Object Instantiation یعنی تأخیر در ایجاد شیء تا لحظه نیاز واقعی.

نکات مهم درباره کش شدن مقدار، اجرای چندنخی و خطاهای احتمالی در Lazy

یک نکته خیلی مهم درباره Lazy این است که مقدار ساخته‌شده را بعد از اولین دسترسی به Value نگه می‌دارد. یعنی اگر بار اول OrdersRepository ساخته شد، دفعات بعدی همان نمونه قبلی برگردانده می‌شود و Factory دوباره اجرا نمی‌شود. پس Lazy فقط ساخت شیء را عقب نمی‌اندازد؛ نتیجه ساخت را هم کش می‌کند.

از نظر اجرای چندنخی هم رفتار پیش‌فرض Lazy معمولاً امن است. یعنی اگر چند Thread هم‌زمان سراغ Value بروند، دات‌نت تلاش می‌کند فقط یک نمونه واقعی ساخته شود. این موضوع در برنامه‌های وب، سرویس‌ها و کدهایی که هم‌زمانی دارند مهم است؛ چون پیاده‌سازی دستی با null check ساده ممکن است در شرایط رقابتی باعث ساخت چندباره شیء شود.

اما Lazy همیشه بدون دردسر نیست. اگر Factory هنگام ساخت شیء خطا بدهد، همان خطا می‌تواند در دسترسی‌های بعدی هم دوباره دیده شود؛ مخصوصاً وقتی حالت پیش‌فرض thread-safe استفاده شده باشد. بنابراین منطق داخل Factory نباید بی‌محابا شامل عملیات ناپایدار مثل اتصال نامطمئن به سرویس خارجی باشد، مگر اینکه برای مدیریت خطا، retry یا fallback تصمیم درستی گرفته باشید.

همچنین حواستان باشد Lazy جایگزین طراحی خوب نیست. اگر شیء شما منابعی مثل فایل، کانکشن یا آبجکت‌های قابل Dispose نگه می‌دارد، باید چرخه عمر آن را جدی بگیرید. Lazy فقط زمان ساخت را کنترل می‌کند، نه زمان آزادسازی منابع را.

چه زمانی استفاده از Lazy Object Instantiation انتخاب خوبی است و چه زمانی نه

Lazy Object Instantiation وقتی ارزش دارد که ساخت شیء واقعاً هزینه‌بر باشد؛ مثلاً بارگذاری تعداد زیادی رکورد، خواندن تنظیمات سنگین، ساخت سرویس‌های وابسته یا آماده‌سازی آبجکت‌هایی که شاید در همه مسیرهای اجرای برنامه استفاده نشوند. اگر احتمال مصرف شیء پایین است، Lazy می‌تواند مصرف حافظه و زمان شروع برنامه را بهتر کند.

اما اگر شیء سبک است، تقریباً همیشه استفاده می‌شود، یا ساخت آن باید خطاهای احتمالی را همان ابتدای اجرای برنامه آشکار کند، Lazy انتخاب جذابی نیست. در این حالت فقط پیچیدگی اضافه می‌کنید و زمان بروز خطا را عقب می‌اندازید. همچنین برای منابع قابل آزادسازی مثل کانکشن، فایل یا آبجکت‌های IDisposable باید حتماً چرخه عمر را جداگانه مدیریت کنید.

پس قاعده ساده این است: Lazy را برای «هزینه سنگین و مصرف احتمالی» استفاده کنید، نه برای پنهان کردن طراحی بد یا فرار از مدیریت وابستگی‌ها. اگر قرار است شیء حتماً ساخته شود، ساخت مستقیم معمولاً خواناتر و قابل پیش‌بینی‌تر است.