مقالات › C#

آموزش Generic در C#؛ کلاس و متد جنریک، Constraintها و Variance

جنریک‌ها را در سی‌شارپ کامل یاد بگیرید: پارامتر نوع، متد و کلاس جنریک، محدودیت با where، Covariance و Contravariance، اعضای static abstract و الگوهای کاربردی.

جنریک‌ها اجازه می‌دهند یک بار کد بنویسید و با انواع مختلف از آن استفاده کنید؛ بدون از دست دادن ایمنی نوع یا کارایی. List<T>، Dictionary<TKey, TValue>، Task<T> و خود LINQ همگی جنریک هستند. فهم عمیق جنریک‌ها کمک می‌کند کتابخانه‌های قابل استفاده‌ی مجدد طراحی کنید، از Cast و Boxing خلاص شوید و سورس دات‌نت را با اطمینان بخوانید.

جنریک‌ها چه مشکلی را حل می‌کنند؟

قبل از جنریک‌ها، کالکشن‌های قابل استفاده‌ی مجدد object نگه می‌داشتند:

var list = new ArrayList();
list.Add(42);
list.Add("oops");             // بدون مشکل کامپایل می‌شود
int first = (int)list[0];     // Cast لازم است
int second = (int)list[1];    // 💥 InvalidCastException در زمان اجرا

دو مشکل: نبود ایمنی نوع (خطا در زمان اجرا به‌جای زمان کامپایل) و Boxing برای انواع مقداری (تخصیص حافظه و کد کندتر). جنریک‌ها هر دو را حل می‌کنند:

var numbers = new List<int>();
numbers.Add(42);
// numbers.Add("oops");      ❌ خطای زمان کامپایل
int first = numbers[0];       // بدون Cast و بدون Boxing

متدهای جنریک

public static T Max<T>(T a, T b) where T : IComparable<T> =>
    a.CompareTo(b) >= 0 ? a : b;

Max(3, 7);            // 7 — T به‌عنوان int استنتاج می‌شود
Max("apple", "pear"); // "pear"

کامپایلر معمولاً آرگومان نوع را از روی پارامترها استنتاج می‌کند؛ برای همین به‌ندرت Max<int>(...) می‌نویسید.

کلاس‌های جنریک

public class Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }

    private Result(bool ok, T? value, string? error) =>
        (IsSuccess, Value, Error) = (ok, value, error);

    public static Result<T> Success(T value) => new(true, value, null);
    public static Result<T> Failure(string error) => new(false, default, error);
}

Result<User> result = await userService.RegisterAsync(request);
if (!result.IsSuccess) return BadRequest(result.Error);

نوع جنریک Result<T> روشی رایج برای برگرداندن موفقیت یا شکست از سرویس‌های اپلیکیشن است، بدون اینکه برای حالت‌های قابل انتظار Exception پرتاب شود.

محدودیت‌ها (Constraint) با where

محدودیت‌ها به کامپایلر می‌گویند یک پارامتر نوع چه توانایی‌هایی دارد؛ هم اعضا را قابل استفاده می‌کنند و هم جلوی استفاده‌ی نادرست را می‌گیرند.

محدودیت معنی
where T : class نوع ارجاعی
where T : struct نوع مقداری غیر Nullable
where T : notnull نوع غیر Nullable (مقداری یا ارجاعی)
where T : new() سازنده‌ی عمومی بدون پارامتر دارد
where T : BaseEntity از یک کلاس پایه مشتق شده
where T : IComparable<T> یک اینترفیس را پیاده‌سازی می‌کند
where T : unmanaged نوع مقداری Unmanaged (برای کدهای سطح پایین)
where TDerived : TBase یک پارامتر نوع از دیگری مشتق شده

محدودیت‌ها قابل ترکیب‌اند:

public interface IEntity { int Id { get; } }

public class Repository<TEntity>(AppDbContext db)
    where TEntity : class, IEntity
{
    public Task<TEntity?> GetAsync(int id, CancellationToken ct = default) =>
        db.Set<TEntity>().FirstOrDefaultAsync(e => e.Id == id, ct);

    public void Add(TEntity entity) => db.Set<TEntity>().Add(entity);
}

به‌خاطر IEntity، متد می‌تواند از e.Id استفاده کند و به‌خاطر class، متد Set<TEntity>() در EF Core آن را می‌پذیرد.

اینترفیس‌های جنریک

public interface IHandler<in TRequest, TResponse>
{
    Task<TResponse> HandleAsync(TRequest request, CancellationToken ct);
}

public class GetArticleHandler(AppDbContext db) : IHandler<GetArticle, ArticleDto?>
{
    public Task<ArticleDto?> HandleAsync(GetArticle request, CancellationToken ct) =>
        db.Articles.Where(a => a.Slug == request.Slug)
            .Select(a => new ArticleDto(a.Title, a.Content))
            .FirstOrDefaultAsync(ct);
}

