اندازهی قلم متن
تخمین مدت زمان مطالعهی مطلب:
سیزده دقیقه
در مطلب معرفی خواص init-only، با روش معرفی خواص immutable آشنا شدیم. نوع جدیدی که به C# 9.0 به نام record اضافه شدهاست، قسمتی از آن بر اساس همان خواص init-only کار میکند. به همین جهت مطالعهی آن مطلب، پیش از ادامهی بحث جاری، ضروری است.
چرا در C# 9.0 تا این اندازه بر روی سادگی ایجاد اشیاء Immutable تمرکز شدهاست؟
به شیءای Immutable گفته میشود که پس از وهله سازی ابتدایی آن، وضعیت آن دیگر قابل تغییر نباشد. همچنین به کلاسی Immutable گفته میشود که تمام وهلههای ساخته شدهی از آن نیز Immutable باشند. نمونهی یک چنین شیءای را از نگارش 1 دات نت در حال استفاده هستیم: رشتهها. رشتهها در دات نت غیرقابل تغییر هستند و هرگونه تغییری بر روی آنها، سبب ایجاد یک رشتهی جدید (یک شیء جدید) میشود. نوع جدید record نیز به همین صورت عمل میکند.
مزایای وجود Immutability:
- اشیاء Immutable یا غیرقابل تغییر، thread-safe هستند که در نتیجه، برنامه نویسی همزمان و موازی را بسیار ساده میکنند؛ چون چندین thread میتوانند با شیءای کار کنند که دسترسی به آن، تنها read-only است.
- اشیاء Immutable از اثرات جانبی، مانند تغییرات آنها در متدهای مختلف در امان هستند. میتوانید آنها را به هر متدی ارسال کنید و مطمئن باشید که پس از پایان کار، این شیء تغییری نکردهاست.
- کار با اشیاء Immutable، امکان بهینه سازی حافظه را میسر میکنند. برای مثال NET runtime.، هش رشتههای تعریف شدهی در برنامه را در پشت صحنه نگهداری میکند تا مطمئن شود که تخصیص حافظهی اضافی، برای رشتههای تکراری صورت نمیگیرد. نمونهی دیگر آن نمایش حرف "a" در یک ادیتور یا نمایشگر است. زمانیکه یک شیء Immutable حاوی اطلاعات حرف "a"، ایجاد شود، به سادگی میتوان این تک وهله را جهت نمایش هزاران حرف "a" مورد استفادهی مجدد قرار داد، بدون اینکه نگران مصرف حافظهی بالای برنامه باشیم.
- کار با اشیاء Immutable به باگهای کمتری ختم میشود؛ چون همواره امکان تغییر حالت درونی یک شیء، توسط قسمتهای مختلف برنامه، میتواند به باگهای ناخواستهای منتهی شوند.
- Hash listها که در جهت بهبود کارآیی برنامهها بسیار مورد استفاده قرار میگیرند، بر اساس کلیدهایی Immutable قابل تشکیل هستند.
روش تعریف نوعهای جدید record
کلاس سادهی زیر را در نظر بگیرید:
برای تبدیل آن به یک نوع جدید record فقط کافی است واژهی کلیدی class آنرا با record جایگزین کنیم (به آن nominal record هم میگویند):
نحوهی کار با آن و وهله سازی آن نیز دقیقا مانند کلاسها است:
و ... در اینجا امکان انتساب مقداری به خاصیت Name وجود دارد؛ یعنی این خاصیت به صورت پیشفرض Immutable نیست.
روش تعریف دومی نیز در اینجا میسر است (به آن positional record هم میگویند):
با اینکار، به صورت خودکار یک record جدید تشکیل میشود که به همراه خاصیت Name است؛ چیزی شبیه به record قبلی که تعریف کردیم (به همین جهت نیاز است نام آنرا شروع شدهی با حروف بزرگ درنظر بگیریم). با این تفاوت که این record، اینبار دارای سازنده است و همچنین خاصیت Name آن از نوع init-only است. در این حالت است که کل record به صورت immutable معرفی میشود؛ وگرنه روش تعریف یک خاصیت معمولی که از نوع init-only نیست (مانند مثال اول)، سبب بروز Immutability نخواهد شد.
برای کار با رکورد دومی که تعریف کردیم باید سازندهی این record را مقدار دهی کرد:
و همانطور که ملاحظه میکنید، چون خاصیت Name از نوع init-only است و در سازندهی record تعریف شده مقدار دهی شدهاست، دیگر نمیتوان آنرا مقدار دهی مجدد کرد. همچنین در اینجا امکان استفادهی از object initializers مانند new User { Name = "User 1" } نیز وجود ندارد؛ چون به همراه یک سازندهی به صورت خودکار تولید شدهاست که خاصیتی init-only را مقدار دهی کردهاست.
نوع جدید record چه اطلاعاتی را به صورت خودکار تولید میکند؟
روش دوم تعریف recordها اگر در نظر بگیریم:
و در این حالت برنامه را کامپایل کنیم، به کدهای زیر که حاصل از دیکامپایل است، میرسیم:
این خروجی به صورت خودکار تولید شدهی توسط کامپایلر، چنین نکاتی را به همراه دارد:
- recordها هنوز هم در اصل همان classهای استاندارد #C هستند (یعنی در اصل reference type هستند).
- این کلاس به همراه یک سازنده و یک خاصیت init-only است (بر اساس تعاریف ما).
- متد ToString آن بازنویسی شدهاست تا اگر آنرا بر روی شیء حاصل، فراخوانی کردیم، به صورت خودکار نمایش زیبایی را از محتوای آن ارائه دهد.
- این کلاس از نوع <IEquatable<User است که امکان مقایسهی اشیاء record را به سادگی میسر میکند. برای این منظور متدهای GetHashCode و Equals آن به صورت خودکار بازنویسی و تکمیل شدهاند (یعنی مقایسهی آن شبیه به value-type است).
- این کلاس امکان clone کردن اطلاعات جاری را مهیا میکند.
- همچنین به همراه یک متد Deconstruct هم هست که جهت انتساب خواص تعریف شدهی در آن، به یک tuple مفید است.
بنابراین یک رکورد به همراه قابلیتهایی است که سالها در زبان #C وجود داشتهاند و شاید ما به سادگی حاضر به تشکیل و تکمیل آنها نمیشدیم؛ اما اکنون کامپایلر زحمت کدنویسی خودکار آنها را متقبل میشود!
ساخت یک وهلهی جدید از یک record با clone کردن آن
اگر به کدهای حاصل از دیکامپایل فوق دقت کنید، یک قسمت جدید clone هم با syntax خاصی در آن ظاهر شدهاست:
زمانیکه یک شیء Immutable است، دیگر نمیتوان مقادیر خواص آنرا در ادامه تغییر داد. اما اگر نیاز به اینکار وجود داشت، باید چکار کنیم؟ در C# 9.0 برای ایجاد وهلهی جدید معادلی از یک record، واژهی کلیدی جدیدی را به نام with، اضافه کردهاند. برای نمونه اگر record زیر را در نظر بگیریم که دارای دو خاصیت نام و سن است:
وهله سازی متداول آن به صورت زیر خواهد بود:
اما اگر خواستیم خاصیت سن آنرا تغییر دهیم، میتوان با استفاده از واژهی کلیدی with، به صورت زیر عمل کرد:
کاری که در اصل در اینجا انجام میشود، ابتدا clone کردن شیء user1 است (یعنی دقیقا یک وهلهی جدید از user1 را با تمام اطلاعات قبلی آن در اختیار ما قرار میدهد که این وهله، ارجاعی را به شیء قبلی ندارد و از آن منقطع است). بنابراین نام user2، دقیقا همان "User 1" است که پیشتر تنظیم کردیم؛ با این تفاوت که اینبار مقدار سن آن متفاوت است. با استفاده از cloning، هنوز شیء user1 که immutable است، دست نخورده باقی ماندهاست و توسط with میتوان خواص آنرا تغییر داد و حاصل کار، یک شیء کاملا جدید است که مکان آن در حافظه، با مکان شیء user1 در حافظه، یکی نیست.
مقایسهی نوعهای record
در کدهای حاصل از دیکامپایل فوق، قسمت عمدهای از آن به تکمیل اینترفیس <IEquatable<User پرداخته شده بود. به همین جهت اکنون دو رکورد با مقادیر خواص یکسانی را ایجاد میکنیم:
سپس یکبار آنها را از طریق عملگر == و بار دیگر به کمک متد Equals، مقایسه میکنیم:
خروجی هر دو حالت، True است:
این مورد، یکی از مهمترین تفاوتهای recordها با classها هستند.
- زمانیکه عملگر == را بر روی شیء user1 و user2 اعمال میکنیم، اگر User، از نوع کلاس معمولی باشد، حاصل آن false خواهد بود؛ چون این دو، به یک مکان از حافظه اشاره نمیکنند، حتی با اینکه مقادیر خواص هر دو شیء یکی است.
- اما اگر به قطعه کد دیکامپایل شده دقت کنید، در یک رکورد که هر چند در اصل یک کلاس است، حتی عملگر == نیز بازنویسی شدهاست تا در پشت صحنه همان متد Equals را فراخوانی کند و این متد با توجه به پیاده سازی اینترفیس <IEquatable<User، اینبار دقیقا مقادیر خواص رکورد را یک به یک مقایسه کرده و نتیجهی حاصل را باز میگرداند:
این متدی است که به صورت خودکار توسط کامپایلر جهت مقایسهی مقادیر خواص رکورد جدید تعریف شده، تشکیل شدهاست. به عبارتی recordها از لحاظ مقایسه، شبیه به value objects عمل میکنند؛ هرچند در اصل یک کلاس هستند.
یک نکته: بازنویسی عملگر == در SDK نگارش rc2 فعلی رخدادهاست و در نگارشهای قبلی preview، اینگونه نبود.
امکان ارثبری در recordها
دو رکورد زیر را در نظر بگیرید که اولی به همراه Name است و نمونهی مشتق شدهی از آن، خاصیت init-only سن را نیز به همراه دارد:
در اینجا روش دیگر تعریف recordها را ملاحظه میکنید که شبیه به کلاسها است و خواص آن init-only هستند. در این حالت اگر مقایسهی زیر را انجام دهیم:
به خروجی زیر خواهیم رسید:
علت آن را هم پیشتر بررسی کردیم. تساوی رکوردها بر اساس مقایسهی مقدار تک تک خواص آنها صورت میگیرد و چون user1 به همراه سن نیست، مقایسهی این دو، false را بر میگرداند.
امکان تعریف ارثبری رکوردها به صورت زیر نیز وجود دارد و الزاما نیازی به روش تعریف کلاس مانند آنها، مانند مثال فوق نیست:
رکوردها متد ToString را بازنویسی میکنند
در مثال قبلی اگر یک ToString را بر روی اشیاء تشکیل شده فراخوانی کنیم:
به این خروجیها میرسیم:
که حاصل بازنویسی خودکار متد ToString در پشت صحنه است.
امکان استفادهی از Deconstruct در رکوردها
دو روش برای تعریف رکوردها وجود دارند؛ یکی شبیه به تعریف کلاسها است و دیگری تعریف یک سطری، که positional record نیز نامیده میشود:
فقط در حالت تعریف یک سطری positional record فوق است که خروجی خودکار نهایی تولیدی، به همراه public void Deconstruct نیز خواهد بود:
در این حالت میتوان از tuples نیز برای کار با آن استفاده کرد:
واژهی «positional» نیز دقیقا به همین قابلیت اشاره میکند که بر اساس موقعیت خواص تعریف شدهی در رکورد، امکان Deconstruct آنها به متغیرهای یک tuple وجود دارد. حالت تعریف کلاس مانند رکوردها، nominal نام دارد.
امکان استفادهی از نوعهای record در ASP.NET Core 5x
سیستم model binding در ASP.NET Core 5x، از نوعهای record نیز پشتیبانی میکند؛ یک مثال:
پرسش و پاسخ
آیا نوعهای record به صورت value type معرفی میشوند؟
پاسخ: خیر. رکوردها در اصل reference type هستند؛ اما از لحاظ مقایسه، شبیه به value types عمل میکنند.
آیا میتوان در یک کلاس، خاصیتی از نوع رکورد را تعریف کرد؟
پاسخ: بله. از این لحاظ محدودیتی وجود ندارد.
آیا میتوان در رکوردها، از struct و یا کلاسها جهت تعریف خواص استفاده کرد؟
پاسخ: بله. از این لحاظ محدودیتی وجود ندارد.
آیا میتوان از واژهی کلیدی with با کلاسها و یا structها استفاده کرد؟
پاسخ: خیر. این واژهی کلیدی در C# 9.0 مختص به رکوردها است.
آیا رکوردها به صورت پیشفرض Immutable هستند؟
پاسخ: اگر آنها را به صورت positional records تعریف کنید، بله. چون در این حالت خواص تشکیل شدهی توسط آنها از نوع init-only هستند. در غیراینصورت، میتوان خواص غیر init-only را نیز به تعریف رکوردها اضافه کرد.
چرا در C# 9.0 تا این اندازه بر روی سادگی ایجاد اشیاء Immutable تمرکز شدهاست؟
به شیءای Immutable گفته میشود که پس از وهله سازی ابتدایی آن، وضعیت آن دیگر قابل تغییر نباشد. همچنین به کلاسی Immutable گفته میشود که تمام وهلههای ساخته شدهی از آن نیز Immutable باشند. نمونهی یک چنین شیءای را از نگارش 1 دات نت در حال استفاده هستیم: رشتهها. رشتهها در دات نت غیرقابل تغییر هستند و هرگونه تغییری بر روی آنها، سبب ایجاد یک رشتهی جدید (یک شیء جدید) میشود. نوع جدید record نیز به همین صورت عمل میکند.
مزایای وجود Immutability:
- اشیاء Immutable یا غیرقابل تغییر، thread-safe هستند که در نتیجه، برنامه نویسی همزمان و موازی را بسیار ساده میکنند؛ چون چندین thread میتوانند با شیءای کار کنند که دسترسی به آن، تنها read-only است.
- اشیاء Immutable از اثرات جانبی، مانند تغییرات آنها در متدهای مختلف در امان هستند. میتوانید آنها را به هر متدی ارسال کنید و مطمئن باشید که پس از پایان کار، این شیء تغییری نکردهاست.
- کار با اشیاء Immutable، امکان بهینه سازی حافظه را میسر میکنند. برای مثال NET runtime.، هش رشتههای تعریف شدهی در برنامه را در پشت صحنه نگهداری میکند تا مطمئن شود که تخصیص حافظهی اضافی، برای رشتههای تکراری صورت نمیگیرد. نمونهی دیگر آن نمایش حرف "a" در یک ادیتور یا نمایشگر است. زمانیکه یک شیء Immutable حاوی اطلاعات حرف "a"، ایجاد شود، به سادگی میتوان این تک وهله را جهت نمایش هزاران حرف "a" مورد استفادهی مجدد قرار داد، بدون اینکه نگران مصرف حافظهی بالای برنامه باشیم.
- کار با اشیاء Immutable به باگهای کمتری ختم میشود؛ چون همواره امکان تغییر حالت درونی یک شیء، توسط قسمتهای مختلف برنامه، میتواند به باگهای ناخواستهای منتهی شوند.
- Hash listها که در جهت بهبود کارآیی برنامهها بسیار مورد استفاده قرار میگیرند، بر اساس کلیدهایی Immutable قابل تشکیل هستند.
روش تعریف نوعهای جدید record
کلاس سادهی زیر را در نظر بگیرید:
public class User { public string Name { set; get; } }
public record User { public string Name { set; get; } }
var user = new User(); user.Name = "User 1";
روش تعریف دومی نیز در اینجا میسر است (به آن positional record هم میگویند):
public record User(string Name);
برای کار با رکورد دومی که تعریف کردیم باید سازندهی این record را مقدار دهی کرد:
var user = new User("User 1"); // Error: Init-only property or indexer 'User.Name' can only be assigned // in an object initializer, or on 'this' or 'base' in an instance constructor // or an 'init' accessor. [CS9Features]csharp(CS8852) user.Name = "User 1";
نوع جدید record چه اطلاعاتی را به صورت خودکار تولید میکند؟
روش دوم تعریف recordها اگر در نظر بگیریم:
public record User(string Name);
using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Text; using CS9Features; public class User : IEquatable<User> { protected virtual Type EqualityContract { [System.Runtime.CompilerServices.NullableContext(1)] [CompilerGenerated] get { return typeof(User); } } public string Name { get; set/*init*/; } public User(string Name) { this.Name = Name; base..ctor(); } public override string ToString() { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("User"); stringBuilder.Append(" { "); if (PrintMembers(stringBuilder)) { stringBuilder.Append(" "); } stringBuilder.Append("}"); return stringBuilder.ToString(); } protected virtual bool PrintMembers(StringBuilder builder) { builder.Append("Name"); builder.Append(" = "); builder.Append((object?)Name); return true; } [System.Runtime.CompilerServices.NullableContext(2)] public static bool operator !=(User? r1, User? r2) { return !(r1 == r2); } [System.Runtime.CompilerServices.NullableContext(2)] public static bool operator ==(User? r1, User? r2) { return (object)r1 == r2 || (r1?.Equals(r2) ?? false); } public override int GetHashCode() { return EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(Name); } public override bool Equals(object? obj) { return Equals(obj as User); } public virtual bool Equals(User? other) { return (object)other != null && EqualityContract == other!.EqualityContract && EqualityComparer<string>.Default.Equals(Name, other!.Name); } public virtual User <Clone>$() { return new User(this); } protected User(User original) { Name = original.Name; } public void Deconstruct(out string Name) { Name = this.Name; } }
- recordها هنوز هم در اصل همان classهای استاندارد #C هستند (یعنی در اصل reference type هستند).
- این کلاس به همراه یک سازنده و یک خاصیت init-only است (بر اساس تعاریف ما).
- متد ToString آن بازنویسی شدهاست تا اگر آنرا بر روی شیء حاصل، فراخوانی کردیم، به صورت خودکار نمایش زیبایی را از محتوای آن ارائه دهد.
- این کلاس از نوع <IEquatable<User است که امکان مقایسهی اشیاء record را به سادگی میسر میکند. برای این منظور متدهای GetHashCode و Equals آن به صورت خودکار بازنویسی و تکمیل شدهاند (یعنی مقایسهی آن شبیه به value-type است).
- این کلاس امکان clone کردن اطلاعات جاری را مهیا میکند.
- همچنین به همراه یک متد Deconstruct هم هست که جهت انتساب خواص تعریف شدهی در آن، به یک tuple مفید است.
بنابراین یک رکورد به همراه قابلیتهایی است که سالها در زبان #C وجود داشتهاند و شاید ما به سادگی حاضر به تشکیل و تکمیل آنها نمیشدیم؛ اما اکنون کامپایلر زحمت کدنویسی خودکار آنها را متقبل میشود!
ساخت یک وهلهی جدید از یک record با clone کردن آن
اگر به کدهای حاصل از دیکامپایل فوق دقت کنید، یک قسمت جدید clone هم با syntax خاصی در آن ظاهر شدهاست:
public virtual User <Clone>$() { return new User(this); }
public record User(string Name, int Age);
var user1 = new User("User 1", 21);
var user2 = user1 with { Age = 31 };
مقایسهی نوعهای record
در کدهای حاصل از دیکامپایل فوق، قسمت عمدهای از آن به تکمیل اینترفیس <IEquatable<User پرداخته شده بود. به همین جهت اکنون دو رکورد با مقادیر خواص یکسانی را ایجاد میکنیم:
var user1 = new User("User 1", 21); var user2 = new User("User 1", 21);
Console.WriteLine("user1.Equals(user2) -> {0}", user1.Equals(user2)); Console.WriteLine("user1 == user2 -> {0}", user1 == user2);
user1.Equals(user2) -> True user1 == user2 -> True
- زمانیکه عملگر == را بر روی شیء user1 و user2 اعمال میکنیم، اگر User، از نوع کلاس معمولی باشد، حاصل آن false خواهد بود؛ چون این دو، به یک مکان از حافظه اشاره نمیکنند، حتی با اینکه مقادیر خواص هر دو شیء یکی است.
- اما اگر به قطعه کد دیکامپایل شده دقت کنید، در یک رکورد که هر چند در اصل یک کلاس است، حتی عملگر == نیز بازنویسی شدهاست تا در پشت صحنه همان متد Equals را فراخوانی کند و این متد با توجه به پیاده سازی اینترفیس <IEquatable<User، اینبار دقیقا مقادیر خواص رکورد را یک به یک مقایسه کرده و نتیجهی حاصل را باز میگرداند:
public virtual bool Equals(User? other) { return (object)other != null && EqualityContract == other!.EqualityContract && EqualityComparer<string>.Default.Equals(Name, other!.Name) && EqualityComparer<int>.Default.Equals(Age, other!.Age); }
یک نکته: بازنویسی عملگر == در SDK نگارش rc2 فعلی رخدادهاست و در نگارشهای قبلی preview، اینگونه نبود.
امکان ارثبری در recordها
دو رکورد زیر را در نظر بگیرید که اولی به همراه Name است و نمونهی مشتق شدهی از آن، خاصیت init-only سن را نیز به همراه دارد:
public record User { public string Name { get; init; } public User(string name) { Name = name; } } public record UserWithAge : User { public int Age { get; init; } public UserWithAge(string name, int age) : base(name) { Age = age; } }
var user1 = new User("User 1"); var user2 = new UserWithAge("User 1", 21); Console.WriteLine("user1.Equals(user2) -> {0}", user1.Equals(user2)); Console.WriteLine("user1 == user2 -> {0}", user1 == user2);
user1.Equals(user2) -> False user1 == user2 -> False
امکان تعریف ارثبری رکوردها به صورت زیر نیز وجود دارد و الزاما نیازی به روش تعریف کلاس مانند آنها، مانند مثال فوق نیست:
public abstract record Food(int Calories); public record Milk(int C, double FatPercentage) : Food(C);
رکوردها متد ToString را بازنویسی میکنند
در مثال قبلی اگر یک ToString را بر روی اشیاء تشکیل شده فراخوانی کنیم:
Console.WriteLine(user1.ToString()); Console.WriteLine(user2.ToString());
User { Name = User 1 } UserWithAge { Name = User 1, Age = 21 }
امکان استفادهی از Deconstruct در رکوردها
دو روش برای تعریف رکوردها وجود دارند؛ یکی شبیه به تعریف کلاسها است و دیگری تعریف یک سطری، که positional record نیز نامیده میشود:
public record Person(string Name, int Age);
public void Deconstruct(out string Name, out int Age) { Name = this.Name; Age = this.Age; }
var (name, age) = new Person("User 1", 21);
امکان استفادهی از نوعهای record در ASP.NET Core 5x
سیستم model binding در ASP.NET Core 5x، از نوعهای record نیز پشتیبانی میکند؛ یک مثال:
public record Person([Required] string Name, [Range(0, 150)] int Age); public class PersonController { public IActionResult Index() => View(); [HttpPost] public IActionResult Index(Person person) { // ... } }
پرسش و پاسخ
آیا نوعهای record به صورت value type معرفی میشوند؟
پاسخ: خیر. رکوردها در اصل reference type هستند؛ اما از لحاظ مقایسه، شبیه به value types عمل میکنند.
آیا میتوان در یک کلاس، خاصیتی از نوع رکورد را تعریف کرد؟
پاسخ: بله. از این لحاظ محدودیتی وجود ندارد.
آیا میتوان در رکوردها، از struct و یا کلاسها جهت تعریف خواص استفاده کرد؟
پاسخ: بله. از این لحاظ محدودیتی وجود ندارد.
آیا میتوان از واژهی کلیدی with با کلاسها و یا structها استفاده کرد؟
پاسخ: خیر. این واژهی کلیدی در C# 9.0 مختص به رکوردها است.
آیا رکوردها به صورت پیشفرض Immutable هستند؟
پاسخ: اگر آنها را به صورت positional records تعریف کنید، بله. چون در این حالت خواص تشکیل شدهی توسط آنها از نوع init-only هستند. در غیراینصورت، میتوان خواص غیر init-only را نیز به تعریف رکوردها اضافه کرد.