C# مدرن
نسخههای تازه C# کد تکراری را کم میکنند و خطاها را زودتر نشان میدهند. record برای دادهای که با مقدارش شناخته میشود، pattern matching برای تصمیمهای خوانا، و nullable برای پیدا کردن null قبل از اجرا. بدان هر ابزار کجا مناسب است و کجا دام دارد.
نویسنده: 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);
}
این کد چند مشکل دارد:
- کد تکراری زیاد. برای یک داده ساده، باید متد Equals و GetHashCode را با دست بنویسیم. اگر فیلدی اضافه شود و یادمان برود آن را به Equals اضافه کنیم، باگ داریم.
- هر کسی میتواند آن را تغییر دهد. آدرس یک سفارش ثبتشده نباید بیصدا عوض شود.
- مقدار null پنهان است. هیچ چیز نمیگوید City ممکن است خالی باشد. خطای NullReferenceException فقط در Production پیدا میشود.
نسخههای تازه C# برای همین مشکلها ابزار دارند. در این درس مهمترینها را میبینیم. آخرین نسخه پایدار C# 14 است که همراه .NET 10 آمده است.
نوع record: دادهای که با مقدارش شناخته میشود
همان آدرس، با record:
public sealed record Address(string City, string PostalCode);
کامپایلر این کارها را برای ما انجام میدهد:
- خاصیتها را میسازد. هر دو فقط هنگام ساختن مقدار میگیرند (init). بعد از آن تغییر نمیکنند.
- متدهای Equals و GetHashCode را میسازد. دو record وقتی برابرند که همه فیلدهایشان برابر باشد.
- متد ToString خوانا میسازد. برای لاگ مفید است.
- امکان with را میدهد. یک کپی با چند تغییر.
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 و کی 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);
}
}
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 روشن است. از این به بعد، هر نوع ارجاعی دو حالت دارد:
- نوع string یعنی «هرگز null نیست». اگر null در آن بریزی، کامپایلر هشدار میدهد.
- نوع string با علامت سؤال یعنی «شاید null باشد». اگر بدون چک از آن استفاده کنی، کامپایلر هشدار میدهد.
کامپایلر مسیر کد را دنبال میکند و میفهمد کجا مقدار چک شده است:
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>
یک نکته مهم: این چک فقط در زمان کامپایل است. در زمان اجرا، دادهای که از 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,
};
چند نوع الگو که در این کد دیدیم:
- الگوی خاصیت. با آکولاد، خاصیتهای شیء را چک میکند. حتی خاصیت تودرتو، مثل شهرِ آدرس.
- الگوی مقایسهای. با علامتهایی مثل بزرگتر یا کوچکتر.
- الگوی دور ریختنی. خط زیر (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);
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;
قانونهای مهم
- برای داده بدون هویت، record. برای چیزی که هویت دارد و تغییر میکند، class.
- مقایسه record سطحی است. List داخل record را با دقت استفاده کن.
- گزینه Nullable را روشن نگه دار و هشدارها را جدی بگیر. بهتر است آنها را خطا کنی.
- عملگر ! را فقط وقتی بزن که واقعاً مطمئنی و دلیلش را میدانی.
- در switch، ترتیب الگوها مهم است. الگوی کلیتر را پایینتر بگذار.
- امکان تازه را فقط وقتی استفاده کن که کد را خواناتر کند. هدف کد سادهتر است، نه کد کوتاهتر.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| record برای Entity در EF Core | مقایسه با همه فیلدها، رفتار عجیب در change tracking. | class با شناسه. |
| List قابل تغییر داخل record | دو record یک List مشترک دارند و مقایسه غلط است. | collection فقطخواندنی، یا class. |
| خاموش کردن Nullable یا نادیده گرفتن هشدارها | NullReferenceException در Production. | روشن نگه داشتن و خطا کردن هشدارها. |
| پخش کردن ! برای ساکت کردن کامپایلر | هشدار حذف میشود، مشکل نه. | چک واقعی null. |
| الگوی کلی بالای الگوی خاص در switch | خطهای پایین هیچ وقت اجرا نمیشوند. | ترتیب از خاص به کلی. |
| فرض اینکه پارامتر primary constructor تغییر نمیکند | کسی آن را وسط کلاس عوض میکند. | فیلد readonly جدا. |
خلاصه در شش خط
- نوع record برای دادهای است که با مقدارش شناخته میشود. Equals و with را خودش میسازد.
- مقایسه record سطحی است. collection داخل آن را با دقت استفاده کن.
- گزینه Nullable، خطای null را در زمان کامپایل نشان میدهد. عملگر ! آن را فقط پنهان میکند.
- عبارت switch با الگوها، قانونهای کسبوکار را خوانا میکند. ترتیب الگوها مهم است.
- پارامترهای primary constructor در class فیلد readonly نیستند.
- نسخه C# 14 خاصیت extension، کلمه field و انتساب شرطی با null را آورده است.