این همان شکلی است که پشت کتابخانه‌هایی مثل MediatR و بسیاری از پیاده‌سازی‌های CQRS قرار دارد. ثبت جنریک باز (Open Generic) همه‌ی Handlerها را خودکار متصل می‌کند:

builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));

Covariance و Contravariance

Variance تعیین می‌کند آیا یک نوع جنریک می‌تواند جای همان نوع جنریک با آرگومان نوع دیگری استفاده شود یا نه.

Covariance (out) — «تولیدکننده‌ها»

IEnumerable<string> names = new List<string> { "Ali", "Sara" };
IEnumerable<object> objects = names;   // ✅ مجاز: IEnumerable<out T>

دنباله‌ای از رشته‌ها، دنباله‌ای از اشیا هم هست؛ چون IEnumerable<T> فقط T را برمی‌گرداند.

Contravariance (in) — «مصرف‌کننده‌ها»

Action<object> printAny = o => Console.WriteLine(o);
Action<string> printString = printAny;  // ✅ مجاز: Action<in T>

چیزی که می‌تواند هر شیئی را پردازش کند، قطعاً یک رشته را هم می‌تواند.

چرا List<T> ثابت (Invariant) است

List<string> names = new();
// List<object> objects = names;  ❌ مجاز نیست
// objects.Add(42);  — یک int وارد لیست رشته‌ها می‌شد!

List<T> هم T را می‌گیرد و هم برمی‌گرداند؛ اجازه‌ی Variance ایمنی نوع را از بین می‌برد. فقط اینترفیس‌ها و Delegateها می‌توانند Variant باشند و فقط وقتی T در یک جهت استفاده شود.

مقدار پیش‌فرض و بررسی نوع

public static T? FirstOrNone<T>(IEnumerable<T> items) =>
    items.FirstOrDefault(); // default(T): برای ارجاعی null، برای int صفر و...

public static string Describe<T>(T value) => value switch
{
    int i    => $"int {i}",
    string s => $"string '{s}'",
    null     => "null",
    _        => typeof(T).Name
};

ریاضیات جنریک با اعضای static abstract (C# 11)

اینترفیس‌ها حالا می‌توانند اعضای static تعریف کنند و الگوریتم‌هایی ممکن می‌شوند که برای همه‌ی انواع عددی کار می‌کنند:

public static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
    var total = T.Zero;
    foreach (var v in values) total += v;
    return total;
}

Sum(new[] { 1, 2, 3 });           // int
Sum(new[] { 1.5m, 2.25m });       // decimal

نکات کارایی

  • برای انواع مقداری، Runtime برای هر نوع کد اختصاصی تولید می‌کند؛ بدون Boxing و با سرعت کامل.
  • برای انواع ارجاعی یک پیاده‌سازی مشترک استفاده می‌شود.
  • کد جنریک تقریباً همیشه به سرعت کد اختصاصی دست‌نویس است.

اشتباهات رایج

  1. تعمیم بیش از حد — متد جنریکی که فقط با یک نوع استفاده می‌شود، فقط پیچیدگی اضافه می‌کند.
  2. نبود محدودیت — و بعد Cast کردن داخل متد که هدف جنریک را از بین می‌برد.
  3. Repository جنریک همه‌جا — IRepository<T> با ۱۵ متد اغلب قدرت EF Core را پنهان می‌کند؛ Repositoryهای مشخص با متدهای معنادار معمولاً بهترند.
  4. استفاده از پارامتر object در جایی که جنریک ایمنی نوع را حفظ می‌کرد.

جمع‌بندی

جنریک‌ها کد قابل استفاده‌ی مجدد با ایمنی نوع در زمان کامپایل و بدون Boxing فراهم می‌کنند. برای الگوریتم‌ها و ظرف‌های عمومی از متد و کلاس جنریک استفاده کنید، نیازمندی‌ها را با محدودیت بیان کنید، Variance را بفهمید (out برای تولیدکننده، in برای مصرف‌کننده) و از قابلیت‌های مدرن مثل ریاضیات جنریک بهره ببرید. با استفاده‌ی سنجیده، جنریک‌ها APIها را هم انعطاف‌پذیر و هم امن می‌کنند.

پرسش‌های متداول

تفاوت T? برای کلاس و Struct چیست؟

با where T : struct، T? یعنی Nullable<T>. برای انواع ارجاعی یک حاشیه‌نویسی Nullable است. بدون محدودیت، T? یعنی «ممکن است مقدار پیش‌فرض باشد».

می‌توانم چند اینترفیس را محدودیت بگذارم؟

بله: where T : IComparable<T>, IFormattable, new() — محدودیت سازنده باید آخر بیاید.

جنریک‌ها در زمان کامپایل حل می‌شوند یا زمان اجرا؟

هر دو: کامپایلر نوع‌ها را بررسی می‌کند و Runtime نمونه‌های مشخص می‌سازد (Reified Generics)؛ برای همین typeof(T) در زمان اجرا کار می‌کند.