فصل ۶: Enum، Guid و مبانی Equality Comparison
فروش یا انتشار این ترجمه منوط به داشتن مجوز لازم از صاحب حقوق اثر است.
Enum Conversionها
سه روش برای نمایش Value مربوط به Enum وجود دارد:
- بهصورت Enum Member.
- بهصورت Underlying Integral Value آن.
- بهصورت String.
در این بخش توضیح میدهیم چگونه بین هر یک Convert کنید.
Conversion از Enum به Integral
به یاد بیاورید که Explicit Cast بین Enum Member و Integral Value آن تبدیل انجام میدهد. اگر Enum Type را در Compile Time میدانید، Explicit Cast Approach صحیح است:
[Flags]
public enum BorderSides { Left=1, Right=2, Top=4, Bottom=8 }
...
int i = (int) BorderSides.Top; // i == 4
BorderSides side = (BorderSides) i; // side == BorderSides.Top
به همین روش میتوانید Instance از System.Enum را به Integral Type آن Cast کنید. Trick این است که ابتدا به object و سپس به Integral Type Cast کنید:
static int GetIntegralValue (Enum anyEnum)
{
return (int) (object) anyEnum;
}
این روش به دانستن Integral Type وابسته است: Methodی که نوشتیم اگر Enumای دریافت کند که Integral Type آن long باشد Crash میکند. برای نوشتن Methodی که با Enum دارای هر Integral Type کار کند، سه Approach دارید. Approach اول فراخوانی Convert.ToDecimal است:
static decimal GetAnyIntegralValue (Enum anyEnum)
{
return Convert.ToDecimal (anyEnum);
}
این روش کار میکند زیرا هر Integral Type، شامل ulong، بدون Loss of Information به decimal تبدیل میشود. Approach دوم این است که برای گرفتن Integral Type مربوط به Enum، Enum.GetUnderlyingType را فراخوانی و سپس Convert.ChangeType را اجرا کنید:
static object GetBoxedIntegralValue (Enum anyEnum)
{
Type integralType = Enum.GetUnderlyingType (anyEnum.GetType());
return Convert.ChangeType (anyEnum, integralType);
}
همانطور که Example بعدی نشان میدهد، این روش Integral Type اصلی را حفظ میکند:
object result = GetBoxedIntegralValue (BorderSides.Top);
Console.WriteLine (result); // 4
Console.WriteLine (result.GetType()); // System.Int32
Approach سوم این است که Format یا ToString را با Format String با مقدار "d" یا "D" فراخوانی کنید. این کار Integral Value مربوط به Enum را به صورت String میدهد و هنگام نوشتن Custom Serialization Formatterها مفید است:
static string GetIntegralValueAsString (Enum anyEnum)
{
return anyEnum.ToString ("D"); // returns something like "4"
}
Conversion از Integral به Enum
Enum.ToObject یک Integral Value را به Instance از Enum Type مشخصشده Convert میکند:
object bs = Enum.ToObject (typeof (BorderSides), 3);
Console.WriteLine (bs); // Left, Right
این Dynamic Equivalent کد زیر است:
BorderSides bs = (BorderSides) 3;
ToObject برای پذیرفتن همهٔ Integral Typeها و همچنین object Overload شده است. مورد آخر با هر Boxed Integral Type کار میکند.
String Conversionها
برای تبدیل Enum به String، میتوانید Method Static با نام Enum.Format را فراخوانی کنید یا روی Instance، ToString را اجرا کنید. هر Method یک Format String میپذیرد که میتواند "G" برای Default Formatting Behavior، "D" برای Emit کردن Underlying Integral Value بهصورت String، "X" برای همان Value در Hexadecimal، یا "F" برای Format کردن Memberهای ترکیبی Enum بدون Attribute با نام Flags باشد. Exampleهای اینها را در «Standard Format Strings and Parsing Flags» در صفحهٔ 323 فهرست کردیم.
Enum.Parse String را به Enum تبدیل میکند. Enum Type و Stringای را میپذیرد که میتواند چند Member داشته باشد:
BorderSides leftRight = (BorderSides) Enum.Parse (typeof (BorderSides),
"Left, Right");
Argument سوم اختیاری اجازهٔ Case-insensitive Parsing میدهد. اگر Member پیدا نشود ArgumentException Throw میشود.
Enumerate کردن Enum Valueها
Enum.GetValues Arrayای شامل تمام Memberهای Enum Type مشخص را برمیگرداند:
foreach (Enum value in Enum.GetValues (typeof (BorderSides)))
Console.WriteLine (value);
Composite Memberهایی مانند LeftRight = Left | Right نیز Include میشوند. Enum.GetNames همان Function را انجام میدهد اما Array از String برمیگرداند.
Enumها چگونه کار میکنند
Semantics مربوط به Enumها عمدتاً توسط Compiler Enforce میشود. در CLR، میان Enum Instance در حالت Unboxed و Underlying Integral Value آن هیچ Runtime Differenceای وجود ندارد. افزون بر این، تعریف Enum در CLR فقط Subtypeای از System.Enum با Fieldهای Static از نوع Integral برای هر Member است. این موضوع استفادهٔ عادی از Enum را بسیار Efficient میکند و Runtime Cost آن با Integral Constantها برابر است.
نقطهضعف این Strategy آن است که Enumها Static Type Safety فراهم میکنند اما Strong Type Safety نه. Exampleای از این موضوع را در فصل 3 دیدیم:
[Flags] public enum BorderSides { Left=1, Right=2, Top=4, Bottom=8 }
...
BorderSides b = BorderSides.Left;
b += 1234; // No error!
وقتی Compiler نتواند Validation انجام دهد، مانند این Example، Runtime نیز Backupای برای Throw کردن Exception ندارد.
این گفته که در Runtime میان Enum Instance و Integral Value آن تفاوتی نیست ممکن است با کد زیر متناقض به نظر برسد:
[Flags] public enum BorderSides { Left=1, Right=2, Top=4, Bottom=8 }
...
Console.WriteLine (BorderSides.Right.ToString()); // Right
Console.WriteLine (BorderSides.Right.GetType().Name); // BorderSides
با توجه به ماهیت Enum Instance در Runtime، انتظار دارید این کد 2 و Int32 چاپ کند! علت Behavior آن Trick دیگری در Compile Time است. C# پیش از فراخوانی Virtual Methodهای Enum Instance مانند ToString یا GetType آن را بهطور صریح Box میکند. وقتی Enum Instance Box میشود، Runtime Wrapperای میگیرد که به Enum Type آن Reference دارد.
Struct با نام Guid
Struct با نام Guid یک Globally Unique Identifier را نمایش میدهد: Valueای شانزدهبایتی که هنگام تولید تقریباً با قطعیت در جهان Unique است. Guidها اغلب برای انواع Key در Applicationها و Databaseها استفاده میشوند. 2128 یا 3.4 × 1038 Guid یکتا وجود دارد.
Method Static با نام Guid.NewGuid یک Guid یکتا تولید میکند:
Guid g = Guid.NewGuid ();
Console.WriteLine (g.ToString()); // 0d57629c-7d6e-4847-97cb-9e2fc25083fe
برای Instantiate کردن Value موجود از یکی از Constructorها استفاده میکنید. دو Constructor مفیدتر:
public Guid (byte[] b); // Accepts a 16-byte array
public Guid (string g); // Accepts a formatted string
هنگامی که Guid بهصورت String نمایش داده میشود، به شکل Hexadecimal Number سیودورقمی Format میشود و میتواند بعد از Digitهای 8، 12، 16 و 20 Hyphen اختیاری داشته باشد. کل String را نیز میتوان بهصورت اختیاری در Bracket یا Brace قرار داد:
Guid g1 = new Guid ("{0d57629c-7d6e-4847-97cb-9e2fc25083fe}");
Guid g2 = new Guid ("0d57629c7d6e484797cb9e2fc25083fe");
Console.WriteLine (g1 == g2); // True
چون Guid یک Struct است، Value-type Semantics را رعایت میکند؛ بنابراین Equality Operator در Example قبل کار میکند.
Method با نام ToByteArray یک Guid را به Byte Array تبدیل میکند.
Property Static با نام Guid.Empty یک Guid خالی، یعنی تمام Zeroها، برمیگرداند. این Value اغلب بهجای null استفاده میشود.
Equality Comparison
تا اینجا فرض کردیم Operatorهای == و != تمام چیزی هستند که برای Equality Comparison وجود دارد. اما موضوع Equality پیچیدهتر و ظریفتر است و گاهی نیازمند Methodها و Interfaceهای اضافی است. این بخش Protocolهای Standard در C# و .NET برای Equality را بررسی میکند و بهویژه روی دو Question تمرکز دارد:
- چه زمانی
== و != برای Equality Comparison کافی یا ناکافیاند و Alternativeها چیستند؟ - چگونه و چه زمانی باید Equality Logic یک Type را Customize کنید؟
اما پیش از بررسی Detailهای Equality Protocol و روش Customize کردن آنها، ابتدا باید Concept مقدماتی Value Equality در برابر Referential Equality را بررسی کنیم.
Value Equality در برابر Referential Equality
دو نوع Equality وجود دارد:
- Value equality
- دو Value از جهتی Equivalent هستند.
- Referential equality
- دو Reference دقیقاً به همان Object اشاره میکنند.
مگر اینکه Override شده باشد:
- Value Typeها از Value Equality استفاده میکنند.
- Reference Typeها از Referential Equality استفاده میکنند. این Behavior در Anonymous Typeها و Recordها Override میشود.
در واقع Value Typeها فقط میتوانند از Value Equality استفاده کنند، مگر زمانی که Box شوند. Demonstration سادهٔ Value Equality، Compare کردن دو Number است:
int x = 5, y = 5;
Console.WriteLine (x == y); // True (by virtue of value equality)
Demonstration پیچیدهتر Compare کردن دو Struct از نوع DateTimeOffset است. کد زیر True چاپ میکند چون دو DateTimeOffset به یک Point in Time اشاره دارند و بنابراین Equivalent در نظر گرفته میشوند:
var dt1 = new DateTimeOffset (2010, 1, 1, 1, 1, 1, TimeSpan.FromHours(8));
var dt2 = new DateTimeOffset (2010, 1, 1, 2, 1, 1, TimeSpan.FromHours(9));
Console.WriteLine (dt1 == dt2); // True
Reference Typeها بهطور Default Referential Equality دارند. در Example زیر، f1 و f2 Equal نیستند، با وجود اینکه Objectهایشان Content یکسان دارند:
class Foo { public int X; }
...
Foo f1 = new Foo { X = 5 };
Foo f2 = new Foo { X = 5 };
Console.WriteLine (f1 == f2); // False
در مقابل، f3 و f1 Equal هستند زیرا به همان Object Reference دارند:
Foo f3 = f1;
Console.WriteLine (f1 == f3); // True
در ادامهٔ این بخش توضیح میدهیم چگونه میتوانید Reference Typeها را Customize کنید تا Value Equality نشان دهند. Example آن Class با نام Uri در Namespace با نام System است:
Uri uri1 = new Uri ("http://www.linqpad.net");
Uri uri2 = new Uri ("http://www.linqpad.net");
Console.WriteLine (uri1 == uri2); // True
Class با نام string Behavior مشابهی دارد:
var s1 = "http://www.linqpad.net";
var s2 = "http://" + "www.linqpad.net";
Console.WriteLine (s1 == s2); // True
Standard Equality Protocolها
سه Protocol Standard وجود دارد که Typeها میتوانند برای Equality Comparison Implement کنند:
- Operatorهای
== و !=. - Virtual Method با نام
Equals در object. - Interface با نام
IEquatable<T>.
علاوه بر این، Pluggable Protocolها و Interface با نام IStructuralEquatable وجود دارند که در فصل 7 توضیح میدهیم.
== و !=
در بسیاری Exampleها دیدهایم Operatorهای Standard با نام == و != چگونه Equality/Inequality Comparison انجام میدهند. ظرافتهای == و != از اینجا ناشی میشود که Operator هستند و بنابراین بهصورت Static Resolve میشوند؛ در واقع آنها به شکل Static Function Implement شدهاند. در نتیجه، وقتی از == یا != استفاده میکنید، C# در Compile Time تصمیم میگیرد کدام Type Comparison را انجام دهد و هیچ Virtual Behavior وارد کار نمیشود. معمولاً این Behavior مطلوب است. در Example زیر، Compiler، == را به Type با نام int Hardwire میکند چون x و y هر دو int هستند:
int x = 5;
int y = 5;
Console.WriteLine (x == y); // True
اما در Example بعدی، Compiler Operator با نام == را به Type با نام object Bind میکند:
object x = 5;
object y = 5;
Console.WriteLine (x == y); // False
چون object یک Class و در نتیجه Reference Type است، Operator با نام == مربوط به object از Referential Equality برای Compare کردن x و y استفاده میکند. Result برابر false است زیرا x و y هرکدام به Boxed Object متفاوتی روی Heap اشاره میکنند.
Virtual Method با نام Object.Equals
برای Equal در نظر گرفتن صحیح x و y در Example قبل میتوانیم از Virtual Method با نام Equals استفاده کنیم. Equals در System.Object تعریف شده و بنابراین برای همهٔ Typeها Available است:
object x = 5;
object y = 5;
Console.WriteLine (x.Equals (y)); // True
Equals در Runtime و بر اساس Actual Type مربوط به Object Resolve میشود. در این مورد، Method با نام Equals مربوط به Int32 فراخوانی میشود که Value Equality را روی Operandها اعمال و true برمیگرداند. در Reference Typeها، Equals بهصورت Default Referential Equality Comparison انجام میدهد؛ در Structها، Equals با فراخوانی Equals روی هر Field، Structural Comparison انجام میدهد.
بنابراین Equals برای Equal کردن دو Object بهشکل Type-agnostic مناسب است. Method زیر دو Object از هر Type را Compare میکند:
public static bool AreEqual (object obj1, object obj2)
=> obj1.Equals (obj2);
بااینحال یک Case وجود دارد که این روش شکست میخورد. اگر Argument اول null باشد، NullReferenceException میگیرید. Fix:
public static bool AreEqual (object obj1, object obj2)
{
if (obj1 == null) return obj2 == null;
return obj1.Equals (obj2);
}
یا کوتاهتر:
public static bool AreEqual (object obj1, object obj2)
=> obj1 == null ? obj2 == null : obj1.Equals (obj2);
Static Method با نام object.Equals
Class با نام object یک Helper Method Static فراهم میکند که کار AreEqual در Example قبل را انجام میدهد. نام آن Equals است، درست مانند Virtual Method، اما Conflict وجود ندارد چون دو Argument میپذیرد:
public static bool Equals (object objA, object objB)
این Method یک Null-safe Equality Comparison Algorithm برای زمانی فراهم میکند که Typeها در Compile Time Unknown باشند:
object x = 3, y = 3;
Console.WriteLine (object.Equals (x, y)); // True
x = null;
Console.WriteLine (object.Equals (x, y)); // False
y = null;
Console.WriteLine (object.Equals (x, y)); // True
Application مفید آن هنگام نوشتن Generic Typeهاست. کد زیر اگر object.Equals را با Operator با نام == یا != جایگزین کنید Compile نمیشود:
class Test <T>
{
T _value;
public void SetValue (T newValue)
{
if (!object.Equals (newValue, _value))
{
_value = newValue;
OnValueChanged();
}
}
protected virtual void OnValueChanged() { ... }
}
Operatorها در اینجا ممنوعاند زیرا Compiler نمیتواند به Static Method مربوط به Type نامعلوم Bind شود.
Static Method با نام object.ReferenceEquals
گاهی باید Referential Equality Comparison را Force کنید. Static Method با نام object.ReferenceEquals دقیقاً همین کار را انجام میدهد:
Widget w1 = new Widget();
Widget w2 = new Widget();
Console.WriteLine (object.ReferenceEquals (w1, w2)); // False
class Widget { ... }
ممکن است بخواهید این کار را انجام دهید چون Widget میتواند Virtual Method با نام Equals را به شکلی Override کند که w1.Equals(w2) مقدار true بدهد. افزون بر آن، Widget میتواند Operator با نام == را Overload کند تا w1==w2 نیز true برگرداند. در چنین Caseهایی، فراخوانی object.ReferenceEquals Semantics معمول Referential Equality را تضمین میکند.
Interface با نام IEquatable<T>
یکی از پیامدهای فراخوانی object.Equals این است که روی Value Typeها Boxing را Force میکند. در Scenarioهای بسیار Performance-sensitive این نامطلوب است، زیرا Boxing در مقایسه با خود Comparison نسبتاً Expensive است. Solution در C# 2.0 با Interface با نام IEquatable<T> معرفی شد:
public interface IEquatable<T>
{
bool Equals (T other);
}
Idea این است که IEquatable<T>، در صورت Implement شدن، همان Result فراخوانی Virtual Method با نام Equals مربوط به object را میدهد، اما سریعتر. بیشتر Typeهای Basic در .NET، IEquatable<T> را Implement میکنند. میتوانید IEquatable<T> را بهعنوان Constraint در Generic Type استفاده کنید:
class Test<T> where T : IEquatable<T>
{
public bool IsEqual (T a, T b)
{
return a.Equals (b); // No boxing with generic T
}
}
اگر Generic Constraint را حذف کنیم، Class همچنان Compile میشود اما a.Equals(b) به object.Equals کندتر Bind میشود، با فرض اینکه T Value Type باشد.
وقتی Equals و == Equal نیستند
پیشتر گفتیم گاهی مفید است == و Equals Definition متفاوتی از Equality اعمال کنند. برای Example:
double x = double.NaN;
Console.WriteLine (x == x); // False
Console.WriteLine (x.Equals (x)); // True
Operator با نام == مربوط به Type با نام double Enforce میکند که یک NaN هرگز Equal با هیچ چیز، حتی NaN دیگر، نباشد. از دید Mathematics این Naturalترین Behavior است و Behavior زیرین CPU را منعکس میکند.
اما Method با نام Equals ملزم است Reflexive Equality را اعمال کند؛ یعنی:
x.Equals (x) must always return true.
Collectionها و Dictionaryها به این Behavior از Equals متکیاند؛ در غیر این صورت نمیتوانند Itemای را که قبلاً Store کردهاند پیدا کنند.
اینکه Equals و == Definitionهای متفاوتی از Equality اعمال کنند، در Value Typeها بسیار Rare است. Scenario رایجتر مربوط به Reference Typeهاست؛ زمانی که Author، Equals را Customize میکند تا Value Equality انجام دهد، اما == را با Referential Equality پیشفرض باقی میگذارد. Class با نام StringBuilder دقیقاً همین کار را میکند:
var sb1 = new StringBuilder ("foo");
var sb2 = new StringBuilder ("foo");
Console.WriteLine (sb1 == sb2); // False (referential equality)
Console.WriteLine (sb1.Equals (sb2)); // True (value equality)
اکنون بررسی میکنیم Equality را چگونه Customize کنیم.
Equality و Custom Typeها
Behavior پیشفرض Equality Comparison را به یاد بیاورید:
- Value Typeها از Value Equality استفاده میکنند.
- Reference Typeها از Referential Equality استفاده میکنند مگر اینکه Override شده باشد، مانند Anonymous Typeها و Recordها.
همچنین:
- Method با نام
Equals مربوط به Struct بهطور Default Structural Value Equality را اعمال میکند؛ یعنی هر Field در Struct را Compare میکند.
گاهی هنگام نوشتن یک Type، Override کردن این Behavior منطقی است. دو Case برای این کار وجود دارد:
- تغییر Meaning مربوط به Equality.
- افزایش Speed مربوط به Equality Comparison برای Structها.
تغییر Meaning مربوط به Equality
تغییر Meaning مربوط به Equality زمانی منطقی است که Behavior پیشفرض == و Equals برای Type شما Unnatural باشد و Consumer انتظار آن را نداشته باشد. Example، DateTimeOffset است؛ Structای با دو Private Field: یک UTC DateTime و یک Numeric Integer Offset. اگر این Type را مینوشتید، احتمالاً میخواستید Equality Comparison فقط UTC DateTime Field را در نظر بگیرد و Offset Field را نه. Example دیگر Numeric Typeهایی هستند که از Valueهای NaN پشتیبانی میکنند، مانند...
مانند float و double. اگر خودتان چنین Typeهایی را Implement میکردید، میخواستید اطمینان حاصل کنید Logic مربوط به Compare کردن NaN در Equality Comparisonها پشتیبانی میشود.
در Classها گاهی طبیعیتر است که بهجای Referential Equality، Value Equality را بهعنوان Default ارائه دهید. این حالت اغلب برای Classهای کوچک که Piece سادهای از Data را نگه میدارند رخ میدهد، مانند System.Uri یا System.String.
در Recordها، Compiler بهصورت خودکار Structural Equality را Implement میکند، یعنی هر Field را Compare میکند. بااینحال گاهی این Comparison شامل Fieldهایی میشود که نمیخواهید Compare شوند یا Objectهایی که به Comparison Logic ویژه نیاز دارند، مانند Collectionها. Process مربوط به Override کردن Equality در Recordها کمی متفاوت است، زیرا Recordها Pattern ویژهای را دنبال میکنند که برای سازگاری با Ruleهای Inheritance آنها طراحی شده است.
افزایش Speed مربوط به Equality Comparison در Structها
Algorithm پیشفرض Structural Equality Comparison برای Structها نسبتاً Slow است. در دست گرفتن این Process با Override کردن Equals میتواند Performance را حدود پنج برابر بهتر کند. Overload کردن Operator با نام == و Implement کردن IEquatable<T> Equality Comparison بدون Boxing را ممکن میکند و میتواند Speed را دوباره حدود پنج برابر افزایش دهد.
Case عجیب دیگری نیز برای Customize کردن Equality وجود دارد: بهبود Hashing Algorithm مربوط به Struct برای Performance بهتر در Hashtable. این از واقعیت ناشی میشود که Equality Comparison و Hashing بهشدت به هم وابستهاند. تا لحظاتی دیگر Hashing را بررسی میکنیم.
چگونه Equality Semantics را Override کنیم
برای Override کردن Equality در Class یا Struct، Stepها عبارتاند از:
GetHashCode() و Equals() را Override کنید.- بهصورت اختیاری
!= و == را Overload کنید. - بهصورت اختیاری
IEquatable<T> را Implement کنید.
در Recordها Process متفاوت و سادهتر است، چون Compiler از قبل Equality Methodها و Operatorها را مطابق Pattern ویژهٔ خودش Override میکند. اگر میخواهید دخالت کنید باید با این Pattern سازگار شوید؛ یعنی Method با نام Equals با Signature شبیه زیر بنویسید:
record Test (int X, int Y)
{
public virtual bool Equals (Test t) => t != null && t.X == X && t.Y == Y;
}