مقالات › C#

Nullable Reference Types در C#؛ خداحافظی با NullReferenceException

با Nullable Reference Types باگ‌های null را در زمان کامپایل بگیرید: فعال‌سازی، علامت ?، تحلیل وضعیت null، عملگرها، Attributeها، الگوهای EF Core و DTO و مهاجرت کدهای قدیمی.

NullReferenceException رایج‌ترین خطای زمان اجرا در تاریخ دات‌نت است؛ تونی هور ارجاع null را «اشتباه میلیارد دلاری» خود نامید. از C# 8، Nullable Reference Types (NRT) به کامپایلر اجازه می‌دهد ردیابی کند کدام ارجاع‌ها ممکن است null باشند و قبل از اجرای کد به شما هشدار دهد. NRT که در همه‌ی قالب‌های پروژه‌ی جدید فعال است، حالا بخش اصلی نوشتن C# قابل اعتماد است.

ایده در یک مثال

با فعال بودن NRT، انواع ارجاعی به‌طور پیش‌فرض غیر Nullable هستند. با ? اعلام می‌کنید که چیزی ممکن است null باشد:

public class User
{
    public string Email { get; set; } = "";     // هرگز null نیست
    public string? MiddleName { get; set; }     // ممکن است null باشد
}

int Length(User user) => user.MiddleName.Length;
// ⚠️ CS8602: Dereference of a possibly null reference.

حالا کامپایلر می‌داند MiddleName ممکن است null باشد و شما را مجبور به مدیریتش می‌کند:

int Length(User user) => user.MiddleName?.Length ?? 0;

نکته‌ی مهم: NRT فقط تحلیل زمان کامپایل است. رفتار زمان اجرا را تغییر نمی‌دهد؛ یک string اگر از کد بررسی‌نشده، JSON یا Reflection بیاید، همچنان از نظر فنی می‌تواند null باشد.

فعال‌سازی

<PropertyGroup>
  <Nullable>enable</Nullable>
  <!-- اختیاری: تبدیل هشدارهای Nullable به خطا -->
  <WarningsAsErrors>Nullable</WarningsAsErrors>
</PropertyGroup>

می‌توانید آن را برای هر فایل جداگانه هم با #nullable enable فعال کنید؛ برای مهاجرت تدریجی کدبیس‌های بزرگ مفید است.

تحلیل وضعیت null؛ کامپایلر بررسی‌های شما را دنبال می‌کند

کامپایلر ردیابی می‌کند یک متغیر در هر نقطه از کد «احتمالاً null» است یا «قطعاً غیر null»:

string? name = GetName();

if (name is null)
    return "anonymous";

return name.ToUpper();   // ✅ بدون هشدار: name اینجا null نیست

کامپایلر is null، is not null، == null، string.IsNullOrEmpty، تطبیق الگو و return زودهنگام را می‌فهمد.

عملگرهای null

string? city = customer.Address?.City;          // ?.  شرطی null
string display = city ?? "Unknown";             // ??  جایگزین null
settings.Theme ??= "dark";                      // ??= مقداردهی اگر null است
string definitely = maybeNull!;                 // !   null-forgiving (با احتیاط)

عملگر null-forgiving یعنی ! به کامپایلر می‌گوید «به من اعتماد کن، این null نیست». هشدار را ساکت می‌کند بدون اینکه بررسی زمان اجرا اضافه کند؛ اگر اشتباه کرده باشید، همچنان NullReferenceException می‌گیرید. فقط وقتی از آن استفاده کنید که چیزی را می‌دانید که کامپایلر نمی‌تواند بفهمد (و بهتر است با یک کامنت دلیلش را بنویسید).

سازنده، اعضای required و مقداردهی اولیه

Propertyهای غیر Nullable باید مقداردهی اولیه شوند. گزینه‌ها:

// ۱) مقدار پیش‌فرض
public string Title { get; set; } = string.Empty;

// ۲) سازنده
public class Article(string title) { public string Title { get; } = title; }

// ۳) اعضای required (C# 11) — فراخواننده باید مقداردهی کند
public class CreateArticle
{
    public required string Title { get; init; }
    public string? Subtitle { get; init; }
}

var cmd = new CreateArticle { Title = "Nullable reference types" };   // ✅
var bad = new CreateArticle();                                         // ❌ خطای کامپایل

required اغلب تمیزترین انتخاب برای DTOها و کلاس‌های Options است.

کمک به کامپایلر با Attributeها

وقتی متدهای خودتان null را بررسی می‌کنند، Attributeهای System.Diagnostics.CodeAnalysis به کامپایلر می‌گویند چه چیزی را تضمین می‌کنند:

public static bool TryGetUser(int id, [NotNullWhen(true)] out User? user)
{
    user = _users.GetValueOrDefault(id);
    return user is not null;
}

if (TryGetUser(5, out var user))
    Console.WriteLine(user.Email);   // ✅ کامپایلر می‌داند user null نیست

public static void ThrowIfMissing([NotNull] object? value, string name)
{
    if (value is null) throw new ArgumentNullException(name);
}

Attributeهای مفید دیگر:

