فصل ۴: Anonymous Typeها، Tupleها و Recordها
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
Extension Methodها در برابر Extension Methodها
اگر دو Extension Method دارای Signature یکسان باشند، برای رفع ابهام دربارهٔ Methodی که باید فراخوانی شود، Extension Method باید مانند یک Static Method معمولی فراخوانی شود. با این حال، اگر یکی از Extension Methodها Argumentهای خاصتری داشته باشد، Method خاصتر اولویت پیدا میکند.
برای روشنشدن موضوع، دو Class زیر را در نظر بگیرید:
static class StringHelper
{
public static bool IsCapitalized (this string s) {...}
}
static class ObjectHelper
{
public static bool IsCapitalized (this object s) {...}
}
کد زیر Method با نام IsCapitalized متعلق به StringHelper را فراخوانی میکند:
bool test1 = "Perth".IsCapitalized();
Classها و Structها از Interfaceها خاصتر در نظر گرفته میشوند.
تنزلدادن یک Extension Method
سناریوی جالبی ممکن است زمانی رخ دهد که Microsoft یک Extension Method به یکی از Libraryهای Runtime در .NET اضافه کند که با Extension Method موجود در یک Library شخص ثالث تعارض دارد. بهعنوان نویسندهٔ Library شخص ثالث، ممکن است بخواهید Extension Method خود را «پس بگیرید»، اما بدون حذف آن و بدون شکستن Binary Compatibility با مصرفکنندگان موجود.
خوشبختانه این کار بهسادگی با حذف Keyword با نام this از تعریف Extension Method انجام میشود. با این کار Extension Method شما به یک Static Method معمولی تنزل پیدا میکند. زیبایی این راهحل در این است که هر Assembly که در برابر نسخهٔ قدیمی Library شما Compile شده است همچنان کار خواهد کرد و مانند قبل به Method شما Bind میشود. علت این است که فراخوانیهای Extension Method هنگام Compile به فراخوانی Static Method تبدیل میشوند.
مصرفکنندگان تنها زمانی از این تنزل تأثیر میپذیرند که دوباره Compile کنند؛ در آن زمان، فراخوانیهای Extension Method قبلی شما به نسخهٔ Microsoft Bind خواهند شد، البته اگر Namespace مربوطه Import شده باشد. اگر مصرفکننده همچنان بخواهد Method شما را فراخوانی کند، میتواند آن را بهصورت Static Method فراخوانی کند.
Anonymous Typeها
Anonymous Type یک Class ساده است که Compiler بهصورت آنی برای نگهداری مجموعهای از مقدارها ایجاد میکند. برای ساخت یک Anonymous Type، Keyword با نام new را بههمراه یک Object Initializer بهکار ببرید و Propertyها و مقدارهایی را که Type باید در خود نگه دارد مشخص کنید؛ برای مثال:
var dude = new { Name = "Bob", Age = 23 };
Compiler این کد را تقریباً به شکل زیر ترجمه میکند:
internal class AnonymousGeneratedTypeName
{
private string name; // Actual field name is irrelevant
private int age; // Actual field name is irrelevant
public AnonymousGeneratedTypeName (string name, int age)
{
this.name = name; this.age = age;
}
public string Name => name;
public int Age => age;
// The Equals and GetHashCode methods are overridden (see Chapter 6).
// The ToString method is also overridden.
}
...
var dude = new AnonymousGeneratedTypeName ("Bob", 23);
برای Reference دادن به یک Anonymous Type باید از Keyword با نام var استفاده کنید، زیرا این Type نامی ندارد.
نام Property یک Anonymous Type میتواند از Expressionای که خودش Identifier است یا به یک Identifier ختم میشود استنباط شود؛ بنابراین:
int Age = 23;
var dude = new { Name = "Bob", Age, Age.ToString().Length };
معادل کد زیر است:
var dude = new { Name = "Bob", Age = Age, Length = Age.ToString().Length };
دو Instance از Anonymous Type که در یک Assembly اعلام شدهاند، اگر Elementهای آنها از نظر نام و Type یکسان باشند، Type زیرین یکسانی خواهند داشت:
var a1 = new { X = 2, Y = 4 };
var a2 = new { X = 2, Y = 4 };
Console.WriteLine (a1.GetType() == a2.GetType()); // True
علاوه بر این، Method با نام Equals Override میشود تا Structural Equality Comparison، یعنی مقایسهٔ Data، را انجام دهد:
Console.WriteLine (a1.Equals (a2)); // True
درحالیکه Equality Operator یعنی == Referential Comparison انجام میدهد:
Console.WriteLine (a1 == a2); // False
میتوانید Arrayهایی از Anonymous Typeها به شکل زیر بسازید:
var dudes = new[]
{
new { Name = "Bob", Age = 30 },
new { Name = "Tom", Age = 40 }
};
یک Method نمیتواند بهشکل مفید Objectی با Anonymous Type برگرداند، زیرا نوشتن Methodی که Return Type آن var باشد غیرقانونی است:
var Foo() => new { Name = "Bob", Age = 30 }; // Not legal!
در بخشهای بعدی Recordها و Tupleها را توضیح میدهیم که رویکردهای جایگزینی برای برگرداندن چند مقدار از یک Method ارائه میکنند.
Anonymous Typeها Immutable هستند، بنابراین Instanceها بعد از ساختهشدن قابل تغییر نیستند. با این حال، از C# 10 میتوانید از Keyword با نام with برای ساخت Copy همراه با تغییرات، یعنی Nondestructive Mutation، استفاده کنید:
var a1 = new { A = 1, B = 2, C = 3, D = 4, E = 5 };
var a2 = a1 with { E = 10 };
Console.WriteLine (a2); // { A = 1, B = 2, C = 3, D = 4, E = 10 }
Anonymous Typeها هنگام نوشتن LINQ Queryها بهخصوص مفید هستند؛ فصل 8 را ببینید.
Tupleها
Tupleها نیز مانند Anonymous Typeها راه سادهای برای نگهداری مجموعهای از مقدارها فراهم میکنند. Tupleها عمدتاً با هدف فراهمکردن امکان بازگرداندن چند مقدار از یک Method، بدون متوسلشدن به Parameterهای out، وارد C# شدند؛ کاری که با Anonymous Typeها نمیتوانید انجام دهید. از آن زمان Recordها نیز معرفی شدهاند و رویکرد Typeدار و مختصری ارائه میکنند که در بخش بعدی توضیح خواهیم داد.
سادهترین راه برای ساخت Tuple Literal این است که مقدارهای موردنظر را داخل پرانتز فهرست کنید. این کار Tupleای با Elementهای بدون نام میسازد که با Item1، Item2 و مانند آن به آنها Reference میدهید:
var bob = ("Bob", 23); // Allow compiler to infer the element types
Console.WriteLine (bob.Item1); // Bob
Console.WriteLine (bob.Item2); // 23
Tupleها Value Type هستند و Elementهای Mutable، یعنی Read/Write، دارند:
var joe = bob; // joe is a *copy* of bob
joe.Item1 = "Joe"; // Change joe’s Item1 from Bob to Joe
Console.WriteLine (bob); // (Bob, 23)
Console.WriteLine (joe); // (Joe, 23)
برخلاف Anonymous Typeها، میتوانید Tuple Type را صریحاً مشخص کنید. کافی است Type هر Element را داخل پرانتز فهرست کنید:
(string,int) bob = ("Bob", 23);
این یعنی میتوانید بهطور مفید یک Tuple را از Method برگردانید:
(string,int) person = GetPerson(); // Could use 'var' instead if we want
Console.WriteLine (person.Item1); // Bob
Console.WriteLine (person.Item2); // 23
(string,int) GetPerson() => ("Bob", 23);
Tupleها با Genericها بهخوبی کار میکنند؛ بنابراین Typeهای زیر همگی قانونیاند:
Task<(string,int)>
Dictionary<(string,int),Uri>
IEnumerable<(int id, string name)> // See below for naming elements
نامگذاری Elementهای Tuple
هنگام ساخت Tuple Literal میتوانید بهصورت اختیاری نامهای معناداری به Elementها بدهید:
var tuple = (name:"Bob", age:23);
Console.WriteLine (tuple.name); // Bob
Console.WriteLine (tuple.age); // 23
هنگام مشخصکردن Tuple Type نیز میتوانید همین کار را انجام دهید:
var person = GetPerson();
Console.WriteLine (person.name); // Bob
Console.WriteLine (person.age); // 23
(string name, int age) GetPerson() => ("Bob", 23);
توجه کنید که همچنان میتوانید Elementها را بدون نام در نظر بگیرید و با Item1، Item2 و مانند آن به آنها Reference بدهید، هرچند Visual Studio این Fieldها را از IntelliSense مخفی میکند.
نام Elementها بهطور خودکار از نام Property یا Field استنباط میشود:
var now = DateTime.Now;
var tuple = (now.Day, now.Month, now.Year);
Console.WriteLine (tuple.Day); // OK
Tupleها زمانی از نظر Type با یکدیگر Compatible هستند که Type Elementهایشان بهترتیب یکسان باشد. لازم نیست نام Elementها یکسان باشد:
(string name, int age, char sex) bob1 = ("Bob", 23, 'M');
(string age, int sex, char name) bob2 = bob1; // No error!
مثال خاص ما به نتیجههای گیجکنندهای منجر میشود:
Console.WriteLine (bob2.name); // M
Console.WriteLine (bob2.age); // Bob
Console.WriteLine (bob2.sex); // 23
Type Erasure
پیشتر گفتیم Compiler زبان C#، Anonymous Typeها را با ساختن Classهای سفارشی دارای Propertyهای نامدار برای هر Element مدیریت میکند. در Tupleها C# متفاوت عمل میکند و از خانوادهای از Structهای Generic از پیش موجود استفاده میکند:
public struct ValueTuple<T1>
public struct ValueTuple<T1,T2>
public struct ValueTuple<T1,T2,T3>
...
هر یک از Structهای ValueTuple<> Fieldهایی با نام Item1، Item2 و مانند آن دارد. بنابراین (string,int) Alias برای ValueTuple<string,int> است؛ در نتیجه Elementهای نامگذاریشدهٔ Tuple در Typeهای زیرین Property Name متناظری ندارند. در عوض، نامها فقط در Source Code و در تصور Compiler وجود دارند. در Runtime نامها عمدتاً ناپدید میشوند؛ بنابراین اگر Programی را Decompile کنید که به Elementهای نامگذاریشدهٔ Tuple Reference میدهد، فقط Referenceهایی به Item1، Item2 و مانند آن خواهید دید. علاوه بر این، وقتی Tuple Variable را بعد از Assign شدن به یک object در Debugger بررسی کنید یا آن را در LINQPad با Dump نمایش دهید، نام Elementها وجود ندارند. و در بیشتر موارد با Reflection، فصل 18، نمیتوانید نام Elementهای Tuple را در Runtime مشخص کنید. یعنی در APIهایی مانند System.Net.Http.HttpClient، Tupleها در سناریوهایی مانند مثال زیر نمیتوانند جای Anonymous Typeها را بگیرند:
// Create JSON payload:
var json = JsonContent.Create (new { id = 123, name = "Test" })
Alias دادن به Tupleها (C# 12)
از C# 12 میتوانید از Directive با نام using برای تعریف Alias برای Tupleها استفاده کنید:
using Point = (int, int);
Point p = (3, 4);
این قابلیت با Tupleهایی که Elementهای نامگذاریشده دارند نیز کار میکند:
using Point = (int X, int Y); // Legal (but not necessarily *good*!)
Point p = (3, 4);
باز هم بهزودی میبینیم Recordها راهحل کاملاً Typeدار و به همان اندازه مختصری ارائه میکنند:
Point p = new (3, 4);
record Point (int X, int Y);
ValueTuple.Create
همچنین میتوانید Tupleها را از طریق Factory Method روی Type غیرGeneric با نام ValueTuple بسازید:
ValueTuple<string,int> bob1 = ValueTuple.Create ("Bob", 23);
(string,int) bob2 = ValueTuple.Create ("Bob", 23);
(string name, int age) bob3 = ValueTuple.Create ("Bob", 23);
Deconstruct کردن Tupleها
Tupleها بهصورت Implicit از Deconstruction Pattern پشتیبانی میکنند؛ «Deconstructors» در صفحهٔ 110 را ببینید. بنابراین میتوانید Tuple را بهسادگی به Variableهای جداگانه Deconstruct کنید. مثال زیر را در نظر بگیرید:
var bob = ("Bob", 23);
string name = bob.Item1;
int age = bob.Item2;
با Deconstructor مربوط به Tuple میتوانید Code را به شکل زیر ساده کنید:
var bob = ("Bob", 23);
(string name, int age) = bob; // Deconstruct the bob tuple into
// separate variables (name and age).
Console.WriteLine (name);
Console.WriteLine (age);
Syntax مربوط به Deconstruction بهشکل گیجکنندهای شبیه Syntax اعلان Tuple با Elementهای نامدار است. نمونهٔ زیر تفاوت را برجسته میکند:
(string name, int age) = bob; // Deconstructing a tuple
(string name, int age) bob2 = bob; // Declaring a new tuple
مثال دیگری در ادامه میآید؛ این بار هنگام فراخوانی یک Method و با Type Inference یعنی var:
var (name, age, sex) = GetBob();
Console.WriteLine (name); // Bob
Console.WriteLine (age); // 23
Console.WriteLine (sex); // M
(string, int, char) GetBob() => ("Bob", 23, 'M');
همچنین میتوانید مستقیماً به Fieldها و Propertyها Deconstruct کنید؛ این کار Shortcut خوبی برای مقداردهی چند Field یا Property در Constructor فراهم میکند:
class Point
{
public readonly int X, Y;
public Point (int x, int y) => (X, Y) = (x, y);
}
Equality Comparison
همانند Anonymous Typeها، Method با نام Equals Structural Equality Comparison انجام میدهد. یعنی بهجای Reference، Data زیرین را مقایسه میکند:
var t1 = ("one", 1);
var t2 = ("one", 1);
Console.WriteLine (t1.Equals (t2)); // True
علاوه بر این، ValueTuple<> Operatorهای == و != را Overload میکند:
Console.WriteLine (t1 == t2); // True (from C# 7.3)
Tupleها همچنین Method با نام GetHashCode را Override میکنند و استفاده از Tuple بهعنوان Key در Dictionary را عملی میسازند. Equality Comparison را در «Equality Comparison» صفحهٔ 344 و Dictionaryها را در فصل 7 بهتفصیل پوشش میدهیم.
Typeهای ValueTuple<> همچنین IComparable را Implement میکنند؛ «Order Comparison» در صفحهٔ 355 را ببینید. بنابراین میتوان از Tupleها بهعنوان Sorting Key استفاده کرد.
Classهای System.Tuple
خانوادهٔ دیگری از Typeهای Generic را در Namespace با نام System و با نام Tuple، نه ValueTuple، پیدا خواهید کرد. این Typeها در سال 2010 معرفی شدند و بهصورت Class تعریف شده بودند، درحالیکه Typeهای ValueTuple Struct هستند. در نگاه پسینی، تعریف Tupleها بهعنوان Class اشتباه تلقی شد: در سناریوهایی که معمولاً Tuple استفاده میشود، Structها مزیت Performance اندکی دارند، زیرا از Memory Allocation غیرضروری جلوگیری میکنند، و تقریباً هیچ نقطهضعفی ندارند. بنابراین وقتی Microsoft در C# 7 پشتیبانی زبانی از Tuple را اضافه کرد، Typeهای موجود Tuple را نادیده گرفت و ValueTuple جدید را ترجیح داد. ممکن است هنوز Classهای Tuple را در Codeهایی که پیش از C# 7 نوشته شدهاند ببینید. این Classها پشتیبانی زبانی ویژهای ندارند و به شکل زیر استفاده میشوند:
Tuple<string,int> t = Tuple.Create ("Bob", 23); // Factory method
Console.WriteLine (t.Item1); // Bob
Console.WriteLine (t.Item2); // 23
Recordها
Record نوع ویژهای از Class یا Struct است که برای کارکرد خوب با Dataهای Immutable، یعنی Read-only، طراحی شده است. مفیدترین قابلیت آن Nondestructive Mutation است؛ بااینحال Recordها برای ساخت Typeهایی که فقط Data را با هم ترکیب یا نگهداری میکنند نیز مفیدند. در موارد ساده، Boilerplate Code را حذف میکنند و درعینحال Equality Semantics مناسبتر برای Typeهای Immutable را رعایت میکنند.
Recordها کاملاً Constructهای Compile-time زبان C# هستند. در Runtime، CLR آنها را صرفاً Class یا Struct میبیند، همراه با تعدادی عضو Synthesized اضافی که Compiler افزوده است.
پیشزمینه
نوشتن Typeهای Immutable، یعنی Typeهایی که Fieldهایشان پس از Initialization قابل تغییر نیست، راهبرد محبوبی برای سادهکردن Software و کاهش Bugها است. این موضوع یکی از جنبههای اصلی Functional Programming نیز هست؛ جایی که از Mutable State پرهیز میشود و Functionها مانند Data در نظر گرفته میشوند. LINQ از این اصل الهام گرفته است.
برای «تغییر» یک Object Immutable باید Object جدیدی بسازید و Data را با اعمال تغییرات خود Copy کنید؛ به این کار Nondestructive Mutation گفته میشود. از نظر Performance، این کار آنقدر که تصور میکنید ناکارآمد نیست، زیرا Shallow Copy همیشه کافی است؛ Deep Copy که در آن Subobjectها و Collectionها را نیز Copy میکنید، وقتی Data Immutable باشد ضروری نیست. اما از نظر تلاش برنامهنویسی، Implement کردن Nondestructive Mutation میتواند بسیار ناکارآمد باشد، بهخصوص وقتی Propertyهای زیادی وجود دارند. Recordها این مشکل را با Pattern مورد پشتیبانی زبان حل میکنند.
مسئلهٔ دوم این است که Programmerها، بهویژه Functional Programmerها، گاهی Typeهای Immutable را فقط برای ترکیب Data استفاده میکنند، بدون افزودن Behavior. تعریف چنین Typeهایی بیش از حد لازم کار میخواهد، زیرا باید Constructorای بنویسید که هر Parameter را به هر Public Property Assign کند؛ Deconstructor هم ممکن است مفید باشد. در Recordها Compiler میتواند این کار را برای شما انجام دهد.
در نهایت، یکی از پیامدهای Immutable بودن Object این است که Identity آن نمیتواند تغییر کند؛ در نتیجه برای چنین Typeهایی پیادهسازی Structural Equality از Referential Equality مفیدتر است. Structural Equality یعنی دو Instance زمانی یکساناند که Data آنها یکسان باشد، همانند Tupleها. Recordها بهطور پیشفرض Structural Equality را به شما میدهند، فارغ از اینکه Type زیرین Class باشد یا Struct، و بدون Boilerplate Code.
تعریف Record
تعریف Record شبیه تعریف Class یا Struct است و میتواند همان انواع Memberها، از جمله Field، Property، Method و غیره را دربر گیرد. Recordها میتوانند Interfaceها را Implement کنند و Recordهای مبتنی بر Class میتوانند از Recordهای مبتنی بر Class دیگر Inherit کنند.
بهطور پیشفرض Type زیرین Record یک Class است:
record Point { } // Point is a class
از C# 10، Type زیرین Record میتواند Struct هم باشد:
record struct Point { } // Point is a struct
record class نیز قانونی است و همان معنی record را دارد.
یک Record ساده ممکن است فقط مجموعهای از Propertyهای Init-only و شاید یک Constructor داشته باشد:
record Point
{
public Point (double x, double y) => (X, Y) = (x, y);
public double X { get; init; }
public double Y { get; init; }
}
هنگام Compile، C# تعریف Record را به Class یا Struct تبدیل میکند و مراحل اضافی زیر را انجام میدهد:
- یک Protected Copy Constructor و یک Method مخفی با نام Clone مینویسد تا Nondestructive Mutation را ممکن کند.
- Functionهای مرتبط با Equality را Override/Overload میکند تا Structural Equality را Implement کند.
- Method با نام
ToString() را Override میکند تا Public Propertyهای Record را مانند Anonymous Typeها بسط دهد.
اعلان Record قبلی تقریباً به این شکل بسط پیدا میکند:
class Point
{
public Point (double x, double y) => (X, Y) = (x, y);
public double X { get; init; }
public double Y { get; init; }
protected Point (Point original) // “Copy constructor”
{
this.X = original.X; this.Y = original.Y
}
// This method has a strange compiler-generated name:
public virtual Point <Clone>$() => new Point (this); // Clone method
// Additional code to override Equals, ==, !=, GetHashCode, ToString()
// ...
}
Parameter Listها
تعریف Record را میتوان با استفاده از Parameter List کوتاهتر کرد:
record Point (double X, double Y)
{
// You can optionally define additional class members here...
}
Parameterها میتوانند Modifierهای in و params داشته باشند، اما out یا ref نه. اگر Parameter List مشخص شود، Compiler مراحل اضافی زیر را انجام میدهد:
- برای هر Parameter یک Init-only Property مینویسد.
- یک Primary Constructor برای پرکردن Propertyها مینویسد.
- یک Deconstructor مینویسد.
یعنی اگر Record با نام Point را صرفاً به شکل زیر اعلام کنیم:
record Point (double X, double Y);
Compiler در نهایت تقریباً دقیقاً همان چیزی را تولید میکند که در بسط قبلی فهرست کردیم. یک تفاوت کوچک این است که نام Parameterها در Primary Constructor بهجای x و y، برابر X و Y خواهد بود:
public Point (double X, double Y) // “Primary constructor”
{
this.X = X; this.Y = Y;
}
تفاوت دیگر هنگام تعریف Parameter List این است که Compiler یک Deconstructor نیز تولید میکند:
public void Deconstruct (out double X, out double Y) // Deconstructor
{
X = this.X; Y = this.Y;
}
Recordهای دارای Parameter List میتوانند با Syntax زیر Subclass شوند:
record Point3D (double X, double Y, double Z) : Point (X, Y);
Compiler سپس Primary Constructor را به شکل زیر منتشر میکند:
class Point3D : Point
{
public double Z { get; init; }
public Point3D (double X, double Y, double Z) : base (X, Y)
=> this.Z = Z;
}
Mutability در Record Structها
وقتی Parameter List را در یک Record Struct تعریف میکنید، Compiler بهجای Init-only Propertyها، Propertyهای Writable تولید میکند، مگر اینکه پیش از اعلان Record از readonly استفاده کنید:
readonly record struct Point (double X, double Y);
منطق این تصمیم آن است که در Use Caseهای معمول، مزیتهای ایمنی Immutability از Immutable بودن خود Struct ناشی نمیشود، بلکه از Immutable بودن محل قرارگیری آن ناشی میشود. در مثال زیر نمیتوانیم Field با نام X را Mutate کنیم، حتی با اینکه X Writable است:
var test = new Immutable();
test.Field.X++; // Prohibited, because Field is readonly
test.Prop.X++; // Prohibited, because Prop is {get;} only
class Immutable
{
public readonly Mutable Field;
public Mutable Prop { get; }
}
struct Mutable { public int X, Y; }
و هرچند میتوانیم کار زیر را انجام دهیم:
var test = new Immutable();
Mutable m = test.Prop;
m.X++;
تمام چیزی که به دست میآوریم Mutate کردن یک Local Variable، یعنی Copy از test.Prop، است. Mutate کردن Local Variable میتواند Optimization مفیدی باشد و مزیتهای یک Immutable Type System را باطل نمیکند.
در مقابل، اگر Field را Writable Field و Prop را Writable Property میکردیم، صرفنظر از نحوهٔ اعلان Struct با نام Mutable، میتوانستیم محتوای آنها را بهسادگی جایگزین کنیم.
Nondestructive Mutation
مهمترین کاری که Compiler برای همهٔ Recordها انجام میدهد نوشتن Copy Constructor و یک Method مخفی با نام Clone است. این قابلیت Nondestructive Mutation را از طریق Keyword با نام with ممکن میسازد:
Point p1 = new Point (3, 3);
Point p2 = p1 with { Y = 4 };
Console.WriteLine (p2); // Point { X = 3, Y = 4 }
record Point (double X, double Y);
در این مثال p2 Copy از p1 است، اما Property با نام Y آن روی 4 تنظیم شده است. فایدهٔ این قابلیت وقتی Propertyهای بیشتری وجود دارند آشکارتر است:
Test t1 = new Test (1, 2, 3, 4, 5, 6, 7, 8);
Test t2 = t1 with { A = 10, C = 30 };
Console.WriteLine (t2);
record Test (int A, int B, int C, int D, int E, int F, int G, int H);
خروجی به شکل زیر است:
Test { A = 10, B = 2, C = 30, D = 4, E = 5, F = 6, G = 7, H = 8 }
Nondestructive Mutation در دو مرحله رخ میدهد:
- ابتدا Copy Constructor، Record را Clone میکند. بهطور پیشفرض هر یک از Fieldهای زیرین Record را Copy میکند و یک Replica وفادار میسازد، درحالیکه Logic موجود در Init Accessorها و Overhead آن را دور میزند. همهٔ Fieldها شامل میشوند: Public و Private، و نیز Fieldهای مخفی پشتیبان Automatic Propertyها.
- سپس هر Property موجود در Member Initializer List Update میشود؛ این بار با استفاده از Init Accessorها.
Compiler کد:
Test t2 = t1 with { A = 10, C = 30 };
را به چیزی که از نظر Functionality معادل کد زیر است ترجمه میکند:
Test t2 = new Test(t1); // Use copy constructor to clone t1 field by field
t2.A = 10; // Update property A
t2.C = 30; // Update property C
اگر همین Code را صریحاً مینوشتید Compile نمیشد، زیرا A و C Init-only Property هستند. علاوه بر این، Copy Constructor برابر protected است؛ C# با فراخوانی آن از طریق Public Hidden Methodای که با نام عجیب <Clone>$ داخل Record مینویسد این محدودیت را دور میزند.
در صورت نیاز میتوانید Copy Constructor خودتان را تعریف کنید. C# در این صورت بهجای نوشتن نسخهٔ خودش از تعریف شما استفاده میکند:
protected Point (Point original)
{
this.X = original.X; this.Y = original.Y;
}
نوشتن Custom Copy Constructor میتواند زمانی مفید باشد که Record شما Subobject یا Collectionهای Mutable داشته باشد که میخواهید Clone کنید، یا Fieldهای محاسبهشدهای داشته باشید که میخواهید Clear شوند. متأسفانه فقط میتوانید Implementation پیشفرض را جایگزین کنید، نه اینکه آن را Enhance کنید.
Property Validation
با Propertyهای صریح میتوانید Validation Logic را داخل Init Accessorها بنویسید. در مثال زیر تضمین میکنیم X هرگز NaN یعنی Not a Number نباشد:
record Point
{
// Notice that we assign x to the X property (and not the _x field):
public Point (double x, double y) => (X, Y) = (x, y);
double _x;
public double X
{
get => _x;
init
{
if (double.IsNaN (value))
throw new ArgumentException ("X Cannot be NaN");
_x = value;
}
}
public double Y { get; init; }
}
Design ما تضمین میکند Validation هم هنگام Construction و هم هنگام Nondestructive Mutation Object انجام شود:
Point p1 = new Point (2, 3);
Point p2 = p1 with { X = double.NaN }; // throws an exception
به یاد بیاورید که Copy Constructor تولیدشده بهصورت خودکار همهٔ Fieldها و Automatic Propertyها را Copy میکند. یعنی Copy Constructor تولیدشده اکنون تقریباً به شکل زیر خواهد بود:
protected Point (Point original)
{
_x = original._x; Y = original.Y;
}
توجه کنید Copy کردن Field با نام _x Accessor مربوط به Property با نام X را دور میزند. بااینحال این موضوع نمیتواند چیزی را خراب کند، زیرا Objectی را با وفاداری Copy میکند که قبلاً از طریق Init Accessor مربوط به X بهصورت امن مقداردهی شده است.
Calculated Fieldها و Lazy Evaluation
یکی از Patternهای محبوب Functional Programming که با Typeهای Immutable خوب کار میکند Lazy Evaluation است؛ در آن یک Value تا زمانی که لازم نشده محاسبه نمیشود و سپس برای استفادهٔ دوباره Cache میشود. فرض کنید میخواهیم Propertyای در Record با نام Point تعریف کنیم که فاصله از Origin یعنی (0, 0) را برگرداند:
record Point (double X, double Y)
{
public double DistanceFromOrigin => Math.Sqrt (X*X + Y*Y);
}
اکنون تلاش میکنیم این Code را Refactor کنیم تا از هزینهٔ محاسبهٔ دوبارهٔ DistanceFromOrigin هر بار که Property Access میشود جلوگیری کنیم. ابتدا Property List را حذف میکنیم و X، Y و DistanceFromOrigin را بهصورت Read-only Property تعریف میکنیم. سپس میتوانیم مورد آخر را داخل Constructor محاسبه کنیم:
record Point
{
public double X { get; }
public double Y { get; }
public double DistanceFromOrigin { get; }
public Point (double x, double y) =>
(X, Y, DistanceFromOrigin) = (x, y, Math.Sqrt (x*x + y*y));
}
این کار میکند، اما اجازهٔ Nondestructive Mutation نمیدهد؛ تبدیل X و Y به Init-only Property Code را خراب میکرد، چون DistanceFromOrigin بعد از اجرای Init Accessorها Stale میشد. همچنین Suboptimal است، زیرا محاسبه همیشه انجام میشود، فارغ از اینکه Property با نام DistanceFromOrigin اصلاً خوانده شود یا نه. راهحل Optimal این است که مقدار آن را در یک Field Cache کنیم و بهصورت Lazy، یعنی هنگام اولین استفاده، پر کنیم:
record Point
{
...
double? _distance;
public double DistanceFromOrigin
{
get
{
if (_distance == null)
_distance = Math.Sqrt (X*X + Y*Y);
return _distance.Value;
}
}
}
با Null-Coalescing Assignment Operator در C# یعنی ??= میتوانیم کل اعلان Property را به یک خط کاهش دهیم:
public double DistanceFromOrigin => _distance ??= Math.Sqrt (X*X + Y*Y);
این عبارت میگوید اگر _distance Non-null است آن را برگردان؛ در غیر این صورت Math.Sqrt (X*X + Y*Y) را برگردان، درحالیکه همزمان آن را به _distance Assign میکنی.
برای اینکه این روش با Init-only Propertyها کار کند، به یک مرحلهٔ دیگر نیاز داریم: وقتی X یا Y از طریق Init Accessor Update میشوند، Field Cacheشدهٔ _distance را Clear کنیم. Code کامل به شکل زیر است:
record Point
{
public Point (double x, double y) => (X, Y) = (x, y);
double _x, _y;
public double X { get => _x; init { _x = value; _distance = null; } }
public double Y { get => _y; init { _y = value; _distance = null; } }
double? _distance;
public double DistanceFromOrigin => _distance ??= Math.Sqrt (X*X + Y*Y);
}
اکنون Point را میتوان بهصورت Nondestructive Mutate کرد:
Point p1 = new Point (2, 3);
Console.WriteLine (p1.DistanceFromOrigin); // 3.605551275463989
Point p2 = p1 with { Y = 4 };
Console.WriteLine (p2.DistanceFromOrigin); // 4.47213595499958
یک Bonus خوب این است که Copy Constructor تولیدشده بهصورت خودکار Field Cacheشدهٔ _distance را هم Copy میکند. یعنی اگر Record Propertyهای دیگری داشته باشد که در Calculation دخیل نیستند، Nondestructive Mutation آن Propertyها باعث از دست رفتن غیرضروری مقدار Cacheشده نمیشود. اگر این Bonus را نمیخواهید، جایگزین Clear کردن Cache در Init Accessorها این است که Custom Copy Constructorای بنویسید که Field Cacheشده را نادیده بگیرد. این روش مختصرتر است، چون با Parameter Listها کار میکند و Custom Copy Constructor میتواند از Deconstructor بهره بگیرد:
record Point (double X, double Y)
{
double? _distance;
public double DistanceFromOrigin => _distance ??= Math.Sqrt (X*X + Y*Y);
protected Point (Point other) => (X, Y) = other;
}
توجه کنید در هر دو راهحل، افزودن Lazy Calculated Fieldها Structural Equality Comparison پیشفرض را میشکند، چون چنین Fieldهایی ممکن است Populate شده باشند یا نشده باشند. بااینحال بهزودی میبینیم اصلاح آن نسبتاً ساده است.
Primary Constructorها
وقتی Record را با Parameter List تعریف میکنید، Compiler اعلان Propertyها را بهصورت خودکار تولید میکند، همراه با Primary Constructor و Deconstructor. همانطور که دیدیم، این روش در موارد ساده خوب کار میکند؛ و در موارد پیچیدهتر میتوانید Parameter List را حذف کرده و اعلان Propertyها و Constructor را دستی بنویسید.
C# یک گزینهٔ میانی با کاربرد محدود نیز ارائه میکند؛ اگر حاضر باشید با Semantics کنجکاویبرانگیز Primary Constructorها کنار بیایید، میتوانید Parameter List را تعریف کنید و همزمان بعضی یا همهٔ Property Declarationها را خودتان بنویسید:
record Student (string ID, string LastName, string GivenName)
{
public string ID { get; } = ID;
}
در این حالت تعریف Property با نام ID را «در اختیار گرفتیم» و آن را Read-only، بهجای Init-only، تعریف کردیم؛ بنابراین از Nondestructive Mutation کنار گذاشته میشود. اگر هرگز لازم نیست Property خاصی را بهصورت Nondestructive Mutate کنید، Read-only کردن آن به شما اجازه میدهد Data محاسبهشده را بدون نیاز به Coding مکانیزم Refresh در Record ذخیره کنید.
توجه کنید لازم بود Property Initializer قرار دهیم:
public string ID { get; } = ID;
وقتی تعریف Property را «در اختیار میگیرید»، مسئول Initialization مقدار آن میشوید؛ Primary Constructor دیگر این کار را بهصورت خودکار انجام نمیدهد. این رفتار دقیقاً با Primary Constructorهای Class و Struct منطبق است.
همچنین توجه کنید ID سمت راست Initializer به Primary Constructor Parameter اشاره میکند، نه Property با نام ID.
مطابق Semantics مربوط به Primary Constructorها در Class و Struct، «Primary Constructors» در صفحهٔ 235 را ببینید، Primary Constructor Parameterها، یعنی در این مثال ID، LastName و GivenName، بهصورت جادویی برای تمام Field Initializerها و Property Initializerها قابل مشاهدهاند. میتوانیم با بسط مثال این موضوع را نشان دهیم:
record Student (string ID, string LastName, string FirstName)
{
public string ID { get; } = ID;
readonly int _enrollmentYear = int.Parse (ID.Substring (0, 4));
}
باز هم ID سمت راست به Primary Constructor Parameter اشاره میکند، نه Property. علت نبودن Ambiguity این است که Access به Propertyها از داخل Initializerها غیرقانونی است.
در این مثال _enrollmentYear را از چهار Digit نخست ID محاسبه کردیم. هرچند ذخیرهٔ آن در Read-only Field امن است، چون Property با نام ID Read-only است و نمیتواند Nondestructive Mutate شود، این Code در دنیای واقعی چندان خوب کار نمیکند. علت این است که بدون Constructor صریح، هیچ محل مرکزی برای Validate کردن ID و Throw کردن Exception معنادار در صورت Invalid بودن آن وجود ندارد؛ چیزی که Requirement رایجی است.
Validation همچنین دلیل خوبی برای نیاز به نوشتن Init-only Accessor صریح است؛ همانطور که در «Property Validation» صفحهٔ 232 گفتیم. متأسفانه Primary Constructorها در این سناریو خوب عمل نمیکنند. برای روشنشدن موضوع Record زیر را در نظر بگیرید که در آن Init Accessor یک Null Validation Check انجام میدهد:
record Person (string Name)
{
string _name = Name;
public string Name
{
get => _name;
init => _name = value ?? throw new ArgumentNullException ("Name");
}
}
چون Name Automatic Property نیست، نمیتواند Initializer تعریف کند. بهترین کاری که میتوانیم انجام دهیم قرار دادن Initializer روی Backing Field است. متأسفانه این کار Null Check را دور میزند:
var p = new Person (null); // Succeeds! (bypasses the null check)
مشکل این است که راهی برای Assign کردن Primary Constructor Parameter به Property وجود ندارد، مگر اینکه خود Constructor را بنویسیم. هرچند Workaroundهایی وجود دارند، مانند جداکردن Init Validation Logic در یک Static Method جداگانه که دو بار فراخوانیاش کنیم، سادهترین Workaround این است که Parameter List را بهکلی کنار بگذاریم و یک Constructor معمولی را دستی بنویسیم، و در صورت نیاز Deconstructor را نیز:
record Person
{
public Person (string name) => Name = name; // Assign to *PROPERTY*
string _name;
public string Name { get => _name; init => ... }
}
Recordها و Equality Comparison
همانند Structها، Anonymous Typeها و Tupleها، Recordها Structural Equality را بهصورت آماده ارائه میکنند؛ یعنی دو Record زمانی Equal هستند که Fieldها و Automatic Propertyهای آنها Equal باشند:
var p1 = new Point (1, 2);
var p2 = new Point (1, 2);
Console.WriteLine (p1.Equals (p2)); // True
record Point (double X, double Y);
Equality Operator نیز با Recordها کار میکند، همانطور که با Tupleها کار میکند:
Console.WriteLine (p1 == p2); // True
Implementation پیشفرض Equality در Recordها ناگزیر Fragile است. بهطور خاص، اگر Record شامل Lazy Value، Transient Value، Array یا Collection Type باشد، این Implementation میشکند؛ Collectionها برای Equality Comparison به Handling ویژه نیاز دارند. خوشبختانه در صورت نیاز به کارکرد Equality، اصلاح آن نسبتاً آسان است و از افزودن Behavior کامل Equality به Class یا Struct کار کمتری میبرد.
برخلاف Class و Struct، Method با نام object.Equals را Override نمیکنید و نمیتوانید Override کنید؛ در عوض یک Public Method با نام Equals و Signature زیر تعریف میکنید:
record Point (double X, double Y)
{
double _someOtherField;
public virtual bool Equals (Point other) =>
other != null && X == other.X && Y == other.Y;
}
Method با نام Equals باید virtual باشد، نه override، و باید Strongly Typed باشد بهطوریکه Type واقعی Record را بپذیرد؛ در اینجا Point و نه object. وقتی Signature را درست بنویسید، Compiler بهصورت خودکار Method شما را در Implementation جای میدهد.
در مثال ما Equality Logic را طوری تغییر دادیم که فقط X و Y را مقایسه کند و _someOtherField را نادیده بگیرد.