Levelwise
فارسی
پایه‌های C# و .NET

C# مدرن

نسخه‌های تازه C# کد تکراری را کم می‌کنند و خطاها را زودتر نشان می‌دهند. record برای داده‌ای که با مقدارش شناخته می‌شود، pattern matching برای تصمیم‌های خوانا، و nullable برای پیدا کردن null قبل از اجرا. بدان هر ابزار کجا مناسب است و کجا دام دارد.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۶ دقیقهمثال سفارش در فروشگاه اینترنتیکد C# 14 و .NET 10

نویسنده: bezzad

مشکل: کد زیاد برای کار ساده

در فروشگاه اینترنتی ما، آدرس ارسال سفارش این‌طور نوشته شده است:

public class Address
{
    public string City { get; set; }
    public string PostalCode { get; set; }

    public override bool Equals(object obj) =>
        obj is Address other && City == other.City && PostalCode == other.PostalCode;

    public override int GetHashCode() => HashCode.Combine(City, PostalCode);
}

این کد چند مشکل دارد:

  1. کد تکراری زیاد. برای یک داده ساده، باید متد Equals و GetHashCode را با دست بنویسیم. اگر فیلدی اضافه شود و یادمان برود آن را به Equals اضافه کنیم، باگ داریم.
  2. هر کسی می‌تواند آن را تغییر دهد. آدرس یک سفارش ثبت‌شده نباید بی‌صدا عوض شود.
  3. مقدار null پنهان است. هیچ چیز نمی‌گوید City ممکن است خالی باشد. خطای NullReferenceException فقط در Production پیدا می‌شود.

نسخه‌های تازه C# برای همین مشکل‌ها ابزار دارند. در این درس مهم‌ترین‌ها را می‌بینیم. آخرین نسخه پایدار C# 14 است که همراه .NET 10 آمده است.

نوع record: داده‌ای که با مقدارش شناخته می‌شود

همان آدرس، با record:

public sealed record Address(string City, string PostalCode);

کامپایلر این کارها را برای ما انجام می‌دهد:

  1. خاصیت‌ها را می‌سازد. هر دو فقط هنگام ساختن مقدار می‌گیرند (init). بعد از آن تغییر نمی‌کنند.
  2. متدهای Equals و GetHashCode را می‌سازد. دو record وقتی برابرند که همه فیلدهایشان برابر باشد.
  3. متد ToString خوانا می‌سازد. برای لاگ مفید است.
  4. امکان with را می‌دهد. یک کپی با چند تغییر.
class Addressمقایسه با آدرس حافظهتهران1234567890@0x01تهران1234567890@0x02≠a == b → falseداده یکی است، ولی دو شیء جدا هستندrecord Addressمقایسه با مقدار همه فیلدهاتهران1234567890@0x03تهران1234567890@0x04=a == b → trueشهر و کد پستی یکی است، پس برابرند
دو class با داده یکسان، دو شیء جدا هستند. دو record با داده یکسان، برابرند.
var home = new Address("Tehran", "1234567890");
var same = new Address("Tehran", "1234567890");
Console.WriteLine(home == same); // True

var office = home with { PostalCode = "1111111111" }; // a new object; home does not change
دام record: مقایسه سطحی است. اگر یک record یک List داشته باشد، Equals فقط آدرس List را مقایسه می‌کند، نه محتوای آن را. متد with هم فقط آدرس List را کپی می‌کند. پس دو record ممکن است یک List مشترک داشته باشند. برای فیلدهای collection در record مراقب باش.

کی record و کی class؟

record

  • داده‌ای که با مقدارش شناخته می‌شود: آدرس، مبلغ پول، بازه تاریخ.
  • پیام‌ها و DTO ها: درخواست و پاسخ API، رویدادها.
  • داده‌ای که نباید بعد از ساختن تغییر کند.

class

  • چیزی که هویت دارد و در طول زمان تغییر می‌کند: سفارش، مشتری.
  • سرویس‌ها: CheckoutService و مانند آن.
  • مدل‌های Entity در EF Core. این مدل‌ها با شناسه شناخته می‌شوند، نه با مقایسه همه فیلدها.

اگر داده کوچک است و نمی‌خواهی روی Heap ساخته شود، از record struct استفاده کن. مثلاً یک مبلغ پول با دو فیلد.

Primary Constructor در class

از C# 12، class هم می‌تواند پارامترهای سازنده را کنار اسمش بگیرد. این روش برای سرویس‌ها و DI خیلی رایج است:

public sealed class CheckoutService(ShopDb db, TimeProvider clock)
{
    public async Task PlaceAsync(Order order, CancellationToken ct)
    {
        order.PlacedAt = clock.GetUtcNow();
        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);
    }
}
پارامتر، فیلد readonly نیست. پارامترهای primary constructor در class را می‌شود داخل کلاس دوباره مقداردهی کرد. کامپایلر جلوی آن را نمی‌گیرد. اگر می‌خواهی مطمئن باشی تغییر نمی‌کند، آن را در یک فیلد readonly بریز.

required و init

برای کلاسی که سازنده ندارد، کلمه required می‌گوید این خاصیت حتماً باید هنگام ساختن مقدار بگیرد. کلمه init می‌گوید فقط هنگام ساختن:

public sealed class ProductDto
{
    public required string Sku { get; init; }
    public required decimal Price { get; init; }
    public string? Description { get; init; }
}

var dto = new ProductDto { Sku = "P-907", Price = 129_500 }; // without Sku: compile error

قابلیت Nullable: پیدا کردن null قبل از اجرا

در پروژه‌های تازه .NET، گزینه Nullable روشن است. از این به بعد، هر نوع ارجاعی دو حالت دارد:

  1. نوع string یعنی «هرگز null نیست». اگر null در آن بریزی، کامپایلر هشدار می‌دهد.
  2. نوع string با علامت سؤال یعنی «شاید null باشد». اگر بدون چک از آن استفاده کنی، کامپایلر هشدار می‌دهد.

کامپایلر مسیر کد را دنبال می‌کند و می‌فهمد کجا مقدار چک شده است:

Customer? c =Find(id);c.Nameهشدار: شاید خالی باشدif (c is null)return NotFound();c.Nameبدون هشدارکامپایلر یاد می‌گیرد که بعد از این چک، مقدار خالی نیست
قبل از چک، کامپایلر هشدار می‌دهد. بعد از چک، می‌داند مقدار خالی نیست.
public Customer? FindCustomer(Guid id) => db.Customers.Find(id);

var customer = FindCustomer(id);
Console.WriteLine(customer.Name); // warning CS8602: possibly null

if (customer is null)
    return Results.NotFound();

Console.WriteLine(customer.Name); // OK: the compiler knows it is not null

برای اینکه این هشدارها نادیده گرفته نشوند، آن‌ها را در فایل پروژه به خطا تبدیل کن:

<PropertyGroup>
  <Nullable>enable</Nullable>
  <WarningsAsErrors>nullable</WarningsAsErrors>
</PropertyGroup>
علامت تعجب را پخش نکن. عملگر ! به کامپایلر می‌گوید «ساکت شو، من مطمئنم». هشدار حذف می‌شود، ولی مشکل نه. اگر اشتباه کنی، همان NullReferenceException را در Production می‌گیری.

یک نکته مهم: این چک فقط در زمان کامپایل است. در زمان اجرا، داده‌ای که از JSON یا دیتابیس می‌آید هنوز می‌تواند null باشد. پس ورودی‌های بیرونی را همچنان validate کن.

تصمیم‌های خوانا با Pattern Matching

قانون هزینه ارسال در فروشگاه ما:

  • سفارش ۲ میلیون تومان یا بیشتر: ارسال رایگان.
  • بار سنگین‌تر از ۳۰ کیلو: ۲۵۰ هزار تومان.
  • ارسال در تهران: ۵۰ هزار تومان.
  • بقیه شهرها: ۸۰ هزار تومان.

با if و else، این قانون در چند خط تودرتو پخش می‌شود. با عبارت switch، کد تقریباً شبیه همان جمله‌های کسب‌وکار است:

public static decimal ShippingCost(Order order) => order switch
{
    { Total: >= 2_000_000 }     => 0,
    { WeightKg: > 30 }          => 250_000,
    { Address.City: "Tehran" }  => 50_000,
    _                           => 80_000,
};
سفارشorder switch{ Total: >= 2_000_000 }{ WeightKg: > 30 }{ Address.City: "Tehran" }_ارسال رایگانبار سنگینهزینه شهر تهرانهزینه شهرهای دیگراز بالا به پایین. اولین الگویی که جور شود، برنده است
الگوها به ترتیب از بالا چک می‌شوند. پس ترتیب خط‌ها خودش بخشی از قانون است.