Attribute معنی
[NotNullWhen(true)] وقتی متد true برگرداند، آرگومان null نیست
[MaybeNullWhen(false)] وقتی false برگرداند، خروجی ممکن است null باشد
[NotNull] بعد از برگشت متد، آرگومان null نیست
[MemberNotNull(nameof(Field))] متد تضمین می‌کند یک فیلد مقداردهی شده است
[return: NotNullIfNotNull(nameof(input))] اگر ورودی null نباشد، خروجی هم null نیست

برای بررسی آرگومان‌ها متدهای کمکی داخلی را ترجیح دهید: ArgumentNullException.ThrowIfNull(value);.

جنریک‌ها و Nullability

public T? FindOrDefault<T>(IEnumerable<T> items, Func<T, bool> predicate) =>
    items.FirstOrDefault(predicate);   // T? یعنی «ممکن است مقدار پیش‌فرض باشد»

public void Store<T>(T value) where T : notnull { }   // رد کردن آرگومان‌های نوع Nullable

Entity Framework Core و Nullability

EF Core از حاشیه‌نویسی‌های NRT برای تعیین Nullable بودن ستون‌ها استفاده می‌کند:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;       // ستون NOT NULL
    public string? Description { get; set; }        // ستون NULL
    public Category Category { get; set; } = null!; // Navigation الزامی
    public int CategoryId { get; set; }
}

= null! یک اصطلاح رایج و پذیرفته‌شده برای Propertyهایی است که EF Core هنگام ساخت موجودیت مقداردهی می‌کند: کامپایلر دیگر اعتراض نمی‌کند و ستون همچنان NOT NULL است. در EF Core 7 به بعد Propertyهای required هم گزینه‌ی دیگری هستند.

APIها، JSON و DTOها

NRT JSON ورودی را اعتبارسنجی نمی‌کند؛ کلاینت همچنان می‌تواند برای یک Property غیر Nullable مقدار null بفرستد. NRT را با اعتبارسنجی ترکیب کنید:

public record RegisterRequest(
    [property: Required] string Email,
    [property: Required, MinLength(8)] string Password,
    string? Referral);

ASP.NET Core همراه با [ApiController] هم Propertyهای ارجاعی غیر Nullable را در اعتبارسنجی مدل به‌طور ضمنی الزامی در نظر می‌گیرد.

مهاجرت یک پروژه‌ی موجود

  1. <Nullable>enable</Nullable> را فعال کنید و تعداد هشدارها را ببینید؛ نترسید.
  2. اول کلاس‌های برگ (DTOها، موجودیت‌ها، ابزارها) را درست کنید، بعد سرویس‌هایی که از آن‌ها استفاده می‌کنند.
  3. صادقانه حاشیه‌نویسی کنید: اگر چیزی می‌تواند null باشد ? بگذارید؛ آن را با ! پنهان نکنید.
  4. برای فایل‌هایی که هنوز نمی‌توانید درستشان کنید موقتاً از #nullable disable استفاده کنید.
  5. وقتی تعداد هشدارها به صفر رسید، آن‌ها را به خطا تبدیل کنید تا مشکلات دوباره برنگردند.

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

  1. پاشیدن ! همه‌جا — هدف را از بین می‌برد.
  2. Nullable کردن همه‌چیز — آن‌وقت هر فراخواننده‌ای به بررسی null نیاز دارد؛ غیر Nullable با مقدار پیش‌فرض را ترجیح دهید.
  3. اعتماد به حاشیه‌نویسی‌ها در مرزها — داده‌ی JSON، دیتابیس یا کتابخانه‌های دیگر همچنان اعتبارسنجی لازم دارد.
  4. برگرداندن null برای کالکشن‌ها — به‌جای آن یک کالکشن خالی برگردانید ([]).

جمع‌بندی

Nullable Reference Types باگ‌های null را از کرش‌های زمان اجرا به هشدارهای زمان کامپایل تبدیل می‌کند. آن را در همه‌ی پروژه‌ها فعال کنید، منظور را با ? بیان کنید، اعضای غیر Nullable را مقداردهی کنید (مقدار پیش‌فرض، سازنده یا required)، بگذارید تحلیل وضعیت null بررسی‌های شما را دنبال کند، متدهای کمکی را با Attribute حاشیه‌نویسی کنید و ! را فقط وقتی استفاده کنید که واقعاً از کامپایلر بیشتر می‌دانید. نتیجه کدی است که دقیقاً می‌گوید کجا null مجاز است — و تقریباً دیگر خبری از NullReferenceException نیست.

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

آیا فعال کردن NRT نحوه‌ی اجرای برنامه را تغییر می‌دهد؟

نه. فقط تحلیل ایستا و حاشیه‌نویسی است و IL کامپایل‌شده همان رفتار را دارد.

چرا با فعال بودن NRT هنوز NullReferenceException می‌گیرم؟

هشدارها ممکن است نادیده گرفته یا سرکوب شوند و داده‌های بیرونی (JSON، Reflection، کتابخانه‌های قدیمی) می‌توانند null داشته باشند. در مرزها اعتبارسنجی کنید.

آیا کالکشن‌ها باید Nullable باشند؟

به‌ندرت. کالکشن‌های غیر Nullable با مقدار اولیه‌ی خالی را ترجیح دهید تا فراخواننده هرگز به بررسی null نیاز نداشته باشد.