Primary Constructors چیست؟
Primary Constructors، قابلیتی است که به C# 12 اضافه شدهاست تا توسط آن بتوان خواص را مستقیما توسط پارامترهای سازندهی یک کلاس تعریف و همچنین مقدار دهی کرد. هدف از آن، کاهش قابل ملاحظهی یکسری کدهای تکراری و مشخص است تا به کلاسهایی زیباتر، کمحجمتر و خواناتر برسیم. برای مثال کلاس متداول زیر را درنظر بگیرید:
public class Employee { public string FirstName { get; set; } public string LastName { get; set; } public DateTime HireDate { get; set; } public decimal Salary { get; set; } public Employee(string firstName, string lastName, DateTime hireDate, decimal salary) { FirstName = firstName; LastName = lastName; HireDate = hireDate; Salary = salary; } }
اکنون اگر بخواهیم همین کلاس را با استفاده از ویژگی Primary Constructor اضافه شده به C# 12.0 بازنویسی کنیم، به قطعه کد زیر میرسیم:
public class Employee(string firstName, string lastName, DateTime hireDate, decimal salary) { public string FirstName { get; set; } = firstName; public string LastName { get; set; } = lastName; public DateTime HireDate { get; set; } = hireDate; public decimal Salary { get; set; } = salary; }
var employee = new Employee("John", "Doe", new DateTime(2020, 1, 1), 50000);
یک نکته: اگر از Rider و یا ReSharper استفاده میکنید، یک چنین Refactoring توکاری جهت سهولت کار، به آنها اضافه شدهاست و به سرعت میتوان این تبدیلات را توسط آنها انجام داد.
توضیحات:
- متد سازنده در این حالت، به ظاهر حذف شده و به قسمت تعریف کلاس منتقل شدهاست.
- تمام مقدار دهیهای آغازین موجود در متد سازندهی پیشین نیز حذف شدهاند و مستقیما به قسمت تعریف خواص، منتقل شدهاند.
در نتیجه از یک کلاس 15 سطری، به کلاسی 7 سطری رسیدهایم که کاهش حجم قابل ملاحظهای را پیدا کردهاست.
نکته 1: هیچ ضرورتی وجود ندارد که به همراه یک primary constructor، خواصی هم مانند مثال فوق ارائه شوند؛ چون پارامترهای آن در تمام اعضای این کلاس، به همین شکل، قابل دسترسی هستند. در این مثال صرفا جهت بازسازی کد قبلی، این خواص اضافی را مشاهده میکنید. یعنی اگر تنها قرار بود، کار تزریق وابستگیها صورت گیرد که عموما به همراه تعریف فیلدهایی جهت انتساب پارامترهای متد سازنده به آنها است، استفاده از یک primary constructor، کدهای فوق را بیش از این هم فشردهتر میکرد و ... یک سطری میشد.
نکته 2: استفاده از پارامترهای سازندهی اولیه، صرفا جهت مقدار دهی خواص عمومی یک کلاس، یک code smell هستند! چون میتوان یک چنین کارهایی را به نحو شکیلتری توسط required properties معرفی شدهی در C# 11، پیاده سازی کرد.
بررسی تاریخچهی primary constructors
همانطور که در مقدمهی بحث نیز عنوان شد، primary constructors قابلیت جدیدی نیست و برای نمونه به همراه C# 9 و مفهوم جدید رکوردهای آن، ارائه شد:
public record class Book(string Title, string Publisher);
پس از آن در C# 10، این توسعه ادامه یافت و به امکان تعریف record structها، بسط یافت که در اینجا هم قابلیت تعریف primary constructors وجود دارد:
public record struct Color(int R, int G, int B);
پس از این مقدمات، اکنون در C# 12 نیز میتوان primary constructors را به تمام کلاسها و structهای معمولی هم اعمال کرد؛ با این تفاوت که در اینجا برخلاف رکوردها، کدهای خواصهای متناظر، به صورت خودکار تولید نمیشوند و اگر به آنها نیاز دارید، باید آنها را همانند مثال ابتدای بحث، خودتان به صورت دستی تعریف کنید.
primary constructors کلاسها و structهای معمولی، با primary constructors رکوردها یکی نیست
در C# 12 و به همراه معرفی primary constructors مخصوص کلاسها و structهای معمولی آن، از روش متفاوتی برای دسترسی به پارامترهای تعریف شده، استفاده میکند که به آن capturing semantics هم میگویند. در این حالت پارامترهای تعریف شدهی در یک primary constructor، توسط هر عضوی از آن کلاس قابل استفادهاست که یکی از کاربردهای آن، ساده کردن تعاریف تزریق وابستگیها است. در این حالت دیگر نیازی نیست تا ابتدا یک فیلد را برای انتساب به پارامتر تزریق شده تعریف کرد و سپس از آن فیلد، استفاده نمود؛ مستقیما میتوان با همان پارامتر تعریف شده، در متدها و اعضای کلاس، کار کرد.
برای مثال سرویس زیر را که از تزریق وابستگیها، در سازندهی خود استفاده میکند، درنظر بگیرید:
public class MyService { private readonly IDepedent _dependent; public MyService(IDependent dependent) { _dependent = dependent; } public void Do() { _dependent.DoWork(); } }
public class MyService(IDependent dependent) { public void Do() { dependent.DoWork(); } }
البته مفهوم Captures هم در زبان #C جدید نیست و در ابتدا به همراه anonymous methods و بعدها به همراه lambda expressions، معرفی و بکار گرفته شد. برای مثال درون یک lambda expression، اگر از متغیری خارج از آن lambda expressions استفاده شود، کامپایلر یک capture از آن متغیر را تهیه کرده و استفاده میکند.
بنابراین به صورت خلاصه primary constructors در رکوردها، با هدف تعریف خواص عمومی فقط خواندنی، ارائه شدند؛ اما primary constructors ارائه شدهی در C# 12 که اینبار قابل اعمال به کلاسها و structs معمولی است، بیشتر هدف ساده سازی تعریف کدهای تکراری private fields را دنبال میکند. برای نمونه این کدی است که کامپایلر برای primary constructor مثال ابتدای بحث تولید میکند و در اینجا نحوهی تولید خودکار این فیلدهای خصوصی را مشاهده میکنید:
using System; using System.Diagnostics; using System.Runtime.CompilerServices; namespace CS8Tests { [NullableContext(1)] [Nullable(0)] public class Employee { [CompilerGenerated] [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string <FirstName>k__BackingField; [CompilerGenerated] [DebuggerBrowsable(DebuggerBrowsableState.Never)] private string <LastName>k__BackingField; [CompilerGenerated] [DebuggerBrowsable(DebuggerBrowsableState.Never)] private DateTime <HireDate>k__BackingField; [CompilerGenerated] [DebuggerBrowsable(DebuggerBrowsableState.Never)] private Decimal <Salary>k__BackingField; public Employee(string firstName, string lastName, DateTime hireDate, Decimal salary) { this.<FirstName>k__BackingField = firstName; this.<LastName>k__BackingField = lastName; this.<HireDate>k__BackingField = hireDate; this.<Salary>k__BackingField = salary; base..ctor(); } public string FirstName { [CompilerGenerated] get { return this.<FirstName>k__BackingField; } [CompilerGenerated] set { this.<FirstName>k__BackingField = value; } } public string LastName { [CompilerGenerated] get { return this.<LastName>k__BackingField; } [CompilerGenerated] set { this.<LastName>k__BackingField = value; } } public DateTime HireDate { [CompilerGenerated] get { return this.<HireDate>k__BackingField; } [CompilerGenerated] set { this.<HireDate>k__BackingField = value; } } public Decimal Salary { [CompilerGenerated] get { return this.<Salary>k__BackingField; } [CompilerGenerated] set { this.<Salary>k__BackingField = value; } } } }
یک نکته: برای مشاهدهی یک چنین کدهایی میتوانید از منوی Tools->IL Viewer برنامهی Rider استفاده کرده و در برگهی ظاهر شده، گزینهی #Low-Level C آنرا انتخاب نمائید.
امکان تعریف سازندههای دیگر، به همراه سازندهی اولیه
اگر به کدهای #Low-Level C تولیدی فوق دقت کنید، این کلاس، به همراه یک سازندهی خالی بدون پارامتر (parameter less constructor) نیست و سازندهی پیشفرضی (default constructor) برای آن درنظر گرفته نشدهاست ... اما اگر کلاسی به همراه یک primary constructor تعریف شد، میتوان با استفاده از واژهی کلیدی this، سازندهی ثانویهای را هم برای آن تعریف کرد:
public class Person(string firstName, string lastName) { public Person() : this("John", "Smith") { } public Person(string firstName) : this(firstName, "Smith") { } public string FullName => $"{firstName} {lastName}"; }
امکان ارثبری و تعریف سازندهی اولیه
مثال زیر را درنظر بگیرید که در آن کلاس مشتق شدهی از کلاس User، یک سازندهی اولیه را تعریف کرده:
public class User { public User(string firstName, string lastName) { } } public class Editor(string firstName, string lastName) : User { }
البته این محدودیت با structها وجود ندارد؛ چون structها، value type هستند و همواره به صورت پیشفرض، به همراه یک سازندهی پیش فرض بدون پارامتر، تولید میشوند.
یک مثال: قطعه کد متداول ارثبری زیر را درنظر بگیرید که در آن، کلاس مشتق شده به کمک واژهی کلید base، امکان تعریف سازندهی جدیدی را یافته و یکی از پارامترهای سازندهی کلاس پایه را مقدار دهی میکند:
public class Automobile { public Automobile(int wheels, int seats) { Wheels = wheels; Seats = seats; } public int Wheels { get; } public int Seats { get; } } public class Car : Automobile { public Car(int seats) : base(4, seats) { } }
public class Automobile(int wheels, int seats) { public int Wheels { get; } = wheels; public int Seats { get; } = seats; } public class Car(int seats) : Automobile(4, seats);
و یا یک نمونه مثال دیگر آن به صورت زیر است که در آن، ذکر بدنهی کلاس در C# 12، الزامی ندارد:
public class MyBaseClass(string s); // no body required public class Derived(int i, string s, bool b) : MyBaseClass(s) { public int I { get; set; } = i; public string B => b.ToString(); }
توصیه به پرهیز از double capturing
با مفهوم capture در این مطلب آشنا شدیم. در مثال زیر دوبار از پارامتر سازندهی age، در دو قسمت عمومی شده، استفاده شدهاست:
public class Human(int age) { // initialization public int Age { get; set; } = age; // capture public string Bio => $"My age is {age}!"; }
var p = new Human(42); Console.WriteLine(p.Age); // Output: 42 Console.WriteLine(p.Bio); // Output: My age is 42! p.Age++; Console.WriteLine(p.Age); // Output: 43 Console.WriteLine(p.Bio); // Output: My age is 42! // !
درک بهتر آن، نیاز به #Low-Level C کلاس Human را دارد:
using System.Diagnostics; using System.Runtime.CompilerServices; namespace CS8Tests { [NullableContext(1)] [Nullable(0)] public class Human { [CompilerGenerated] [DebuggerBrowsable(DebuggerBrowsableState.Never)] private int <age>P; [CompilerGenerated] [DebuggerBrowsable(DebuggerBrowsableState.Never)] private int <Age>k__BackingField; public Human(int age) { this.<age>P = age; this.<Age>k__BackingField = this.<age>P; base..ctor(); } public int Age { [CompilerGenerated] get { return this.<Age>k__BackingField; } [CompilerGenerated] set { this.<Age>k__BackingField = value; } } public string Bio { get { DefaultInterpolatedStringHandler interpolatedStringHandler = new DefaultInterpolatedStringHandler(11, 1); interpolatedStringHandler.AppendLiteral("My age is "); interpolatedStringHandler.AppendFormatted<int>(this.<age>P); interpolatedStringHandler.AppendLiteral("!"); return interpolatedStringHandler.ToStringAndClear(); } } } }
public Human(int age) { this.<age>P = age; this.<Age>k__BackingField = this.<age>P; base..ctor(); }