چند نوع الگو که در این کد دیدیم:

  1. الگوی خاصیت. با آکولاد، خاصیت‌های شیء را چک می‌کند. حتی خاصیت تودرتو، مثل شهرِ آدرس.
  2. الگوی مقایسه‌ای. با علامت‌هایی مثل بزرگ‌تر یا کوچک‌تر.
  3. الگوی دور ریختنی. خط زیر (underscore) یعنی «هر چیز دیگر».

می‌شود الگوها را با کلمه‌های and، or و not ترکیب کرد. الگوی لیست هم شکل یک آرایه را چک می‌کند:

public static string Label(string[] tags) => tags switch
{
    []                => "no tags",
    ["sale", ..]      => "on sale",
    [var single]      => single,
    _                 => $"{tags.Length} tags",
};

if (order.Status is not (OrderStatus.Cancelled or OrderStatus.Refunded))
    await SendInvoiceAsync(order, ct);
کمک کامپایلر: اگر روی یک enum کار می‌کنی و یک حالت را در switch جا بیندازی، کامپایلر هشدار می‌دهد که همه حالت‌ها پوشش داده نشده‌اند.

Collection Expressions

از C# 12، برای ساختن آرایه، List و بقیه collection ها یک شکل ساده داریم: براکت. علامت دو نقطه، یک collection را داخل دیگری پخش می‌کند:

int[] featured = [101, 233, 318];
List<int> homePage = [.. featured, 450, 512];
List<OrderLine> lines = []; // an empty list

تازه‌های C# 14

Extension Members

قبلاً فقط می‌شد متد extension نوشت. حالا با بلوک extension، می‌شود خاصیت extension هم نوشت:

public static class OrderExtensions
{
    extension(Order order)
    {
        public bool HasFreeShipping => order.Total >= 2_000_000;
    }
}

if (order.HasFreeShipping)
    banner.Show("Free shipping!");

کلمه field

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

public sealed class Product
{
    public required string Name
    {
        get;
        set => field = string.IsNullOrWhiteSpace(value)
            ? throw new ArgumentException("Name is required.", nameof(value))
            : value.Trim();
    }
}

انتساب شرطی با null

عملگر علامت سؤال و نقطه حالا سمت چپ انتساب هم کار می‌کند. اگر شیء null باشد، انتساب انجام نمی‌شود:

customer?.LastOrderAt = DateTimeOffset.UtcNow;

قانون‌های مهم

  1. برای داده بدون هویت، record. برای چیزی که هویت دارد و تغییر می‌کند، class.
  2. مقایسه record سطحی است. List داخل record را با دقت استفاده کن.
  3. گزینه Nullable را روشن نگه دار و هشدارها را جدی بگیر. بهتر است آن‌ها را خطا کنی.
  4. عملگر ! را فقط وقتی بزن که واقعاً مطمئنی و دلیلش را می‌دانی.
  5. در switch، ترتیب الگوها مهم است. الگوی کلی‌تر را پایین‌تر بگذار.
  6. امکان تازه را فقط وقتی استفاده کن که کد را خواناتر کند. هدف کد ساده‌تر است، نه کد کوتاه‌تر.

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

اشتباه نتیجه راه درست
record برای Entity در EF Core مقایسه با همه فیلدها، رفتار عجیب در change tracking. class با شناسه.
List قابل تغییر داخل record دو record یک List مشترک دارند و مقایسه غلط است. collection فقط‌خواندنی، یا class.
خاموش کردن Nullable یا نادیده گرفتن هشدارها NullReferenceException در Production. روشن نگه داشتن و خطا کردن هشدارها.
پخش کردن ! برای ساکت کردن کامپایلر هشدار حذف می‌شود، مشکل نه. چک واقعی null.
الگوی کلی بالای الگوی خاص در switch خط‌های پایین هیچ وقت اجرا نمی‌شوند. ترتیب از خاص به کلی.
فرض اینکه پارامتر primary constructor تغییر نمی‌کند کسی آن را وسط کلاس عوض می‌کند. فیلد readonly جدا.

خلاصه در شش خط

  1. نوع record برای داده‌ای است که با مقدارش شناخته می‌شود. Equals و with را خودش می‌سازد.
  2. مقایسه record سطحی است. collection داخل آن را با دقت استفاده کن.
  3. گزینه Nullable، خطای null را در زمان کامپایل نشان می‌دهد. عملگر ! آن را فقط پنهان می‌کند.
  4. عبارت switch با الگوها، قانون‌های کسب‌وکار را خوانا می‌کند. ترتیب الگوها مهم است.
  5. پارامترهای primary constructor در class فیلد readonly نیستند.
  6. نسخه C# 14 خاصیت extension، کلمه field و انتساب شرطی با null را آورده است.